このコミットが含まれているのは:
@@ -51,7 +51,7 @@ const CountsAxes = ({ params, update }: {
|
||||
return <SortableList groups={groups} name={name} onChange={commit}
|
||||
renderGroup={(id, content, over) => <fieldset className="mb-2 min-w-0">
|
||||
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
|
||||
<div className={cn ('rounded border dark:border-slate-700',
|
||||
<div className={cn ('rounded border px-1 dark:border-slate-700',
|
||||
over && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{content}</div>
|
||||
</fieldset>} renderItem={(id, group) => {
|
||||
const axis = axes.find (axis => axis.field === id)
|
||||
|
||||
@@ -185,16 +185,16 @@ const CountsControls = (
|
||||
className={inputClass (false, 'w-20 py-1')}
|
||||
onChange={event => update ('limit', event.target.value)}>
|
||||
{[20, 50, 100].map (count => <option key={count} value={count}>
|
||||
{label ('shared:labels.resultsCount', { count })}</option>)}
|
||||
{message ('shared:labels.resultsCount', { count }).text}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1 text-sm">
|
||||
{details && <label className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={draft.get ('row_numbers') === '1'}
|
||||
onChange={event => {
|
||||
update ('row_numbers', event.target.checked ? '1' : '0')
|
||||
displayRowNumbers (event.target.checked)
|
||||
}}/>{label ('rowNumbers')}
|
||||
</label>
|
||||
</label>}
|
||||
{details && resource === 'posts' && <label
|
||||
className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={thumbnail}
|
||||
|
||||
@@ -32,8 +32,10 @@ const CountsExportProgress = (
|
||||
const job = useRef<string | null> (null)
|
||||
const creation = useRef<Promise<{ job: string }> | null> (null)
|
||||
const downloaded = useRef (false)
|
||||
const registration = useRef (false)
|
||||
const attempt = useRef<string | null> (null)
|
||||
const downloadDeadline = useRef (0)
|
||||
const acknowledgementTimer = useRef<ReturnType<typeof setTimeout> | null> (null)
|
||||
const releaseDownload = useRef<(() => void) | null> (null)
|
||||
const [downloadError, setDownloadError] = useState<string | null> (null)
|
||||
const pollRequest = useRef<AbortController | null> (null)
|
||||
@@ -79,25 +81,33 @@ const CountsExportProgress = (
|
||||
const response = await apiGet<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal })
|
||||
if (!active) return
|
||||
if (!active || terminal.current) return
|
||||
const result = (cancelled.current && response.status === 'ready')
|
||||
? await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal }) : response
|
||||
if (!active) return
|
||||
if (!active || terminal.current) return
|
||||
lastResponse = Date.now ()
|
||||
setState (result)
|
||||
terminal.current = ['failed', 'cancelled'].includes (result.status)
|
||||
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
||||
{
|
||||
downloaded.current = true
|
||||
registration.current = true
|
||||
const receipt = await apiPost<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
||||
locale, signal: controller.signal })
|
||||
if (!active || cancelled.current) return
|
||||
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
|
||||
downloaded.current = true
|
||||
attempt.current = receipt.downloadAttempt
|
||||
downloadDeadline.current = Date.now () + 30_000
|
||||
acknowledgementTimer.current = setTimeout (() => {
|
||||
if (!active || terminal.current) return
|
||||
terminal.current = true
|
||||
pollRequest.current?.abort ()
|
||||
releaseDownload.current?.()
|
||||
setDownloadError ('timeout')
|
||||
}, 30_000)
|
||||
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
|
||||
if (active)
|
||||
{
|
||||
@@ -132,13 +142,13 @@ const CountsExportProgress = (
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
if (active)
|
||||
if (active && !terminal.current)
|
||||
{
|
||||
if ((isApiError (error) && error.response?.status === 404)
|
||||
if (downloaded.current || registration.current || (isApiError (error) && error.response?.status === 404)
|
||||
|| Date.now () - lastResponse > 30_000)
|
||||
{
|
||||
terminal.current = true
|
||||
if (downloaded.current) setDownloadError ('unavailable')
|
||||
setDownloadError ('unavailable')
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
return
|
||||
}
|
||||
@@ -160,13 +170,14 @@ const CountsExportProgress = (
|
||||
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
|
||||
}
|
||||
creation.current.then (async result => {
|
||||
if (!active) return
|
||||
if (!active || terminal.current) return
|
||||
job.current = result.job
|
||||
if (cancelled.current) await handle.cancel ()
|
||||
await poll (result.job)
|
||||
}).catch (() => {
|
||||
if (active)
|
||||
{
|
||||
setDownloadError ('failed')
|
||||
terminal.current = true
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
}
|
||||
@@ -176,8 +187,9 @@ const CountsExportProgress = (
|
||||
if (timer != null) clearTimeout (timer)
|
||||
pollRequest.current?.abort ()
|
||||
releaseDownload.current?.()
|
||||
if (acknowledgementTimer.current != null) clearTimeout (acknowledgementTimer.current)
|
||||
}
|
||||
}, [handle, locale, payload, controls])
|
||||
}, [handle, locale])
|
||||
useEffect (() => {
|
||||
controls.setActions ([])
|
||||
}, [controls])
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useContext, useEffect, useRef } from 'react'
|
||||
import SortableItem from '@/components/common/SortableItem'
|
||||
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
||||
|
||||
@@ -129,7 +130,10 @@ const CountsSortHeader = (
|
||||
}
|
||||
change (field, event.ctrlKey || event.metaKey)
|
||||
}}>
|
||||
<span data-counts-label className="truncate" lang={label.language}>{label.text}</span>
|
||||
<span data-counts-label className={cn ('truncate', label.text === '********'
|
||||
&& 'text-red-600 dark:text-red-400')}
|
||||
title={label.text === '********' ? message ('maskedReason').text : undefined}
|
||||
lang={label.language}>{label.text}</span>
|
||||
<span aria-hidden data-counts-sort-indicator className="inline-block w-10 shrink-0">
|
||||
{priority >= 0 && <>{direction === 'asc' ? '▲' : '▼'}
|
||||
{sorts.length > 1 && priority + 1}</>}</span>
|
||||
|
||||
@@ -26,7 +26,7 @@ const CountsSummaryTable = (
|
||||
const definition = useCountsDefinition ()
|
||||
const locale = useLocale ()
|
||||
const { message } = useUITranslation ('counts')
|
||||
const numbered = params.get ('row_numbers') === '1'
|
||||
const numbered = false
|
||||
const rows = data.rows ?? []
|
||||
const cols = data.cols ?? []
|
||||
const cells = data.cells ?? []
|
||||
@@ -260,7 +260,9 @@ const CountsSummaryTable = (
|
||||
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',
|
||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
|
||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value === '********' ? <span
|
||||
className="text-red-600 dark:text-red-400"
|
||||
title={message ('maskedReason').text}>{value}</span> : value}</td>
|
||||
})}
|
||||
{columnHeaders.map (column => <td key={columnKey (column)}
|
||||
data-counts-copy={String (count (row, column))}
|
||||
@@ -271,11 +273,12 @@ 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)} aria-label={
|
||||
{[...row.values, ...column.values].includes ('__counts_private__')
|
||||
? <span>{count (row, column)}</span> : <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>
|
||||
{count (row, column)}</PrefetchLink>}
|
||||
</td>)}
|
||||
</tr>)}</tbody>
|
||||
</CountsSelectableTable>
|
||||
|
||||
@@ -488,6 +488,7 @@ export const countsSummaryValue = (
|
||||
): string => {
|
||||
const message = (key: string, values?: Record<string, string | number>) =>
|
||||
translateUIMessage (key, 'counts', locale, values).text
|
||||
if (value === '__counts_private__') return '********'
|
||||
if (value == null) return axis.field === 'user' ? message ('bot') : ''
|
||||
if (['language_code', 'source_language_code'].includes (axis.field))
|
||||
return languageUIMessage (String (value), locale).text
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
{
|
||||
"_untranslated": ["*"]
|
||||
"_untranslated": [
|
||||
"*",
|
||||
"outputRows",
|
||||
"maskedReason",
|
||||
"downloadErrors",
|
||||
"axisLimit",
|
||||
"privateCondition"
|
||||
],
|
||||
"outputRows": "行数:{{value}} 件",
|
||||
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
|
||||
"downloadErrors": {
|
||||
"unavailable": "ダウンロードの認可が失われたか、ファイルの有効期限が切れています。",
|
||||
"failed": "ダウンロード応答を開始できませんでした。",
|
||||
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
||||
},
|
||||
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
|
||||
}
|
||||
|
||||
@@ -290,5 +290,14 @@
|
||||
"periodSeparator": "〜",
|
||||
"bot": "$t(history:bot)",
|
||||
"actorWithId": "{{name}} (#{{id}})",
|
||||
"axisWithGrain": "{{field}}({{grain}})"
|
||||
"axisWithGrain": "{{field}}({{grain}})",
|
||||
"outputRows": "行数:{{value}} 件",
|
||||
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
|
||||
"downloadErrors": {
|
||||
"unavailable": "ダウンロードの認可が失われたか、ファイルの有効期限が切れています。",
|
||||
"failed": "ダウンロード応答を開始できませんでした。",
|
||||
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
||||
},
|
||||
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const renderPage = (extra = '') => renderWithProviders (<>
|
||||
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
|
||||
|
||||
beforeEach (() => {
|
||||
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
|
||||
fetchCounts.mockReset ().mockResolvedValue (detailResult)
|
||||
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@ const CountsResourceContent = (
|
||||
cacheParams.set ('definition', state ? 'latest' : 'history')
|
||||
const [running, setRunning] = useState (false)
|
||||
const [runError, setRunError] = useState (false)
|
||||
const [runFailure, setRunFailure] = useState<unknown> (null)
|
||||
const [runColumnLimit, setRunColumnLimit] = useState<number | null> (null)
|
||||
const pendingRun = useRef<PendingCountsRun | null> (null)
|
||||
const applied = useRef (params)
|
||||
@@ -219,6 +220,7 @@ const CountsResourceContent = (
|
||||
if (pendingRun.current !== pending) return
|
||||
pendingRun.current = null
|
||||
setRunning (false)
|
||||
setRunFailure (failure)
|
||||
setRunColumnLimit (tooManyColumns (failure))
|
||||
setRunError (true)
|
||||
}
|
||||
@@ -228,6 +230,14 @@ const CountsResourceContent = (
|
||||
const waiting = running || (!definitionError && (definition == null
|
||||
|| pending || fetching || placeholder
|
||||
|| (!params.has ('execution_at') && !errorFlg)))
|
||||
const failure = runError ? runFailure : queryError
|
||||
const errorBody = isApiError (failure) ? failure.response?.data as {
|
||||
error?: string; max_axes?: number; maxAxes?: number } : undefined
|
||||
const errorCode = errorBody?.error
|
||||
const failureMessage = errorCode === 'too_many_axes'
|
||||
? label ('axisLimit', { max: errorBody?.maxAxes ?? errorBody?.max_axes ?? '' })
|
||||
: (errorCode === 'private_condition' ? label ('privateCondition')
|
||||
: label ('common:fetchError'))
|
||||
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
|
||||
|
||||
|
||||
@@ -371,14 +381,14 @@ const CountsResourceContent = (
|
||||
<p>{label ('columnLimit.title')}</p>
|
||||
<p>{label ('columnLimit.message', { max: columnLimit })}</p>
|
||||
</div> : ((definitionError || errorFlg || runError)
|
||||
&& <p role="alert">{label ('common:fetchError')}</p>)}
|
||||
&& <p role="alert">{failureMessage}</p>)}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
aria-busy={waiting}>
|
||||
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
||||
{definition != null && data != null && <>
|
||||
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||
{data.excludedCountScope === 'current_query'
|
||||
<span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||
{resultMode === 'details' && data.excludedCountScope === 'current_query'
|
||||
&& data.excludedCount != null && data.excludedCount > 0 && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{label ('excludedRecords', { count: data.excludedCount })}</span>)}
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||
|
||||
describe ('Help global edition history', () => {
|
||||
beforeEach (() => {
|
||||
window.history.replaceState (null, '', '/ja/help/changes')
|
||||
helpFixture.reset ()
|
||||
vi.mocked (apiGet).mockResolvedValue ({ locales: [{ code: 'ja', name: '日本語' }] })
|
||||
})
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする