diff --git a/backend/app/services/counts_query.rb b/backend/app/services/counts_query.rb index 5dde9b1..07e1adf 100644 --- a/backend/app/services/counts_query.rb +++ b/backend/app/services/counts_query.rb @@ -59,17 +59,24 @@ class CountsQuery def summary expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" : "#{ @table }.created_by_user_id" - grouped = @scope.group(Arel.sql(expression)) - sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql - groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups") group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression - expressions = { 'group' => [group_order], 'count' => ['COUNT(*)'] } - final = ["(#{ expression }) IS NULL ASC", "#{ expression } ASC"] - rows = grouped.order(Arel.sql(order_sql(expressions, final))) - .limit(limit).offset((page - 1) * limit).count - names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {} + sql = @scope.group(Arel.sql(expression)).select(Arel.sql( + "#{ expression } AS group_key, COUNT(*) AS record_count, " + + "#{ group_order } AS group_name")).to_sql + connection = @scope.klass.connection + source = "FROM (#{ sql }) counts_groups" + groups = connection.select_value("SELECT COUNT(*) #{ source }") + expressions = { 'group' => ['group_name'], 'count' => ['record_count'] } + order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC']) + rows = connection.select_all( + "SELECT group_key, group_name, record_count #{ source } " + + "ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }") { group_count: groups.to_i, - groups: rows.map { |key, value| { key:, name: names[key], count: value } } } + groups: rows.map do |row| + { key: row['group_key'], + name: axis == 'user' ? row['group_name'] : nil, + count: row['record_count'].to_i } + end } end private @@ -168,9 +175,9 @@ class CountsQuery first = timestamp('from') last = timestamp('before') raise ArgumentError if first && last && first >= last - raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '0')) + raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1')) - @scope = @scope.where(Arel.sql(latest_expression)) if @params['latest'] == '1' + @scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0' @scope = @scope.where("#{ @table }.created_at >= ?", first) if first @scope = @scope.where("#{ @table }.created_at < ?", last) if last if @params['resource_id'].present? diff --git a/frontend/src/components/counts/CountsCellMenu.tsx b/frontend/src/components/counts/CountsCellMenu.tsx new file mode 100644 index 0000000..9eb51b6 --- /dev/null +++ b/frontend/src/components/counts/CountsCellMenu.tsx @@ -0,0 +1,88 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { createPortal } from 'react-dom' + +import { useUITranslation } from '@/lib/useUITranslation' + +export type CountsMenuAction = 'copy' | 'copyHeaders' | 'row' | 'column' | 'all' + + +const CountsCellMenu = ( + { x, y, act, close }: { + x: number + y: number + act: (action: CountsMenuAction) => void + close: () => void }, +) => { + const root = useRef (null) + const { label, message } = useUITranslation ('counts') + useLayoutEffect (() => { + const menu = root.current + if (menu == null) return + const rect = menu.getBoundingClientRect () + menu.style.left = `${Math.max (4, Math.min (x, window.innerWidth - rect.width - 4))}px` + menu.style.top = `${Math.max (4, Math.min (y, window.innerHeight - rect.height - 4))}px` + menu.querySelector ('button')?.focus ({ preventScroll: true }) + }, [x, y]) + useEffect (() => { + const outside = (event: Event) => { + if (!root.current?.contains (event.target as Node)) close () + } + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape') + { + event.preventDefault () + event.stopPropagation () + close () + } + } + document.addEventListener ('pointerdown', outside, true) + document.addEventListener ('keydown', escape, true) + window.addEventListener ('resize', close) + document.addEventListener ('scroll', outside, true) + return () => { + document.removeEventListener ('pointerdown', outside, true) + document.removeEventListener ('keydown', escape, true) + window.removeEventListener ('resize', close) + document.removeEventListener ('scroll', outside, true) + } + }, [close]) + const actions: CountsMenuAction[] = ['copy', 'copyHeaders', 'row', 'column', 'all'] + return createPortal ( +
event.preventDefault ()} + onPointerDown={event => event.stopPropagation ()} + onKeyDown={event => { + event.stopPropagation () + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase () === 'c') + { + event.preventDefault () + act ('copy') + return + } + const buttons = Array.from (root.current?.querySelectorAll ('button') ?? []) + const current = buttons.indexOf (document.activeElement as HTMLButtonElement) + if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes (event.key)) + { + event.preventDefault () + const next = event.key === 'Home' ? 0 : (event.key === 'End' ? buttons.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) + % buttons.length) + buttons[next]?.focus () + } + if (event.key === 'Tab') close () + }}> + {actions.map (action => ( + ))} +
, document.body) +} + + +export default CountsCellMenu diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx index 7ffa0b7..1139ebc 100644 --- a/frontend/src/components/counts/CountsControls.tsx +++ b/frontend/src/components/counts/CountsControls.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from 'react' +import { useEffect, useId, useState } from 'react' import CountsFieldTree from '@/components/counts/CountsFieldTree' import DateTimeField from '@/components/common/DateTimeField' @@ -14,18 +14,32 @@ import type { CountsResource } from '@/lib/counts' const CountsControls = ( - { resource, applied, open, setOpen, execute, displayJSON }: { + { resource, applied, open, setOpen, execute, displayJSON, reorder }: { resource: CountsResource applied: URLSearchParams open: boolean setOpen: (open: boolean) => void execute: (params: URLSearchParams) => void - displayJSON: (enabled: boolean) => void }, + displayJSON: (enabled: boolean) => void + reorder: (value: string) => void }, ) => { const { label, message } = useUITranslation ('counts') const locale = useLocale () const id = useId () const [draft, setDraft] = useState (() => new URLSearchParams (applied)) + const order = applied.get ('order') + const nestedJSON = applied.get ('nested_json') + useEffect (() => { + setDraft (previous => { + const next = new URLSearchParams (previous) + for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON })) + { + if (value == null) next.delete (key) + else next.set (key, value) + } + return next + }) + }, [order, nestedJSON]) const [error, setError] = useState (false) const [pending, setPending] = useState (false) const mode = draft.get ('mode') ?? 'summary' @@ -113,12 +127,15 @@ const CountsControls = ( {message (`axes.${axis}`).text}))} ) : ( - )} + { + update (key, value) + if (key === 'order') reorder (value) + }}/>)}
{label ('filters')} diff --git a/frontend/src/components/counts/CountsFieldTree.tsx b/frontend/src/components/counts/CountsFieldTree.tsx index eb12c39..97efc8d 100644 --- a/frontend/src/components/counts/CountsFieldTree.tsx +++ b/frontend/src/components/counts/CountsFieldTree.tsx @@ -1,19 +1,33 @@ -import { countChildPaths, fieldsForCounts, selectedCountChildren, - selectedCountColumns } from '@/lib/counts' +import { DndContext, MouseSensor, TouchSensor, closestCenter, + useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core' + +import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields, + selectedCountChildren, selectedCountColumns } from '@/lib/counts' +import { cn } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' import type { CountField, CountsResource } from '@/lib/counts' +type BranchProps = { + 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 } + const FieldBranch = ( - { field, path, enabled, selected, select }: { - field: CountField - path: string - enabled: boolean - selected: Set - select: (path: string, checked: boolean) => void }, + { field, path, enabled, selected, params, select, update, move, previous, next }: BranchProps, ) => { - const { label } = useUITranslation ('counts') + 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 checked = selected.has (path) const control = - return field.children ? ( -
- {control} -
- {field.children.map (child => ( - ))} + return ( +
+
+ {fixed ? : ( + )} + {field.children ? ( +
+ {control} +
+ +
+
) :
{control}
}
-
) :
{control}
+ ) +} + + +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 + })} + ) } @@ -61,9 +139,8 @@ const CountsFieldTree = ( } return ( <> - {fields.map (field => ( - ))} +

{label ('nested.selection')}

) } diff --git a/frontend/src/components/counts/CountsNestedTable.tsx b/frontend/src/components/counts/CountsNestedTable.tsx index 753987a..c0d3ace 100644 --- a/frontend/src/components/counts/CountsNestedTable.tsx +++ b/frontend/src/components/counts/CountsNestedTable.tsx @@ -3,7 +3,7 @@ import PrefetchLink from '@/components/PrefetchLink' import CountsSortHeader from '@/components/counts/CountsSortHeader' import CountsSelectableTable from '@/components/counts/CountsSelectableTable' import { changeCountSort, readCountSort, selectedCountChildren, - writeCountSort } from '@/lib/counts' + writeCountSort, orderedCountFields } from '@/lib/counts' import { countsLogicalName, countsNestedExport, countsNestedOrder, countsNestedText } from '@/lib/countsDisplay' import { useLocale } from '@/lib/useLocale' @@ -25,7 +25,8 @@ const CountsNestedTable = ( const { message } = useUITranslation ('counts') const locale = useLocale () const selected = selectedCountChildren (params) - const columns = fields.filter (field => selected.has (`${path}.${field.key}`)) + const columns = orderedCountFields (fields, params, path) + .filter (field => selected.has (`${path}.${field.key}`)) const allSorts = readCountSort (params.get ('child_sort')) const sorts = allSorts.filter (sort => sort.key.startsWith (`${path}/`)) .map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) })) @@ -50,21 +51,19 @@ const CountsNestedTable = ( return display.text } return ( -
- - {title.text} ({rows.length}) - +
{params.get ('nested_json') === '1' ? (
 	      {JSON.stringify (countsNestedExport (rows, fields, path, params, locale), null, 2)}
 	    
) : (columns.length > 0 && rows.length > 0 && (
+ className="relative isolate max-h-72 w-full min-w-0 overflow-auto"> field.key)} identity={`${path}:${params}`} name={title} - className="border text-xs dark:border-slate-700"> + className="text-xs"> {columns.map (field => ( @@ -101,7 +100,7 @@ const CountsNestedTable = ( ))}
))} -
) + ) } diff --git a/frontend/src/components/counts/CountsResults.tsx b/frontend/src/components/counts/CountsResults.tsx index 6184951..14ee4ca 100644 --- a/frontend/src/components/counts/CountsResults.tsx +++ b/frontend/src/components/counts/CountsResults.tsx @@ -8,6 +8,7 @@ import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts, readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts' import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay' +import useDialogue from '@/lib/dialogues/useDialogue' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' @@ -25,6 +26,7 @@ const CountsResults = ( ) => { const { label, message } = useUITranslation ('counts') const locale = useLocale () + const dialogue = useDialogue () const summary = (params.get ('mode') ?? 'summary') === 'summary' const axis = params.get ('axis') ?? 'month' const columns = selectedCountColumns (resource, params) @@ -70,8 +72,22 @@ const CountsResults = ( case 'thumbnail': { const url = countsImageURL (row.thumbnailBase) - return url == null ? null : + return url == null ? null : ( + ) } case 'url': case 'thumbnail_base': @@ -107,7 +123,8 @@ const CountsResults = ( : columns.map (column => column === 'thumbnail' - ? + ? {label (countColumns[column])} diff --git a/frontend/src/components/counts/CountsSelectableTable.tsx b/frontend/src/components/counts/CountsSelectableTable.tsx index ba669ad..075043e 100644 --- a/frontend/src/components/counts/CountsSelectableTable.tsx +++ b/frontend/src/components/counts/CountsSelectableTable.tsx @@ -1,20 +1,20 @@ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import CountsCellMenu from '@/components/counts/CountsCellMenu' +import { countsClipboard, writeCountsClipboard } from '@/lib/countsClipboard' import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout' import { cn } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' import type { ClipboardEvent, ReactNode } from 'react' +import type { CountsMenuAction } from '@/components/counts/CountsCellMenu' + type Point = { row: number; column: number } type Selection = { identity: string; anchor: Point; end: Point } const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]' -const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character => - ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!) - - const cellAt = (table: HTMLTableElement, point: Point) => table.tBodies[0]?.rows[point.row]?.cells[point.column] @@ -34,6 +34,9 @@ const CountsSelectableTable = ( const touchArmed = useRef (false) const touchPress = useRef<{ x: number; y: number timer: ReturnType } | null> (null) + const [menu, setMenu] = useState<{ x: number; y: number; point: Point + identity: string } | null> (null) + const suppressClick = useRef (false) const [stored, setStored] = useState (null) const selection = stored?.identity === identity ? stored : null const layout = useCountsTableLayout () @@ -87,10 +90,19 @@ const CountsSelectableTable = ( return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex, column: (cell as HTMLTableCellElement).cellIndex } } - const copy = (event: ClipboardEvent) => { - if (bounds == null || root.current == null || document.activeElement !== root.current) return - event.preventDefault () - event.stopPropagation () + const closeMenu = useCallback (() => { + setMenu (null) + root.current?.focus ({ preventScroll: true }) + }, []) + const openMenu = (point: Point, x: number, y: number) => { + const inside = bounds != null && point.row >= bounds.top && point.row <= bounds.bottom + && point.column >= bounds.left && point.column <= bounds.right + if (inside) setStored (selection) + else setStored ({ identity, anchor: point, end: point }) + setMenu ({ point, x, y, identity }) + } + const clipboard = (headers = false) => { + if (bounds == null || root.current == null) return null const rows: string[][] = [] for (let row = bounds.top; row <= bounds.bottom; ++row) { @@ -102,18 +114,50 @@ const CountsSelectableTable = ( } rows.push (values) } - const text = rows.map (row => row.map (value => { - const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value - return `"${safe.replaceAll ('"', '""')}"` - }).join ('\t')).join ('\r\n') - const html = '' + rows.map (row => '' + row.map (value => - ``).join ('') - + '').join ('') + '
${escapeHTML ( - /^\s*[=+@-]/.test (value) ? `'${value}` : value)}
' - event.clipboardData.setData ('text/plain', text) + if (headers) + { + const heading = root.current.tHead?.rows[0] + rows.unshift (Array.from ({ length: bounds.right - bounds.left + 1 }, (entry, index) => { + void entry + const cell = heading?.cells[bounds.left + index] + return cell?.dataset.countsHeading ?? cell?.textContent ?? '' + })) + } + return countsClipboard (rows, headers) + } + const menuAction = (action: CountsMenuAction) => { + if (menu == null || root.current == null) return + if (action === 'copy' || action === 'copyHeaders') + { + const payload = clipboard (action === 'copyHeaders') + if (payload != null) void writeCountsClipboard (payload).catch (error => { + console.error (error) + }) + } + else + { + const lastRow = (root.current.tBodies[0]?.rows.length ?? 1) - 1 + const lastColumn = columns.length - 1 + const anchor = { + row: action === 'row' ? menu.point.row : 0, + column: action === 'column' ? menu.point.column : 0 } + const end = { + row: action === 'row' ? menu.point.row : lastRow, + column: action === 'column' ? menu.point.column : lastColumn } + setStored ({ identity, anchor, end }) + } + closeMenu () + } + const copy = (event: ClipboardEvent) => { + if (document.activeElement !== root.current) return + const payload = clipboard () + if (payload == null) return + event.preventDefault () + event.stopPropagation () + event.clipboardData.setData ('text/plain', payload.text) try { - event.clipboardData.setData ('text/html', html) + event.clipboardData.setData ('text/html', payload.html) } catch { @@ -140,10 +184,18 @@ const CountsSelectableTable = ( '[&>tbody>tr>td[data-counts-selected]]:outline-blue-500', '[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)} onCopy={copy} + onClickCapture={event => { + if ((event.target as Element).closest ('table') !== event.currentTarget + || !suppressClick.current) return + event.preventDefault () + event.stopPropagation () + suppressClick.current = false + }} onPointerDown={event => { const element = event.target as Element - if (element.closest ('table') !== event.currentTarget - || element.closest (interactive) != null || event.button !== 0) return + if (element.closest ('table') !== event.currentTarget) return + suppressClick.current = false + if (element.closest (interactive) != null || event.button !== 0) return if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll')) { const rect = element.getBoundingClientRect () @@ -153,6 +205,7 @@ const CountsSelectableTable = ( const point = pointFor (element) if (point == null) return event.stopPropagation () + setMenu (null) event.currentTarget.focus ({ preventScroll: true }) setStored ({ identity, anchor: event.shiftKey && selection != null ? selection.anchor : point, end: point }) @@ -169,6 +222,8 @@ const CountsSelectableTable = ( const pointerId = event.pointerId touchPress.current = { x: event.clientX, y: event.clientY, timer: setTimeout (() => { + suppressClick.current = true + openMenu (point, event.clientX, event.clientY) dragging.current = true touchArmed.current = true table.setPointerCapture (pointerId) @@ -182,6 +237,10 @@ const CountsSelectableTable = ( if (!dragging.current || event.buttons === 0) return const point = pointFor (document.elementFromPoint (event.clientX, event.clientY)) if (point == null) return + if (touchArmed.current && touchPress.current != null + && Math.hypot (event.clientX - touchPress.current.x, + event.clientY - touchPress.current.y) <= 8) return + setMenu (null) event.stopPropagation () setStored (previous => previous?.identity !== identity ? previous : { ...previous, end: point }) @@ -191,10 +250,27 @@ const CountsSelectableTable = ( if (event.target === event.currentTarget) finish () }} onContextMenu={event => { - if (touchPress.current != null || touchArmed.current) event.preventDefault () + const element = event.target as Element + if (element.closest ('table') !== event.currentTarget) return + const point = pointFor (element) + if (point == null || element.closest ('[data-counts-resize]') != null) return + event.preventDefault () + event.stopPropagation () + openMenu (point, event.clientX, event.clientY) }} onKeyDown={event => { if (event.target !== event.currentTarget) return + if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) + { + const point = selection?.end ?? { row: 0, column: 0 } + const rect = cellAt (event.currentTarget, point)?.getBoundingClientRect () + if (rect != null) + { + event.preventDefault () + openMenu (point, rect.left, rect.bottom) + } + return + } if (event.key === 'Escape') { setStored (null) @@ -221,6 +297,8 @@ const CountsSelectableTable = ( ))} {children} + {menu?.identity === identity && } ) } diff --git a/frontend/src/components/counts/CountsSortHeader.tsx b/frontend/src/components/counts/CountsSortHeader.tsx index 67711ca..d14b5e8 100644 --- a/frontend/src/components/counts/CountsSortHeader.tsx +++ b/frontend/src/components/counts/CountsSortHeader.tsx @@ -36,7 +36,8 @@ const CountsSortHeader = ( direction: message (`sort.${state}`).text }) return ( - 1 && priority >= 0) ? 'other' : state}>