このコミットが含まれているのは:
@@ -7,6 +7,7 @@ import { localiseTo } from '@/lib/locales'
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
import { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { isCountsPath } from '@/lib/routeLocales'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { currentRenderContext } from '@/lib/renderContext'
|
||||
@@ -53,6 +54,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
const setOverlay = useOverlayStore (s => s.setActive)
|
||||
|
||||
const doPrefetch = async () => {
|
||||
if (isCountsPath (new URL (url).pathname)) return true
|
||||
try
|
||||
{
|
||||
await prefetchForURL (qc, url)
|
||||
@@ -94,6 +96,12 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
|
||||
ev.preventDefault ()
|
||||
|
||||
if (isCountsPath (new URL (url).pathname))
|
||||
{
|
||||
navigate (path, { replace, ...(state && { state }) })
|
||||
return
|
||||
}
|
||||
|
||||
flushSync (() => {
|
||||
setOverlay (true)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import CountsConditionValues from '@/components/counts/CountsConditionValues'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsEventTypes, countsFieldLabel, countsFilterFields,
|
||||
useCountsDefinition }
|
||||
import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
|
||||
countsSetOperators, useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
@@ -33,12 +34,14 @@ const ConditionEditor = (
|
||||
})
|
||||
const { field, operator } = condition
|
||||
const operators = fields.find (item => item.key === field)?.operators ?? []
|
||||
const inputs = countsConditionInputs (fields.find (item => item.key === field), operator)
|
||||
useEffect (() => {
|
||||
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
||||
onSelect: () => save (condition) }])
|
||||
}, [controls, condition, initial, label, save])
|
||||
return <div className="space-y-2">
|
||||
<select value={field} aria-label={message ('field').text} className={inputClass ()}
|
||||
<select value={field} aria-label={message ('field').text}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
onChange={event => {
|
||||
const field = event.target.value
|
||||
const operator = fields.find (item => item.key === field)?.operators[0]
|
||||
@@ -47,17 +50,39 @@ const ConditionEditor = (
|
||||
{fields.filter (field => field.operators.length > 0).map (field => (
|
||||
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
|
||||
</select>
|
||||
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
|
||||
<select value={operator} aria-label={message ('operator').text}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
onChange={event => setCondition (previous => {
|
||||
const operator = event.target.value
|
||||
const next = { ...previous, operator }
|
||||
if (operator !== 'range') delete next.end
|
||||
if ((operator === 'like') !== (previous.operator === 'like')) delete next.value
|
||||
const choices = countsConditionInputs (fields.find (item => item.key === field), operator)
|
||||
if (countsSetOperators.includes (operator))
|
||||
{
|
||||
delete next.value
|
||||
delete next.end
|
||||
next.input = choices.includes (previous.input ?? 'single') ? previous.input : choices[0]
|
||||
}
|
||||
else
|
||||
{
|
||||
delete next.values
|
||||
delete next.input
|
||||
}
|
||||
return next
|
||||
})}>
|
||||
{operators.map (key => <option key={key} value={key}>
|
||||
{message (`operators.${key}`).text}</option>)}
|
||||
</select>
|
||||
<label className="block text-sm">{label ('pattern.input')}
|
||||
<select value={condition.input ?? inputs[0]}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
onChange={event => setCondition (previous => ({ ...previous,
|
||||
input: event.target.value as CountsCondition['input'] }))}>
|
||||
{inputs.map (input => <option key={input} value={input}>
|
||||
{message (`pattern.${input}`).text}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -99,7 +124,7 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
|
||||
const namespace = type === 'boolean' ? 'truthValues' : 'events'
|
||||
return <select value={condition[key] ?? ''} disabled={condition.blank}
|
||||
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
|
||||
aria-label={aria.text}
|
||||
lang={aria.language}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}>
|
||||
@@ -110,13 +135,13 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
</select>
|
||||
}
|
||||
if (type === 'datetime')
|
||||
return <DateTimeField precision="second" className="h-7 w-48 min-w-0 max-w-full py-1"
|
||||
return <DateTimeField precision="second" className="h-9 w-48 min-w-0 max-w-full py-1"
|
||||
value={condition[key] ?? ''} disabled={condition.blank}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
||||
return <input value={condition[key] ?? ''} disabled={condition.blank}
|
||||
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
||||
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}/>
|
||||
}
|
||||
@@ -130,24 +155,32 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
|
||||
aria-label={`${fieldName (condition.field)} ${message (
|
||||
`operators.${condition.operator}`).text}`}
|
||||
className="h-7 min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
|
||||
className="min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
|
||||
<span className="min-w-0 break-words">{fieldName (condition.field)}</span>
|
||||
{condition.operator !== 'range' && <span aria-hidden="true" className="shrink-0">
|
||||
{label (`operatorSymbols.${condition.operator}`)}</span>}
|
||||
</Button>
|
||||
{countsSetOperators.includes (condition.operator) ? (
|
||||
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
|
||||
condition={condition}
|
||||
field={metadata.find (field => field.key === condition.field)!}
|
||||
definition={definition} params={params}
|
||||
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
|
||||
sum + (position === index ? 0 : item.values?.length ?? 0), 0))}
|
||||
change={values => patch (index, { values })}/>) : (
|
||||
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
{valueInput (condition, index, 'value')}
|
||||
{condition.operator === 'range' && <>
|
||||
<span aria-hidden="true" className="shrink-0">{label ('operatorSymbols.range')}</span>
|
||||
{valueInput (condition, index, 'end')}
|
||||
</>}
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="inline-flex shrink-0 items-center gap-1">
|
||||
<label className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={condition.blank ?? false}
|
||||
onChange={event => patch (index, { blank: event.target.checked })}/>
|
||||
{label ('blank')}</label>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-1"
|
||||
<Button type="button" variant="ghost" size="sm" className="px-1"
|
||||
aria-label={message ('remove').text}
|
||||
onClick={() => write (conditions.filter ((_, position) => index !== position))}>
|
||||
×</Button>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsUseLatest } from '@/lib/counts'
|
||||
import { fetchCountsCandidates } from '@/lib/countsDefinitions'
|
||||
import { countsKeys } from '@/lib/queryKeys'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
|
||||
from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const CountsCandidatePicker = (
|
||||
{ field, definition, params, initial, maximum, controls, save }: {
|
||||
field: CountsFieldMetadata; params: URLSearchParams
|
||||
definition: CountsDefinition
|
||||
initial: (string | number | boolean)[]; maximum: number
|
||||
controls: DialogueFormControls; save: (values: string[]) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const locale = useLocale ()
|
||||
const [selected, setSelected] = useState (() => new Set (initial.map (String)))
|
||||
const [columns, setColumns] = useState (['id', 'name', 'category', 'kind'])
|
||||
const [draft, setDraft] = useState ('')
|
||||
const [search, setSearch] = useState ('')
|
||||
const [category, setCategory] = useState ('')
|
||||
const [page, setPage] = useState (1)
|
||||
const point = useRef (params.get ('execution_at'))
|
||||
const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
|
||||
for (const key of ['timezone', 'at', 'execution_at'])
|
||||
if (params.has (key)) request.set (key, params.get (key)!)
|
||||
if (countsUseLatest (params)) request.delete ('at')
|
||||
if (category) request.set ('category', category)
|
||||
const dynamic = field.candidateSource === 'tags'
|
||||
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
|
||||
queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
|
||||
queryFn: ({ signal }) => {
|
||||
const pinned = new URLSearchParams (request)
|
||||
if (point.current != null) pinned.set ('execution_at', point.current)
|
||||
return fetchCountsCandidates (pinned, locale, signal)
|
||||
}, retry: false })
|
||||
useEffect (() => {
|
||||
if (data != null) point.current = data.executionAt
|
||||
}, [data])
|
||||
useEffect (() => {
|
||||
controls.setActions ([{ label: label ('pattern.selection'),
|
||||
onSelect: () => save ([...selected]) }])
|
||||
}, [controls, label, save, selected])
|
||||
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
|
||||
value: choice.value, name: message (choice.label).text }))
|
||||
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
|
||||
row.name?.includes (search))
|
||||
const headings: Record<string, string> = {
|
||||
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
|
||||
kind: 'columns.kind' }
|
||||
const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
|
||||
const visible = available.filter (column => columns.includes (column))
|
||||
const categories = definition.filterFields?.find (field =>
|
||||
field.candidateSource === 'categories')?.choices ?? []
|
||||
const cell = (row: CountsCandidate, column: string) => {
|
||||
if (column === 'category') return row.category == null ? ''
|
||||
: message (`common:categories.${row.category}`).text
|
||||
if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
|
||||
return column === 'id' ? row.id : row.name
|
||||
}
|
||||
return <div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
|
||||
aria-label={message ('common:search').text} value={draft}
|
||||
onChange={event => setDraft (event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault ()
|
||||
setSearch (draft)
|
||||
setPage (1)
|
||||
}}/>
|
||||
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
|
||||
{label ('common:search')}</Button>
|
||||
</div>
|
||||
{dynamic && <select className={inputClass (false, 'h-9 w-full')}
|
||||
aria-label={message ('common:categoryLabel').text} value={category}
|
||||
onChange={event => { setCategory (event.target.value); setPage (1) }}>
|
||||
<option value=""/>
|
||||
{categories.map (choice => <option key={choice.value} value={choice.value}>
|
||||
{message (choice.label).text}</option>)}
|
||||
</select>}
|
||||
<fieldset className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<legend>{label ('pattern.columns')}</legend>
|
||||
{available.map (column => <label key={column} className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={columns.includes (column)} onChange={event =>
|
||||
setColumns (previous => event.target.checked ? [...previous, column]
|
||||
: previous.filter (value => value !== column))}/>{label (headings[column])}
|
||||
</label>)}
|
||||
</fieldset>
|
||||
<p className="text-sm">{label ('pattern.value')}{':'}
|
||||
{label (dynamic ? 'columns.resourceId' : 'common:categoryLabel')}
|
||||
</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setSelected (new Set ())}>
|
||||
{label ('shared:actions.reset')}</Button>
|
||||
{errorFlg && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
<div className="max-h-[40dvh] overflow-auto" aria-busy={fetching}>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead><tr><th className="p-2">{label ('pattern.selection')}</th>
|
||||
{visible.map (column => <th key={column} className="p-2">
|
||||
{label (headings[column])}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
|
||||
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
|
||||
aria-label={row.name ?? String (row.id ?? '')}
|
||||
disabled={!selected.has (row.value) && selected.size >= maximum}
|
||||
onChange={event => setSelected (previous => {
|
||||
const next = new Set (previous)
|
||||
if (event.target.checked) next.add (row.value)
|
||||
else next.delete (row.value)
|
||||
return next
|
||||
})}/></td>
|
||||
{visible.map (column => <td key={column} className="break-words p-2">
|
||||
{cell (row, column)}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{dynamic && <div className="flex items-center justify-between gap-2">
|
||||
<Button type="button" variant="outline" disabled={page === 1 || fetching}
|
||||
onClick={() => setPage (previous => previous - 1)}>
|
||||
{label ('common:pagination.previous')}</Button>
|
||||
<span>{page}</span>
|
||||
<Button type="button" variant="outline" disabled={!data?.more || fetching}
|
||||
onClick={() => setPage (previous => previous + 1)}>
|
||||
{label ('common:pagination.next')}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
export default CountsCandidatePicker
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
|
||||
from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const CountsConditionValues = (
|
||||
{ condition, field, definition, params, maximum, change }: {
|
||||
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
|
||||
params: URLSearchParams; maximum: number
|
||||
change: (values: (string | number | boolean)[]) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const dialogue = useDialogue ()
|
||||
const [draft, setDraft] = useState ('')
|
||||
const values = condition.values ?? []
|
||||
const pick = () => {
|
||||
void dialogue.form ({ title: label (field.candidateSource === 'tags'
|
||||
? 'pattern.link' : 'pattern.pattern'), size: 'large', footerLayout: 'inline',
|
||||
body: controls => <CountsCandidatePicker field={field} definition={definition}
|
||||
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
|
||||
}
|
||||
if (condition.input === 'list' || field.candidateSource === 'tags')
|
||||
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
|
||||
{label ('pattern.list')}{' ('}{values.length}{')'}</Button>
|
||||
const append = () => {
|
||||
if (draft === '' || values.length >= maximum
|
||||
|| values.some (value => String (value) === draft))
|
||||
return
|
||||
change ([...values, draft])
|
||||
setDraft ('')
|
||||
}
|
||||
const input = () => {
|
||||
if (field.type === 'datetime')
|
||||
return <DateTimeField precision="second" value={draft} disabled={condition.blank}
|
||||
className="h-9 w-48 min-w-0 max-w-full" aria-label={message ('value').text}
|
||||
onChange={value => setDraft (value ?? '')}/>
|
||||
if (field.type === 'boolean' || field.choices != null)
|
||||
{
|
||||
const choices = field.choices ?? ['true', 'false'].map (value => ({
|
||||
value, label: `counts:truthValues.${value}` }))
|
||||
return <select value={draft} disabled={condition.blank}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
|
||||
aria-label={message ('value').text} onChange={event => setDraft (event.target.value)}>
|
||||
<option value=""/>
|
||||
{choices.map (choice => <option key={choice.value} value={choice.value}>
|
||||
{message (choice.label).text}</option>)}
|
||||
</select>
|
||||
}
|
||||
return <input value={draft} disabled={condition.blank}
|
||||
type={field.type === 'integer' ? 'number' : 'text'} aria-label={message ('value').text}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
|
||||
onChange={event => setDraft (event.target.value)}/>
|
||||
}
|
||||
const display = (value: string | number | boolean) => {
|
||||
const choice = field.choices?.find (choice => choice.value === String (value))
|
||||
if (choice != null) return message (choice.label).text
|
||||
if (field.type === 'boolean') return message (`truthValues.${value}`).text
|
||||
return String (value)
|
||||
}
|
||||
return <div className="flex min-w-0 max-w-full flex-wrap items-center gap-1">
|
||||
{values.map ((value, index) => <span key={`${index}:${value}`}
|
||||
className="inline-flex items-center gap-1">
|
||||
{display (value)}<Button type="button" size="sm" variant="ghost"
|
||||
aria-label={message ('remove').text} disabled={condition.blank}
|
||||
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
|
||||
</span>)}
|
||||
{input ()}
|
||||
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|
||||
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
|
||||
<Button type="button" variant="ghost" disabled={condition.blank}
|
||||
onClick={() => change ([])}>{label ('shared:actions.reset')}</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
export default CountsConditionValues
|
||||
@@ -41,8 +41,8 @@ const CountsSummaryTable = (
|
||||
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
|
||||
countsSummaryValue (value, axis, index, data, locale)
|
||||
const aggregateLabel = (header: CountsPivotHeader) => message (
|
||||
header.kind === 'grand_total' ? (rows.length + cols.length === 1
|
||||
? 'singleTotal' : 'grandTotal') : 'subtotal').text
|
||||
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
|
||||
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
|
||||
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
|
||||
const levels = Math.max (1, cols.length)
|
||||
const headingLevels = cols.length === 0 ? 1 : levels * 2
|
||||
@@ -77,6 +77,8 @@ const CountsSummaryTable = (
|
||||
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
|
||||
const totals = new Map ((data.pivotTotals ?? []).map (cell => [
|
||||
JSON.stringify ([cell.row, cell.column]), cell.count]))
|
||||
const totalLabels = new Map ((data.pivotTotals ?? []).map (cell => [
|
||||
JSON.stringify ([cell.row, cell.column]), cell.labelKey]))
|
||||
const count = (row: CountsPivotHeader, column: CountsPivotHeader) =>
|
||||
(row.values.length === rows.length && column.values.length === cols.length)
|
||||
? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0
|
||||
@@ -101,16 +103,16 @@ const CountsSummaryTable = (
|
||||
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
||||
})
|
||||
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
|
||||
if (row.kind !== 'data') return 1
|
||||
if (row.values.length <= axis) return 1
|
||||
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
|
||||
const previous = rowHeaders[index - 1]
|
||||
if (previous?.kind === 'data'
|
||||
if (previous != null && previous.values.length > axis
|
||||
&& JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0
|
||||
let span = 1
|
||||
while (index + span < rowHeaders.length)
|
||||
{
|
||||
const next = rowHeaders[index + span]
|
||||
if (next.kind !== 'data'
|
||||
if (next.values.length <= axis
|
||||
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
|
||||
++span
|
||||
}
|
||||
@@ -246,8 +248,11 @@ const CountsSummaryTable = (
|
||||
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
||||
{rowLabels[index].map ((value, offset) => {
|
||||
const axis = offset - (numbered ? 1 : 0)
|
||||
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
|
||||
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
|
||||
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
|
||||
return span === 0 ? null : <td key={offset} rowSpan={span}
|
||||
colSpan={aggregate ? leading - axis : 1}
|
||||
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
|
||||
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
||||
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
||||
@@ -264,7 +269,11 @@ const CountsSummaryTable = (
|
||||
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||
? '' : undefined}
|
||||
className="border px-2 py-1 dark:border-slate-700">
|
||||
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
|
||||
<PrefetchLink to={drill (row, column)} aria-label={
|
||||
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
|
||||
: `${message (totalLabels.get (
|
||||
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
|
||||
{count (row, column)}</PrefetchLink>
|
||||
</td>)}
|
||||
</tr>)}</tbody>
|
||||
</CountsSelectableTable>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする