このコミットが含まれているのは:
@@ -59,17 +59,24 @@ class CountsQuery
|
|||||||
def summary
|
def summary
|
||||||
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
|
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
|
||||||
"#{ @table }.created_by_user_id"
|
"#{ @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
|
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
|
||||||
expressions = { 'group' => [group_order], 'count' => ['COUNT(*)'] }
|
sql = @scope.group(Arel.sql(expression)).select(Arel.sql(
|
||||||
final = ["(#{ expression }) IS NULL ASC", "#{ expression } ASC"]
|
"#{ expression } AS group_key, COUNT(*) AS record_count, " +
|
||||||
rows = grouped.order(Arel.sql(order_sql(expressions, final)))
|
"#{ group_order } AS group_name")).to_sql
|
||||||
.limit(limit).offset((page - 1) * limit).count
|
connection = @scope.klass.connection
|
||||||
names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {}
|
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,
|
{ 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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -168,9 +175,9 @@ class CountsQuery
|
|||||||
first = timestamp('from')
|
first = timestamp('from')
|
||||||
last = timestamp('before')
|
last = timestamp('before')
|
||||||
raise ArgumentError if first && last && first >= last
|
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 >= ?", first) if first
|
||||||
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
|
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
|
||||||
if @params['resource_id'].present?
|
if @params['resource_id'].present?
|
||||||
|
|||||||
@@ -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<HTMLDivElement> (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 (
|
||||||
|
<div ref={root} role="menu" aria-label={message ('selection.table').text}
|
||||||
|
className="fixed z-50 min-w-40 rounded border border-slate-400 bg-white
|
||||||
|
py-1 text-sm text-slate-900 shadow-lg dark:border-slate-600
|
||||||
|
dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
style={{ left: x, top: y }} onContextMenu={event => 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 => (
|
||||||
|
<button key={action} type="button" role="menuitem"
|
||||||
|
className="block w-full px-3 py-1 text-left hover:bg-slate-200
|
||||||
|
hover:text-slate-900 focus:bg-slate-200 focus:text-slate-900
|
||||||
|
dark:hover:bg-slate-700 dark:hover:text-slate-100
|
||||||
|
dark:focus:bg-slate-700 dark:focus:text-slate-100"
|
||||||
|
onClick={() => act (action)}>{label (`selection.${action}`)}</button>))}
|
||||||
|
</div>, document.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsCellMenu
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
|
||||||
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
@@ -14,18 +14,32 @@ import type { CountsResource } from '@/lib/counts'
|
|||||||
|
|
||||||
|
|
||||||
const CountsControls = (
|
const CountsControls = (
|
||||||
{ resource, applied, open, setOpen, execute, displayJSON }: {
|
{ resource, applied, open, setOpen, execute, displayJSON, reorder }: {
|
||||||
resource: CountsResource
|
resource: CountsResource
|
||||||
applied: URLSearchParams
|
applied: URLSearchParams
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
execute: (params: URLSearchParams) => void
|
execute: (params: URLSearchParams) => void
|
||||||
displayJSON: (enabled: boolean) => void },
|
displayJSON: (enabled: boolean) => void
|
||||||
|
reorder: (value: string) => void },
|
||||||
) => {
|
) => {
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const id = useId ()
|
const id = useId ()
|
||||||
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
|
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 [error, setError] = useState (false)
|
||||||
const [pending, setPending] = useState (false)
|
const [pending, setPending] = useState (false)
|
||||||
const mode = draft.get ('mode') ?? 'summary'
|
const mode = draft.get ('mode') ?? 'summary'
|
||||||
@@ -113,12 +127,15 @@ const CountsControls = (
|
|||||||
{message (`axes.${axis}`).text}</option>))}
|
{message (`axes.${axis}`).text}</option>))}
|
||||||
</select>
|
</select>
|
||||||
</label>) : (
|
</label>) : (
|
||||||
<CountsFieldTree resource={resource} params={draft} update={update}/>)}
|
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
|
||||||
|
update (key, value)
|
||||||
|
if (key === 'order') reorder (value)
|
||||||
|
}}/>)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||||
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
||||||
<label className="flex items-center gap-1">
|
<label className="flex items-center gap-1">
|
||||||
<input type="checkbox" checked={draft.get ('latest') === '1'}
|
<input type="checkbox" checked={draft.get ('latest') !== '0'}
|
||||||
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
||||||
{label ('latestOnly')}
|
{label ('latestOnly')}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import { countChildPaths, fieldsForCounts, selectedCountChildren,
|
import { DndContext, MouseSensor, TouchSensor, closestCenter,
|
||||||
selectedCountColumns } from '@/lib/counts'
|
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 { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { CountField, CountsResource } from '@/lib/counts'
|
import type { CountField, CountsResource } from '@/lib/counts'
|
||||||
|
|
||||||
|
type BranchProps = {
|
||||||
const FieldBranch = (
|
|
||||||
{ field, path, enabled, selected, select }: {
|
|
||||||
field: CountField
|
field: CountField
|
||||||
path: string
|
path: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
selected: Set<string>
|
selected: Set<string>
|
||||||
select: (path: string, checked: boolean) => void },
|
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, 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 checked = selected.has (path)
|
||||||
const control = <label className="inline-flex items-center gap-1">
|
const control = <label className="inline-flex items-center gap-1">
|
||||||
<input type="checkbox" checked={checked} disabled={!enabled}
|
<input type="checkbox" checked={checked} disabled={!enabled}
|
||||||
@@ -21,15 +35,79 @@ const FieldBranch = (
|
|||||||
onChange={event => select (path, event.target.checked)}/>
|
onChange={event => select (path, event.target.checked)}/>
|
||||||
{label (field.label)}
|
{label (field.label)}
|
||||||
</label>
|
</label>
|
||||||
return field.children ? (
|
return (
|
||||||
<details className="my-1">
|
<div ref={drop.setNodeRef} className={cn ('flex items-start gap-1',
|
||||||
|
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
|
||||||
|
<div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
|
||||||
|
style={drag.transform == null ? undefined : {
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
transform: `translate3d(${drag.transform.x}px, ${drag.transform.y}px, 0)` }}>
|
||||||
|
{fixed ? <span className="w-6 shrink-0"/> : (
|
||||||
|
<button type="button" ref={drag.setActivatorNodeRef}
|
||||||
|
className="mt-1 w-6 shrink-0 cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
{...drag.attributes} {...drag.listeners}
|
||||||
|
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||||
|
aria-label={message ('reorder', { column: message (field.label).text }).text}
|
||||||
|
lang={message ('reorder').language}
|
||||||
|
onClick={event => event.stopPropagation ()}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (!['ArrowUp', 'ArrowDown'].includes (event.key)) return
|
||||||
|
event.preventDefault ()
|
||||||
|
event.stopPropagation ()
|
||||||
|
const target = event.key === 'ArrowUp' ? previous : next
|
||||||
|
if (target != null) move (field.key, target)
|
||||||
|
}}><span aria-hidden>⠿</span></button>)}
|
||||||
|
{field.children ? (
|
||||||
|
<details className="my-1 min-w-0 flex-1">
|
||||||
<summary className="cursor-pointer">{control}</summary>
|
<summary className="cursor-pointer">{control}</summary>
|
||||||
<div className="ml-4 border-l pl-2 dark:border-slate-700">
|
<div className="ml-4 border-l pl-2 dark:border-slate-700">
|
||||||
{field.children.map (child => (
|
<FieldGroup fields={field.children} path={path} params={params}
|
||||||
<FieldBranch key={child.key} field={child} path={`${path}.${child.key}`}
|
enabled={enabled && checked} selected={selected} select={select}
|
||||||
enabled={enabled && checked} selected={selected} select={select}/>))}
|
update={update}/>
|
||||||
</div>
|
</div>
|
||||||
</details>) : <div className="my-1">{control}</div>
|
</details>) : <div className="my-1">{control}</div>}
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const FieldGroup = (
|
||||||
|
{ fields, path, params, enabled, selected, select, update }: {
|
||||||
|
fields: CountField[]
|
||||||
|
path: string
|
||||||
|
params: URLSearchParams
|
||||||
|
enabled: boolean
|
||||||
|
selected: Set<string>
|
||||||
|
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 (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||||
|
accessibility={{ announcements: {
|
||||||
|
onDragStart: () => 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 <FieldBranch key={field.key} field={field}
|
||||||
|
path={path ? `${path}.${field.key}` : field.key}
|
||||||
|
enabled={enabled} selected={selected} params={params}
|
||||||
|
select={select} update={update} move={move}
|
||||||
|
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
|
||||||
|
})}
|
||||||
|
</DndContext>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,9 +139,8 @@ const CountsFieldTree = (
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{fields.map (field => (
|
<FieldGroup fields={fields} path="" params={params} enabled selected={selected}
|
||||||
<FieldBranch key={field.key} field={field} path={field.key}
|
select={select} update={update}/>
|
||||||
enabled selected={selected} select={select}/>))}
|
|
||||||
<p className="mt-2 text-xs">{label ('nested.selection')}</p>
|
<p className="mt-2 text-xs">{label ('nested.selection')}</p>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PrefetchLink from '@/components/PrefetchLink'
|
|||||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||||
import { changeCountSort, readCountSort, selectedCountChildren,
|
import { changeCountSort, readCountSort, selectedCountChildren,
|
||||||
writeCountSort } from '@/lib/counts'
|
writeCountSort, orderedCountFields } from '@/lib/counts'
|
||||||
import { countsLogicalName, countsNestedExport,
|
import { countsLogicalName, countsNestedExport,
|
||||||
countsNestedOrder, countsNestedText } from '@/lib/countsDisplay'
|
countsNestedOrder, countsNestedText } from '@/lib/countsDisplay'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
@@ -25,7 +25,8 @@ const CountsNestedTable = (
|
|||||||
const { message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const selected = selectedCountChildren (params)
|
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 allSorts = readCountSort (params.get ('child_sort'))
|
||||||
const sorts = allSorts.filter (sort => sort.key.startsWith (`${path}/`))
|
const sorts = allSorts.filter (sort => sort.key.startsWith (`${path}/`))
|
||||||
.map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) }))
|
.map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) }))
|
||||||
@@ -50,21 +51,19 @@ const CountsNestedTable = (
|
|||||||
return display.text
|
return display.text
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<details className="w-full min-w-0 max-w-full" open>
|
<div className="counts-nested w-full min-w-0 max-w-full">
|
||||||
<summary className="cursor-pointer text-xs" title={message ('nested.toggle').text}>
|
|
||||||
<LocalisedText language={title.language}>{title.text}</LocalisedText> ({rows.length})
|
|
||||||
</summary>
|
|
||||||
{params.get ('nested_json') === '1' ? (
|
{params.get ('nested_json') === '1' ? (
|
||||||
<pre data-counts-scroll
|
<pre data-counts-scroll
|
||||||
className="mt-1 max-h-72 w-full overflow-auto whitespace-pre-wrap
|
aria-label={title.text} lang={title.language}
|
||||||
|
className="max-h-72 w-full overflow-auto whitespace-pre-wrap
|
||||||
break-words text-xs">
|
break-words text-xs">
|
||||||
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale), null, 2)}
|
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale), null, 2)}
|
||||||
</pre>) : (columns.length > 0 && rows.length > 0 && (
|
</pre>) : (columns.length > 0 && rows.length > 0 && (
|
||||||
<div data-counts-scroll
|
<div data-counts-scroll
|
||||||
className="relative isolate mt-1 max-h-72 w-full min-w-0 overflow-auto">
|
className="relative isolate max-h-72 w-full min-w-0 overflow-auto">
|
||||||
<CountsSelectableTable family={path} columns={columns.map (field => field.key)}
|
<CountsSelectableTable family={path} columns={columns.map (field => field.key)}
|
||||||
identity={`${path}:${params}`} name={title}
|
identity={`${path}:${params}`} name={title}
|
||||||
className="border text-xs dark:border-slate-700">
|
className="text-xs">
|
||||||
<thead className="sticky top-0 z-10 bg-background text-foreground
|
<thead className="sticky top-0 z-10 bg-background text-foreground
|
||||||
dark:bg-background dark:text-foreground"><tr>
|
dark:bg-background dark:text-foreground"><tr>
|
||||||
{columns.map (field => (
|
{columns.map (field => (
|
||||||
@@ -101,7 +100,7 @@ const CountsNestedTable = (
|
|||||||
</tr>))}</tbody>
|
</tr>))}</tbody>
|
||||||
</CountsSelectableTable>
|
</CountsSelectableTable>
|
||||||
</div>))}
|
</div>))}
|
||||||
</details>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
|||||||
import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
|
import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
|
||||||
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
|
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
|
||||||
import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay'
|
import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay'
|
||||||
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ const CountsResults = (
|
|||||||
) => {
|
) => {
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const dialogue = useDialogue ()
|
||||||
const summary = (params.get ('mode') ?? 'summary') === 'summary'
|
const summary = (params.get ('mode') ?? 'summary') === 'summary'
|
||||||
const axis = params.get ('axis') ?? 'month'
|
const axis = params.get ('axis') ?? 'month'
|
||||||
const columns = selectedCountColumns (resource, params)
|
const columns = selectedCountColumns (resource, params)
|
||||||
@@ -70,8 +72,22 @@ const CountsResults = (
|
|||||||
case 'thumbnail':
|
case 'thumbnail':
|
||||||
{
|
{
|
||||||
const url = countsImageURL (row.thumbnailBase)
|
const url = countsImageURL (row.thumbnailBase)
|
||||||
return url == null ? null : <PostThumbnailPreview url={url} className="h-10 w-16"
|
return url == null ? null : (
|
||||||
|
<button type="button" className="block" aria-label={message (
|
||||||
|
'shared:labels.thumbnail').text}
|
||||||
|
onClick={event => {
|
||||||
|
const trigger = event.currentTarget
|
||||||
|
void dialogue.form ({
|
||||||
|
title: label ('shared:labels.thumbnail'),
|
||||||
|
cancelText: label ('shared:dialogue.close'),
|
||||||
|
size: 'viewport',
|
||||||
|
body: () => <img src={url} alt={message ('shared:labels.thumbnail').text}
|
||||||
|
referrerPolicy="no-referrer" className="h-full w-full object-contain"/>
|
||||||
|
}).then (() => trigger.focus ({ preventScroll: true }))
|
||||||
|
}}>
|
||||||
|
<PostThumbnailPreview url={url} className="h-10 w-16"
|
||||||
alt={message ('shared:labels.thumbnail').text} referrerPolicy="no-referrer"/>
|
alt={message ('shared:labels.thumbnail').text} referrerPolicy="no-referrer"/>
|
||||||
|
</button>)
|
||||||
}
|
}
|
||||||
case 'url':
|
case 'url':
|
||||||
case 'thumbnail_base':
|
case 'thumbnail_base':
|
||||||
@@ -107,7 +123,8 @@ const CountsResults = (
|
|||||||
<CountsSortHeader field="count" label={message ('metric')}
|
<CountsSortHeader field="count" label={message ('metric')}
|
||||||
sorts={sorts} change={sort}/>
|
sorts={sorts} change={sort}/>
|
||||||
</> : columns.map (column => column === 'thumbnail'
|
</> : columns.map (column => column === 'thumbnail'
|
||||||
? <th key={column} className="relative px-2 py-1">
|
? <th key={column} data-counts-heading={message (countColumns[column]).text}
|
||||||
|
className="relative px-2 py-1">
|
||||||
<span className="sr-only">{label (countColumns[column])}</span>
|
<span className="sr-only">{label (countColumns[column])}</span>
|
||||||
<CountsResizeHandle field={column} label={message (countColumns[column])}/>
|
<CountsResizeHandle field={column} label={message (countColumns[column])}/>
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -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 { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { ClipboardEvent, ReactNode } from 'react'
|
import type { ClipboardEvent, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import type { CountsMenuAction } from '@/components/counts/CountsCellMenu'
|
||||||
|
|
||||||
type Point = { row: number; column: number }
|
type Point = { row: number; column: number }
|
||||||
type Selection = { identity: string; anchor: Point; end: Point }
|
type Selection = { identity: string; anchor: Point; end: Point }
|
||||||
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
|
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) =>
|
const cellAt = (table: HTMLTableElement, point: Point) =>
|
||||||
table.tBodies[0]?.rows[point.row]?.cells[point.column]
|
table.tBodies[0]?.rows[point.row]?.cells[point.column]
|
||||||
|
|
||||||
@@ -34,6 +34,9 @@ const CountsSelectableTable = (
|
|||||||
const touchArmed = useRef (false)
|
const touchArmed = useRef (false)
|
||||||
const touchPress = useRef<{ x: number; y: number
|
const touchPress = useRef<{ x: number; y: number
|
||||||
timer: ReturnType<typeof setTimeout> } | null> (null)
|
timer: ReturnType<typeof setTimeout> } | null> (null)
|
||||||
|
const [menu, setMenu] = useState<{ x: number; y: number; point: Point
|
||||||
|
identity: string } | null> (null)
|
||||||
|
const suppressClick = useRef (false)
|
||||||
const [stored, setStored] = useState<Selection | null> (null)
|
const [stored, setStored] = useState<Selection | null> (null)
|
||||||
const selection = stored?.identity === identity ? stored : null
|
const selection = stored?.identity === identity ? stored : null
|
||||||
const layout = useCountsTableLayout ()
|
const layout = useCountsTableLayout ()
|
||||||
@@ -87,10 +90,19 @@ const CountsSelectableTable = (
|
|||||||
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
|
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
|
||||||
column: (cell as HTMLTableCellElement).cellIndex }
|
column: (cell as HTMLTableCellElement).cellIndex }
|
||||||
}
|
}
|
||||||
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
|
const closeMenu = useCallback (() => {
|
||||||
if (bounds == null || root.current == null || document.activeElement !== root.current) return
|
setMenu (null)
|
||||||
event.preventDefault ()
|
root.current?.focus ({ preventScroll: true })
|
||||||
event.stopPropagation ()
|
}, [])
|
||||||
|
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[][] = []
|
const rows: string[][] = []
|
||||||
for (let row = bounds.top; row <= bounds.bottom; ++row)
|
for (let row = bounds.top; row <= bounds.bottom; ++row)
|
||||||
{
|
{
|
||||||
@@ -102,18 +114,50 @@ const CountsSelectableTable = (
|
|||||||
}
|
}
|
||||||
rows.push (values)
|
rows.push (values)
|
||||||
}
|
}
|
||||||
const text = rows.map (row => row.map (value => {
|
if (headers)
|
||||||
const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value
|
{
|
||||||
return `"${safe.replaceAll ('"', '""')}"`
|
const heading = root.current.tHead?.rows[0]
|
||||||
}).join ('\t')).join ('\r\n')
|
rows.unshift (Array.from ({ length: bounds.right - bounds.left + 1 }, (entry, index) => {
|
||||||
const html = '<table>' + rows.map (row => '<tr>' + row.map (value =>
|
void entry
|
||||||
`<td style="white-space:pre-wrap">${escapeHTML (
|
const cell = heading?.cells[bounds.left + index]
|
||||||
/^\s*[=+@-]/.test (value) ? `'${value}` : value)}</td>`).join ('')
|
return cell?.dataset.countsHeading ?? cell?.textContent ?? ''
|
||||||
+ '</tr>').join ('') + '</table>'
|
}))
|
||||||
event.clipboardData.setData ('text/plain', text)
|
}
|
||||||
|
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<HTMLTableElement>) => {
|
||||||
|
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
|
try
|
||||||
{
|
{
|
||||||
event.clipboardData.setData ('text/html', html)
|
event.clipboardData.setData ('text/html', payload.html)
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -140,10 +184,18 @@ const CountsSelectableTable = (
|
|||||||
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
|
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
|
||||||
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
|
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
|
||||||
onCopy={copy}
|
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 => {
|
onPointerDown={event => {
|
||||||
const element = event.target as Element
|
const element = event.target as Element
|
||||||
if (element.closest ('table') !== event.currentTarget
|
if (element.closest ('table') !== event.currentTarget) return
|
||||||
|| element.closest (interactive) != null || event.button !== 0) return
|
suppressClick.current = false
|
||||||
|
if (element.closest (interactive) != null || event.button !== 0) return
|
||||||
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
|
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
|
||||||
{
|
{
|
||||||
const rect = element.getBoundingClientRect ()
|
const rect = element.getBoundingClientRect ()
|
||||||
@@ -153,6 +205,7 @@ const CountsSelectableTable = (
|
|||||||
const point = pointFor (element)
|
const point = pointFor (element)
|
||||||
if (point == null) return
|
if (point == null) return
|
||||||
event.stopPropagation ()
|
event.stopPropagation ()
|
||||||
|
setMenu (null)
|
||||||
event.currentTarget.focus ({ preventScroll: true })
|
event.currentTarget.focus ({ preventScroll: true })
|
||||||
setStored ({ identity, anchor: event.shiftKey && selection != null
|
setStored ({ identity, anchor: event.shiftKey && selection != null
|
||||||
? selection.anchor : point, end: point })
|
? selection.anchor : point, end: point })
|
||||||
@@ -169,6 +222,8 @@ const CountsSelectableTable = (
|
|||||||
const pointerId = event.pointerId
|
const pointerId = event.pointerId
|
||||||
touchPress.current = { x: event.clientX, y: event.clientY,
|
touchPress.current = { x: event.clientX, y: event.clientY,
|
||||||
timer: setTimeout (() => {
|
timer: setTimeout (() => {
|
||||||
|
suppressClick.current = true
|
||||||
|
openMenu (point, event.clientX, event.clientY)
|
||||||
dragging.current = true
|
dragging.current = true
|
||||||
touchArmed.current = true
|
touchArmed.current = true
|
||||||
table.setPointerCapture (pointerId)
|
table.setPointerCapture (pointerId)
|
||||||
@@ -182,6 +237,10 @@ const CountsSelectableTable = (
|
|||||||
if (!dragging.current || event.buttons === 0) return
|
if (!dragging.current || event.buttons === 0) return
|
||||||
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY))
|
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY))
|
||||||
if (point == null) return
|
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 ()
|
event.stopPropagation ()
|
||||||
setStored (previous => previous?.identity !== identity ? previous : {
|
setStored (previous => previous?.identity !== identity ? previous : {
|
||||||
...previous, end: point })
|
...previous, end: point })
|
||||||
@@ -191,10 +250,27 @@ const CountsSelectableTable = (
|
|||||||
if (event.target === event.currentTarget) finish ()
|
if (event.target === event.currentTarget) finish ()
|
||||||
}}
|
}}
|
||||||
onContextMenu={event => {
|
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 => {
|
onKeyDown={event => {
|
||||||
if (event.target !== event.currentTarget) return
|
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')
|
if (event.key === 'Escape')
|
||||||
{
|
{
|
||||||
setStored (null)
|
setStored (null)
|
||||||
@@ -221,6 +297,8 @@ const CountsSelectableTable = (
|
|||||||
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
|
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
|
||||||
{children}
|
{children}
|
||||||
</table>
|
</table>
|
||||||
|
{menu?.identity === identity && <CountsCellMenu x={menu.x} y={menu.y}
|
||||||
|
act={menuAction} close={closeMenu}/>}
|
||||||
</CountsTableScope.Provider>)
|
</CountsTableScope.Provider>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ const CountsSortHeader = (
|
|||||||
direction: message (`sort.${state}`).text })
|
direction: message (`sort.${state}`).text })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
<th data-counts-heading={label.text}
|
||||||
|
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
||||||
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
||||||
<Button type="button" variant="ghost" size="sm"
|
<Button type="button" variant="ghost" size="sm"
|
||||||
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
|
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
|
||||||
|
|||||||
@@ -17,3 +17,29 @@
|
|||||||
.dark .counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
|
.dark .counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
|
||||||
@apply bg-slate-900 text-slate-100;
|
@apply bg-slate-900 text-slate-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.counts-grid > thead {
|
||||||
|
@apply bg-slate-200 text-slate-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counts-grid > thead > tr > th {
|
||||||
|
@apply border-b border-r border-slate-300 bg-slate-200 text-slate-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .counts-grid > thead,
|
||||||
|
.dark .counts-grid > thead > tr > th {
|
||||||
|
@apply border-slate-600 bg-slate-800 text-slate-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counts-grid > tbody > tr > td {
|
||||||
|
@apply border-r border-slate-200 dark:border-slate-700;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counts-grid > tbody > tr > td:has(> .counts-nested) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counts-nested {
|
||||||
|
@apply border-l-2 border-slate-300 dark:border-slate-600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Dialog,
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
import { DialogueContext } from '@/lib/dialogues/useDialogue'
|
import { DialogueContext } from '@/lib/dialogues/useDialogue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
@@ -183,13 +184,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
|
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
|
||||||
? active.options.closeLabel : undefined}
|
? active.options.closeLabel : undefined}
|
||||||
className={
|
className={cn (active.kind === 'form'
|
||||||
active.kind === 'form'
|
? 'flex max-h-[calc(100dvh-1rem)] flex-col overflow-hidden gap-0'
|
||||||
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
|
: 'px-6 pb-6 pt-7', active.kind === 'form' && (
|
||||||
? 'max-w-3xl'
|
active.options.size === 'viewport'
|
||||||
: 'max-w-lg' } flex-col overflow-hidden gap-0`
|
? 'h-[88dvh] w-[88vw] max-w-none'
|
||||||
: 'px-6 pb-6 pt-7'
|
: (active.options.size === 'large' ? 'max-w-3xl' : 'max-w-lg')))}
|
||||||
}
|
|
||||||
onEscapeKeyDown={event => {
|
onEscapeKeyDown={event => {
|
||||||
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
|
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
|
|||||||
@@ -102,6 +102,44 @@ export const fieldsForCounts = (resource: CountsResource): CountField[] =>
|
|||||||
children: nested[key] }))
|
children: nested[key] }))
|
||||||
|
|
||||||
|
|
||||||
|
export const orderedCountFields = (
|
||||||
|
fields: CountField[],
|
||||||
|
params: URLSearchParams,
|
||||||
|
parent = '',
|
||||||
|
): CountField[] => {
|
||||||
|
const prefix = parent ? `${parent}.` : ''
|
||||||
|
const saved = (params.get ('order') ?? '').split (',')
|
||||||
|
const keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
|
||||||
|
const ordered = keys.flatMap (key => {
|
||||||
|
const field = fields.find (field => `${prefix}${field.key}` === key)
|
||||||
|
return field == null ? [] : [field]
|
||||||
|
})
|
||||||
|
return parent ? ordered : [
|
||||||
|
...ordered.filter (field => field.key === 'thumbnail'),
|
||||||
|
...ordered.filter (field => field.key !== 'thumbnail')]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const reorderCountFields = (
|
||||||
|
fields: CountField[],
|
||||||
|
params: URLSearchParams,
|
||||||
|
parent: string,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): string => {
|
||||||
|
const ordered = orderedCountFields (fields, params, parent)
|
||||||
|
const first = ordered.findIndex (field => field.key === from)
|
||||||
|
const last = ordered.findIndex (field => field.key === to)
|
||||||
|
if (first < 0 || last < 0) return params.get ('order') ?? ''
|
||||||
|
const prefix = parent ? `${parent}.` : ''
|
||||||
|
const current = ordered.map (field => `${prefix}${field.key}`)
|
||||||
|
const next = [...current]
|
||||||
|
next.splice (last, 0, ...next.splice (first, 1))
|
||||||
|
return [...(params.get ('order') ?? '').split (',').filter (
|
||||||
|
key => key && !current.includes (key)), ...next].join (',')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const selectedCountColumns = (
|
export const selectedCountColumns = (
|
||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
@@ -110,7 +148,8 @@ export const selectedCountColumns = (
|
|||||||
columnsForCounts (resource).filter (key => ![
|
columnsForCounts (resource).filter (key => ![
|
||||||
'thumbnail', 'original_created_from', 'original_created_before']
|
'thumbnail', 'original_created_from', 'original_created_before']
|
||||||
.includes (key))
|
.includes (key))
|
||||||
return columnsForCounts (resource).filter (key => selected.includes (key))
|
return orderedCountFields (fieldsForCounts (resource), params)
|
||||||
|
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -164,14 +203,18 @@ export const normaliseCountsParams = (
|
|||||||
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
||||||
params.set ('axis', 'month')
|
params.set ('axis', 'month')
|
||||||
if (params.has ('columns'))
|
if (params.has ('columns'))
|
||||||
params.set ('columns', selectedCountColumns (resource, params).join (','))
|
{
|
||||||
|
const selected = new Set (params.get ('columns')!.split (','))
|
||||||
|
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
|
||||||
|
.join (','))
|
||||||
|
}
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
||||||
const result = new URLSearchParams (params)
|
const result = new URLSearchParams (params)
|
||||||
for (const key of ['columns', 'children', 'child_sort', 'nested_json'])
|
for (const key of ['columns', 'children', 'child_sort', 'nested_json', 'order'])
|
||||||
result.delete (key)
|
result.delete (key)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export type CountsClipboard = { text: string; html: string }
|
||||||
|
|
||||||
|
|
||||||
|
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
|
||||||
|
|
||||||
|
|
||||||
|
export const countsClipboard = (rows: string[][], headers = false): CountsClipboard => {
|
||||||
|
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
|
||||||
|
// Prefix formula-like text in both representations, including leading whitespace.
|
||||||
|
const safe = rows.map (row => row.map (value => /^\s*[=+@-]/.test (value)
|
||||||
|
? `'${value}` : value))
|
||||||
|
const text = safe.map (row => row.map (value => /[\t\r\n"]/.test (value)
|
||||||
|
? `"${value.replaceAll ('"', '""')}"` : value).join ('\t')).join ('\r\n')
|
||||||
|
const html = '<html><body><table>' + safe.map ((row, index) => '<tr>' + row.map (value => {
|
||||||
|
const tag = headers && index === 0 ? 'th' : 'td'
|
||||||
|
// Preserve time strings, leading zeroes, and user text instead of inferring dates/formulae.
|
||||||
|
const numeric = value.length <= 15 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test (value)
|
||||||
|
const style = numeric ? 'white-space:pre-wrap' : 'white-space:pre-wrap;mso-number-format:"\\@"'
|
||||||
|
return `<${tag} style="${escapeHTML (style)}">${escapeHTML (value)
|
||||||
|
.replace (/\r\n|\r|\n/g, '<br>')}</${tag}>`
|
||||||
|
}).join ('') + '</tr>').join ('') + '</table></body></html>'
|
||||||
|
return { text, html }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const writeCountsClipboard = async (payload: CountsClipboard): Promise<void> => {
|
||||||
|
if (navigator.clipboard?.write != null && typeof ClipboardItem !== 'undefined')
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await navigator.clipboard.write ([new ClipboardItem ({
|
||||||
|
'text/plain': new Blob ([payload.text], { type: 'text/plain' }),
|
||||||
|
'text/html': new Blob ([payload.html], { type: 'text/html' }) })])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Browsers without HTML clipboard support may still permit plain TSV.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (navigator.clipboard?.writeText != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await navigator.clipboard.writeText (payload.text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Try the synchronous copy event when clipboard permission is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The synchronous copy event fallback is also used on non-secure local origins.
|
||||||
|
const listener = (event: ClipboardEvent) => {
|
||||||
|
event.preventDefault ()
|
||||||
|
event.stopImmediatePropagation ()
|
||||||
|
event.clipboardData?.setData ('text/plain', payload.text)
|
||||||
|
event.clipboardData?.setData ('text/html', payload.html)
|
||||||
|
}
|
||||||
|
document.addEventListener ('copy', listener, true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!document.execCommand ('copy')) throw new Error ('Clipboard unavailable')
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
document.removeEventListener ('copy', listener, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fieldsForCounts, selectedCountChildren, sortedCountChildren } from '@/lib/counts'
|
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
|
||||||
|
sortedCountChildren } from '@/lib/counts'
|
||||||
import { translateUIMessage } from '@/lib/i18n'
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
|
import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -145,7 +146,8 @@ export const countsNestedExport = (
|
|||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
): unknown => {
|
): unknown => {
|
||||||
const selected = selectedCountChildren (params)
|
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}`))
|
||||||
if (columns.length === 0) return rows.map (() => ({ }))
|
if (columns.length === 0) return rows.map (() => ({ }))
|
||||||
return countsNestedOrder (rows, path, params, locale).map (row =>
|
return countsNestedOrder (rows, path, params, locale).map (row =>
|
||||||
Object.fromEntries (columns.map (field => [
|
Object.fromEntries (columns.map (field => [
|
||||||
@@ -167,7 +169,8 @@ export const countsNestedText = (
|
|||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
): string => {
|
): string => {
|
||||||
const selected = selectedCountChildren (params)
|
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}`))
|
||||||
if (columns.length === 0)
|
if (columns.length === 0)
|
||||||
return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
|
return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
|
||||||
return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
|
return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type DialogueFormOptions = { title: ReactNode
|
|||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
body: (controls: DialogueFormControls) => ReactNode
|
body: (controls: DialogueFormControls) => ReactNode
|
||||||
cancelText?: ReactNode
|
cancelText?: ReactNode
|
||||||
size?: 'default' | 'large' }
|
size?: 'default' | 'large' | 'viewport' }
|
||||||
|
|
||||||
type DialogueAPI =
|
type DialogueAPI =
|
||||||
{ confirm: (options: ConfirmOptions) => Promise<boolean>
|
{ confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
},
|
},
|
||||||
"nested": {
|
"nested": {
|
||||||
"toggle": "表内表を展開・折り畳み",
|
"toggle": "表内表を展開・折り畳み",
|
||||||
"selection": "親を OFF にしても子項目の選択は保持します。子項目がすべて OFF の表は件数のみ表示します。",
|
"selection": "親を OFF にしても子項目の選択は保持します。",
|
||||||
"noColumns": "出力項目が選択されていません。",
|
"noColumns": "出力項目が選択されていません。",
|
||||||
"asJSON": "内表を JSON 表示"
|
"asJSON": "内表を JSON 表示"
|
||||||
},
|
},
|
||||||
@@ -74,7 +74,12 @@
|
|||||||
"notRegistered": "名称未登録",
|
"notRegistered": "名称未登録",
|
||||||
"selection": {
|
"selection": {
|
||||||
"table": "集計・明細表",
|
"table": "集計・明細表",
|
||||||
"hint": "クリックでセル選択、ドラッグまたは Shift+クリックで範囲選択。タッチではセルを長押ししてドラッグ。Ctrl+C/Cmd+C でコピー。矢印キーで移動、Shift+矢印キーで範囲を拡張、Escape で解除。"
|
"hint": "クリックでセル選択、ドラッグまたは Shift+クリックで範囲選択。タッチではセルを長押ししてドラッグ。Ctrl+C/Cmd+C でコピー。矢印キーで移動、Shift+矢印キーで範囲を拡張、Escape で解除。",
|
||||||
|
"copy": "コピー",
|
||||||
|
"copyHeaders": "見出し付きコピー",
|
||||||
|
"row": "行を選択",
|
||||||
|
"column": "列を選択",
|
||||||
|
"all": "すべて選択"
|
||||||
},
|
},
|
||||||
"resize": {
|
"resize": {
|
||||||
"column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。"
|
"column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。"
|
||||||
@@ -83,5 +88,6 @@
|
|||||||
"sections": {
|
"sections": {
|
||||||
"range": "{{begin}}~{{end}}",
|
"range": "{{begin}}~{{end}}",
|
||||||
"separator": "、"
|
"separator": "、"
|
||||||
}
|
},
|
||||||
|
"reorder": "{{column}}:出力項目の並べ替え"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
const canonical = params.toString ()
|
const canonical = params.toString ()
|
||||||
const controlParams = new URLSearchParams (params)
|
const controlParams = new URLSearchParams (params)
|
||||||
controlParams.delete ('nested_json')
|
controlParams.delete ('nested_json')
|
||||||
|
controlParams.delete ('order')
|
||||||
const request = countsRequestParams (params)
|
const request = countsRequestParams (params)
|
||||||
const [open, setOpen] = useState (false)
|
const [open, setOpen] = useState (false)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||||
@@ -108,6 +109,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
<CountsControls key={`${resource}:${controlParams}`}
|
<CountsControls key={`${resource}:${controlParams}`}
|
||||||
resource={resource} applied={params}
|
resource={resource} applied={params}
|
||||||
open={open} setOpen={setOpen}
|
open={open} setOpen={setOpen}
|
||||||
|
reorder={value => update ('order', value)}
|
||||||
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
||||||
execute={next => {
|
execute={next => {
|
||||||
if (next.toString () === canonical) void refetch ()
|
if (next.toString () === canonical) void refetch ()
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする