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
{conditions.map ((condition, index) => { - const datetime = countsFilterFields (resource)[condition.field] === 'datetime' + const datetime = countsFilterFields (definition)[condition.field] === 'datetime' return
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const) .map (key => datetime ? patch (index, { blank: event.target.checked })}/> {label ('blank')} + onClick={() => write (conditions.filter ( + (_, position) => index !== position))}>×
})} diff --git a/frontend/src/components/counts/CountsAxes.tsx b/frontend/src/components/counts/CountsAxes.tsx index 1cfffe4..11b8900 100644 --- a/frontend/src/components/counts/CountsAxes.tsx +++ b/frontend/src/components/counts/CountsAxes.tsx @@ -1,124 +1,82 @@ -import { DndContext, MouseSensor, TouchSensor, closestCenter, - useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core' - -import { countsAxisFields, countsFilterFields, countsGrains, readCountsJSON } +import SortableList from '@/components/common/SortableList' +import { countsAxisFields, countsFilterFields, readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions' -import { cn, inputClass } from '@/lib/utils' +import { useClientMotion } from '@/lib/useClientMotion' +import { inputClass } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' -import type { ReactNode } from 'react' - -import type { CountsResource } from '@/lib/counts' import type { CountsAxis } from '@/lib/countsDefinitions' -const AxisItem = ({ id, name, children }: { id: string; name: string; children: ReactNode }) => { - const drag = useDraggable ({ id }) - const drop = useDroppable ({ id }) - return
- {children} -
-} - - -const AxisZone = ({ id, children }: { id: string; children: ReactNode }) => { - const drop = useDroppable ({ id }) - return
- {children} -
-} - - -const CountsAxes = ({ resource, params, update }: { - resource: CountsResource; params: URLSearchParams - update: (key: string, value: string) => void }, +const CountsAxes = ({ params, update }: { + params: URLSearchParams; update: (key: string, value: string) => void }, ) => { const { label, message } = useUITranslation ('counts') - const history = params.get ('definition') === 'history' - const available = countsAxisFields (resource, history) + const definition = useCountsDefinition () + const motion = useClientMotion () + const available = countsAxisFields (definition, params.get ('definition') === 'history') const rows = readCountsJSON (params, 'rows', []) const cols = readCountsJSON (params, 'cols', []) - const groups = { rows, cols } - const sensors = useSensors ( - useSensor (MouseSensor, { activationConstraint: { distance: 4 } }), - useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } })) - const place = (field: string, target: string, position?: string) => { - for (const key of ['rows', 'cols'] as const) - { - const existing = [...rows, ...cols].find (axis => axis.field === field) - const next = groups[key].filter (axis => axis.field !== field) - if (target === key) - { - const index = position == null ? next.length : next.findIndex ( - axis => axis.field === position) - next.splice (index < 0 ? next.length : index, 0, existing ?? { - field, ...(countsFilterFields (resource)[field] === 'datetime' - ? { grain: 'month' } : { }) }) - } - update (key, JSON.stringify (next)) - } + const axes = [...rows, ...cols] + const groups = { + available: available.map (field => field.key).filter (id => !axes.some ( + axis => axis.field === id)), + rows: rows.map (axis => axis.field), cols: cols.map (axis => axis.field) } + const name = (id: string) => message ( + available.find (field => field.key === id)?.label ?? 'axis') + const commit = (next: Record) => { + for (const group of ['rows', 'cols'] as const) + update (group, JSON.stringify (next[group].map (field => axes.find ( + axis => axis.field === field) ?? { field, + ...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) }))) } - return undefined, onDragOver: () => undefined, - onDragMove: () => undefined, onDragEnd: () => undefined, - onDragCancel: () => undefined }, - screenReaderInstructions: { draggable: message ('axis').text } }} - onDragEnd={({ active, over }) => { - if (over == null) return - const [, field] = String (active.id).split ('/') - const [zone, position] = String (over.id).split ('/') - place (field, zone, position) - }}> -
-
{label ('axisItems')} - {available.filter (field => ![...rows, ...cols].some ( - axis => axis.field === field.key)).map (field => ( - - {label (field.label)} - {(['rows', 'cols'] as const).map (target => )} - ))} -
- {(['rows', 'cols'] as const).map (zone =>
- {label (zone)} - {groups[zone].map ((axis, index) => { - const field = available.find (field => field.key === axis.field) - return - {field == null ? null : label (field.label)} - {countsFilterFields (resource)[axis.field] === 'datetime' && } - - - - - })} - -
)} -
-
+ const place = (id: string, target?: 'rows' | 'cols') => { + const next = Object.fromEntries (Object.entries (groups).map (([key, ids]) => [ + key, ids.filter (value => value !== id)])) + if (target != null) next[target].push (id) + else next.available.push (id) + commit (next) + } + return
+ {label (id === 'available' ? 'axisItems' : id)} +
{content}
+
} renderItem={(id, group) => { + const axis = axes.find (axis => axis.field === id) + return
+ {group === 'available' && + {(['rows', 'cols'] as const).map (target => )} + } + {label (available.find (field => field.key === id)?.label ?? 'axis')} + {group !== 'available' && <> + {countsFilterFields (definition)[id] === 'datetime' && } + + } +
+ }}/> } diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx index aa572db..3559750 100644 --- a/frontend/src/components/counts/CountsControls.tsx +++ b/frontend/src/components/counts/CountsControls.tsx @@ -3,12 +3,14 @@ import { useEffect, useId, useState } from 'react' import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions' import CountsAxes from '@/components/counts/CountsAxes' import CountsFieldTree from '@/components/counts/CountsFieldTree' +import AnimatedCollapse from '@/components/common/AnimatedCollapse' import DateTimeField from '@/components/common/DateTimeField' import FormField from '@/components/common/FormField' import HelpPopover from '@/components/common/HelpPopover' import TagInput from '@/components/common/TagInput' import { Button } from '@/components/ui/button' import { normaliseCountsParams } from '@/lib/counts' +import { useClientMotion } from '@/lib/useClientMotion' import { inputClass } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' @@ -27,6 +29,7 @@ const CountsControls = ( ) => { const { label, message } = useUITranslation ('counts') const id = useId () + const motion = useClientMotion () const [draft, setDraft] = useState (() => new URLSearchParams (applied)) const order = applied.get ('order') const nestedJSON = applied.get ('nested_json') @@ -75,7 +78,9 @@ const CountsControls = ( {actions}
- + } 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 ? : ( - )} - {field.children ? ( -
- {control} -
- -
-
) :
{control}
} -
-
) + return <> +
+ + {field.children != null && } +
+ {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}`)} + +
      {['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