このコミットが含まれているのは:
@@ -1,28 +1,31 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
|
||||
import CountsStateFilters from '@/components/counts/CountsStateFilters'
|
||||
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isCountsState, normaliseCountsParams } from '@/lib/counts'
|
||||
import { countsSummaryFields } from '@/lib/countsState'
|
||||
import { fetchTagByName } from '@/lib/tags'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { CountsResource } from '@/lib/counts'
|
||||
|
||||
|
||||
const CountsControls = (
|
||||
{ resource, applied, open, setOpen, execute, displayJSON, reorder }: {
|
||||
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
|
||||
resource: CountsResource
|
||||
applied: URLSearchParams
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
execute: (params: URLSearchParams) => void
|
||||
displayJSON: (enabled: boolean) => void
|
||||
reorder: (value: string) => void },
|
||||
reorder: (value: string) => void
|
||||
actions?: ReactNode },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const locale = useLocale ()
|
||||
@@ -43,13 +46,18 @@ const CountsControls = (
|
||||
}, [order, nestedJSON])
|
||||
const [error, setError] = useState (false)
|
||||
const [pending, setPending] = useState (false)
|
||||
const mode = draft.get ('mode') ?? 'state'
|
||||
const mode = draft.get ('mode') ?? 'summary'
|
||||
const state = isCountsState (draft)
|
||||
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user']
|
||||
const update = (key: string, value: string) => {
|
||||
setDraft (previous => {
|
||||
const next = new URLSearchParams (previous)
|
||||
if (key === 'mode') next.delete ('sort')
|
||||
if (value || key === 'columns' || key === 'children') next.set (key, value)
|
||||
if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort')
|
||||
if (key === 'axis' || key === 'basis') next.delete ('summary_columns')
|
||||
if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags')
|
||||
next.set ('axis', 'month')
|
||||
if (value || ['columns', 'children', 'summary_columns'].includes (key))
|
||||
next.set (key, value)
|
||||
else next.delete (key)
|
||||
return next
|
||||
})
|
||||
@@ -60,7 +68,7 @@ const CountsControls = (
|
||||
try
|
||||
{
|
||||
const next = new URLSearchParams (draft)
|
||||
for (const key of (state || resource === 'posts') ? ['tag'] : ['resource_id'])
|
||||
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
|
||||
{
|
||||
const value = next.get (key)?.trim () ?? ''
|
||||
if (value && !/^[1-9][0-9]*$/.test (value))
|
||||
@@ -99,14 +107,16 @@ const CountsControls = (
|
||||
<select className={inputClass (false, 'w-auto py-1')}
|
||||
aria-label={message ('mode').text} lang={message ('mode').language}
|
||||
value={mode} onChange={event => update ('mode', event.target.value)}>
|
||||
{[['state', 'summary'], ['trend', 'state.trend'], ['snapshot', 'state.details'],
|
||||
['summary', 'state.history'], ['details', 'state.historyDetails']].map (
|
||||
([value, key]) => <option key={value} value={value} lang={message (key).language}>
|
||||
{message (key).text}</option>)}
|
||||
{['summary', 'details'].map (value => (
|
||||
<option key={value} value={value} lang={message (value).language}>
|
||||
{message (value).text}</option>))}
|
||||
</select>
|
||||
{actions}
|
||||
</div>
|
||||
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
||||
<div id={id} hidden={!open} className="border-b py-2 dark:border-slate-700">
|
||||
<div id={id} hidden={!open}
|
||||
className="max-h-[40dvh] overflow-y-auto overscroll-contain border-b py-2
|
||||
dark:border-slate-700 md:max-h-[45dvh]">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<fieldset className="min-w-0" data-counts-output>
|
||||
<legend className="mb-2 font-semibold">{label ('output')}</legend>
|
||||
@@ -118,33 +128,59 @@ const CountsControls = (
|
||||
}}/>
|
||||
{label ('nested.asJSON')}
|
||||
</label>
|
||||
{(mode === 'state' || mode === 'trend') ? (
|
||||
<p className="text-sm">{label (
|
||||
mode === 'trend' ? 'state.trend' : 'state.list')}</p>) : (mode === 'summary' ? (
|
||||
<label className="flex flex-wrap items-center gap-2">
|
||||
{label ('axis')}
|
||||
<select className={inputClass (false, 'w-auto py-1')}
|
||||
value={draft.get ('axis') ?? 'month'}
|
||||
aria-label={message ('axis').text} lang={message ('axis').language}
|
||||
onChange={event => update ('axis', event.target.value)}>
|
||||
{['month', 'user'].map (axis => (
|
||||
<option key={axis} value={axis} lang={message (`axes.${axis}`).language}>
|
||||
{message (`axes.${axis}`).text}</option>))}
|
||||
</select>
|
||||
</label>) : (
|
||||
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
|
||||
<FormField label={label ('measure')}>
|
||||
{() => <select value={state ? 'state' : 'history'}
|
||||
className={inputClass (false, 'w-auto py-1')}
|
||||
aria-label={message ('measure').text} lang={message ('measure').language}
|
||||
onChange={event => update ('basis', event.target.value)}>
|
||||
<option value="state" lang={message (`state.totals.${resource}`).language}>
|
||||
{message (`state.totals.${resource}`).text}</option>
|
||||
<option value="history" lang={message ('metric').language}>
|
||||
{message ('metric').text}</option>
|
||||
</select>}
|
||||
</FormField>
|
||||
{mode === 'summary' && <label className="my-2 flex flex-wrap items-center gap-2">
|
||||
{label ('axis')}
|
||||
<select className={inputClass (false, 'w-auto py-1')}
|
||||
value={draft.get ('axis')
|
||||
?? ((state && resource === 'posts') ? 'tags' : 'month')}
|
||||
aria-label={message ('axis').text} lang={message ('axis').language}
|
||||
onChange={event => update ('axis', event.target.value)}>
|
||||
{axes.map (axis => <option key={axis} value={axis}
|
||||
lang={message (`axes.${axis}`).language}>
|
||||
{message (`axes.${axis}`).text}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
<CountsFieldTree resource={resource} params={draft}
|
||||
fields={mode === 'summary' ? countsSummaryFields (resource, draft) : undefined}
|
||||
selectionKey={mode === 'summary' ? 'summary_columns' : 'columns'}
|
||||
parent={mode === 'summary' ? 'summary' : ''} update={(key, value) => {
|
||||
update (key, value)
|
||||
if (key === 'order') reorder (value)
|
||||
}}/>))}
|
||||
}}/>
|
||||
</fieldset>
|
||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
||||
{state ? <CountsStateFilters params={draft} update={update}/> : <>
|
||||
<label className="flex items-center gap-1">
|
||||
<FormField label={label ('state.at')}>
|
||||
{() => <DateTimeField value={draft.get ('at') ?? ''}
|
||||
aria-label={message ('state.at').text} lang={message ('state.at').language}
|
||||
onChange={value => update ('at', value ?? '')}/>}
|
||||
</FormField>
|
||||
{!state && <label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={draft.get ('latest') !== '0'}
|
||||
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
||||
{label ('latestOnly')}
|
||||
</label>
|
||||
</label>}
|
||||
{state && resource === 'posts' && <FormField label={label ('columns.kind')}>
|
||||
{() => <select value={draft.get ('kind') ?? 'internal'}
|
||||
className={inputClass (false, 'w-auto py-1')}
|
||||
aria-label={message ('columns.kind').text} lang={message ('columns.kind').language}
|
||||
onChange={event => update ('kind', event.target.value)}>
|
||||
{['internal', 'external'].map (kind => <option key={kind} value={kind}
|
||||
lang={message (`kinds.${kind}`).language}>
|
||||
{message (`kinds.${kind}`).text}</option>)}
|
||||
</select>}
|
||||
</FormField>}
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{['from', 'before'].map (key => (
|
||||
<FormField key={key} label={label (`fields.${key}`)}>
|
||||
@@ -167,7 +203,6 @@ const CountsControls = (
|
||||
className={inputClass (false, 'py-1')}/>}
|
||||
</FormField>))}
|
||||
</div>
|
||||
</>}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DndContext, MouseSensor, TouchSensor, closestCenter,
|
||||
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
||||
|
||||
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
|
||||
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
||||
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
@@ -36,7 +36,8 @@ const FieldBranch = (
|
||||
{label (field.label)}
|
||||
</label>
|
||||
return (
|
||||
<div ref={drop.setNodeRef} className={cn ('flex items-start gap-1',
|
||||
<div ref={drop.setNodeRef} className={cn ('max-w-full rounded border border-slate-200 px-1',
|
||||
'dark:border-slate-700',
|
||||
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 : {
|
||||
@@ -60,7 +61,7 @@ const FieldBranch = (
|
||||
{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">
|
||||
<div className="ml-2 border-l pl-1 dark:border-slate-700">
|
||||
<FieldGroup fields={field.children} path={path} params={params}
|
||||
enabled={enabled && checked} selected={selected} select={select}
|
||||
update={update}/>
|
||||
@@ -99,6 +100,7 @@ const FieldGroup = (
|
||||
onDragEnd={({ active, over }) => {
|
||||
if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
|
||||
}}>
|
||||
<div className="flex flex-wrap items-start gap-1">
|
||||
{ordered.map (field => {
|
||||
const position = movable.findIndex (item => item.key === field.key)
|
||||
return <FieldBranch key={field.key} field={field}
|
||||
@@ -107,28 +109,36 @@ const FieldGroup = (
|
||||
select={select} update={update} move={move}
|
||||
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
|
||||
})}
|
||||
</div>
|
||||
</DndContext>)
|
||||
}
|
||||
|
||||
|
||||
const CountsFieldTree = (
|
||||
{ resource, params, update }: {
|
||||
{ resource, params, update, fields: suppliedFields,
|
||||
selectionKey = 'columns', parent = '' }: {
|
||||
resource: CountsResource
|
||||
fields?: CountField[]
|
||||
selectionKey?: string
|
||||
parent?: string
|
||||
params: URLSearchParams
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const { label } = useUITranslation ('counts')
|
||||
const fields = fieldsForCounts (resource)
|
||||
const roots = selectedCountColumns (resource, params)
|
||||
const fields = suppliedFields ?? fieldsForCounts (resource)
|
||||
const roots = suppliedFields == null ? selectedCountColumns (resource, params)
|
||||
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
|
||||
const selected = selectedCountChildren (params)
|
||||
for (const root of roots) selected.add (root)
|
||||
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
|
||||
for (const root of roots) selected.add (rootPath (root))
|
||||
const select = (path: string, checked: boolean) => {
|
||||
if (!path.includes ('.'))
|
||||
const root = fields.find (field => rootPath (field.key) === path)
|
||||
if (root != null)
|
||||
{
|
||||
const next = new Set<string> (roots)
|
||||
if (checked) next.add (path)
|
||||
else next.delete (path)
|
||||
update ('columns', fields.filter (field => next.has (field.key))
|
||||
if (checked) next.add (root.key)
|
||||
else next.delete (root.key)
|
||||
update (selectionKey, fields.filter (field => next.has (field.key))
|
||||
.map (field => field.key).join (','))
|
||||
return
|
||||
}
|
||||
@@ -139,9 +149,9 @@ const CountsFieldTree = (
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FieldGroup fields={fields} path="" params={params} enabled selected={selected}
|
||||
<FieldGroup fields={fields} path={parent} params={params} enabled selected={selected}
|
||||
select={select} update={update}/>
|
||||
<p className="mt-2 text-xs">{label ('nested.selection')}</p>
|
||||
{suppliedFields == null && <p className="mt-2 text-xs">{label ('nested.selection')}</p>}
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,34 +27,11 @@ const CountsResults = (
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const locale = useLocale ()
|
||||
const dialogue = useDialogue ()
|
||||
const summary = (params.get ('mode') ?? 'summary') === 'summary'
|
||||
const axis = params.get ('axis') ?? 'month'
|
||||
const columns = selectedCountColumns (resource, params)
|
||||
const fields = fieldsForCounts (resource)
|
||||
const sorts = readCountSort (params.get ('sort') ?? (summary ? 'group:asc' : 'created_at:desc'))
|
||||
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
|
||||
const sort = (key: string, additive: boolean) =>
|
||||
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||
const drillDown = (key: string | number | null): string => {
|
||||
const next = new URLSearchParams (params)
|
||||
next.set ('mode', 'details')
|
||||
next.delete ('page')
|
||||
next.delete ('sort')
|
||||
if (axis === 'month')
|
||||
{
|
||||
const first = `${key}-01T00:00Z`
|
||||
const date = new Date (first)
|
||||
date.setUTCMonth (date.getUTCMonth () + 1)
|
||||
const last = date.toISOString ().slice (0, 16) + 'Z'
|
||||
const from = next.get ('from')
|
||||
const before = next.get ('before')
|
||||
next.set ('from',
|
||||
(from != null && Date.parse (from) > Date.parse (first)) ? from : first)
|
||||
next.set ('before',
|
||||
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
|
||||
}
|
||||
else next.set ('user_id', String (key ?? 'none'))
|
||||
return `?${next}`
|
||||
}
|
||||
const cell = (row: CountsVersion, column: CountColumn) => {
|
||||
const field = fields.find (field => field.key === column)!
|
||||
const value = countsCellText (row, column, locale)
|
||||
@@ -109,20 +86,15 @@ const CountsResults = (
|
||||
}
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
{!summary && columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||
<CountsSelectableTable family={summary ? 'summary' : resource}
|
||||
columns={summary ? ['group', 'count'] : columns}
|
||||
{columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||
<CountsSelectableTable family={resource}
|
||||
columns={columns}
|
||||
identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${
|
||||
(data.versions ?? []).map (row => row.id)}`}
|
||||
className="text-left text-sm">
|
||||
<thead className="sticky top-0 z-20 bg-background text-foreground
|
||||
dark:bg-background dark:text-foreground"><tr>
|
||||
{summary ? <>
|
||||
<CountsSortHeader field="group" label={message (`axes.${axis}`)}
|
||||
sorts={sorts} change={sort}/>
|
||||
<CountsSortHeader field="count" label={message ('metric')}
|
||||
sorts={sorts} change={sort}/>
|
||||
</> : columns.map (column => column === 'thumbnail'
|
||||
{columns.map (column => column === 'thumbnail'
|
||||
? <th key={column} data-counts-heading={message (countColumns[column]).text}
|
||||
className="relative px-2 py-1">
|
||||
<span className="sr-only">{label (countColumns[column])}</span>
|
||||
@@ -134,18 +106,7 @@ const CountsResults = (
|
||||
field => field.key === column)?.children != null}/>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{summary ? (data.groups ?? []).map (group => (
|
||||
<tr key={String (group.key)} className="border-b dark:border-slate-700">
|
||||
<td className="break-words border-b px-2 py-1 dark:border-slate-700"
|
||||
data-counts-copy={group.key == null ? message (
|
||||
'history:bot').text : group.name ?? String (group.key)}>
|
||||
<PrefetchLink to={drillDown (group.key)}>
|
||||
{group.key == null
|
||||
? label ('history:bot') : group.name ?? String (group.key)}
|
||||
</PrefetchLink></td>
|
||||
<td className="border-b px-2 py-1 dark:border-slate-700"
|
||||
data-counts-copy={String (group.count)}>{group.count}</td>
|
||||
</tr>)) : (data.versions ?? []).map (row => (
|
||||
{(data.versions ?? []).map (row => (
|
||||
<tr key={row.id} className="border-b dark:border-slate-700">
|
||||
{columns.map (column => (
|
||||
<td key={column}
|
||||
@@ -158,7 +119,7 @@ const CountsResults = (
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</CountsSelectableTable>)}
|
||||
{(summary ? data.groups?.length ?? 0 : data.versions?.length ?? 0) === 0
|
||||
{(data.versions?.length ?? 0) === 0
|
||||
&& <p className="p-2">{label ('empty')}</p>}
|
||||
</div>)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
|
||||
const CountsStateFilters = (
|
||||
{ params, update }: {
|
||||
params: URLSearchParams
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const trend = params.get ('mode') === 'trend'
|
||||
const dates = trend ? ['from', 'before'] : ['at']
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{dates.map (key => {
|
||||
const text = key === 'at' ? 'state.at' : `fields.${key}`
|
||||
return <FormField key={key} label={label (text)}>
|
||||
{() => <DateTimeField value={params.get (key) ?? ''}
|
||||
aria-label={message (text).text} lang={message (text).language}
|
||||
onChange={value => update (key, value ?? '')}/>}
|
||||
</FormField>
|
||||
})}
|
||||
</div>
|
||||
{trend && <FormField label={label ('state.grain')}>
|
||||
{() => <select value={params.get ('grain') ?? 'month'}
|
||||
className={inputClass (false, 'py-1')}
|
||||
aria-label={message ('state.grain').text}
|
||||
onChange={event => update ('grain', event.target.value)}>
|
||||
{['day', 'month', 'year'].map (grain => (
|
||||
<option key={grain} value={grain} lang={message (`state.grains.${grain}`).language}>
|
||||
{message (`state.grains.${grain}`).text}</option>))}
|
||||
</select>}
|
||||
</FormField>}
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<FormField label={label ('columns.kind')}>
|
||||
{() => <select value={params.get ('kind') ?? 'internal'}
|
||||
className={inputClass (false, 'py-1')} aria-label={message ('columns.kind').text}
|
||||
onChange={event => update ('kind', event.target.value)}>
|
||||
{['internal', 'external'].map (kind => (
|
||||
<option key={kind} value={kind} lang={message (`kinds.${kind}`).language}>
|
||||
{message (`kinds.${kind}`).text}</option>))}
|
||||
</select>}
|
||||
</FormField>
|
||||
<FormField label={label ('fields.tag')}>
|
||||
{() => <input value={params.get ('tag') ?? ''} required={trend}
|
||||
aria-label={message ('fields.tag').text} className={inputClass (false, 'py-1')}
|
||||
onChange={event => update ('tag', event.target.value)}/>}
|
||||
</FormField>
|
||||
<FormField label={label ('fields.limit')}>
|
||||
{() => <input type="number" min={1} max={100}
|
||||
value={params.get ('limit') ?? '20'} aria-label={message ('fields.limit').text}
|
||||
className={inputClass (false, 'py-1')}
|
||||
onChange={event => update ('limit', event.target.value)}/>}
|
||||
</FormField>
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
export default CountsStateFilters
|
||||
@@ -1,56 +0,0 @@
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import { changeCountSort, readCountSort, writeCountSort } from '@/lib/counts'
|
||||
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountsResult } from '@/lib/counts'
|
||||
|
||||
|
||||
const CountsStateResults = (
|
||||
{ data, params, pending, update }: {
|
||||
data: CountsResult
|
||||
params: URLSearchParams
|
||||
pending: boolean
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const trend = params.get ('mode') === 'trend'
|
||||
const fields = countsStateFields (trend)
|
||||
const sorts = readCountSort (params.get ('sort') ?? (trend ? 'period:asc' : 'count:desc'))
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
<CountsSelectableTable family={trend ? 'state_trend' : 'state_tags'}
|
||||
columns={fields.map (field => field.key)} identity={params.toString ()}
|
||||
className="text-left text-sm">
|
||||
<thead className="sticky top-0 z-20"><tr>
|
||||
{fields.map (field => (
|
||||
<CountsSortHeader key={field.key} field={field.key} label={message (field.label)}
|
||||
sorts={sorts} change={(key, additive) => update (
|
||||
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))}
|
||||
</tr></thead>
|
||||
<tbody>{(data.stateRows ?? []).map (row => (
|
||||
<tr key={`${row.kind}:${row.tagId}:${row.at}`}>
|
||||
{fields.map (field => {
|
||||
const value = countsStateCell (row, field.key, locale)
|
||||
return <td key={field.key} data-counts-copy={value}
|
||||
className="break-words border-b px-2 py-1 dark:border-slate-700">
|
||||
{field.key === 'count' && row.count != null
|
||||
? <PrefetchLink to={countsStateDrill (params, row.at, 'posts', row)}>
|
||||
{value}</PrefetchLink>
|
||||
: <LocalisedText language={message (field.label).language}>
|
||||
{value}</LocalisedText>}
|
||||
</td>
|
||||
})}
|
||||
</tr>))}</tbody>
|
||||
</CountsSelectableTable>
|
||||
{data.stateRows?.length === 0 && <p className="p-2">{label ('empty')}</p>}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
export default CountsStateResults
|
||||
@@ -0,0 +1,63 @@
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
||||
import { countsSummaryCell, countsSummaryDrill, countsSummaryRows,
|
||||
selectedCountsSummaryFields } from '@/lib/countsState'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountsResource, CountsResult } from '@/lib/counts'
|
||||
|
||||
|
||||
const CountsSummaryResults = (
|
||||
{ resource, data, params, pending, update }: {
|
||||
resource: CountsResource
|
||||
data: CountsResult
|
||||
params: URLSearchParams
|
||||
pending: boolean
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const fields = selectedCountsSummaryFields (resource, params)
|
||||
const rows = countsSummaryRows (data)
|
||||
const sorts = readCountSort (params.get ('sort') ?? (
|
||||
params.get ('axis') === 'tags' ? 'count:desc' : 'group:asc'))
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
{fields.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||
<CountsSelectableTable family={`summary_${params.get ('axis')}`}
|
||||
columns={fields.map (field => field.key)}
|
||||
identity={`${countsRequestParams (params)}:${fields.map (field => field.key)}`}
|
||||
className="text-left text-sm">
|
||||
<thead className="sticky top-0 z-20"><tr>
|
||||
{fields.map (field => (
|
||||
<CountsSortHeader key={field.key} field={field.key}
|
||||
label={message (field.label)}
|
||||
sorts={sorts} change={(key, additive) => update (
|
||||
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))}
|
||||
</tr></thead>
|
||||
<tbody>{rows.map (row => (
|
||||
<tr key={String (row.key)}>
|
||||
{fields.map (field => {
|
||||
const value = countsSummaryCell (row, field.key, locale)
|
||||
const content = value.language == null ? value.text : (
|
||||
<LocalisedText language={value.language}>{value.text}</LocalisedText>)
|
||||
return <td key={field.key} data-counts-copy={value.text}
|
||||
className="break-words border-b px-2 py-1 dark:border-slate-700">
|
||||
{(['count', 'group'].includes (field.key) && row.count != null)
|
||||
? <PrefetchLink to={countsSummaryDrill (resource, params, row)}>
|
||||
{content}</PrefetchLink>
|
||||
: content}
|
||||
</td>
|
||||
})}
|
||||
</tr>))}</tbody>
|
||||
</CountsSelectableTable>)}
|
||||
{rows.length === 0 && <p className="p-2">{label ('empty')}</p>}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
export default CountsSummaryResults
|
||||
新しいイシューから参照
ユーザーをブロックする