このコミットが含まれているのは:
2026-10-31 13:50:10 +09:00
コミット dba409c3e8
33個のファイルの変更、1490行の追加、236行の削除
+3
ファイルの表示
@@ -345,6 +345,9 @@ test ('counts SSR uses query/definitions, noindex and the common site title', as
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
assert.match (result.head, /rel="canonical"/)
assert.doesNotMatch (result.html, /private-discard|hidden-title|__counts_private__/)
assert.ok (requests.some (request => {
const url = new URL (request.url, origin)
return url.pathname === '/counts/query' && url.searchParams.get ('resource') === resource
+14
ファイルの表示
@@ -117,3 +117,17 @@ it ('derives mixed parent selection and permits selecting all children again aft
'tags.name_ja', 'tags.name_ain', 'tags.aliases_ja', 'tags.aliases_ain'])
expect (params.get ('columns')).toBe ('tags')
})
it ('offers row numbers only in details and keeps limit in display settings', () => {
const view = renderControls (appliedParams ({ mode: 'summary', row_numbers: '1' }))
expect (screen.queryByRole ('checkbox', { name: '行番号' })).not.toBeInTheDocument ()
const limit = screen.getByRole ('combobox', { name: '表示件数' })
expect (Array.from (limit.querySelectorAll ('option')).map (option => option.value))
.toEqual (['20', '50', '100'])
expect (limit).toHaveValue ('100')
expect (limit.querySelector ('span')).toBeNull ()
view.unmount ()
renderControls (appliedParams ({ row_numbers: '1' }))
expect (screen.getByRole ('checkbox', { name: '行番号' })).toBeChecked ()
})
+198
ファイルの表示
@@ -0,0 +1,198 @@
import { act, cleanup, screen } from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CountsExportProgress from '@/components/counts/CountsExportProgress'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (), apiGet: vi.fn (), apiDelete: vi.fn (), startAPIDownload: vi.fn () }))
vi.mock ('@/lib/api', async original => ({
...await original<typeof import ('@/lib/api')> (), ...api }))
const ready = { status: 'ready', processed: 5, total: 5, token: 'file' }
const receipt = { downloadAttempt: 'attempt', downloadStatus: 'pending', token: 'file' }
const controls = () => ({ close: vi.fn (), setActions: vi.fn (), confirm: vi.fn () })
const flush = async (milliseconds = 0) => act (async () => {
await vi.advanceTimersByTimeAsync (milliseconds)
})
beforeEach (() => {
vi.useFakeTimers ()
Object.values (api).forEach (mock => mock.mockReset ())
api.apiPost.mockImplementation (async (path: string) =>
path.endsWith ('/download') ? receipt : { job: 'job' })
api.apiGet.mockResolvedValue (ready)
api.apiDelete.mockResolvedValue ({ status: 'cancelled', processed: 5, total: 5 })
api.startAPIDownload.mockReturnValue (vi.fn ())
})
afterEach (() => { cleanup (); vi.useRealTimers () })
const renderProgress = (format = 'csv') => {
const actions = controls ()
const handle = { cancel: async () => false }
const element = (next = actions) => <StrictMode>
<CountsExportProgress payload={{ resource: 'posts', format }} locale="ja"
handle={handle} controls={next}/>
</StrictMode>
return { ...renderWithProviders (element ()), actions, handle, element }
}
describe ('Counts native download acknowledgement', () => {
it.each (['csv', 'json']) ('closes %s after the matching GET acknowledgement', async format => {
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValue ({ ...ready,
downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress (format)
await flush ()
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
expect (view.actions.close).not.toHaveBeenCalled ()
const query = api.startAPIDownload.mock.calls[0][3] as URLSearchParams
expect (Object.fromEntries (query)).toEqual ({ job: 'job', attempt: 'attempt' })
const next = controls ()
view.rerender (view.element (next))
await flush (800)
expect (next.close).toHaveBeenCalledTimes (1)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('times out independently of pending status requests', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockImplementation (() => new Promise (() => {}))
const view = renderProgress ()
await flush ()
await flush (30_000)
expect (screen.getByRole ('alert')).toHaveTextContent ('タイムアウト')
expect (view.actions.close).not.toHaveBeenCalled ()
expect (api.startAPIDownload.mock.results[0].value).toHaveBeenCalled ()
})
it ('retains the modal after acceptance failure without repeated POSTs', async () => {
api.apiPost.mockResolvedValueOnce ({ job: 'job' })
.mockRejectedValueOnce (new Error ('offline'))
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).not.toHaveBeenCalled ()
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('retries a temporary status transport failure without a second native GET', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValueOnce (new Error ('offline'))
.mockResolvedValue ({ ...ready, downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress ()
await flush ()
await flush (2800)
expect (view.actions.close).toHaveBeenCalledTimes (1)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('cancels an acceptance in flight and never starts the resulting receipt', async () => {
let resolve!: (value: typeof receipt) => void
api.apiPost.mockResolvedValueOnce ({ job: 'job' }).mockImplementationOnce (
() => new Promise (complete => { resolve = complete }))
const view = renderProgress ()
await flush ()
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
await act (async () => { resolve (receipt) })
await flush (1000)
expect (api.startAPIDownload).not.toHaveBeenCalled ()
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('releases an unmounted iframe and ignores late acknowledgements', async () => {
const view = renderProgress ()
await flush ()
const release = api.startAPIDownload.mock.results[0].value
view.unmount ()
await flush ()
expect (release).toHaveBeenCalledTimes (1)
await flush (40_000)
expect (view.actions.close).not.toHaveBeenCalled ()
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('keeps authentication expiry and iframe errors visible', async () => {
const view = renderProgress ()
await flush ()
act (() => { api.startAPIDownload.mock.calls[0][2] () })
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('shows the worker-specific reason instead of a download-success message', async () => {
api.apiPost.mockRejectedValueOnce ({ isAxiosError: true,
response: { status: 503, data: { error: 'export_worker_unavailable' } } })
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('ワーカーが稼働していません')
expect (view.actions.close).not.toHaveBeenCalled ()
expect (await view.handle.cancel ()).toBe (true)
})
it ('shows revoked authority and does not close after a successful acceptance', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValue ({ isAxiosError: true,
response: { status: 404 } })
const view = renderProgress ()
await flush ()
await flush (800)
expect (screen.getByRole ('alert')).toHaveTextContent ('認可が失われたか')
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('stops after cancellation failure and permits an explicit cancellation retry', async () => {
const view = renderProgress ()
await flush ()
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
await act (async () => { expect (await view.handle.cancel ()).toBe (false) })
const requests = api.apiGet.mock.calls.length
await flush (40_000)
expect (api.apiGet).toHaveBeenCalledTimes (requests)
expect (screen.getByRole ('alert')).toBeInTheDocument ()
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
expect (api.apiDelete).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('ignores a different attempt even when it reports started', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValueOnce ({ ...ready,
downloadAttempt: 'another', downloadStatus: 'started' }).mockResolvedValue ({ ...ready,
downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress ()
await flush ()
await flush (800)
expect (view.actions.close).not.toHaveBeenCalled ()
await flush (800)
expect (view.actions.close).toHaveBeenCalledTimes (1)
})
it ('keeps the created job available for cancellation retry during initial registration', async () => {
let resolve!: (value: { job: string }) => void
api.apiPost.mockImplementationOnce (() => new Promise (complete => { resolve = complete }))
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
const view = renderProgress ()
await flush ()
let pending!: Promise<boolean>
act (() => { pending = view.handle.cancel () })
await act (async () => {
resolve ({ job: 'created-during-cancel' })
expect (await pending).toBe (false)
})
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
expect (api.apiDelete.mock.calls.map (call => call[0])).toEqual ([
'/counts/export/jobs/created-during-cancel', '/counts/export/jobs/created-during-cancel'])
expect (api.startAPIDownload).not.toHaveBeenCalled ()
})
it ('stops immediately if native download navigation cannot be created', async () => {
api.startAPIDownload.mockImplementationOnce (() => { throw new Error ('navigation blocked') })
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (api.apiGet).toHaveBeenCalledTimes (1)
expect (view.actions.close).not.toHaveBeenCalled ()
})
})
+169 -113
ファイルの表示
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import LocalisedText from '@/components/LocalisedText'
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
import { countsErrorMessage } from '@/lib/countsDisplay'
import { useClientMotion } from '@/lib/useClientMotion'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils'
@@ -26,178 +27,228 @@ const CountsExportProgress = (
const motion = useClientMotion ()
const [state, setState] = useState<ExportStatus> ({
status: 'starting', processed: 0, total: null })
const [cancelError, setCancelError] = useState (false)
const [cancelError, setCancelError] = useState<unknown> (null)
const [failure, setFailure] = useState<unknown> (null)
const [downloadError, setDownloadError] = useState<string | null> (null)
// One mounted dialogue owns one immutable job, even if its parent rerenders.
const initial = useRef ({ payload, locale })
const actions = useRef (controls)
actions.current = controls
const terminal = useRef (false)
const finished = useRef (false)
const cancelAction = useRef<() => Promise<boolean>> (async () => false)
const cancelled = useRef (false)
const generation = useRef (0)
const job = useRef<string | null> (null)
const creation = useRef<Promise<{ job: string }> | null> (null)
const downloaded = useRef (false)
const registration = useRef (false)
const registration = useRef<Promise<ExportStatus> | null> (null)
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)
const cancellation = useRef<Promise<boolean> | null> (null)
useEffect (() => { handle.cancel = () => cancelAction.current () }, [handle])
useEffect (() => {
handle.cancel = async () => {
if (terminal.current) return true
const epoch = ++generation.current
let active = true
let lastResponse = Date.now ()
let timer: ReturnType<typeof setTimeout> | null = null
let acknowledgementTimer: ReturnType<typeof setTimeout> | null = null
let request: AbortController | null = null
const current = () => active && generation.current === epoch
const stop = (reason: string) => {
if (!current () || terminal.current) return
terminal.current = true
if (timer != null) clearTimeout (timer)
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
request?.abort ()
releaseDownload.current?.()
releaseDownload.current = null
setDownloadError (reason)
}
const cancel = (): Promise<boolean> => {
if (finished.current || (terminal.current && job.current == null))
return Promise.resolve (true)
cancelled.current = true
if (job.current != null)
{
terminal.current = true
if (timer != null) clearTimeout (timer)
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
request?.abort ()
releaseDownload.current?.()
releaseDownload.current = null
cancellation.current ??= (async () => {
const controller = new AbortController ()
const deadline = setTimeout (() => controller.abort (), 15_000)
try
{
setCancelError (false)
const created = await creation.current
if (created == null) return false
job.current = created.job
const result = await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (job.current)}`, {
locale, signal: controller.signal })
setState (result)
`/counts/export/jobs/${encodeURIComponent (created.job)}`, {
locale: initial.current.locale, signal: controller.signal })
if (current ())
{
setState (result)
setCancelError (null)
finished.current = ['cancelled', 'failed'].includes (result.status)
terminal.current = finished.current
if (!terminal.current)
{
if (timer != null) clearTimeout (timer)
timer = setTimeout (() => poll (created.job), 800)
}
}
return ['cancelled', 'failed'].includes (result.status)
}
catch
catch (error)
{
setCancelError (true)
if (isApiError (error) && error.response?.status === 404) return true
if (current ()) setCancelError (error)
return false
}
finally
{
clearTimeout (deadline)
cancellation.current = null
}
}
return false
}) ()
return cancellation.current
}
cancelAction.current = cancel
const armDeadline = () => {
if (downloadDeadline.current === 0) return
acknowledgementTimer = setTimeout (() => stop ('timeout'),
Math.max (0, downloadDeadline.current - Date.now ()))
}
}, [handle, locale])
useEffect (() => {
let active = true
let lastResponse = Date.now ()
let timer: ReturnType<typeof setTimeout> | undefined
const poll = async (token: string) => {
if (!current () || terminal.current) return
const controller = new AbortController ()
pollRequest.current = controller
request = controller
const deadline = setTimeout (() => controller.abort (), 15_000)
try
{
const response = await apiGet<ExportStatus> (
const result = await apiGet<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, {
locale, signal: controller.signal })
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 || terminal.current) return
locale: initial.current.locale, signal: controller.signal })
if (!current () || terminal.current) return
lastResponse = Date.now ()
setState (result)
terminal.current = ['failed', 'cancelled'].includes (result.status)
if (result.status === 'ready' && result.token != null && !downloaded.current)
if (cancelled.current)
{
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)
if (await cancel ()) return
}
else if (result.status === 'ready')
{
if (result.token == null) { stop ('failed'); return }
if (attempt.current == null)
{
registration.current ??= apiPost<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
locale: initial.current.locale, signal: controller.signal })
const receipt = await registration.current
if (!current () || terminal.current || cancelled.current) return
if (receipt.downloadAttempt == null) { stop ('failed'); return }
attempt.current = receipt.downloadAttempt
downloadDeadline.current = Date.now () + 30_000
armDeadline ()
try
{
releaseDownload.current = startAPIDownload ('/counts/export', result.token,
() => stop ('failed'), new URLSearchParams ({
job: token, attempt: receipt.downloadAttempt }))
}
catch
{
stop ('failed')
return
}
}
if (result.downloadAttempt === attempt.current)
{
if (result.downloadStatus === 'started')
{
terminal.current = true
setDownloadError ('failed')
finished.current = true
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
// Rack yielded a chunk; browser receipt and saving are not confirmed.
releaseDownload.current = null
actions.current.close ()
return
}
}, new URLSearchParams ({ job: token, attempt: receipt.downloadAttempt }))
}
if (attempt.current != null && result.downloadAttempt === attempt.current)
{
if (result.downloadStatus === 'started' && !cancelled.current)
{
terminal.current = true
// Native navigation continues after the dialogue unmounts.
releaseDownload.current = null
controls.close ()
return
}
if (['failed', 'unavailable'].includes (result.downloadStatus ?? ''))
{
terminal.current = true
setDownloadError (result.downloadStatus!)
if (['failed', 'unavailable', 'timeout'].includes (result.downloadStatus ?? ''))
{ stop (result.downloadStatus!); return }
}
}
if (attempt.current != null && Date.now () > downloadDeadline.current)
else if (['failed', 'cancelled'].includes (result.status))
{
finished.current = true
terminal.current = true
setDownloadError ('timeout')
releaseDownload.current?.()
return
}
if (!terminal.current) timer = setTimeout (() => poll (token), 800)
if (timer != null) clearTimeout (timer)
timer = setTimeout (() => poll (token), 800)
}
catch (error)
{
if (active && !terminal.current)
{
if (downloaded.current || registration.current || (isApiError (error) && error.response?.status === 404)
|| Date.now () - lastResponse > 30_000)
{
terminal.current = true
setDownloadError ('unavailable')
setState (previous => ({ ...previous, status: 'failed' }))
return
}
setCancelError (true)
timer = setTimeout (() => poll (token), 2000)
}
if (!current () || terminal.current) return
const status = isApiError (error) ? error.response?.status : null
if ([400, 401, 403, 422].includes (status ?? 0)) setFailure (error)
if (registration.current != null && attempt.current == null)
{ stop ('failed'); return }
if (status != null && status >= 400 && status < 500)
{ stop ('unavailable'); return }
if (Date.now () - lastResponse >= 30_000)
{ stop ('timeout'); return }
timer = setTimeout (() => poll (token), 2000)
}
finally
{
clearTimeout (deadline)
if (pollRequest.current === controller) pollRequest.current = null
if (request === controller) request = null
}
}
if (creation.current == null)
{
const controller = new AbortController ()
const deadline = setTimeout (() => controller.abort (), 15_000)
creation.current = apiPost<{ job: string }> ('/counts/export/jobs', payload, {
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
creation.current = apiPost<{ job: string }> (
'/counts/export/jobs', initial.current.payload, {
locale: initial.current.locale, signal: controller.signal })
.finally (() => clearTimeout (deadline))
}
creation.current.then (async result => {
if (!active || terminal.current) return
armDeadline ()
void creation.current.then (result => {
if (!current () || 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' }))
}
void poll (result.job)
}).catch (error => {
if (current ()) setFailure (error)
stop ('failed')
})
return () => {
active = false
if (timer != null) clearTimeout (timer)
pollRequest.current?.abort ()
releaseDownload.current?.()
if (acknowledgementTimer.current != null) clearTimeout (acknowledgementTimer.current)
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
request?.abort ()
// StrictMode immediately installs another effect; real unmount releases the frame.
queueMicrotask (() => {
if (generation.current !== epoch) return
releaseDownload.current?.()
releaseDownload.current = null
})
}
}, [handle, locale])
useEffect (() => {
controls.setActions ([])
}, [controls])
}, [])
useEffect (() => { controls.setActions ([]) }, [controls])
const percent = state.total == null ? null : (state.total === 0 ? 100
: Math.min (100, Math.floor (state.processed / state.total * 100)))
const busy = !['ready', 'failed', 'cancelled'].includes (state.status)
const phase = message (`exportProgress.${state.status === 'running'
? state.phase ?? 'running' : state.status}`)
const busy = downloadError == null && cancelError == null
&& !['failed', 'cancelled'].includes (state.status)
const phaseKey = (downloadError != null || cancelError != null) ? 'failed'
: (state.status === 'running' ? state.phase ?? 'running'
: (state.status === 'ready' ? 'writing' : state.status))
const phase = message (`exportProgress.${phaseKey}`)
const failureMessage = countsErrorMessage (failure ?? state, locale)
const cancellationMessage = countsErrorMessage (cancelError, locale)
return <div className="space-y-3 py-2" aria-busy={busy}>
<p>{label ('exportProgress.format', {
format: message (`exportFormats.${payload.format}`).text })}</p>
@@ -214,9 +265,14 @@ const CountsExportProgress = (
? label ('exportProgress.processed', { count: state.processed })
: label ('exportProgress.measured', {
processed: state.processed, total: state.total ?? 0, percent })}</p>
{downloadError != null && <p role="alert">
{label (`downloadErrors.${downloadError}`)}</p>}
{cancelError && <p role="alert">{label ('common:fetchError')}</p>}
{(downloadError != null || state.status === 'failed') && <p role="alert"
lang={failureMessage.language}>
{failure != null || state.status === 'failed'
? failureMessage.text
: label (`downloadErrors.${downloadError}`)}
{downloadError != null && <><br/>{label ('errors.stopped')}</>}</p>}
{cancelError != null && <p role="alert" lang={cancellationMessage.language}>
{cancellationMessage.text}</p>}
</div>
}
+7 -5
ファイルの表示
@@ -11,14 +11,15 @@ import type { CountSort } from '@/lib/counts'
const CountsSortHeader = (
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true, dragId, totalKind }: {
{ field, label, sorts, change, structured = false, rowSpan, colSpan,
resizable = true, dragId, totalKind, disabled = false }: {
field: string
label: { text: string; language: string }
sorts: CountSort[]
change: (key: string, additive: boolean) => void
structured?: boolean
rowSpan?: number; colSpan?: number; resizable?: boolean
dragId?: string; totalKind?: string },
dragId?: string; totalKind?: string; disabled?: boolean },
) => {
const { message } = useUITranslation ('counts')
const family = useContext (CountsTableScope)
@@ -84,7 +85,7 @@ const CountsSortHeader = (
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
<div ref={heading} className="flex min-w-0 items-center">
{shell?.handle}
<Button type="button" variant="ghost" size="sm"
<Button type="button" variant="ghost" size="sm" disabled={disabled}
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
lang={aria.language} aria-label={aria.text} title={title.text}
onPointerDown={event => {
@@ -141,8 +142,9 @@ const CountsSortHeader = (
</div>
{resizable && <CountsResizeHandle field={field} label={label}/>}
</th>)
return dragId == null ? render () : <SortableItem id={dragId} name={message ('reorder', { column: label.text })}
handleId={`column-${dragId}`} moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
return dragId == null ? render () : <SortableItem id={dragId}
name={message ('reorder', { column: label.text })} handleId={`column-${dragId}`}
moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
}
+46
ファイルの表示
@@ -0,0 +1,46 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import { CountsDefinitionContext } from '@/lib/countsDefinitions'
import { postDefinition } from '@/test/fixtures/counts'
import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts'
const result: CountsResult = {
count: 4, page: 1, limit: 100, groupCount: 1, outputCount: 1,
rows: [{ field: 'resource_id' }], cols: [], columnValuesEditable: false,
rowHeaders: [{ values: ['__counts_private__'], kind: 'data' },
{ values: [], kind: 'grand_total' }],
columnHeaders: [{ values: [], kind: 'grand_total' }],
cells: [{ values: ['__counts_private__'], count: 4 }],
pivotTotals: [{ row: [], column: [], count: 4 }], axisLabels: {} }
describe ('Counts summary privacy and row numbering', () => {
it ('copies masked labels and omits private links and legacy numbering', () => {
const { container } = renderWithProviders (
<CountsDefinitionContext.Provider value={postDefinition}>
<CountsTableLayout><CountsSummaryResults resource="posts" data={result}
params={new URLSearchParams ('mode=summary&row_numbers=1&rows=resource_id&cols=')}
pending={false} update={vi.fn ()}/></CountsTableLayout>
</CountsDefinitionContext.Provider>, { route: '/ja/counts/posts' })
const label = screen.getByText ('********')
expect (label).toHaveAttribute ('title', expect.stringContaining ('集計件数には含まれます'))
expect (label).toHaveClass ('text-red-600', 'dark:text-red-400')
const row = label.closest ('tr')!
expect (row.querySelector ('a')).toBeNull ()
expect (container.innerHTML).not.toContain ('__counts_private__')
expect (screen.queryByText ('No.')).not.toBeInTheDocument ()
expect (row.querySelectorAll ('td')).toHaveLength (2)
const table = screen.getByRole ('grid')
table.focus ()
fireEvent.keyDown (table, { key: 'Home' })
const setData = vi.fn ()
fireEvent.copy (table, { clipboardData: { setData } })
const plain = setData.mock.calls.find (entry => entry[0] === 'text/plain')?.[1]
expect (plain).toContain ('********')
expect (plain).not.toContain ('__counts_private__')
})
})
+28 -26
ファイルの表示
@@ -1,17 +1,17 @@
import SortableColumns from '@/components/common/SortableColumns'
import { countsSummaryValue } from '@/lib/countsDisplay'
import PrefetchLink from '@/components/PrefetchLink'
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 { changeCountSort, countsMaskedValue, countsRequestParams, readCountSort, writeCountSort }
from '@/lib/counts'
import { countsAxisLabel, useCountsDefinition }
from '@/lib/countsDefinitions'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState }
from '@/lib/queryState'
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
@@ -21,12 +21,12 @@ const CountsSummaryTable = (
{ resource, data, params, pending, update, dragIds = {} }: {
dragIds?: Record<string, string>
resource: CountsResource; data: CountsResult; params: URLSearchParams
pending: boolean; update: (key: string, value: string, extra?: Record<string, string>) => void },
pending: boolean
update: (key: string, value: string, extra?: Record<string, string>) => void },
) => {
const definition = useCountsDefinition ()
const locale = useLocale ()
const { message } = useUITranslation ('counts')
const numbered = false
const rows = data.rows ?? []
const cols = data.cols ?? []
const cells = data.cells ?? []
@@ -49,7 +49,8 @@ const CountsSummaryTable = (
const headingLevels = cols.length === 0 ? 1 : levels * 2
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
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)}`
const measures = readCountState<Record<string, string | number | null>[]> (
params, 'measures', [])
const measureKey = (header: CountsPivotHeader) => {
@@ -66,13 +67,14 @@ const CountsSummaryTable = (
const next = new URLSearchParams (params)
const nextSort = changeCountSort (sorts, key, additive)
const active = new Set (nextSort.map (sort => sort.key))
const records = measures.filter (record => record.key !== key && active.has (String (record.key)))
const records = measures.filter (record =>
record.key !== key && active.has (String (record.key)))
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
update ('sort', writeCountSort (nextSort),
queryRecordParams (next, 'measures'))
}
const keys = [...(numbered ? ['row_number'] : []),
const keys = [
...Array.from ({ length: leading }, (_, index) => `a${index}`),
...columnHeaders.map (columnKey)]
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
@@ -86,22 +88,21 @@ const CountsSummaryTable = (
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
const next = new URLSearchParams (params)
const existing = readCountState<Record<string, string | number | null>[]> (params, 'conditions', [])
const existing = readCountState<Record<string, string | number | null>[]> (
params, 'conditions', [])
setCountState (next, 'conditions', encodeCountState ('conditions', [...existing,
...row.values.map ((value, index) => ({ ...rows[index], operator: 'axis_eq', value })),
...column.values.map ((value, index) => ({ ...cols[index], operator: 'axis_eq', value }))]))
...column.values.map ((value, index) => ({
...cols[index], operator: 'axis_eq', value }))]))
next.set ('mode', 'details')
next.delete ('sort')
next.delete ('page')
return `/counts/${resource}?${next}`
}
let ordinal = (data.page - 1) * data.limit
const rowLabels = rowHeaders.map (header => {
const labels = names (header, rows, 0)
// With no row axes the sole result row is not an additional rollup row.
const number = rows.length === 0 ? '1' : (header.kind === 'data' ? String (++ordinal) : '')
if (header.kind !== 'data') labels.push (aggregateLabel (header))
return [...(numbered ? [number] : []),
return [
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
@@ -121,11 +122,10 @@ const CountsSummaryTable = (
return span
}))
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
if (span === 0) labels[column] = ''
}))
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
...Array.from ({ length: leading }, (_, index) =>
level === 0 && rows[index] != null
? countsAxisLabel (rows[index], definition, locale).text : ''),
@@ -142,7 +142,7 @@ const CountsSummaryTable = (
return display (header.values[level], cols[level], rows.length + level)
})])
const axisHeadingRows = cols.map ((axis, level) => [
...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''),
...Array.from ({ length: leading }, () => ''),
...columnHeaders.map ((header, index) => {
if (header.values.length < level) return ''
const previous = columnHeaders[index - 1]
@@ -155,7 +155,7 @@ const CountsSummaryTable = (
: axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
if (cols.length > 0)
{
for (let index = 0; index < leading + (numbered ? 1 : 0); ++index)
for (let index = 0; index < leading; ++index)
{
headingRows[0][index] = valueHeadingRows[0][index]
headingRows[1][index] = ''
@@ -214,6 +214,7 @@ const CountsSummaryTable = (
const field = leaf ? columnKey (header) : sortKey
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
colSpan={span} resizable={leaf}
disabled={header.values.includes (countsMaskedValue)}
rowSpan={(aggregate && cols.length > 0) ? (levels - level) * 2 - 1 : 1}
totalKind={header.kind !== 'data' ? header.kind : undefined}
dragId={level === 0 && header.values.length > 0
@@ -235,7 +236,6 @@ const CountsSummaryTable = (
<thead className="sticky top-0 z-20">
{headingRows.map ((_, tier) => <tr key={tier}>
{tier === 0 && <>
{numbered && <CountsRowNumber rowSpan={headingLevels}/>}
{Array.from ({ length: leading }, (_, index) => rows[index] == null
? <th key={index} rowSpan={headingLevels}/>
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
@@ -249,7 +249,7 @@ const CountsSummaryTable = (
</thead>
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => {
const axis = offset - (numbered ? 1 : 0)
const axis = offset
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
@@ -259,8 +259,8 @@ const CountsSummaryTable = (
data-counts-aggregate={row.kind !== 'data' ? '' : 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',
numbered && offset === 0 && 'text-right tabular-nums')}>{value === '********' ? <span
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700">
{value === '********' ? <span
className="text-red-600 dark:text-red-400"
title={message ('maskedReason').text}>{value}</span> : value}</td>
})}
@@ -273,8 +273,9 @@ const CountsSummaryTable = (
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className="border px-2 py-1 dark:border-slate-700">
{[...row.values, ...column.values].includes ('__counts_private__')
? <span>{count (row, column)}</span> : <PrefetchLink to={drill (row, column)} aria-label={
{[...row.values, ...column.values].includes (countsMaskedValue)
? <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)}`}>
@@ -291,7 +292,8 @@ const CountsSummaryResults = (props: Parameters<typeof CountsSummaryTable>[0]) =
const { message } = useUITranslation ('counts')
const headers = props.data.columnHeaders ?? []
const columns = props.data.cols ?? []
if (columns.length === 0) return <CountsSummaryTable {...props}/>
if (columns.length === 0 || props.data.columnValuesEditable === false)
return <CountsSummaryTable {...props}/>
const groups = [...new Set (headers.filter (header => header.values.length > 0)
.map (header => JSON.stringify (header.values.slice (0, 1))))]
const ids = groups.map ((_, index) => `group${index}`)
+36
ファイルの表示
@@ -162,4 +162,40 @@ describe ('api helpers', () => {
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('aborts one write while a shared CSRF lookup remains pending for another write', async () => {
let complete!: (value: { data: { token: string } }) => void
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
const { apiPost } = await import ('@/lib/api')
const controller = new AbortController ()
const first = apiPost ('/counts/export/jobs', {}, { signal: controller.signal })
const second = apiPost ('/posts', {})
const rejected = expect (first).rejects.toMatchObject ({ name: 'AbortError' })
controller.abort ()
await rejected
complete ({ data: { token: 'shared' } })
await second
expect (mocks.client.get).toHaveBeenCalledWith ('/users/csrf',
expect.objectContaining ({ timeout: 15_000 }))
expect (mocks.client.post).toHaveBeenCalledTimes (1)
expect (mocks.client.post.mock.calls[0][0]).toBe ('/posts')
})
it ('releases native download frames after failure or explicit cancellation', async () => {
const { startAPIDownload } = await import ('@/lib/api')
const failure = vi.fn ()
const release = startAPIDownload ('/counts/export', 'file', failure,
new URLSearchParams ({ job: 'job', attempt: 'attempt' }))
const frame = document.querySelector ('iframe')!
expect (new URL (frame.src).searchParams.get ('attempt')).toBe ('attempt')
frame.dispatchEvent (new Event ('error'))
frame.dispatchEvent (new Event ('error'))
expect (failure).toHaveBeenCalledTimes (1)
expect (frame).not.toBeInTheDocument ()
release ()
const cancel = startAPIDownload ('/counts/export', 'file', failure)
cancel ()
expect (document.querySelector ('iframe')).toBeNull ()
expect (failure).toHaveBeenCalledTimes (1)
})
})
+42 -10
ファイルの表示
@@ -63,7 +63,22 @@ const withSession = (opt?: Opt): AxiosRequestConfig => {
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
const token = await csrfToken ()
const signal = opt?.signal
if (signal?.aborted) throw new DOMException ('Aborted', 'AbortError')
const pending = csrfToken ()
// A shared CSRF lookup must not make an individual aborted write wait forever.
const token = signal == null ? await pending : await new Promise<string> ((resolve, reject) => {
const abort = () => reject (new DOMException ('Aborted', 'AbortError'))
signal.addEventListener ('abort', abort, { once: true })
if (signal.aborted) abort ()
void pending.then (value => {
signal.removeEventListener ('abort', abort)
resolve (value)
}, error => {
signal.removeEventListener ('abort', abort)
reject (error)
})
})
const config = withSession (opt)
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
}
@@ -73,7 +88,8 @@ const csrfToken = async (): Promise<string> => {
const state = csrfState ()
if (state.token != null)
return state.token
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
state.pending ??= client.get<{ token: string }> (
'/users/csrf', { ...withSession (), timeout: 15_000 })
.then (response => {
if (csrfState () !== state)
return csrfToken ()
@@ -223,19 +239,35 @@ export const startAPIDownload = (
): (() => void) => {
const frame = document.createElement ('iframe')
frame.hidden = true
let timer: ReturnType<typeof window.setTimeout> | null = null
let closed = false
const release = () => {
if (closed) return
closed = true
if (timer != null) window.clearTimeout (timer)
frame.remove ()
}
const failure = () => {
if (closed) return
release ()
onError?.()
}
frame.addEventListener ('load', () => {
const document = frame.contentDocument
if (document != null && document.URL !== 'about:blank')
try
{
onError?.()
frame.remove ()
const content = frame.contentDocument
// Attachment navigations normally have no document. An error document is failure.
if (content != null && content.URL !== 'about:blank') failure ()
}
catch
{
// Cross-origin navigation cannot be inspected; the authenticated ACK decides.
}
})
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
frame.addEventListener ('error', failure)
const suffix = query == null ? '' : `?${query.toString ()}`
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}${suffix}`
document.body.append (frame)
// Attachments do not reliably fire load; keep this isolated from page navigation.
const timer = window.setTimeout (() => frame.remove (), 10 * 60_000)
return () => { window.clearTimeout (timer); frame.remove () }
timer = window.setTimeout (release, 10 * 60_000)
return release
}
+22 -1
ファイルの表示
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
import { countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
import { countsErrorMessage, countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
from '@/lib/countsDisplay'
import { countsClipboard } from '@/lib/countsClipboard'
import { createUII18n, translateUIMessage } from '@/lib/i18n'
@@ -157,3 +157,24 @@ describe ('Counts logical display and exports', () => {
expect (isCountsPath (path.split ('?')[0])).toBe (true)
})
})
describe ('Counts safe failure messages', () => {
it.each ([
[400, { error: 'too_many_axes', max_axes: 8 }, '最大8個'],
[422, { error: 'too_many_columns', max_columns: 73 }, '最大73列'],
[503, { error: 'counts_query_timeout' }, '時間制限'],
[503, { error: 'export_worker_unavailable' }, 'ワーカー'],
[400, {}, '入力条件'], [401, {}, '認証状態'], [403, {}, '権限']]) (
'distinguishes HTTP %s with a known safe response', (status, data, expected) => {
const failure = { isAxiosError: true, response: { status, data } }
expect (countsErrorMessage (failure, 'ja').text).toContain (expected)
})
it ('never renders arbitrary exception messages or private identifiers', () => {
const failure = { isAxiosError: true, response: { status: 500,
data: { error: 'SELECT private_title FROM secret_table', message: 'private-user-17' } } }
const text = countsErrorMessage (failure, 'ja').text
expect (text).not.toMatch (/SELECT|private|secret_table/)
expect (countsErrorMessage ({ error: 'worker_stalled' }, 'ja').text).toContain ('停止')
})
})
+3
ファイルの表示
@@ -8,6 +8,8 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
export const countsMaskedValue = '__counts_private__'
export type CountsResource = 'posts' | 'tags' | 'nico_tags'
| 'tag_descriptions' | 'tag_groups' | 'materials'
export type CountsName = TagNameSnapshot & {
@@ -77,6 +79,7 @@ export type CountsResult = {
executionAt?: string
columnValues?: (string | number | null)[][]
cells?: { values: (string | number | null)[]; count: number }[]
columnValuesEditable?: boolean
conditionLabels?: Record<string, Record<string, string | null>>
axisLabels?: Record<string, Record<string, string | null>>
axisNames?: Record<string, Record<string, Record<string, unknown>>>
+35 -2
ファイルの表示
@@ -1,4 +1,5 @@
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
import { isApiError } from '@/lib/api'
import { countsMaskedValue, fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
import { languageUIMessage, translateUIMessage } from '@/lib/i18n'
@@ -12,6 +13,37 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
// Render only known codes and numerical limits, never arbitrary server error text.
export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
const response = isApiError (failure) ? failure.response : null
const body = (response?.data ?? failure) as {
error?: unknown; max_axes?: unknown; maxAxes?: unknown
max_columns?: unknown; maxColumns?: unknown } | null
const code = body != null && typeof body === 'object' ? body.error : null
const message = (key: string, values?: Record<string, string | number>) =>
translateUIMessage (key, 'counts', locale, values)
if (code === 'too_many_axes' || code === 'too_many_columns')
{
const maximum = code === 'too_many_axes' ? body?.maxAxes ?? body?.max_axes
: body?.maxColumns ?? body?.max_columns
if (typeof maximum === 'number')
return message (code === 'too_many_axes' ? 'axisLimit' : 'columnLimit.message', {
max: maximum })
}
if (code === 'private_condition') return message ('privateCondition')
if (code === 'counts_query_timeout' || code === 'generation_timeout')
return message ('errors.queryTimeout')
if (code === 'export_worker_unavailable' || code === 'worker_unavailable')
return message ('errors.workerUnavailable')
if (['worker_stalled', 'interrupted', 'generation_failed', 'export_too_large',
'export_storage_full'].includes (String (code))) return message ('errors.stopped')
if (response?.status === 401) return message ('errors.authentication')
if (response?.status === 403) return message ('errors.permission')
if (response?.status === 400 || response?.status === 422) return message ('errors.input')
return message ('common:fetchError')
}
export const countsFieldValue = (row: CountRecord, field: string): unknown =>
row[field] ?? row[field.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())]
@@ -450,6 +482,7 @@ export const countsConditionValue = (
axis: CountsAxis, locale: LocaleCode, timezone: string,
labels?: Record<string, Record<string, string | null>>,
): string => {
if (value === countsMaskedValue) return '********'
if (axis.field === 'user' && value == null)
return translateUIMessage ('bot', 'counts', locale).text
if (value == null) return 'NULL'
@@ -488,7 +521,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 === countsMaskedValue) 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
+11 -2
ファイルの表示
@@ -5,7 +5,8 @@
"maskedReason",
"downloadErrors",
"axisLimit",
"privateCondition"
"privateCondition",
"errors"
],
"outputRows": "行数:{{value}} 件",
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
@@ -15,5 +16,13 @@
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
},
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
"errors": {
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
"workerUnavailable": "エクスポートワーカーが稼働していません。",
"stopped": "エクスポート処理が停止しました。再実行してください。",
"input": "入力条件が正しくありません。",
"permission": "この操作を実行する権限がありません。",
"authentication": "認証状態を確認して再実行してください。"
}
}
+9 -1
ファイルの表示
@@ -299,5 +299,13 @@
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
},
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
"errors": {
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
"workerUnavailable": "エクスポートワーカーが稼働していません。",
"stopped": "エクスポート処理が停止しました。再実行してください。",
"input": "入力条件が正しくありません。",
"permission": "この操作を実行する権限がありません。",
"authentication": "認証状態を確認して再実行してください。"
}
}
+18 -1
ファイルの表示
@@ -26,7 +26,6 @@ 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)
})
@@ -104,4 +103,22 @@ describe ('Counts execution and URL state', () => {
expect (await screen.findByRole ('alert')).toHaveTextContent ('73')
expect (screen.getByRole ('alert')).toHaveTextContent ('列数が上限')
})
it ('can execute after an initial column-limit failure with no execution timestamp', async () => {
fetchCounts.mockRejectedValueOnce ({ isAxiosError: true,
response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } })
renderPage ()
await screen.findByRole ('alert')
execute ()
await screen.findByText ('Recorded title')
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
expect (fetchCounts).toHaveBeenCalledTimes (2)
})
it ('keeps the page heading and common site title consistent', async () => {
renderPage ()
await screen.findByText ('Recorded title')
await waitFor (() => expect (document.title).toContain ('広場集計 | '))
expect (screen.getByRole ('heading', { name: '広場集計' })).toBeInTheDocument ()
})
})
+4 -10
ファイルの表示
@@ -17,7 +17,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { countsErrorMessage, countsExportLabels } from '@/lib/countsDisplay'
import { isApiError } from '@/lib/api'
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
visibleCountsResources, countsFieldLabel,
@@ -231,13 +231,7 @@ const CountsResourceContent = (
|| 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 failureMessage = countsErrorMessage (failure, locale)
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
@@ -355,7 +349,7 @@ const CountsResourceContent = (
resource={resource} applied={params}
conditionLabels={errorFlg ? undefined : data?.conditionLabels}
conditionLabelsParams={resultParams.toString ()}
pending={running || fetching || !params.has ('execution_at')}
pending={running || fetching}
open={open} setOpen={setOpen}
reorder={value => update ('order', value)}
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
@@ -381,7 +375,7 @@ const CountsResourceContent = (
<p>{label ('columnLimit.title')}</p>
<p>{label ('columnLimit.message', { max: columnLimit })}</p>
</div> : ((definitionError || errorFlg || runError)
&& <p role="alert">{failureMessage}</p>)}
&& <p role="alert" lang={failureMessage.language}>{failureMessage.text}</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">
+9 -5
ファイルの表示
@@ -1,6 +1,10 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
import { afterEach, beforeEach, vi } from 'vitest'
beforeEach (() => {
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
})
afterEach (() => {
cleanup ()
@@ -16,10 +20,10 @@ window.scroll = function () {
;
}
window.requestAnimationFrame = callback => {
callback (0)
return 0
}
// Animation callbacks must advance time and must not recurse synchronously.
window.requestAnimationFrame = callback => window.setTimeout (
() => callback (performance.now ()), 16)
window.cancelAnimationFrame = identifier => window.clearTimeout (identifier)
Object.defineProperty (window, 'matchMedia', {
configurable: true,