diff --git a/backend/app/services/counts_table_query.rb b/backend/app/services/counts_table_query.rb index a863e12..99aeeca 100644 --- a/backend/app/services/counts_table_query.rb +++ b/backend/app/services/counts_table_query.rb @@ -98,10 +98,10 @@ class CountsTableQuery extras << "sort#{ index }.count AS sort#{ index }" "sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }" elsif row_keys.include?(key) - "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }" + axis_order_sql(key, direction) end end - order_rows = row_sort + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" } + order_rows = row_sort + row_keys.map { axis_order_sql(_1) } order_rows = ['singleton ASC'] if order_rows.empty? grouped = "SELECT #{ row_selection } FROM cells #{ row_group }" selection = (['grouped.*'] + extras).join(', ') @@ -115,10 +115,10 @@ class CountsTableQuery order_cols = @sort.filter_map do |key, direction| next unless key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key) - "cells.#{ key } #{ direction.upcase }" + axis_order_sql(key, direction, qualifier: 'cells') end order = ['page_rows.position'] + order_cols + - axes.all.each_index.map { "cells.a#{ _1 }" } + axes.all.each_index.map { axis_order_sql("a#{ _1 }", qualifier: 'cells') } records = @connection.select_all(<<~SQL) #{ prefix }, row_groups AS (#{ group_sql }), numbered AS (SELECT row_groups.*, @@ -231,39 +231,41 @@ class CountsTableQuery # detail pagination continue to use the authorised population exclusively. source = CountsPopulation.new( @params.merge('execution_at' => population.at.iso8601(6)), authorisation_flg: false) - records = source.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count denied = source.scope.except(:select) .where(Arel.sql("NOT (#{ HistoryAccess.sql(@definition.model, 'v') })")) .optimizer_hints('MAX_EXECUTION_TIME(10000)').count - rows = records - if mode == 'summary' - unrestricted_axes = CountsAxes.new(source) - relation = source.scope - joins = unrestricted_axes.joins - relation = relation.joins(joins) unless joins.empty? - expressions = unrestricted_axes.expressions - keys = axes.rows.each_index.map { "a#{ _1 }" } - if keys.empty? - rows = 1 - else - selected = expressions.each_with_index.map { |expression, index| - "#{ expression } AS a#{ index }" - } - sql = relation.except(:select).select(Arel.sql(selected.join(', '))) - .distinct.to_sql - prefix = "WITH cells AS (#{ sql })" - groups = @connection.select_value(<<~SQL).to_i - #{ prefix } - SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM ( - SELECT #{ keys.join(', ') } FROM cells GROUP BY #{ keys.join(', ') } - ) output_groups - SQL - rows = output_row_count(prefix, keys, CountsPivotLayout.depths(keys, @sort), groups) - end - end + rows = if mode == 'details' + source.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count + else + summary_output_count(source) + end @output_totals = { output_count: rows, excluded_count: denied } end + def summary_output_count(source) + keys = axes.rows.each_index.map { "a#{ _1 }" } + return 1 if keys.empty? + + unrestricted_axes = CountsAxes.new(source) + relation = source.scope + joins = unrestricted_axes.joins + relation = relation.joins(joins) unless joins.empty? + expressions = unrestricted_axes.expressions + selected = expressions.each_with_index.map { |expression, index| + "#{ expression } AS a#{ index }" + } + sql = relation.except(:select).select(Arel.sql(selected.join(', '))) + .distinct.to_sql + prefix = "WITH cells AS (#{ sql })" + groups = @connection.select_value(<<~SQL).to_i + #{ prefix } + SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM ( + SELECT #{ keys.join(', ') } FROM cells GROUP BY #{ keys.join(', ') } + ) output_groups + SQL + output_row_count(prefix, keys, CountsPivotLayout.depths(keys, @sort), groups) + end + def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja')) def limited_columns(prefix) @@ -272,9 +274,7 @@ class CountsTableQuery configured = @sort.select { |key, _direction| keys.include?(key) } directions = (configured + keys.map { [_1, 'asc'] }).uniq { _1.first } - order = directions.map { |key, direction| - "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }" - }.join(', ') + order = directions.map { |key, direction| axis_order_sql(key, direction) }.join(', ') custom = QueryState.records(@params, 'column_order') if custom.any? raise ArgumentError unless custom.map { _1['value'] }.uniq.length == custom.length @@ -311,6 +311,19 @@ class CountsTableQuery source.select(Arel.sql(selected.join(', '))).to_sql end + def axis_order_sql(key, direction = 'asc', qualifier: nil) + column = qualifier ? "#{ qualifier }.#{ key }" : key + expression = column + if axes.all.fetch(key.delete_prefix('a').to_i).fetch('field') == 'category' + # Tag's enum preserves the same category order as TagsController. + cases = Tag.categories.keys.each_with_index.map { |category, index| + "WHEN #{ @connection.quote(category) } THEN #{ index }" + }.join(' ') + expression = "CASE #{ column } #{ cases } ELSE #{ Tag.categories.length } END" + end + "#{ column } IS NULL ASC, #{ expression } #{ direction.upcase }" + end + def measure_values(key) records = QueryState.records(@params, 'measures').select { _1['key'] == key } raise ArgumentError if records.empty? diff --git a/frontend/src/components/counts/CountsAdditionalConditions.tsx b/frontend/src/components/counts/CountsAdditionalConditions.tsx index 8a61509..27cc5b5 100644 --- a/frontend/src/components/counts/CountsAdditionalConditions.tsx +++ b/frontend/src/components/counts/CountsAdditionalConditions.tsx @@ -71,69 +71,88 @@ const CountsAdditionalConditions = ({ params, update }: { const dialogue = useDialogue () const conditions = readCountState (params, 'conditions', []) const metadata = definition.filterFields ?? definition.fields + const fields = metadata.filter (field => field.operators.length > 0) const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels ?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ') - const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next)) + const write = (next: CountsCondition[]) => update ( + 'conditions', encodeCountState ('conditions', next)) + const replace = (index: number, condition: CountsCondition) => write ( + conditions.map ((item, position) => position === index ? condition : item)) + const patch = (index: number, change: Partial) => + replace (index, { ...conditions[index], ...change }) const edit = (index?: number) => { const initial = index == null ? undefined : conditions[index] const save = (condition: CountsCondition) => { - write (index == null ? [...conditions, condition] - : conditions.map ((item, position) => position === index ? condition : item)) + if (index == null) write ([...conditions, condition]) + else replace (index, condition) } - dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline', - body: controls => }) + void dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline', + body: controls => }) } - const patch = (index: number, change: Partial) => write ( - conditions.map ((item, position) => position === index ? { ...item, ...change } : item)) const valueInput = (condition: CountsCondition, index: number, key: 'value' | 'end') => { const type = countsFilterFields (definition)[condition.field] - const aria = message (key === 'end' ? 'through' : 'value') + const aria = message (key === 'end' ? 'through' + : (condition.operator === 'range' ? 'from' : 'value')) if (type === 'boolean' || condition.field === 'event_type') { const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes] const namespace = type === 'boolean' ? 'truthValues' : 'events' - return patch (index, { [key]: event.target.value })}> + )} } if (type === 'datetime') - return patch (index, { [key]: value ?? '' })}/> - return patch (index, { [key]: event.target.value })}/> + return patch (index, { [key]: value ?? '' })}/> + return patch (index, { [key]: event.target.value })}/> } - return
+ return
{label ('additionalConditions')} - - {conditions.map ((condition, index) => { - return
- - {(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const) - .map (key => valueInput (condition, index, key))} - - -
- })} + + {conditions.map ((condition, index) => ( +
+ +
+ {valueInput (condition, index, 'value')} + {condition.operator === 'range' && <> + + {valueInput (condition, index, 'end')} + } +
+
+ + +
+
))}
} diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx index d741978..c476d00 100644 --- a/frontend/src/components/counts/CountsControls.tsx +++ b/frontend/src/components/counts/CountsControls.tsx @@ -150,6 +150,13 @@ const CountsControls = (
{label ('displaySettings')} + }
-
-
-
+
+
+
{mode === 'summary' ? : <> {label ('output')} @@ -185,6 +192,9 @@ const CountsControls = ( }}/> }
+
+
{label ('filters')} {history &&
@@ -197,12 +207,12 @@ const CountsControls = ( className="min-w-0 flex-1" onChange={value => update ('through', value ?? '')}/>
}
- {['resource_id', 'user_id', 'limit'].map (key => - {() => + {() => update (key, event.target.value)}/>} )} diff --git a/frontend/src/components/counts/CountsSelectableTable.tsx b/frontend/src/components/counts/CountsSelectableTable.tsx index f5b716b..7406bc2 100644 --- a/frontend/src/components/counts/CountsSelectableTable.tsx +++ b/frontend/src/components/counts/CountsSelectableTable.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import CountsCellMenu from '@/components/counts/CountsCellMenu' import { countsClipboard, writeCountsClipboard } from '@/lib/countsClipboard' @@ -13,6 +14,8 @@ import type { CountsMenuAction } from '@/components/counts/CountsCellMenu' type Point = { row: number; column: number } type Selection = { identity: string; anchor: Point; end: Point } +type TextMode = { identity: string; cell: HTMLTableCellElement; nodes: Node[] } +type Caret = { cell: HTMLTableCellElement; left: number; top: number; height: number } const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]' @@ -53,7 +56,10 @@ const CountsSelectableTable = ( ) => { const id = useId () const root = useRef (null) - const textCell = useRef (null) + const [textMode, setTextMode] = useState (null) + const textCell = textMode?.identity === identity ? textMode.cell : null + const [caret, setCaret] = useState (null) + const mousePress = useRef<{ x: number; y: number } | null> (null) const dragging = useRef (false) const touchArmed = useRef (false) const touchPress = useRef<{ x: number; y: number @@ -66,20 +72,95 @@ const CountsSelectableTable = ( const layout = useCountsTableLayout () const { label, message } = useUITranslation ('counts') const clearTextSelection = useCallback (() => { - const cell = textCell.current - if (cell == null) return - delete cell.dataset.countsText - const text = window.getSelection () - if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges () - textCell.current = null + setTextMode (null) }, []) - useEffect (() => () => clearTextSelection (), [identity, clearTextSelection]) + useLayoutEffect (() => { + if (textMode == null) return + const { cell, nodes } = textMode + const table = root.current + if (table == null || textMode.identity !== identity || !table.contains (cell)) + { + setTextMode (null) + return + } + cell.dataset.countsText = '' + table.focus ({ preventScroll: true }) + const range = document.createRange () + range.setStart (nodes[0], 0) + const last = nodes[nodes.length - 1] + range.setEnd (last, last.textContent?.length ?? 0) + const text = window.getSelection () + text?.removeAllRanges () + text?.addRange (range) + return () => { + delete cell.dataset.countsText + const text = window.getSelection () + if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges () + } + }, [identity, textMode]) + useLayoutEffect (() => { + if (textCell == null) return + const updateCaret = () => { + const text = window.getSelection () + if (!document.hasFocus () || text == null || !text.isCollapsed || text.rangeCount === 0 + || !textCell.contains (text.focusNode)) + { + setCaret (null) + return + } + const range = text.getRangeAt (0).cloneRange () + const rect = range.getBoundingClientRect () + const node = text.focusNode + const offset = text.focusOffset + const probe = range.cloneRange () + if (rect.height === 0 && node?.nodeType === Node.TEXT_NODE && node.textContent) + { + if (offset < node.textContent.length) probe.setEnd (node, offset + 1) + else probe.setStart (node, offset - 1) + } + const bounds = rect.height > 0 ? rect : probe.getBoundingClientRect () + if (bounds.height === 0) + { + setCaret (null) + return + } + const cell = textCell.getBoundingClientRect () + const left = (rect.height === 0 && offset === node?.textContent?.length) + ? bounds.right : bounds.left + setCaret ({ cell: textCell, + left: left - cell.left - textCell.clientLeft + textCell.scrollLeft, + top: bounds.top - cell.top - textCell.clientTop + textCell.scrollTop, + height: bounds.height }) + } + updateCaret () + document.addEventListener ('selectionchange', updateCaret) + document.addEventListener ('scroll', updateCaret, true) + window.addEventListener ('resize', updateCaret) + window.addEventListener ('focus', updateCaret) + window.addEventListener ('blur', updateCaret) + return () => { + document.removeEventListener ('selectionchange', updateCaret) + document.removeEventListener ('scroll', updateCaret, true) + window.removeEventListener ('resize', updateCaret) + window.removeEventListener ('focus', updateCaret) + window.removeEventListener ('blur', updateCaret) + } + }, [textCell]) + useEffect (() => { + if (textCell == null) return + const outside = (event: PointerEvent) => { + if (!textCell.contains (event.target as Node)) clearTextSelection () + } + document.addEventListener ('pointerdown', outside, true) + return () => document.removeEventListener ('pointerdown', outside, true) + }, [clearTextSelection, textCell]) const bounds = useMemo (() => selection == null ? null : { top: Math.min (selection.anchor.row, selection.end.row), bottom: Math.max (selection.anchor.row, selection.end.row), left: Math.min (selection.anchor.column, selection.end.column), right: Math.max (selection.anchor.column, selection.end.column) }, [selection]) const finish = () => { + mousePress.current = null dragging.current = false touchArmed.current = false if (touchPress.current != null) clearTimeout (touchPress.current.timer) @@ -98,6 +179,7 @@ const CountsSelectableTable = ( }, []) useLayoutEffect (() => { if (root.current == null) return + if (textCell != null && !root.current.contains (textCell)) setTextMode (null) const grid = cellGrid (root.current) const assigned = new Set () for (const [row, cells] of grid.entries ()) @@ -112,14 +194,14 @@ const CountsSelectableTable = ( cell.id = `${id}-${row}-${column}` if (cell.getAttribute ('role') !== 'rowheader') cell.setAttribute ('role', 'gridcell') cell.setAttribute ('aria-selected', String (selected)) - if (selected) cell.dataset.countsSelected = '' + if (selected && cell !== textCell) cell.dataset.countsSelected = '' else delete cell.dataset.countsSelected } } if (bounds != null) root.current.setAttribute ('aria-activedescendant', grid[bounds.bottom]?.[bounds.right]?.id ?? '') else root.current.removeAttribute ('aria-activedescendant') - }, [bounds, children, id]) + }, [bounds, children, id, textCell]) const pointFor = (element: Element | null, y?: number): Point | null => { const table = root.current if (table == null || element == null || !table.contains (element)) return null @@ -140,6 +222,35 @@ const CountsSelectableTable = ( } return null } + const enterTextSelection = (point: Point) => { + const table = root.current + const cell = table == null ? null : cellAt (table, point) + if (table == null || cell == null) return + // Structured cells retain their independent inner grid instead of selecting its UI. + const nested = cell.querySelector ('table') + if (nested != null) + { + nested.focus ({ preventScroll: true }) + return + } + const walker = document.createTreeWalker (cell, NodeFilter.SHOW_TEXT, { + acceptNode: node => { + const parent = node.parentElement + const auxiliaryFlg = parent?.closest ( + '[hidden],[aria-hidden="true"],.sr-only,svg,[data-counts-resize]') != null + return (parent?.closest ('table') === table && node.textContent?.trim () + && !auxiliaryFlg) + ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + } }) + const nodes: Node[] = [] + for (let node = walker.nextNode (); node != null; node = walker.nextNode ()) + nodes.push (node) + if (nodes.length === 0) return + finish () + clearTextSelection () + setMenu (null) + setTextMode ({ identity, cell, nodes }) + } const closeMenu = useCallback (() => { setMenu (null) root.current?.focus ({ preventScroll: true }) @@ -209,9 +320,9 @@ const CountsSelectableTable = ( } const copy = (event: ClipboardEvent) => { const text = window.getSelection () - if (textCell.current != null && text != null && !text.isCollapsed - && textCell.current.contains (text.anchorNode) - && textCell.current.contains (text.focusNode)) + if (textCell != null && text != null && !text.isCollapsed + && textCell.contains (text.anchorNode) + && textCell.contains (text.focusNode)) { event.stopPropagation () return @@ -254,21 +365,16 @@ const CountsSelectableTable = ( const element = event.target as Element if (element.closest ('table') !== event.currentTarget || element.closest (interactive) != null) return - const cell = element.closest ('td') - if (cell == null) return + if (textCell?.contains (element)) + { + event.stopPropagation () + return + } + const point = pointFor (element, event.clientY) + if (point == null) return event.preventDefault () event.stopPropagation () - finish () - clearTextSelection () - setStored (null) - setMenu (null) - textCell.current = cell - cell.dataset.countsText = '' - const range = document.createRange () - range.selectNodeContents (cell) - const text = window.getSelection () - text?.removeAllRanges () - text?.addRange (range) + enterTextSelection (point) }} onClickCapture={event => { if ((event.target as Element).closest ('table') !== event.currentTarget @@ -281,7 +387,7 @@ const CountsSelectableTable = ( const element = event.target as Element if (element.closest ('table') !== event.currentTarget) return suppressClick.current = false - if (textCell.current != null && textCell.current.contains (element)) + if (textCell != null && textCell.contains (element)) { event.stopPropagation () return @@ -304,9 +410,7 @@ const CountsSelectableTable = ( finish () if (event.pointerType === 'mouse') { - event.preventDefault () - dragging.current = true - event.currentTarget.setPointerCapture (event.pointerId) + mousePress.current = { x: event.clientX, y: event.clientY } } else { @@ -323,10 +427,22 @@ const CountsSelectableTable = ( } }} onPointerMove={event => { + if (mousePress.current != null) + { + if (event.buttons === 0) finish () + else if (!dragging.current && Math.hypot ( + event.clientX - mousePress.current.x, event.clientY - mousePress.current.y) > 5) + { + dragging.current = true + suppressClick.current = true + event.currentTarget.setPointerCapture (event.pointerId) + } + } if (touchPress.current != null && !touchArmed.current && Math.hypot ( event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8) finish () if (!dragging.current || event.buttons === 0) return + event.preventDefault () const point = pointFor (document.elementFromPoint (event.clientX, event.clientY), event.clientY) if (point == null) return if (touchArmed.current && touchPress.current != null @@ -344,7 +460,7 @@ const CountsSelectableTable = ( onContextMenu={event => { const element = event.target as Element if (element.closest ('table') !== event.currentTarget) return - if (textCell.current != null && textCell.current.contains (element)) + if (textCell != null && textCell.contains (element)) { event.stopPropagation () return @@ -357,7 +473,8 @@ const CountsSelectableTable = ( }} onKeyDown={event => { if (event.target !== event.currentTarget) return - if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) + if (textCell == null + && (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))) { const point = selection?.end ?? { row: 0, column: 0 } const rect = cellAt (event.currentTarget, point)?.getBoundingClientRect () @@ -370,11 +487,47 @@ const CountsSelectableTable = ( } if (event.key === 'Escape') { + const textModeFlg = textCell != null clearTextSelection () - setStored (null) + if (!textModeFlg) setStored (null) + event.preventDefault () + return + } + if (event.key === 'Enter' && textCell == null && selection != null) + { + event.preventDefault () + enterTextSelection (selection.end) + return + } + if (textCell != null) + { + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return + const text = window.getSelection () + if (text == null || typeof text.modify !== 'function') return + event.preventDefault () + event.stopPropagation () + const anchor = text.anchorNode + const offset = text.anchorOffset + const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp' + const vertical = event.key === 'ArrowUp' || event.key === 'ArrowDown' + const direction = vertical ? (backward ? 'backward' : 'forward') + : (backward ? 'left' : 'right') + const grain = vertical ? 'line' : ((event.ctrlKey || event.metaKey) ? 'word' : 'character') + text.modify (event.shiftKey ? 'extend' : 'move', direction, grain) + if (!textCell.contains (text.focusNode)) + { + const nodes = textMode?.nodes.filter (node => textCell.contains (node)) ?? [] + const edge = backward ? nodes[0] : nodes[nodes.length - 1] + if (edge != null) + { + const end = backward ? 0 : edge.textContent?.length ?? 0 + if (event.shiftKey && anchor != null && textCell.contains (anchor)) + text.setBaseAndExtent (anchor, offset, edge, end) + else text.setPosition (edge, end) + } + } return } - if (textCell.current != null) return if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return const rows = event.currentTarget.tBodies[0]?.rows if (rows == null || rows.length === 0) return @@ -391,11 +544,17 @@ const CountsSelectableTable = ( cellAt (event.currentTarget, point)?.scrollIntoView ({ block: 'nearest', inline: 'nearest' }) }}> - {label ('selection.hint')} + + {label ('selection.hint')} + {textCell != null && label ('selection.textMode')} + {columns.map (key => ( ))} {children} + {textCell != null && caret != null && caret.cell === textCell && createPortal ( +