このコミットが含まれているのは:
2026-10-26 19:16:19 +09:00
コミット c61d9d76e3
12個のファイルの変更、138行の追加、54行の削除
+2
ファイルの表示
@@ -108,6 +108,8 @@ class CountsExportProjection
end
def csv_display(value, key)
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
return @labels.fetch("category:#{ value }", value) if key == 'category'
+23 -1
ファイルの表示
@@ -30,6 +30,9 @@ class CountsTableExport
@columns = params.fetch('columns', '').split(',')
@headers = JSON.parse(params.fetch('headers', '[]'))
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
if @summary && @headers.any? && @headers.length != @query.axes.all.length + 1
raise ArgumentError
end
unless @summary
if ['posts', 'tags'].include?(resource)
@@ -71,7 +74,7 @@ class CountsTableExport
else
values = if @summary
row[:values].each_with_index.map do |value, index|
page[:axis_labels].fetch("a#{ index }", {}).fetch(value.to_s, value)
summary_value(value, index, page)
end + [row[:count]]
elsif @projection
@projection.csv_record(record, row)
@@ -91,11 +94,29 @@ class CountsTableExport
private
def summary_headers
return @headers if @headers.any?
labels = @labels
@query.axes.all.map { labels.fetch(_1['field'], _1['field']) } +
[labels.fetch('count', 'count')]
end
def summary_value(value, index, page)
field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil?
names = page[:axis_labels].fetch("a#{ index }", {})
name = field == 'user' ? names[value.to_s] : names.fetch(value.to_s, value)
if field == 'user'
name = @labels.fetch('notRegistered', '') if name.nil? || name == ''
pattern = @labels.fetch('actorFormat', '{{name}} (#{{id}})')
return pattern.gsub(/\{\{(name|id)\}\}/) do
Regexp.last_match(1) == 'name' ? name.to_s : value.to_s
end
end
@labels.fetch("#{ field }:#{ value }", name)
end
def project(row)
return { row: row[:values].first(@query.axes.rows.length),
column: row[:values].drop(@query.axes.rows.length), count: row[:count] } if @summary
@@ -148,6 +169,7 @@ class CountsTableExport
end
def csv_logical(key, value)
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
return value ? @labels.fetch('latest', '') : '' if key == 'is_latest'
if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
return @query.population.timezone.utc_to_local(value.utc).iso8601
+15 -3
ファイルの表示
@@ -2,7 +2,8 @@ import { useState } from 'react'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import { countsFieldLabel, countsFilterFields, readCountsJSON, useCountsDefinition }
import { countsEventTypes, countsFieldLabel, countsFilterFields,
readCountsJSON, useCountsDefinition }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
@@ -70,14 +71,25 @@ const CountsAdditionalConditions = ({ params, update }: {
{' · '}{label (`operators.${condition.operator}`)}
</button>
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
.map (key => datetime ? <DateTimeField precision="second" key={key}
.map (key => condition.field === 'event_type' ? (
<select key={key} value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'w-36 py-1')}
aria-label={message ('value').text}
onChange={event => patch (index, { [key]: event.target.value })}>
<option value=""/>
{condition[key] && !countsEventTypes.some (event => event === condition[key])
&& <option value={condition[key]}>{condition[key]}</option>}
{countsEventTypes.map (event => <option key={event} value={event}
lang={message (`events.${event}`).language}>
{message (`events.${event}`).text}</option>)}
</select>) : (datetime ? <DateTimeField precision="second" key={key}
value={condition[key] ?? ''} disabled={condition.blank}
aria-label={message (key === 'end' ? 'through' : 'value').text}
onChange={value => patch (index, { [key]: value ?? '' })}/> : (
<input key={key} value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'w-36 py-1')}
aria-label={message ('value').text}
onChange={event => patch (index, { [key]: event.target.value })}/>))}
onChange={event => patch (index, { [key]: event.target.value })}/>)))}
<label className="inline-flex items-center gap-1">
<input type="checkbox" checked={condition.blank ?? false}
onChange={event => patch (index, { blank: event.target.checked })}/>
+11 -4
ファイルの表示
@@ -1,8 +1,11 @@
import LocalisedText from '@/components/LocalisedText'
import SortableList from '@/components/common/SortableList'
import { countsAxisFields, countsFilterFields, readCountsJSON, useCountsDefinition }
import { countsAxisFields, countsAxisLabel, countsFilterFields,
readCountsJSON, useCountsDefinition }
from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn, inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsAxis } from '@/lib/countsDefinitions'
@@ -13,6 +16,7 @@ const CountsAxes = ({ params, update }: {
) => {
const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition ()
const locale = useLocale ()
const motion = useClientMotion ()
const available = countsAxisFields (definition, params.get ('definition') === 'history')
const rows = readCountsJSON<CountsAxis[]> (params, 'rows', [])
@@ -22,8 +26,11 @@ const CountsAxes = ({ params, update }: {
available: available.map (field => field.key).filter (id => !axes.some (
axis => axis.field === id)),
rows: rows.map (axis => axis.field), cols: cols.map (axis => axis.field) }
const name = (id: string) => message (
available.find (field => field.key === id)?.label ?? 'axis')
const name = (id: string) => {
const axis = axes.find (item => item.field === id)
return axis == null ? message (available.find (field => field.key === id)?.label ?? 'axis')
: countsAxisLabel (axis, definition, locale)
}
const commit = (next: Record<string, string[]>) => {
for (const group of ['rows', 'cols'] as const)
update (group, JSON.stringify (next[group].map (field => axes.find (
@@ -46,7 +53,7 @@ const CountsAxes = ({ params, update }: {
return <div className={cn ('flex items-center gap-2 text-sm',
group !== 'available' && 'flex-wrap')}>
<span className="min-w-0 flex-1 break-words">
{label (available.find (field => field.key === id)?.label ?? 'axis')}
<LocalisedText language={name (id).language}>{name (id).text}</LocalisedText>
</span>
{group === 'available' && <span className="ml-auto inline-flex shrink-0 gap-1">
{(['rows', 'cols'] as const).map (target => <button key={target} type="button"
+14 -12
ファイルの表示
@@ -63,16 +63,9 @@ const CountsControls = (
next.delete ('execution_at')
execute (normaliseCountsParams (resource, next))
}}>
<div className="flex flex-wrap items-center gap-2 border-b pb-1 dark:border-slate-700">
{history ? <div className="flex min-w-0 max-w-full items-center gap-1">
<DateTimeField precision="second" value={draft.get ('from') ?? ''}
aria-label={message ('from').text} lang={message ('from').language}
className="min-w-0 flex-1" onChange={value => update ('from', value ?? '')}/>
<span className="shrink-0">{label ('periodSeparator')}</span>
<DateTimeField precision="second" value={draft.get ('through') ?? ''}
aria-label={message ('through').text} lang={message ('through').language}
className="min-w-0 flex-1" onChange={value => update ('through', value ?? '')}/>
</div> : <div className="flex flex-wrap items-center gap-1 text-sm">
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
dark:border-slate-700">
<div className="flex flex-wrap items-center gap-1 text-sm">
{label ('referenceAt')}<HelpPopover label={message ('referenceAt')}>
{label ('referenceHelp')}</HelpPopover>
<div className="flex min-w-0 max-w-full items-center gap-1">
@@ -86,8 +79,8 @@ const CountsControls = (
onChange={event => update ('current', event.target.checked ? '1' : '0')}/>
{label ('latest')}</label>
</div>
</div>}
</div>
</div>
</div>}
<div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
<Button type="submit" size="sm">{label ('run')}</Button>
<Button type="button" size="sm" variant="outline" aria-expanded={open}
@@ -132,6 +125,15 @@ const CountsControls = (
</fieldset>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="font-semibold">{label ('filters')}</legend>
{history && <div className="flex min-w-0 max-w-full items-center gap-1">
<DateTimeField precision="second" value={draft.get ('from') ?? ''}
aria-label={message ('from').text} lang={message ('from').language}
className="min-w-0 flex-1" onChange={value => update ('from', value ?? '')}/>
<span className="shrink-0">{label ('periodSeparator')}</span>
<DateTimeField precision="second" value={draft.get ('through') ?? ''}
aria-label={message ('through').text} lang={message ('through').language}
className="min-w-0 flex-1" onChange={value => update ('through', value ?? '')}/>
</div>}
<div className="flex flex-wrap gap-2">
{['resource_id', 'user_id', 'limit'].map (key => <FormField key={key}
label={label (key === 'resource_id' ? 'columns.resourceId'
+4 -1
ファイルの表示
@@ -88,8 +88,11 @@ const CountsResults = (
case 'user':
return row.createdByUser?.name ? value : (
<LocalisedText language={message (
row.createdByUser == null ? 'history:bot' : 'history:anonymous').language}>
row.createdByUser == null ? 'bot' : 'history:anonymous').language}>
{value}</LocalisedText>)
case 'event_type':
return <LocalisedText language={message (`events.${row.eventType}`).language}>
{value}</LocalisedText>
case 'category':
return <LocalisedText language={message (`common:categories.${row.category}`).language}>
{value}</LocalisedText>
+14 -5
ファイルの表示
@@ -3,7 +3,9 @@ import CountsRowNumber from '@/components/counts/CountsRowNumber'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions'
import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition }
from '@/lib/countsDefinitions'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource, CountsResult } from '@/lib/counts'
@@ -15,6 +17,8 @@ const CountsSummaryResults = (
resource: CountsResource; data: CountsResult; params: URLSearchParams
pending: boolean; update: (key: string, value: string) => void },
) => {
const definition = useCountsDefinition ()
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const rowNumbers = params.get ('row_numbers') === '1'
const rows = data.rows ?? []
@@ -28,8 +32,10 @@ const CountsSummaryResults = (
if (cols.length === 0 && columnKeys.length === 0) columnKeys.push ('[]')
const sorts = readCountSort (params.get ('sort'))
const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
if (value == null) return ''
if (value == null) return axis.field === 'user' ? message ('bot').text : ''
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
if (axis.field === 'user') return message ('actorWithId', {
name: logical || message ('notRegistered').text, id: value }).text
if (logical != null) return logical
if (['tags', 'user', 'language_code', 'source_language_code'].includes (axis.field))
return message ('notRegistered').text
@@ -57,13 +63,13 @@ const CountsSummaryResults = (
<thead className="sticky top-0 z-20"><tr>
{rowNumbers && <CountsRowNumber/>}
{rows.map ((axis, index) => <CountsSortHeader key={axis.field} field={`a${index}`}
label={message (countsFieldLabel (axis.field))} sorts={sorts}
label={countsAxisLabel (axis, definition, locale)} sorts={sorts}
change={(key, additive) => update ('sort', writeCountSort (
changeCountSort (sorts, key, additive)))}/>)}
{columnKeys.map ((key, index) => {
const values = JSON.parse (key) as (string | number | null)[]
const name = values.map ((value, position) =>
`${message (countsFieldLabel (cols[position].field)).text}: ${
`${countsAxisLabel (cols[position], definition, locale).text}: ${
display (value, cols[position], rows.length + position)}`).join (' / ')
const sortKey = cols.length === 0 ? 'count' : `measure_${[
...new TextEncoder ().encode (key)].map (byte =>
@@ -83,7 +89,10 @@ const CountsSummaryResults = (
{rowNumbers && <CountsRowNumber value={(data.page - 1) * data.limit + rowIndex + 1}/>}
{rowValues.map ((value, index) => <td key={index}
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700"
data-counts-copy={display (value, rows[index], index)}>
data-counts-copy={display (value, rows[index], index)}
lang={(value == null && rows[index].field === 'user')
? message ('bot').language : (rows[index].field === 'event_type'
? message (`events.${value}`).language : undefined)}>
{display (value, rows[index], index)}</td>)}
{columnKeys.map (columnKey => {
const columnValues = JSON.parse (columnKey) as (string | number | null)[]
+19
ファイルの表示
@@ -1,7 +1,9 @@
import { createContext, useContext } from 'react'
import { translateUIMessage } from '@/lib/i18n'
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { CountField, CountsResource } from '@/lib/counts'
export type CountsAxis = { field: string; grain?: string }
@@ -51,6 +53,23 @@ export const countsFieldLabel = (key: string): string => {
}
export const countsEventTypes = ['create', 'update', 'discard', 'restore'] as const
export const countsAxisLabel = (
axis: CountsAxis,
definition: CountsDefinition,
locale: LocaleCode,
): { text: string; language: string } => {
const field = translateUIMessage (countsFieldLabel (axis.field), 'counts', locale)
if (axis.grain == null || definition.fields.find (item => item.key === axis.field)
?.type !== 'datetime') return field
return translateUIMessage ('axisWithGrain', 'counts', locale, {
field: field.text,
grain: translateUIMessage (`grains.${axis.grain}`, 'counts', locale).text })
}
export const countsAxisFields = (
definition: CountsDefinition,
history: boolean,
+8 -3
ファイルの表示
@@ -1,5 +1,6 @@
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { countsEventTypes } from '@/lib/countsDefinitions'
import { translateUIMessage } from '@/lib/i18n'
import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
@@ -51,9 +52,10 @@ export const countsCellText = (
return row.deprecated == null ? '' : date (row.deprecated)
case 'user':
if (row.createdByUser == null)
return translateUIMessage ('history:bot', 'counts', locale).text
return translateUIMessage ('bot', 'counts', locale).text
return row.createdByUser.name
? `${row.createdByUser.name} (#${row.createdByUser.id})`
? translateUIMessage ('actorWithId', 'counts', locale, {
name: row.createdByUser.name, id: row.createdByUser.id }).text
: translateUIMessage ('history:anonymous', 'counts', locale, {
id: row.createdByUser.id }).text
case 'created_at': return date (row.createdAt)
@@ -274,8 +276,11 @@ export const countsExportLabels = (
`history:languageNames.${key}`, 'counts', locale).text
labels.id = translateUIMessage ('columns.resourceId', 'counts', locale).text
labels.name = translateUIMessage ('history:name', 'counts', locale).text
for (const event of ['create', 'update', 'discard', 'restore'])
for (const event of countsEventTypes)
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
labels.bot = translateUIMessage ('bot', 'counts', locale).text
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
name: '{{name}}', id: '{{id}}' }).text
labels.latest = translateUIMessage ('latestValue', 'counts', locale).text
labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text
return labels
+1 -1
ファイルの表示
@@ -49,7 +49,7 @@ export const countsSummaryCell = (
switch (key)
{
case 'group': return row.key == null
? translateUIMessage ('history:bot', 'counts', locale)
? translateUIMessage ('bot', 'counts', locale)
: { text: row.name ?? String (row.key) }
case 'count': return { text: row.count == null ? '—' : String (row.count) }
case 'tag_id': return { text: String (row.tagId) }
+8 -5
ファイルの表示
@@ -173,10 +173,10 @@
"range": "範囲"
},
"events": {
"create": "作成",
"update": "更新",
"discard": "破棄",
"restore": "復元"
"create": "起票",
"update": "異動",
"discard": "除票",
"restore": "恢復"
},
"attributes": {
"sourceLanguage": "翻訳元の言語",
@@ -219,5 +219,8 @@
"path": "出力パス"
},
"rowNumbers": "行番号",
"periodSeparator": "〜"
"periodSeparator": "〜",
"bot": "$t(history:bot)",
"actorWithId": "{{name}} (#{{id}})",
"axisWithGrain": "{{field}}({{grain}})"
}
+19 -19
ファイルの表示
@@ -17,11 +17,12 @@ import { Button } from '@/components/ui/button'
import { countsRequestParams, defaultCountsMode, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { CountsDefinitionContext, countsAxisFields, countsResources, countsFieldLabel,
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
countsResources, countsFieldLabel,
fetchCountsDefinition, readCountsJSON, useCountsDefinition }
from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
import { cn, inputClass } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { useClientMotion } from '@/lib/useClientMotion'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -54,7 +55,6 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
const state = isCountsState (params)
const cacheParams = new URLSearchParams (request)
cacheParams.set ('definition', state ? 'latest' : 'history')
const [format, setFormat] = useState<'json' | 'csv'> ('json')
const [exporting, setExporting] = useState (false)
const [exportError, setExportError] = useState (false)
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
@@ -100,20 +100,25 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
if (key === 'sort') next.delete ('page')
setParams (next)
}
const download = async () => {
const download = async (format: 'csv' | 'json') => {
setExporting (true)
setExportError (false)
try
{
const axes = [...readCountsJSON<CountsAxis[]> (params, 'rows', []),
...readCountsJSON<CountsAxis[]> (params, 'cols', [])]
const fields = mode === 'summary' ? [
...[...readCountsJSON<CountsAxis[]> (params, 'rows', []),
...readCountsJSON<CountsAxis[]> (params, 'cols', [])].map (axis => ({
...axes.map (axis => ({
key: axis.field, label: countsFieldLabel (axis.field) })),
{ key: 'count', label: 'count' }]
: selectedCountColumns (resource, params, definition).map (column =>
fieldsForCounts (resource, definition).find (field => field.key === column)!)
const headers = mode === 'summary'
? [...axes.map (axis => countsAxisLabel (axis, definition, locale).text),
message ('count').text]
: fields.map (field => message (field.label).text)
await exportCounts (resource, params, locale, format,
fields.map (field => field.key), fields.map (field => message (field.label).text),
fields.map (field => field.key), headers,
{ ...countsExportLabels (resource, locale, definition),
...Object.fromEntries (countsAxisFields (definition, !state).map (field => [
field.key, message (field.label).text])), count: message ('count').text },
@@ -193,20 +198,15 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}} actions={<>
<select value={format} aria-label={message ('exportFormat').text}
lang={message ('exportFormat').language}
className={inputClass (false, 'w-auto py-1')}
onChange={event => setFormat (event.target.value as 'json' | 'csv')}>
{(['json', 'csv'] as const).map (value => (
<option key={value} value={value}>
{message (`exportFormats.${value}`).text}</option>))}
</select>
<Button type="button" variant="outline" size="sm"
}} actions={<div className="ml-auto flex shrink-0 items-center gap-2">
{(['csv', 'json'] as const).map (format => <Button key={format}
type="button" variant="outline" size="sm"
disabled={data == null || isError || isPlaceholderData
|| exporting || !params.get ('execution_at')}
onClick={() => void download ()}>{label ('export')}</Button>
</>}/>
onClick={() => void download (format)}>
{label (`exportFormats.${format}`)}</Button>)}
</div>}/>
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
</div>
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}