diff --git a/backend/app/controllers/counts_exports_controller.rb b/backend/app/controllers/counts_exports_controller.rb
index de48b16..ca459ad 100644
--- a/backend/app/controllers/counts_exports_controller.rb
+++ b/backend/app/controllers/counts_exports_controller.rb
@@ -1,6 +1,29 @@
class CountsExportsController < ApplicationController
wrap_parameters false
+ def prepare
+ locale = resolve_locale!
+ payload = request.request_parameters.merge(request.query_parameters)
+ exporter = CountsExporter.new(payload, language_code: locale.language_code)
+ session[:counts_export_owner] ||= SecureRandom.hex(32)
+ token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
+ response.headers['Cache-Control'] = 'private, no-store'
+ render json: { token: }
+ rescue ArgumentError, TypeError, KeyError, JSON::ParserError
+ head :bad_request
+ end
+
+ def download
+ file = CountsExportDownload.find(params[:token], session[:counts_export_owner])
+ return head :not_found unless file
+
+ response.headers['Cache-Control'] = 'private, no-store'
+ send_file file.fetch('path'),
+ filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
+ type: file['format'] == 'json' ? 'application/json' : 'text/csv',
+ disposition: 'attachment'
+ end
+
def index
locale = resolve_locale!
payload = request.request_parameters.except('authenticity_token')
diff --git a/backend/app/controllers/counts_tables_controller.rb b/backend/app/controllers/counts_tables_controller.rb
index ccffdab..503975e 100644
--- a/backend/app/controllers/counts_tables_controller.rb
+++ b/backend/app/controllers/counts_tables_controller.rb
@@ -1,7 +1,20 @@
class CountsTablesController < ApplicationController
+ def definition
+ resolve_locale!
+ definition = CountsResourceDefinition.new(params[:resource])
+ render json: { fields: definition.metadata,
+ axes: { latest: definition.axes(false), history: definition.axes(true) },
+ grains: CountsAxes::GRAINS }
+ rescue ArgumentError
+ head :bad_request
+ end
+
def index
resolve_locale!
render json: CountsTableQuery.new(request.query_parameters).call
+ rescue CountsTableQuery::TooManyColumns
+ render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
+ status: :unprocessable_entity
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
diff --git a/backend/app/services/counts_export_download.rb b/backend/app/services/counts_export_download.rb
new file mode 100644
index 0000000..0dbbd74
--- /dev/null
+++ b/backend/app/services/counts_export_download.rb
@@ -0,0 +1,51 @@
+require 'tempfile'
+require 'fileutils'
+
+# Finish generation before reporting readiness; downloads never hold the full file in RAM.
+class CountsExportDownload
+ EXPIRY = 10.minutes
+
+ def self.directory = Rails.root.join('tmp', 'counts_exports')
+ def self.verifier = Rails.application.message_verifier('counts_export_download')
+
+ def self.prepare(exporter, owner)
+ FileUtils.mkdir_p(directory, mode: 0o700)
+ Dir.glob(directory.join('counts-*.export')).each do |path|
+ File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
+ rescue Errno::ENOENT
+ # Another request may have removed an expired download.
+ end
+ file = Tempfile.create(['counts-', '.part'], directory)
+ complete = false
+ path = file.path
+ begin
+ file.binmode
+ exporter.body.each { file.write(_1) }
+ file.close
+ finished_path = path.sub(/\.part\z/, '.export')
+ File.rename(path, finished_path)
+ path = finished_path
+ token = verifier.generate({ 'file' => File.basename(path),
+ 'resource' => exporter.resource, 'format' => exporter.format,
+ 'owner' => owner }, expires_in: EXPIRY, purpose: 'counts_export')
+ complete = true
+ token
+ ensure
+ file.close unless file.closed?
+ File.unlink(path) if !complete && File.exist?(path)
+ end
+ end
+
+ def self.find(token, owner)
+ metadata = verifier.verified(token, purpose: 'counts_export')
+ return unless metadata.is_a?(Hash) && metadata['owner'] == owner
+ return unless metadata['file']&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
+ return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
+ return unless ['json', 'csv'].include?(metadata['format'])
+
+ path = directory.join(metadata['file'])
+ return unless path.file?
+
+ metadata.merge('path' => path.to_s)
+ end
+end
diff --git a/backend/app/services/counts_table_query.rb b/backend/app/services/counts_table_query.rb
index a47c6dd..bca43c8 100644
--- a/backend/app/services/counts_table_query.rb
+++ b/backend/app/services/counts_table_query.rb
@@ -1,5 +1,8 @@
# Common entrypoint for six version resources; legacy endpoints keep their contracts.
class CountsTableQuery
+ MAX_COLUMNS = 100
+ class TooManyColumns < ArgumentError; end
+
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'].freeze
@@ -45,12 +48,15 @@ class CountsTableQuery
{ page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
definition: population.history ? 'history' : 'latest',
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
- fields: @definition.metadata, count: population.scope.except(:select).count }
+ fields: @definition.metadata,
+ count: population.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count }
end
def summary
expressions = axes.expressions
- selected = expressions.each_with_index.map { |expression, index| "#{ expression } AS a#{ index }" }
+ selected = expressions.each_with_index.map do |expression, index|
+ "#{ expression } AS a#{ index }"
+ end
source = population.scope
source = source.joins(axes.joins) unless axes.joins.empty?
sql = if expressions.empty?
@@ -61,6 +67,7 @@ class CountsTableQuery
.to_sql
end
prefix = "WITH cells AS (#{ sql })"
+ column_values = limited_columns(prefix)
row_keys = axes.rows.each_index.map { "a#{ _1 }" }
# Page whole row tuples so a pivot row is never split across display pages.
row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ')
@@ -84,29 +91,28 @@ class CountsTableQuery
order_rows = row_sort.map(&:last) + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
order_rows = ['singleton ASC'] if order_rows.empty?
group_sql = "SELECT #{ ([row_selection] + extras).join(', ') } FROM cells #{ row_group }"
- total = @connection.select_value(
- "#{ prefix } SELECT COUNT(*) FROM (#{ group_sql }) row_groups").to_i
- join = row_keys.empty? ? 'TRUE' : row_keys.map { "cells.#{ _1 } <=> page_rows.#{ _1 }" }.join(' AND ')
+ total = @connection.select_value(<<~SQL).to_i
+ #{ prefix }
+ SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (#{ group_sql }) row_groups
+ SQL
+ join = row_keys.map { "cells.#{ _1 } <=> page_rows.#{ _1 }" }.join(' AND ')
+ join = 'TRUE' if row_keys.empty?
order_cols = @sort.filter_map do |key, direction|
- "cells.#{ key } #{ direction.upcase }" if key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key)
+ next unless key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key)
+
+ "cells.#{ key } #{ direction.upcase }"
end
+ order = ['page_rows.position'] + order_cols +
+ axes.all.each_index.map { "cells.a#{ _1 }" }
records = @connection.select_all(<<~SQL)
#{ prefix }, row_groups AS (#{ group_sql }),
numbered AS (SELECT row_groups.*,
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups),
page_rows AS (SELECT * FROM numbered
WHERE position > #{ (page - 1) * limit } AND position <= #{ page * limit })
- SELECT cells.* FROM cells JOIN page_rows ON #{ join }
- ORDER BY #{ (['page_rows.position'] + order_cols + axes.all.each_index.map { "cells.a#{ _1 }" }).join(', ') }
+ SELECT /*+ MAX_EXECUTION_TIME(10000) */ cells.* FROM cells JOIN page_rows ON #{ join }
+ ORDER BY #{ order.join(', ') }
SQL
- column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
- column_values = if column_keys.empty?
- [[]]
- else
- @connection.select_rows(
- "#{ prefix } SELECT DISTINCT #{ column_keys.join(', ') } FROM cells " +
- "ORDER BY #{ column_keys.map { "#{ _1 } IS NULL, #{ _1 }" }.join(', ') }")
- end
{ group_count: total, column_values:, cells: records.map do |row|
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
end, axis_labels: axis_labels(records, column_values) }
@@ -120,9 +126,11 @@ class CountsTableQuery
.limit(limit).offset((page - 1) * limit)
rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource)
+ point = population.history ? population.at :
+ population.timestamp(@params['at']) || population.at
values = CountsVersionRepr.many(rows, resource: @definition.resource,
language_code: locale.language_code,
- at: population.history ? population.at : population.timestamp(@params['at']) || population.at)
+ at: point)
values.each_with_index { |value, index| value[:event_type] = rows[index].event_type }
else
users = User.where(id: rows.map(&:created_by_user_id)).pluck(:id, :name).to_h
@@ -155,6 +163,21 @@ class CountsTableQuery
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
+ def limited_columns(prefix)
+ keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
+ return [[]] if keys.empty?
+
+ order = keys.map { "#{ _1 } IS NULL, #{ _1 }" }.join(', ')
+ values = @connection.select_rows(<<~SQL)
+ #{ prefix }
+ SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
+ ORDER BY #{ order } LIMIT #{ MAX_COLUMNS + 1 }
+ SQL
+ raise TooManyColumns if values.length > MAX_COLUMNS
+
+ values
+ end
+
def measure_values(key)
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
unless (value.is_a?(Array) && value.length == axes.columns.length &&
@@ -171,7 +194,9 @@ class CountsTableQuery
'original_created_at' => 'original_created_from' }
field = aliases.fetch(key, key)
expression = @definition.expression(field)
- @definition.fields[field] == :json ? "CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin" : expression
+ return expression unless @definition.fields[field] == :json
+
+ "CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin"
end
def sort_sql
@@ -208,7 +233,8 @@ class CountsTableQuery
external = values.grep(/\Aexternal:/).map { _1.split(':').last.to_i }
point = population.timestamp(@params['at']) || population.at
labels = {}
- [[TagVersion, internal, 'internal'], [NicoTagVersion, external, 'external']].each do |model, ids, kind|
+ sources = [[TagVersion, internal, 'internal'], [NicoTagVersion, external, 'external']]
+ sources.each do |model, ids, kind|
next if ids.empty?
table = model.table_name
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index a8f5df2..0d5e948 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -1,5 +1,8 @@
Rails.application.routes.draw do
+ get 'counts/definitions/:resource', to: 'counts_tables#definition'
get 'counts/query', to: 'counts_tables#index'
+ post 'counts/export/prepare', to: 'counts_exports#prepare'
+ get 'counts/export/files/:token', to: 'counts_exports#download'
get 'counts/export', to: 'counts_exports#index'
post 'counts/export', to: 'counts_exports#index'
get 'counts/state', to: 'counts_states#index'
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 548c439..7fba620 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,6 +11,7 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@mdx-js/react": "^3.1.1",
@@ -574,6 +575,20 @@
"react": ">=16.8.0"
}
},
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 7d93a4d..e9be868 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@mdx-js/react": "^3.1.1",
diff --git a/frontend/src/components/common/AnimatedCollapse.tsx b/frontend/src/components/common/AnimatedCollapse.tsx
new file mode 100644
index 0000000..81de06b
--- /dev/null
+++ b/frontend/src/components/common/AnimatedCollapse.tsx
@@ -0,0 +1,22 @@
+import { motion } from 'framer-motion'
+
+import { useClientMotion } from '@/lib/useClientMotion'
+import { cn } from '@/lib/utils'
+
+import type { ReactNode } from 'react'
+
+
+const AnimatedCollapse = ({ open, children, id, className }: {
+ open: boolean; children: ReactNode; id?: string; className?: string },
+) => {
+ const motionSettings = useClientMotion ()
+ return
+ {children}
+
+}
+
+
+export default AnimatedCollapse
diff --git a/frontend/src/components/common/SortableItem.tsx b/frontend/src/components/common/SortableItem.tsx
new file mode 100644
index 0000000..ada6307
--- /dev/null
+++ b/frontend/src/components/common/SortableItem.tsx
@@ -0,0 +1,50 @@
+import { defaultAnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+
+import { useClientMotion } from '@/lib/useClientMotion'
+import { cn } from '@/lib/utils'
+
+import type { ReactNode } from 'react'
+
+type Name = { text: string; language: string }
+
+
+const SortableItem = ({ id, name, fixed, children, moveGroup }: {
+ id: string; name: Name; fixed?: boolean; children: ReactNode
+ moveGroup: (direction: number) => void },
+) => {
+ const motion = useClientMotion ()
+ const item = useSortable ({ id, disabled: fixed,
+ transition: { duration: motion.duration ('reorder'), easing: motion.easing },
+ animateLayoutChanges: options => !motion.reduced
+ && defaultAnimateLayoutChanges ({ ...options, wasDragging: true }) })
+ return
+
+ {fixed ?
:
}
+
{children}
+
+
+}
+
+
+export default SortableItem
diff --git a/frontend/src/components/common/SortableList.tsx b/frontend/src/components/common/SortableList.tsx
new file mode 100644
index 0000000..9801c4a
--- /dev/null
+++ b/frontend/src/components/common/SortableList.tsx
@@ -0,0 +1,104 @@
+import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor,
+ closestCenter, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
+import { SortableContext, arrayMove, rectSortingStrategy,
+ sortableKeyboardCoordinates } from '@dnd-kit/sortable'
+import { useState } from 'react'
+import { createPortal } from 'react-dom'
+
+import SortableItem from '@/components/common/SortableItem'
+import { useClientMotion } from '@/lib/useClientMotion'
+import { cn } from '@/lib/utils'
+
+import type { ReactNode } from 'react'
+
+type Groups = Record
+type Name = { text: string; language: string }
+
+
+const Group = ({ id, children }: { id: string; children: ReactNode }) => {
+ const drop = useDroppable ({ id: `group:${id}` })
+ return {children}
+}
+
+
+const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = [] }: {
+ groups: Groups; name: (id: string) => Name
+ renderItem: (id: string, group: string) => ReactNode
+ renderGroup: (id: string, content: ReactNode) => ReactNode
+ onChange: (groups: Groups) => void; fixed?: string[] },
+) => {
+ const [preview, setPreview] = useState (null)
+ const [active, setActive] = useState (null)
+ const motion = useClientMotion ()
+ const current = preview ?? groups
+ const sensors = useSensors (
+ useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
+ useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
+ useSensor (KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
+ const owner = (id: string, value: Groups) => Object.keys (value)
+ .find (group => value[group].includes (id))
+ const relocate = (value: Groups, id: string, over: string): Groups => {
+ const source = owner (id, value)
+ const target = over.startsWith ('group:') ? over.slice (6) : owner (over, value)
+ if (source == null || target == null || fixed.includes (id) || fixed.includes (over))
+ return value
+ if (source === target)
+ {
+ const from = value[source].indexOf (id)
+ const to = over.startsWith ('group:') ? value[target].length - 1 : value[target].indexOf (over)
+ if (from === to || to < 0) return value
+ return { ...value, [source]: arrayMove (value[source], from, to) }
+ }
+ const destination = value[target].filter (item => item !== id)
+ const position = destination.indexOf (over)
+ destination.splice (position < 0 ? destination.length : position, 0, id)
+ return { ...value, [source]: value[source].filter (item => item !== id), [target]: destination }
+ }
+ const moveGroup = (id: string, direction: number) => {
+ const keys = Object.keys (groups)
+ const group = owner (id, groups)
+ const target = keys[keys.indexOf (group ?? '') + direction]
+ if (target != null) onChange (relocate (groups, id, `group:${target}`))
+ }
+ const reset = () => { setPreview (null); setActive (null) }
+ return name (String (event.active.id)).text,
+ onDragOver: event => event.over == null ? undefined : (
+ event.over.id.toString ().startsWith ('group:') ? undefined
+ : name (String (event.over.id)).text),
+ onDragMove: () => undefined, onDragEnd: () => undefined,
+ onDragCancel: () => undefined },
+ screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
+ onDragStart={event => { setPreview (groups); setActive (String (event.active.id)) }}
+ onDragOver={event => {
+ if (event.over == null || event.active.id === event.over.id) return
+ setPreview (previous => relocate (
+ previous ?? groups, String (event.active.id), String (event.over!.id)))
+ }} onDragCancel={reset} onDragEnd={event => {
+ if (event.over != null && preview != null) onChange (preview)
+ reset ()
+ }}>
+ {Object.entries (current).map (([group, ids]) =>
+ {renderGroup (group,
+ {ids.map (id => moveGroup (id, direction)}>
+ {renderItem (id, group)}
+ )}
+ )}
+
)}
+ {typeof document !== 'undefined' && createPortal (
+
+ {active == null ? null :
+ {name (active).text}
}
+ , document.body)}
+
+}
+
+
+export default SortableList
diff --git a/frontend/src/components/counts/CountsAdditionalConditions.tsx b/frontend/src/components/counts/CountsAdditionalConditions.tsx
index adaae03..4647210 100644
--- a/frontend/src/components/counts/CountsAdditionalConditions.tsx
+++ b/frontend/src/components/counts/CountsAdditionalConditions.tsx
@@ -2,24 +2,24 @@ import { useState } from 'react'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
-import { countsFieldLabel, countsFilterFields, countsOperators, readCountsJSON }
+import { countsFieldLabel, countsFilterFields, readCountsJSON, useCountsDefinition }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
-import type { CountsResource } from '@/lib/counts'
-import type { CountsCondition } from '@/lib/countsDefinitions'
+import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
-const ConditionEditor = ({ resource, initial, save }: {
- resource: CountsResource; initial?: CountsCondition
+const ConditionEditor = ({ definition, initial, save }: {
+ definition: CountsDefinition; initial?: CountsCondition
save: (condition: CountsCondition) => void },
) => {
const { label, message } = useUITranslation ('counts')
- const fields = countsFilterFields (resource)
+ const fields = countsFilterFields (definition)
const [field, setField] = useState (initial?.field ?? 'resource_id')
const [operator, setOperator] = useState (initial?.operator ?? 'eq')
+ const operators = definition.fields.find (item => item.key === field)?.operators ?? []
return
-
+
}
diff --git a/frontend/src/components/counts/CountsFieldTree.tsx b/frontend/src/components/counts/CountsFieldTree.tsx
index 24fea83..f8fcd26 100644
--- a/frontend/src/components/counts/CountsFieldTree.tsx
+++ b/frontend/src/components/counts/CountsFieldTree.tsx
@@ -1,116 +1,78 @@
-import { DndContext, MouseSensor, TouchSensor, closestCenter,
- useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
+import { useId, useState } from 'react'
-import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
- selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
-import { cn } from '@/lib/utils'
+import AnimatedCollapse from '@/components/common/AnimatedCollapse'
+import SortableList from '@/components/common/SortableList'
+import { countChildPaths, fieldsForCounts, orderedCountFields,
+ selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
+import { useCountsDefinition } from '@/lib/countsDefinitions'
+import { useClientMotion } from '@/lib/useClientMotion'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountField, CountsResource } from '@/lib/counts'
type BranchProps = {
- field: CountField
- path: string
- enabled: boolean
- selected: Set
- params: URLSearchParams
+ field: CountField; path: string; enabled: boolean
+ selected: Set; params: URLSearchParams
select: (path: string, checked: boolean) => void
- update: (key: string, value: string) => void
- move: (from: string, to: string) => void
- previous?: string
- next?: string }
+ update: (key: string, value: string) => void }
-const FieldBranch = (
- { field, path, enabled, selected, params, select, update, move, previous, next }: BranchProps,
-) => {
+const FieldBranch = ({ field, path, enabled, selected, params, select, update }: BranchProps) => {
const { label, message } = useUITranslation ('counts')
- const fixed = path === 'thumbnail'
- const drag = useDraggable ({ id: field.key, disabled: fixed })
- const drop = useDroppable ({ id: field.key, disabled: fixed })
+ const [open, setOpen] = useState (false)
+ const id = useId ()
+ const motion = useClientMotion ()
const checked = selected.has (path)
- const control =
- return (
-
-
- {fixed ?
: (
-
event.stopPropagation ()}
- onKeyDown={event => {
- if (!['ArrowUp', 'ArrowDown'].includes (event.key)) return
- event.preventDefault ()
- event.stopPropagation ()
- const target = event.key === 'ArrowUp' ? previous : next
- if (target != null) move (field.key, target)
- }}>⠿)}
- {field.children ? (
-
- {control}
-
-
-
- ) :
{control}
}
-
-
)
+ return <>
+
+
+ {field.children != null && setOpen (!open)}
+ className="rounded px-1 focus-visible:outline-none focus-visible:ring-2
+ focus-visible:ring-blue-500 dark:focus-visible:ring-blue-400">
+ ▸
+ }
+
+ {field.children != null &&
+
+
+
+ }
+ >
}
-const FieldGroup = (
- { fields, path, params, enabled, selected, select, update }: {
- fields: CountField[]
- path: string
- params: URLSearchParams
- enabled: boolean
- selected: Set
- select: BranchProps['select']
- update: BranchProps['update'] },
+const FieldGroup = ({ fields, path, params, enabled, selected, select, update }: {
+ fields: CountField[]; path: string; params: URLSearchParams
+ enabled: boolean; selected: Set; select: BranchProps['select']
+ update: BranchProps['update'] },
) => {
const { message } = useUITranslation ('counts')
const ordered = orderedCountFields (fields, params, path)
- const movable = ordered.filter (field => path !== '' || field.key !== 'thumbnail')
- const sensors = useSensors (
- useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
- useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }))
- const move = (from: string, to: string) =>
- update ('order', reorderCountFields (fields, params, path, from, to))
- return (
- undefined, onDragOver: () => undefined,
- onDragMove: () => undefined,
- onDragEnd: () => undefined, onDragCancel: () => undefined },
- screenReaderInstructions: { draggable: message ('output').text } }}
- onDragEnd={({ active, over }) => {
- if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
- }}>
-
- {ordered.map (field => {
- const position = movable.findIndex (item => item.key === field.key)
- return
- })}
-
- )
+ const prefix = path ? `${path}.` : ''
+ return field.key) }}
+ fixed={path === '' ? ['thumbnail'] : []}
+ name={key => message ('reorder', {
+ column: message (fields.find (field => field.key === key)!.label).text })}
+ renderGroup={(_, content) => content}
+ onChange={groups => {
+ const current = fields.map (field => `${prefix}${field.key}`)
+ const other = (params.get ('order') ?? '').split (',')
+ .filter (key => key && !current.includes (key))
+ update ('order', [...other, ...groups.items.map (key => `${prefix}${key}`)].join (','))
+ }} renderItem={key => {
+ const field = fields.find (field => field.key === key)!
+ return
+ }}/>
}
@@ -124,8 +86,9 @@ const CountsFieldTree = (
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
- const fields = suppliedFields ?? fieldsForCounts (resource)
- const roots = suppliedFields == null ? selectedCountColumns (resource, params)
+ const definition = useCountsDefinition ()
+ const fields = suppliedFields ?? fieldsForCounts (resource, definition)
+ const roots = suppliedFields == null ? selectedCountColumns (resource, params, definition)
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
const selected = selectedCountChildren (params)
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
diff --git a/frontend/src/components/counts/CountsResults.tsx b/frontend/src/components/counts/CountsResults.tsx
index abdd31f..25a0334 100644
--- a/frontend/src/components/counts/CountsResults.tsx
+++ b/frontend/src/components/counts/CountsResults.tsx
@@ -10,6 +10,7 @@ import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
import { countsCellCopy, countsCellText, countsImageURL, countsNestedRows } from '@/lib/countsDisplay'
import useDialogue from '@/lib/dialogues/useDialogue'
import { useLocale } from '@/lib/useLocale'
+import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountColumn, CountsResource,
@@ -24,11 +25,12 @@ const CountsResults = (
pending: boolean
update: (key: string, value: string) => void },
) => {
+ const definition = useCountsDefinition ()
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const dialogue = useDialogue ()
- const columns = selectedCountColumns (resource, params)
- const fields = fieldsForCounts (resource)
+ const columns = selectedCountColumns (resource, params, definition)
+ const fields = fieldsForCounts (resource, definition)
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
const sort = (key: string, additive: boolean) =>
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 663f623..c6fe256 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -201,26 +201,29 @@ export const isApiError = (err: unknown): err is AxiosError =>
axios.isAxiosError (err)
-// Native downloads let the browser stream to disk without buffering a Blob in app memory.
+// The server acknowledges a complete file before native download starts.
export const apiDownload = async (
path: string,
body: Record,
locale: LocaleCode,
+ onError?: () => void,
): Promise => {
- const token = await csrfToken ()
- const form = document.createElement ('form')
- form.method = 'post'
- form.action = `${API_BASE_URL}${path}?${new URLSearchParams ({ locale })}`
- form.hidden = true
- for (const [name, value] of Object.entries ({ ...body, authenticity_token: token }))
- {
- const input = document.createElement ('input')
- input.type = 'hidden'
- input.name = name
- input.value = value
- form.append (input)
- }
- document.body.append (form)
- form.submit ()
- form.remove ()
+ const { token } = await apiPost<{ token: string }> (`${path}/prepare`, body, { locale })
+ if (typeof token !== 'string' || token.length === 0)
+ throw new Error ('Missing download token')
+ const frame = document.createElement ('iframe')
+ frame.hidden = true
+ frame.addEventListener ('load', () => {
+ const document = frame.contentDocument
+ if (document != null && document.URL !== 'about:blank')
+ {
+ onError?.()
+ frame.remove ()
+ }
+ })
+ frame.addEventListener ('error', () => { onError?.(); frame.remove () })
+ frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}`
+ document.body.append (frame)
+ // Attachments do not reliably fire load; keep this isolated from page navigation.
+ window.setTimeout (() => frame.remove (), 10 * 60_000)
}
diff --git a/frontend/src/lib/clientAnimation.ts b/frontend/src/lib/clientAnimation.ts
index ebc77e8..e680468 100644
--- a/frontend/src/lib/clientAnimation.ts
+++ b/frontend/src/lib/clientAnimation.ts
@@ -32,3 +32,9 @@ export const clientScrollBehaviour = (
animationMode: ClientAnimationMode,
): ScrollBehavior =>
animationMode === 'off' ? 'auto' : 'smooth'
+
+export const clientMotionTokens = {
+ durationMS: { feedback: 120, popover: 160, collapse: 220, reorder: 200, drop: 180 },
+ easing: [.2, 0, 0, 1] as const,
+ cssEasing: 'cubic-bezier(.2, 0, 0, 1)',
+ distance: 4 }
diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts
index 5cf7324..68b50d9 100644
--- a/frontend/src/lib/counts.ts
+++ b/frontend/src/lib/counts.ts
@@ -1,6 +1,7 @@
-import { countsAttributes, countsFieldLabel } from '@/lib/countsDefinitions'
+import { countsFieldLabel } from '@/lib/countsDefinitions'
import { apiDownload, apiGet } from '@/lib/api'
+import type { CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
@@ -135,7 +136,10 @@ const nested: Partial> = {
parents: [{ key: 'id', label: 'counts:columns.resourceId' }] }
-export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
+export const columnsForCounts = (
+ resource: CountsResource,
+ definition?: CountsDefinition,
+): CountColumn[] => [
...(resource === 'posts' ? ['thumbnail'] : []),
'resource_id', 'version_no', 'is_latest',
...(resource === 'posts'
@@ -143,12 +147,16 @@ export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
'original_created_at', 'original_created_from', 'original_created_before']
: (resource === 'tags'
? ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
- : Object.keys (countsAttributes[resource]))),
+ : (definition?.fields.filter (field => ![
+ 'resource_id', 'version_no', 'event_type', 'user', 'created_at']
+ .includes (field.key)).map (field => field.key) ?? []))),
'event_type', 'user', 'created_at']
-export const fieldsForCounts = (resource: CountsResource): CountField[] =>
- columnsForCounts (resource).map (key => ({
+export const fieldsForCounts = (
+ resource: CountsResource,
+ definition?: CountsDefinition,
+): CountField[] => columnsForCounts (resource, definition).map (key => ({
key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
: (countColumns[key] ?? countsFieldLabel (key)),
@@ -196,12 +204,13 @@ export const reorderCountFields = (
export const selectedCountColumns = (
resource: CountsResource,
params: URLSearchParams,
+ definition?: CountsDefinition,
): CountColumn[] => {
const selected = params.has ('columns') ? params.get ('columns')!.split (',') :
- columnsForCounts (resource).filter (key => ![
+ columnsForCounts (resource, definition).filter (key => ![
'thumbnail', 'original_created_from', 'original_created_before']
.includes (key))
- return orderedCountFields (fieldsForCounts (resource), params)
+ return orderedCountFields (fieldsForCounts (resource, definition), params)
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
}
@@ -281,7 +290,7 @@ export const normaliseCountsParams = (
params.set ('through', params.get ('before')!)
for (const key of ['basis', 'axis', 'latest', 'grain', 'before', 'summary_columns'])
params.delete (key)
- if (params.has ('columns'))
+ if (params.has ('columns') && ['posts', 'tags'].includes (resource))
{
const selected = new Set (params.get ('columns')!.split (','))
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
@@ -321,10 +330,11 @@ export const exportCounts = (
columns: string[],
headers: string[],
labels: Record,
+ onError?: () => void,
): Promise => apiDownload ('/counts/export', {
...Object.fromEntries (params), resource, format,
columns: columns.join (','), headers: JSON.stringify (headers),
- labels: JSON.stringify (labels) }, locale)
+ labels: JSON.stringify (labels) }, locale, onError)
diff --git a/frontend/src/lib/countsDefinitions.ts b/frontend/src/lib/countsDefinitions.ts
index 696d946..c4bdae8 100644
--- a/frontend/src/lib/countsDefinitions.ts
+++ b/frontend/src/lib/countsDefinitions.ts
@@ -1,42 +1,41 @@
+import { createContext, useContext } from 'react'
+
+import { apiGet } from '@/lib/api'
+
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 }
-export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' | 'json'
+export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' | 'json' | 'boolean'
export const countsResources: CountsResource[] = [
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
-export const countsGrains = ['year', 'quarter', 'month', 'week', 'day', 'hour']
-export const countsOperators: Record = {
- string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], json: [],
- integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
- datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'] }
+export type CountsFieldMetadata = {
+ key: string; type: CountsFieldType; nullable: boolean; operators: string[] }
+export type CountsDefinition = {
+ fields: CountsFieldMetadata[]
+ axes: { latest: string[]; history: string[] }
+ grains: string[] }
-export const countsAttributes: Record> = {
- posts: { title: 'string', url: 'string', thumbnail_base: 'string', tags_json: 'json',
- parent_post_ids: 'string', video_ms: 'integer', original_created_from: 'datetime',
- original_created_before: 'datetime' },
- tags: { name: 'string', names: 'json', category: 'enum', aliases: 'string',
- parent_tag_ids: 'string', deprecated_at: 'datetime' },
- nico_tags: { name: 'string', linked_tags: 'string' },
- tag_descriptions: { language_code: 'enum', body: 'string', source_language_code: 'enum',
- source_version_no: 'integer' },
- tag_groups: { name: 'string', description: 'string', members: 'json', placements: 'json' },
- materials: { url: 'string', parent_id: 'integer', tag_id: 'integer', tag_name: 'string',
- tag_category: 'enum', discarded_at: 'datetime', updated_at: 'datetime',
- updated_by_user_id: 'integer', export_paths_json: 'json', file_blob_id: 'integer',
- file_filename: 'string', file_content_type: 'string', file_byte_size: 'integer',
- file_checksum: 'string', file_sha256: 'string', source_kind: 'enum', source_uri: 'string',
- source_path: 'string', source_file_id: 'string', normalized_source_key: 'string' } }
+export const CountsDefinitionContext = createContext (null)
+
+
+export const useCountsDefinition = (): CountsDefinition => {
+ const definition = useContext (CountsDefinitionContext)
+ if (definition == null) throw new Error ('Missing Counts definition')
+ return definition
+}
+
+
+export const fetchCountsDefinition = (resource: CountsResource): Promise =>
+ apiGet (`/counts/definitions/${encodeURIComponent (resource)}`)
export const countsFilterFields = (
- resource: CountsResource,
-): Record => ({
- resource_id: 'integer' as const, version_no: 'integer' as const,
- event_type: 'enum' as const, user: 'integer' as const, created_at: 'datetime' as const,
- ...countsAttributes[resource] })
+ definition: CountsDefinition,
+): Record => Object.fromEntries (
+ definition.fields.map (field => [field.key, field.type]))
export const countsFieldLabel = (key: string): string => {
@@ -52,12 +51,11 @@ export const countsFieldLabel = (key: string): string => {
}
-export const countsAxisFields = (resource: CountsResource, history: boolean): CountField[] => [
- ...Object.entries (countsFilterFields (resource))
- .filter (([, type]) => type !== 'json')
- .map (([key]) => ({ key, label: countsFieldLabel (key) })),
- ...(resource === 'posts' ? [{ key: 'tags', label: 'columns.tags' }] : []),
- ...(history ? [{ key: 'changed_field', label: 'changedField' }] : [])]
+export const countsAxisFields = (
+ definition: CountsDefinition,
+ history: boolean,
+): CountField[] => definition.axes[history ? 'history' : 'latest']
+ .map (key => ({ key, label: countsFieldLabel (key) }))
export const readCountsJSON = (params: URLSearchParams, key: string, fallback: T): T => {
diff --git a/frontend/src/lib/countsDisplay.ts b/frontend/src/lib/countsDisplay.ts
index 50ed06f..239491b 100644
--- a/frontend/src/lib/countsDisplay.ts
+++ b/frontend/src/lib/countsDisplay.ts
@@ -5,6 +5,7 @@ import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/ut
import type { CountColumn, CountField, CountRecord,
CountsResource, CountsVersion } from '@/lib/counts'
+import type { CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
@@ -245,6 +246,7 @@ export const countsImageURL = (value: string | null | undefined): string | null
export const countsExportLabels = (
resource: CountsResource,
locale: LocaleCode,
+ definition?: CountsDefinition,
): Record => {
const aliases: Record = { versionNo: 'version_no',
languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' }
@@ -257,7 +259,7 @@ export const countsExportLabels = (
if (field.children) visit (field.children)
}
}
- visit (fieldsForCounts (resource))
+ visit (fieldsForCounts (resource, definition))
for (const key of ['internal', 'external'])
labels[`kind:${key}`] = translateUIMessage (`kinds.${key}`, 'counts', locale).text
for (const key of ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'])
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 9370694..fbc8fcd 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -163,5 +163,6 @@ export const theatresKeys = {
['theatres', locale, id, 'programmes'] as const }
export const countsKeys = {
+ definition: (resource: string) => ['counts', 'definition', resource] as const,
index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const }
diff --git a/frontend/src/lib/useClientMotion.ts b/frontend/src/lib/useClientMotion.ts
new file mode 100644
index 0000000..922c2ad
--- /dev/null
+++ b/frontend/src/lib/useClientMotion.ts
@@ -0,0 +1,19 @@
+import { useReducedMotion } from 'framer-motion'
+
+import { clientAnimationTransition, clientMotionTokens } from '@/lib/clientAnimation'
+import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
+
+
+export const useClientMotion = () => {
+ const settings = useClientBehaviourSettings ()
+ const osReduced = useReducedMotion ()
+ const mode = osReduced ? 'reduced' : settings.animation ?? 'normal'
+ const reduced = mode !== 'normal'
+ const duration = (kind: keyof typeof clientMotionTokens.durationMS) =>
+ reduced ? 0 : clientMotionTokens.durationMS[kind]
+ const transition = (kind: keyof typeof clientMotionTokens.durationMS) =>
+ clientAnimationTransition (mode, {
+ normal: { duration: duration (kind) / 1000, ease: clientMotionTokens.easing },
+ reduced: { duration: 0 } })
+ return { reduced, duration, transition, easing: clientMotionTokens.cssEasing }
+}
diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx
index e610b6b..abb050f 100644
--- a/frontend/src/pages/CountsResourcePage.tsx
+++ b/frontend/src/pages/CountsResourcePage.tsx
@@ -7,6 +7,7 @@ import CountsControls from '@/components/counts/CountsControls'
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
import CountsResults from '@/components/counts/CountsResults'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
+import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import PageHead from '@/components/common/PageHead'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
@@ -16,10 +17,12 @@ import { Button } from '@/components/ui/button'
import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
-import { countsAxisFields, countsResources, countsFieldLabel, readCountsJSON }
+import { CountsDefinitionContext, countsAxisFields, countsResources, countsFieldLabel,
+ fetchCountsDefinition, readCountsJSON, useCountsDefinition }
from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
import { cn, inputClass } from '@/lib/utils'
+import { useClientMotion } from '@/lib/useClientMotion'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -28,7 +31,8 @@ import type { CountsAxis } from '@/lib/countsDefinitions'
import type { CountsResource } from '@/lib/counts'
-const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
+const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
+ const definition = useCountsDefinition ()
const [source, setParams] = useSearchParams ()
const params = normaliseCountsParams (resource, source)
const canonical = params.toString ()
@@ -38,6 +42,11 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const request = countsRequestParams (params)
const [open, setOpen] = useState (false)
const [sidebarOpen, setSidebarOpen] = useState (false)
+ const [expanded, setExpanded] = useState (() => new Set ([resource]))
+ const motion = useClientMotion ()
+ useEffect (() => {
+ setExpanded (previous => new Set ([...previous, resource]))
+ }, [resource])
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const mode = params.get ('mode') ?? 'summary'
@@ -57,7 +66,8 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
&& previousParams.get ('mode') === mode
&& previousParams.get ('rows') === params.get ('rows')
&& previousParams.get ('cols') === params.get ('cols')
- && previousParams.get ('definition') === cacheParams.get ('definition')) ? previous : undefined
+ && previousParams.get ('definition') === cacheParams.get ('definition'))
+ ? previous : undefined
} })
useEffect (() => {
@@ -99,13 +109,14 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
...readCountsJSON (params, 'cols', [])].map (axis => ({
key: axis.field, label: countsFieldLabel (axis.field) })),
{ key: 'count', label: 'count' }]
- : selectedCountColumns (resource, params).map (column =>
- fieldsForCounts (resource).find (field => field.key === column)!)
+ : selectedCountColumns (resource, params, definition).map (column =>
+ fieldsForCounts (resource, definition).find (field => field.key === column)!)
await exportCounts (resource, params, locale, format,
fields.map (field => field.key), fields.map (field => message (field.label).text),
- { ...countsExportLabels (resource, locale),
- ...Object.fromEntries (countsAxisFields (resource, !state).map (field => [
- field.key, message (field.label).text])), count: message ('count').text })
+ { ...countsExportLabels (resource, locale, definition),
+ ...Object.fromEntries (countsAxisFields (definition, !state).map (field => [
+ field.key, message (field.label).text])), count: message ('count').text },
+ () => setExportError (true))
}
catch
@@ -133,11 +144,26 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
{label ('target')}
{countsResources.map (target => -
- {label (`resources.${target}`)}
+ setExpanded (previous => {
+ const next = new Set (previous)
+ if (next.has (target)) next.delete (target)
+ else next.add (target)
+ return next
+ })}>
+ ▸
+ {label (`resources.${target}`)}
+
+
{['latest', 'history'].map (definition => {
const next = new URLSearchParams ({ definition, mode })
- for (const key of ['timezone', 'at', 'execution_at'])
+ for (const key of ['timezone', 'at'])
if (params.get (key)) next.set (key, params.get (key)!)
const active = target === resource
&& params.get ('definition') === definition
@@ -148,6 +174,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
{label (definition)}
})}
+
)}
@@ -197,4 +224,17 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
}
+const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
+ const { label } = useUITranslation ('counts')
+ const { data, isError } = useQuery ({
+ queryKey: countsKeys.definition (resource),
+ queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
+ if (data == null) return {label (
+ isError ? 'common:fetchError' : 'shared:messages.loadingEllipsis')}
+ return
+
+
+}
+
+
export default CountsResourcePage