このコミットが含まれているのは:
@@ -167,13 +167,13 @@ class CountsTableExport
|
|||||||
cols = @query.axes.columns
|
cols = @query.axes.columns
|
||||||
leading = [row_count, cols.any? ? 1 : 0].max
|
leading = [row_count, cols.any? ? 1 : 0].max
|
||||||
columns = page.fetch(:column_headers)
|
columns = page.fetch(:column_headers)
|
||||||
Array.new([cols.length, 1].max) do |level|
|
headings = Array.new([cols.length, 1].max) do |level|
|
||||||
prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' }
|
prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' }
|
||||||
prefix = [''] if row_count.zero? && cols.any?
|
prefix = [''] if row_count.zero? && cols.any?
|
||||||
prefix + columns.each_with_index.map do |header, index|
|
prefix + columns.each_with_index.map do |header, index|
|
||||||
values = header[:values]
|
values = header[:values]
|
||||||
if cols.empty?
|
if cols.empty?
|
||||||
header[:kind] == 'data' ? @labels.fetch('count', '') : total_label(header)
|
@labels.fetch('count', '')
|
||||||
elsif level == values.length && header[:kind] != 'data'
|
elsif level == values.length && header[:kind] != 'data'
|
||||||
total_label(header)
|
total_label(header)
|
||||||
elsif level < values.length
|
elsif level < values.length
|
||||||
@@ -188,6 +188,25 @@ class CountsTableExport
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
return headings if cols.empty?
|
||||||
|
|
||||||
|
cols.each_index.flat_map do |level|
|
||||||
|
prefix = Array.new(leading, '')
|
||||||
|
prefix = headings.first.first(leading) if level.zero?
|
||||||
|
names = columns.each_with_index.map do |header, index|
|
||||||
|
next '' if header[:values].length < level
|
||||||
|
|
||||||
|
previous = index.positive? ? columns[index - 1][:values] : nil
|
||||||
|
if (previous && previous.length >= level &&
|
||||||
|
previous.first(level) == header[:values].first(level))
|
||||||
|
''
|
||||||
|
else
|
||||||
|
@headers[row_count + level].to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
headings[level] = Array.new(leading, '') + headings[level].drop(leading) if level.zero?
|
||||||
|
[prefix + names, headings[level]]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def pivot_cell(row, column, page)
|
def pivot_cell(row, column, page)
|
||||||
|
|||||||
@@ -40,11 +40,9 @@ const CountsControls = (
|
|||||||
const id = useId ()
|
const id = useId ()
|
||||||
const motion = useClientMotion ()
|
const motion = useClientMotion ()
|
||||||
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')
|
|
||||||
const rowNumbers = applied.get ('row_numbers')
|
|
||||||
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
|
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
|
||||||
const previousThumbnail = useRef (thumbnail)
|
const previousApplied = useRef ({ resource, params: new URLSearchParams (applied) })
|
||||||
|
const appliedState = applied.toString ()
|
||||||
const mode = draft.get ('mode') ?? 'summary'
|
const mode = draft.get ('mode') ?? 'summary'
|
||||||
const columns = selectedCountColumns (resource, draft, definition)
|
const columns = selectedCountColumns (resource, draft, definition)
|
||||||
const details = mode === 'details'
|
const details = mode === 'details'
|
||||||
@@ -53,30 +51,42 @@ const CountsControls = (
|
|||||||
&& !countsNestedIsScalar (field.children, field.key, draft)
|
&& !countsNestedIsScalar (field.children, field.key, draft)
|
||||||
&& field.children.some (child => selectedCountChildren (draft)
|
&& field.children.some (child => selectedCountChildren (draft)
|
||||||
.has (`${field.key}.${child.key}`)))
|
.has (`${field.key}.${child.key}`)))
|
||||||
// Preserve absent columns until a real presentation change or an explicit edit.
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (previousThumbnail.current === thumbnail) return
|
const nextApplied = new URLSearchParams (appliedState)
|
||||||
previousThumbnail.current = thumbnail
|
const previous = previousApplied.current
|
||||||
setDraft (previous => {
|
previousApplied.current = { resource, params: nextApplied }
|
||||||
const next = new URLSearchParams (previous)
|
setDraft (draft => {
|
||||||
const columns = selectedCountColumns (resource, previous, definition)
|
if (previous.resource !== resource
|
||||||
.filter (column => column !== 'thumbnail')
|
|| previous.params.get ('definition') !== nextApplied.get ('definition'))
|
||||||
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
|
return nextApplied
|
||||||
return next
|
const keys = new Set ([...previous.params.keys (), ...nextApplied.keys ()])
|
||||||
})
|
const changed = [...keys].filter (key =>
|
||||||
}, [thumbnail, resource, definition])
|
previous.params.get (key) !== nextApplied.get (key))
|
||||||
useEffect (() => {
|
const before = selectedCountColumns (resource, previous.params, definition)
|
||||||
setDraft (previous => {
|
const after = selectedCountColumns (resource, nextApplied, definition)
|
||||||
const next = new URLSearchParams (previous)
|
const selectionChanged = before.filter (column => column !== 'thumbnail').join (' ')
|
||||||
const display = { order, nested_json: nestedJSON, row_numbers: rowNumbers }
|
!== after.filter (column => column !== 'thumbnail').join (' ')
|
||||||
for (const [key, value] of Object.entries (display))
|
const internal = ['execution_at', 'timezone', 'order', 'nested_json', 'row_numbers']
|
||||||
|
if (changed.some (key => key === 'columns' ? selectionChanged : !internal.includes (key)))
|
||||||
|
return nextApplied
|
||||||
|
// Internal URL enrichment and immediate display changes preserve pending edits.
|
||||||
|
const next = new URLSearchParams (draft)
|
||||||
|
for (const key of changed)
|
||||||
{
|
{
|
||||||
if (value == null) next.delete (key)
|
if (key === 'columns')
|
||||||
else next.set (key, value)
|
{
|
||||||
|
const columns = selectedCountColumns (resource, draft, definition)
|
||||||
|
.filter (column => column !== 'thumbnail')
|
||||||
|
next.set ('columns', (after.includes ('thumbnail')
|
||||||
|
? ['thumbnail', ...columns] : columns).join (' '))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next.delete (key)
|
||||||
|
for (const value of nextApplied.getAll (key)) next.append (key, value)
|
||||||
}
|
}
|
||||||
return next
|
return next.toString () === draft.toString () ? draft : next
|
||||||
})
|
})
|
||||||
}, [order, nestedJSON, rowNumbers])
|
}, [appliedState, resource, definition])
|
||||||
const history = draft.get ('definition') === 'history'
|
const history = draft.get ('definition') === 'history'
|
||||||
const update = (key: string, value: string) => setDraft (previous => {
|
const update = (key: string, value: string) => setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const CountsSummaryTable = (
|
|||||||
? 'singleTotal' : 'grandTotal') : 'subtotal').text
|
? 'singleTotal' : 'grandTotal') : 'subtotal').text
|
||||||
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
|
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
|
||||||
const levels = Math.max (1, cols.length)
|
const levels = Math.max (1, cols.length)
|
||||||
|
const headingLevels = cols.length === 0 ? 1 : levels * 2
|
||||||
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
||||||
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
||||||
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
|
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
|
||||||
@@ -119,14 +120,13 @@ const CountsSummaryTable = (
|
|||||||
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
|
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
|
||||||
}))
|
}))
|
||||||
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
|
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
|
||||||
const headingRows = Array.from ({ length: levels }, (_, level) => [
|
const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
|
||||||
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
|
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
|
||||||
...Array.from ({ length: leading }, (_, index) =>
|
...Array.from ({ length: leading }, (_, index) =>
|
||||||
level === 0 && rows[index] != null
|
level === 0 && rows[index] != null
|
||||||
? countsAxisLabel (rows[index], definition, locale).text : ''),
|
? countsAxisLabel (rows[index], definition, locale).text : ''),
|
||||||
...columnHeaders.map ((header, index) => {
|
...columnHeaders.map ((header, index) => {
|
||||||
if (cols.length === 0) return header.kind === 'data'
|
if (cols.length === 0) return message ('count').text
|
||||||
? message ('count').text : aggregateLabel (header)
|
|
||||||
if (level > header.values.length) return ''
|
if (level > header.values.length) return ''
|
||||||
if (level === header.values.length)
|
if (level === header.values.length)
|
||||||
return header.kind === 'data' ? '' : aggregateLabel (header)
|
return header.kind === 'data' ? '' : aggregateLabel (header)
|
||||||
@@ -137,6 +137,48 @@ const CountsSummaryTable = (
|
|||||||
return ''
|
return ''
|
||||||
return display (header.values[level], cols[level], rows.length + level)
|
return display (header.values[level], cols[level], rows.length + level)
|
||||||
})])
|
})])
|
||||||
|
const axisHeadingRows = cols.map ((axis, level) => [
|
||||||
|
...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''),
|
||||||
|
...columnHeaders.map ((header, index) => {
|
||||||
|
if (header.values.length < level) return ''
|
||||||
|
const previous = columnHeaders[index - 1]
|
||||||
|
const prefix = JSON.stringify (header.values.slice (0, level))
|
||||||
|
return (previous != null && previous.values.length >= level
|
||||||
|
&& JSON.stringify (previous.values.slice (0, level)) === prefix)
|
||||||
|
? '' : countsAxisLabel (axis, definition, locale).text
|
||||||
|
})])
|
||||||
|
const headingRows = cols.length === 0 ? valueHeadingRows
|
||||||
|
: axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
|
||||||
|
if (cols.length > 0)
|
||||||
|
{
|
||||||
|
for (let index = 0; index < leading + (numbered ? 1 : 0); ++index)
|
||||||
|
{
|
||||||
|
headingRows[0][index] = valueHeadingRows[0][index]
|
||||||
|
headingRows[1][index] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const axisHeaderCells = (level: number) => {
|
||||||
|
const output = []
|
||||||
|
for (let index = 0; index < columnHeaders.length; ++index)
|
||||||
|
{
|
||||||
|
const header = columnHeaders[index]
|
||||||
|
if (header.values.length < level) continue
|
||||||
|
const prefix = JSON.stringify (header.values.slice (0, level))
|
||||||
|
let span = 1
|
||||||
|
while (index + span < columnHeaders.length)
|
||||||
|
{
|
||||||
|
const sibling = columnHeaders[index + span]
|
||||||
|
if (sibling.values.length < level
|
||||||
|
|| JSON.stringify (sibling.values.slice (0, level)) !== prefix) break
|
||||||
|
++span
|
||||||
|
}
|
||||||
|
const name = countsAxisLabel (cols[level], definition, locale)
|
||||||
|
output.push (<th key={index} colSpan={span} scope="colgroup"
|
||||||
|
lang={name.language} className="px-2 py-1">{name.text}</th>)
|
||||||
|
index += span - 1
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
const matrix = rowHeaders.map ((header, index) => [...rowLabels[index],
|
const matrix = rowHeaders.map ((header, index) => [...rowLabels[index],
|
||||||
...columnHeaders.map (column => String (count (header, column)))])
|
...columnHeaders.map (column => String (count (header, column)))])
|
||||||
const headerCells = (level: number) => {
|
const headerCells = (level: number) => {
|
||||||
@@ -147,8 +189,10 @@ const CountsSummaryTable = (
|
|||||||
if (cols.length > 0 && level > header.values.length) continue
|
if (cols.length > 0 && level > header.values.length) continue
|
||||||
const aggregate = header.kind !== 'data' && level === header.values.length
|
const aggregate = header.kind !== 'data' && level === header.values.length
|
||||||
const leaf = level === levels - 1 || aggregate
|
const leaf = level === levels - 1 || aggregate
|
||||||
const text = aggregate ? aggregateLabel (header)
|
const text =
|
||||||
: (cols.length === 0 ? message ('count').text
|
cols.length === 0
|
||||||
|
? message ('count').text
|
||||||
|
: (aggregate ? aggregateLabel (header)
|
||||||
: display (header.values[level], cols[level], rows.length + level))
|
: display (header.values[level], cols[level], rows.length + level))
|
||||||
let span = 1
|
let span = 1
|
||||||
if (!leaf)
|
if (!leaf)
|
||||||
@@ -165,7 +209,8 @@ const CountsSummaryTable = (
|
|||||||
const sortKey = leaf ? measureKey (header) : `a${rows.length + level}`
|
const sortKey = leaf ? measureKey (header) : `a${rows.length + level}`
|
||||||
const field = leaf ? columnKey (header) : sortKey
|
const field = leaf ? columnKey (header) : sortKey
|
||||||
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
||||||
colSpan={span} rowSpan={aggregate ? levels - level : 1} resizable={leaf}
|
colSpan={span} resizable={leaf}
|
||||||
|
rowSpan={(aggregate && cols.length > 0) ? (levels - level) * 2 - 1 : 1}
|
||||||
totalKind={header.kind !== 'data' ? header.kind : undefined}
|
totalKind={header.kind !== 'data' ? header.kind : undefined}
|
||||||
dragId={level === 0 && header.values.length > 0
|
dragId={level === 0 && header.values.length > 0
|
||||||
? dragIds[JSON.stringify (header.values.slice (0, 1))] : undefined}
|
? dragIds[JSON.stringify (header.values.slice (0, 1))] : undefined}
|
||||||
@@ -184,16 +229,18 @@ const CountsSummaryTable = (
|
|||||||
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
|
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
|
||||||
className="text-left text-sm">
|
className="text-left text-sm">
|
||||||
<thead className="sticky top-0 z-20">
|
<thead className="sticky top-0 z-20">
|
||||||
{headingRows.map ((_, level) => <tr key={level}>
|
{headingRows.map ((_, tier) => <tr key={tier}>
|
||||||
{level === 0 && <>
|
{tier === 0 && <>
|
||||||
{numbered && <CountsRowNumber rowSpan={levels}/>}
|
{numbered && <CountsRowNumber rowSpan={headingLevels}/>}
|
||||||
{Array.from ({ length: leading }, (_, index) => rows[index] == null
|
{Array.from ({ length: leading }, (_, index) => rows[index] == null
|
||||||
? <th key={index} rowSpan={levels}/>
|
? <th key={index} rowSpan={headingLevels}/>
|
||||||
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
|
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
|
||||||
rowSpan={levels} label={countsAxisLabel (rows[index], definition, locale)}
|
rowSpan={headingLevels}
|
||||||
|
label={countsAxisLabel (rows[index], definition, locale)}
|
||||||
sorts={sorts} change={change}/>)}
|
sorts={sorts} change={change}/>)}
|
||||||
</>}
|
</>}
|
||||||
{headerCells (level)}
|
{(cols.length > 0 && tier % 2 === 0)
|
||||||
|
? axisHeaderCells (tier / 2) : headerCells (Math.floor (tier / 2))}
|
||||||
</tr>)}
|
</tr>)}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
||||||
@@ -204,21 +251,19 @@ const CountsSummaryTable = (
|
|||||||
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
|
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
|
||||||
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
||||||
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
||||||
|
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
|
||||||
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
||||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
|
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
|
||||||
})}
|
})}
|
||||||
{columnHeaders.map (column => <td key={columnKey (column)}
|
{columnHeaders.map (column => <td key={columnKey (column)}
|
||||||
data-counts-copy={String (count (row, column))}
|
data-counts-copy={String (count (row, column))}
|
||||||
|
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
|
||||||
data-counts-total={(row.kind === 'grand_total' || column.kind === 'grand_total')
|
data-counts-total={(row.kind === 'grand_total' || column.kind === 'grand_total')
|
||||||
? 'grand_total' : ((row.kind === 'subtotal' || column.kind === 'subtotal')
|
? 'grand_total' : ((row.kind === 'subtotal' || column.kind === 'subtotal')
|
||||||
? 'subtotal' : undefined)}
|
? 'subtotal' : undefined)}
|
||||||
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||||
? '' : undefined}
|
? '' : undefined}
|
||||||
className={cn ('border px-2 py-1 dark:border-slate-700',
|
className="border px-2 py-1 dark:border-slate-700">
|
||||||
(row.kind !== 'data' || column.kind !== 'data')
|
|
||||||
&& 'bg-slate-200 text-slate-950 font-semibold',
|
|
||||||
(row.kind !== 'data' || column.kind !== 'data')
|
|
||||||
&& 'dark:bg-slate-700 dark:text-slate-50')}>
|
|
||||||
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
|
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
|
||||||
</td>)}
|
</td>)}
|
||||||
</tr>)}</tbody>
|
</tr>)}</tbody>
|
||||||
|
|||||||
@@ -18,17 +18,6 @@
|
|||||||
@apply bg-slate-900 text-slate-100;
|
@apply bg-slate-900 text-slate-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
|
|
||||||
background-color: #e2e8f0;
|
|
||||||
color: #0f172a;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
|
|
||||||
background-color: #334155;
|
|
||||||
color: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counts-grid > thead {
|
.counts-grid > thead {
|
||||||
@apply bg-slate-200 text-slate-950;
|
@apply bg-slate-200 text-slate-950;
|
||||||
}
|
}
|
||||||
@@ -55,25 +44,27 @@
|
|||||||
@apply border-l-2 border-slate-300 dark:border-slate-600;
|
@apply border-l-2 border-slate-300 dark:border-slate-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
|
/* Aggregate kind is semantic; only aggregate rows receive a background. */
|
||||||
.counts-grid > thead > tr > th[data-counts-total='subtotal'] {
|
.counts-grid > tbody > tr > td[data-counts-total],
|
||||||
|
.counts-grid > thead > tr > th[data-counts-total] {
|
||||||
|
@apply border-l border-l-slate-400 font-semibold dark:border-l-slate-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) {
|
||||||
background-color: #e2e8f0;
|
background-color: #e2e8f0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
|
.counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) {
|
||||||
.counts-grid > thead > tr > th[data-counts-total='grand_total'] {
|
|
||||||
background-color: #cbd5e1;
|
background-color: #cbd5e1;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.dark .counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
|
.dark .counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) {
|
||||||
.dark .counts-grid > thead > tr > th[data-counts-total='subtotal'] {
|
|
||||||
background-color: #334155;
|
background-color: #334155;
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
}
|
}
|
||||||
.dark .counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
|
.dark .counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) {
|
||||||
.dark .counts-grid > thead > tr > th[data-counts-total='grand_total'] {
|
|
||||||
background-color: #475569;
|
background-color: #475569;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
|
maxWidthRatio?: number
|
||||||
onWidthChange?: (width: number) => void
|
onWidthChange?: (width: number) => void
|
||||||
sidebarKey: string
|
sidebarKey: string
|
||||||
side?: SidebarSide }
|
side?: SidebarSide }
|
||||||
@@ -67,6 +68,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
|
maxWidthRatio,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
sidebarKey,
|
sidebarKey,
|
||||||
side = 'left',
|
side = 'left',
|
||||||
@@ -80,6 +82,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
)
|
)
|
||||||
const rootRef = useRef<HTMLDivElement | null> (null)
|
const rootRef = useRef<HTMLDivElement | null> (null)
|
||||||
const maxWidthRef = useRef (maxWidth)
|
const maxWidthRef = useRef (maxWidth)
|
||||||
|
const maxWidthRatioRef = useRef (maxWidthRatio)
|
||||||
const getContainerWidth = useCallback (() => {
|
const getContainerWidth = useCallback (() => {
|
||||||
const containerWidth = rootRef.current
|
const containerWidth = rootRef.current
|
||||||
?.closest ('[data-sidebar-container]')
|
?.closest ('[data-sidebar-container]')
|
||||||
@@ -93,12 +96,14 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
const viewportWidth =
|
const viewportWidth =
|
||||||
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
||||||
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
||||||
const maxAllowedWidth =
|
const ratioMaxWidth = maxWidthRatioRef.current == null
|
||||||
maxWidthRef.current == null
|
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
|
||||||
|
const configuredMaxWidth = maxWidthRef.current == null
|
||||||
? getSidebarMaxWidth (containerWidth)
|
? getSidebarMaxWidth (containerWidth)
|
||||||
: Math.max (
|
: Math.max (MIN_SIDEBAR_WIDTH,
|
||||||
MIN_SIDEBAR_WIDTH,
|
|
||||||
Math.min (viewportMaxWidth, maxWidthRef.current))
|
Math.min (viewportMaxWidth, maxWidthRef.current))
|
||||||
|
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
|
||||||
|
Math.min (ratioMaxWidth, configuredMaxWidth))
|
||||||
|
|
||||||
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
|
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -171,10 +176,11 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
maxWidthRef.current = maxWidth
|
maxWidthRef.current = maxWidth
|
||||||
|
maxWidthRatioRef.current = maxWidthRatio
|
||||||
const containerWidth = getContainerWidth ()
|
const containerWidth = getContainerWidth ()
|
||||||
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
||||||
applyWidth (nextWidth)
|
applyWidth (nextWidth)
|
||||||
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth])
|
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
|
||||||
|
|
||||||
const updateWidth = useCallback ((clientX: number) => {
|
const updateWidth = useCallback ((clientX: number) => {
|
||||||
const state = resizeRef.current
|
const state = resizeRef.current
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ export const countsRequestParams = (params: URLSearchParams): URLSearchParams =>
|
|||||||
'current', 'row_numbers'])
|
'current', 'row_numbers'])
|
||||||
result.delete (key)
|
result.delete (key)
|
||||||
if (isCountsState (params) && countsUseLatest (params)) result.delete ('at')
|
if (isCountsState (params) && countsUseLatest (params)) result.delete ('at')
|
||||||
|
result.sort ()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -38,15 +38,10 @@ import type { CountsResource } from '@/lib/counts'
|
|||||||
|
|
||||||
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
|
const queryClient = useQueryClient ()
|
||||||
const [source, setParams] = useSearchParams ()
|
const [source, setParams] = useSearchParams ()
|
||||||
const params = normaliseCountsParams (resource, source, definition)
|
const params = normaliseCountsParams (resource, source, definition)
|
||||||
const canonical = params.toString ()
|
const canonical = params.toString ()
|
||||||
const controlParams = new URLSearchParams (params)
|
|
||||||
controlParams.delete ('nested_json')
|
|
||||||
controlParams.delete ('order')
|
|
||||||
controlParams.delete ('row_numbers')
|
|
||||||
controlParams.set ('columns', selectedCountColumns (resource, params, definition)
|
|
||||||
.filter (column => column !== 'thumbnail').sort ().join (' '))
|
|
||||||
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)
|
||||||
@@ -65,8 +60,10 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
const [exporting, setExporting] = useState (false)
|
const [exporting, setExporting] = useState (false)
|
||||||
const exportInFlight = useRef (false)
|
const exportInFlight = useRef (false)
|
||||||
const [exportError, setExportError] = useState (false)
|
const [exportError, setExportError] = useState (false)
|
||||||
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
|
const { data, dataUpdatedAt, isFetching, isPending, isError,
|
||||||
|
isPlaceholderData, refetch } = useQuery ({
|
||||||
enabled: params.has ('timezone'),
|
enabled: params.has ('timezone'),
|
||||||
|
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
||||||
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
||||||
queryFn: () => fetchCounts (resource, params, locale), retry: false,
|
queryFn: () => fetchCounts (resource, params, locale), retry: false,
|
||||||
placeholderData: (previous, query) => {
|
placeholderData: (previous, query) => {
|
||||||
@@ -80,25 +77,22 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
} })
|
} })
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (source.toString () !== canonical)
|
const next = new URLSearchParams (canonical)
|
||||||
setParams (canonical, { replace: true })
|
if (!next.has ('timezone'))
|
||||||
}, [canonical, source, setParams])
|
next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
|
||||||
|
if (data?.executionAt != null && !isPlaceholderData && !isFetching && !isError
|
||||||
useEffect (() => {
|
&& params.has ('timezone') && !params.has ('execution_at'))
|
||||||
const timezone = Intl.DateTimeFormat ().resolvedOptions ().timeZone
|
|
||||||
if (params.get ('timezone') !== timezone)
|
|
||||||
{
|
{
|
||||||
const next = new URLSearchParams (canonical)
|
|
||||||
next.set ('timezone', timezone)
|
|
||||||
setParams (next, { replace: true })
|
|
||||||
}
|
|
||||||
else if (data?.executionAt != null && !isPlaceholderData && !params.has ('execution_at'))
|
|
||||||
{
|
|
||||||
const next = new URLSearchParams (canonical)
|
|
||||||
next.set ('execution_at', data.executionAt)
|
next.set ('execution_at', data.executionAt)
|
||||||
setParams (next, { replace: true })
|
// Pin the response under its server-confirmed time before changing the URL.
|
||||||
|
queryClient.setQueryData (
|
||||||
|
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
||||||
|
data, { updatedAt: dataUpdatedAt })
|
||||||
}
|
}
|
||||||
}, [canonical, data, isPlaceholderData, params, setParams])
|
if (source.toString () !== next.toString ())
|
||||||
|
setParams (next, { replace: true })
|
||||||
|
}, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
|
||||||
|
locale, params, queryClient, resource, setParams, source])
|
||||||
|
|
||||||
|
|
||||||
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
||||||
@@ -166,8 +160,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
||||||
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
||||||
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
||||||
<SidebarComponent sidebarKey="counts" maxWidth={224}
|
<SidebarComponent sidebarKey="counts" maxWidthRatio={.5}
|
||||||
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
|
className={cn ('md:block', !sidebarOpen && 'hidden')}>
|
||||||
<nav id="counts-targets" aria-label={message ('target').text}
|
<nav id="counts-targets" aria-label={message ('target').text}
|
||||||
lang={message ('target').language}>
|
lang={message ('target').language}>
|
||||||
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
||||||
@@ -212,7 +206,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
||||||
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<CountsControls key={`${resource}:${controlParams}`}
|
<CountsControls
|
||||||
resource={resource} applied={params}
|
resource={resource} applied={params}
|
||||||
open={open} setOpen={setOpen}
|
open={open} setOpen={setOpen}
|
||||||
reorder={value => update ('order', value)}
|
reorder={value => update ('order', value)}
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする