このコミットが含まれているのは:
2026-10-25 16:47:05 +09:00
コミット e71663bd22
16個のファイルの変更、523行の追加、89行の削除
+18 -11
ファイルの表示
@@ -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?
+88
ファイルの表示
@@ -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
+22 -5
ファイルの表示
@@ -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>
+97 -20
ファイルの表示
@@ -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 = {
field: CountField
path: string
enabled: boolean
selected: Set<string>
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 = ( const FieldBranch = (
{ field, path, enabled, selected, select }: { { field, path, enabled, selected, params, select, update, move, previous, next }: BranchProps,
field: CountField
path: string
enabled: boolean
selected: Set<string>
select: (path: string, checked: boolean) => void },
) => { ) => {
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',
<summary className="cursor-pointer">{control}</summary> drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
<div className="ml-4 border-l pl-2 dark:border-slate-700"> <div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
{field.children.map (child => ( style={drag.transform == null ? undefined : {
<FieldBranch key={child.key} field={child} path={`${path}.${child.key}`} position: 'relative', zIndex: 1,
enabled={enabled && checked} selected={selected} select={select}/>))} 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>
<div className="ml-4 border-l pl-2 dark:border-slate-700">
<FieldGroup fields={field.children} path={path} params={params}
enabled={enabled && checked} selected={selected} select={select}
update={update}/>
</div>
</details>) : <div className="my-1">{control}</div>}
</div> </div>
</details>) : <div className="my-1">{control}</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>
</>) </>)
} }
+9 -10
ファイルの表示
@@ -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>)
} }
+20 -3
ファイルの表示
@@ -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 : (
alt={message ('shared:labels.thumbnail').text} referrerPolicy="no-referrer"/> <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"/>
</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>
+100 -22
ファイルの表示
@@ -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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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>)
} }
+2 -1
ファイルの表示
@@ -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"
+26
ファイルの表示
@@ -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;
}
+7 -7
ファイルの表示
@@ -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 ()
+46 -3
ファイルの表示
@@ -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
} }
+70
ファイルの表示
@@ -0,0 +1,70 @@
export type CountsClipboard = { text: string; html: string }
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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)
}
}
+6 -3
ファイルの表示
@@ -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 => {
+1 -1
ファイルの表示
@@ -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>
+9 -3
ファイルの表示
@@ -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}}:出力項目の並べ替え"
} }
+2
ファイルの表示
@@ -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 ()