このコミットが含まれているのは:
@@ -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
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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__')
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ('停止')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>>>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "認証状態を確認して再実行してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,5 +299,13 @@
|
||||
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
||||
},
|
||||
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
|
||||
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
|
||||
"errors": {
|
||||
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
|
||||
"workerUnavailable": "エクスポートワーカーが稼働していません。",
|
||||
"stopped": "エクスポート処理が停止しました。再実行してください。",
|
||||
"input": "入力条件が正しくありません。",
|
||||
"permission": "この操作を実行する権限がありません。",
|
||||
"authentication": "認証状態を確認して再実行してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする