このコミットが含まれているのは:
@@ -3,7 +3,8 @@ import { countsSummaryValue } from '@/lib/countsDisplay'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import { changeCountSort, countsMaskedValue, countsRequestParams, readCountSort, writeCountSort }
|
||||
import { changeCountSort, countsMaskedValue, countsRequestParams, countsResourcePath,
|
||||
readCountSort, writeCountSort }
|
||||
from '@/lib/counts'
|
||||
import { countsAxisLabel, useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
@@ -97,7 +98,7 @@ const CountsSummaryTable = (
|
||||
next.set ('mode', 'details')
|
||||
next.delete ('sort')
|
||||
next.delete ('page')
|
||||
return `/counts/${resource}?${next}`
|
||||
return `${countsResourcePath (resource)}?${next}`
|
||||
}
|
||||
const rowLabels = rowHeaders.map (header => {
|
||||
const labels = names (header, rows, 0)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
|
||||
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams, countsResourcePath,
|
||||
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
|
||||
import { countsErrorMessage, countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
|
||||
from '@/lib/countsDisplay'
|
||||
@@ -14,6 +14,30 @@ import type { CountsCondition } from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
describe ('Counts URL contract', () => {
|
||||
it ('distinguishes HTTP, network and internal failures without server details', () => {
|
||||
const http = { isAxiosError: true, response: { status: 503, data: '<private SQL>' } }
|
||||
expect (countsErrorMessage (http, 'ja').text).toContain ('HTTP 503')
|
||||
expect (countsErrorMessage (http, 'ja').text).not.toContain ('private SQL')
|
||||
expect (countsErrorMessage ({ isAxiosError: true }, 'ja').text)
|
||||
.toBe ('APIサーバーとの通信に失敗しました。')
|
||||
expect (countsErrorMessage (new Error ('private stack'), 'ja').text)
|
||||
.toBe ('内部処理で問題が発生しました。')
|
||||
})
|
||||
it ('explains a totals timeout using the server time limit', () => {
|
||||
const failure = { isAxiosError: true, response: { status: 503,
|
||||
data: { error: 'counts_query_timeout', stage: 'totals', timeout_seconds: 30 } } }
|
||||
const text = countsErrorMessage (failure, 'ja').text
|
||||
expect (text).toContain ('集計処理が制限時間を超えました。')
|
||||
expect (text).toContain ('小計・総計の算出が30秒以内に完了しませんでした。')
|
||||
expect (text).toContain ('集計期間や行・列の項目を減らして')
|
||||
expect (countsErrorMessage (failure, 'ain-Kana').language).toBe ('ja')
|
||||
})
|
||||
it ('uses hyphenated page paths for internal resource identifiers', () => {
|
||||
expect (countsResourcePath ('nico_tags')).toBe ('/counts/nico-tags')
|
||||
expect (countsResourcePath ('tag_descriptions')).toBe ('/counts/tag-descriptions')
|
||||
expect (countsResourcePath ('tag_groups')).toBe ('/counts/tag-groups')
|
||||
expect (countsResourcePath ('posts')).toBe ('/counts/posts')
|
||||
})
|
||||
it ('defaults to latest summary, limit 100, and no axes', () => {
|
||||
const params = normaliseCountsParams ('posts', new URLSearchParams (), postDefinition)
|
||||
expect (params.get ('definition')).toBe ('latest')
|
||||
|
||||
@@ -12,6 +12,10 @@ export const countsMaskedValue = '__counts_private__'
|
||||
|
||||
export type CountsResource = 'posts' | 'tags' | 'nico_tags'
|
||||
| 'tag_descriptions' | 'tag_groups' | 'materials'
|
||||
|
||||
export const countsResourcePath = (resource: CountsResource): string =>
|
||||
`/counts/${resource.replaceAll ('_', '-')}`
|
||||
|
||||
export type CountsName = TagNameSnapshot & {
|
||||
languageCode: string; languageName?: string | null; scriptName?: string | null }
|
||||
export type CountsTag = {
|
||||
|
||||
@@ -17,7 +17,8 @@ import type { LocaleCode } from '@/lib/locales'
|
||||
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
|
||||
error?: unknown; stage?: unknown; timeout_seconds?: unknown; timeoutSeconds?: 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>) =>
|
||||
@@ -31,6 +32,11 @@ export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
|
||||
max: maximum })
|
||||
}
|
||||
if (code === 'private_condition') return message ('privateCondition')
|
||||
if (code === 'counts_query_timeout' && body?.stage === 'totals')
|
||||
{
|
||||
const seconds = body.timeoutSeconds ?? body.timeout_seconds
|
||||
if (typeof seconds === 'number') return message ('errors.totalsTimeout', { seconds })
|
||||
}
|
||||
if (code === 'counts_query_timeout' || code === 'generation_timeout')
|
||||
return message ('errors.queryTimeout')
|
||||
if (code === 'export_worker_unavailable' || code === 'worker_unavailable')
|
||||
@@ -40,7 +46,9 @@ export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
|
||||
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')
|
||||
if (response != null) return message ('errors.http', { status: response.status })
|
||||
if (isApiError (failure)) return message ('errors.network')
|
||||
return message ('errors.internal')
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +166,7 @@ export const countsLogicalName = (
|
||||
if (value == null) return { text: '' }
|
||||
if (field.startsWith ('aliases_') && Array.isArray (value))
|
||||
return { text: value.map (entry => typeof entry === 'string' ? entry
|
||||
: String (entry?.name ?? '')).join ('\n') }
|
||||
: String (entry?.name ?? '')).join (' ') }
|
||||
if (field === 'sections')
|
||||
return { text: (countsSectionRanges (value, locale) ?? []).join (
|
||||
translateUIMessage ('sections.separator', 'counts', locale).text) }
|
||||
@@ -528,6 +536,8 @@ export const countsSummaryValue = (
|
||||
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
|
||||
if (axis.field === 'user') return message ('actorWithId', {
|
||||
name: logical || message ('notRegistered'), id: value })
|
||||
if (axis.field === 'parent_tag_ids')
|
||||
return String (value) === '' ? '' : logical || message ('notRegistered')
|
||||
if (logical != null) return logical
|
||||
if (axis.field === 'tags')
|
||||
return message ('notRegistered')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isCountsState, selectedCountFields } from '@/lib/counts'
|
||||
import { countsResourcePath, isCountsState, selectedCountFields } from '@/lib/counts'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
|
||||
import type { CountField, CountsResource, CountsResult } from '@/lib/counts'
|
||||
@@ -90,5 +90,5 @@ export const countsSummaryDrill = (
|
||||
next.set ('before',
|
||||
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
|
||||
}
|
||||
return `/counts/${resource}?${next}`
|
||||
return `${countsResourcePath (resource)}?${next}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"_untranslated": ["errors.totalsTimeout", "errors.http", "errors.network",
|
||||
"errors.internal", "errors.exportStart"],
|
||||
"titles": {
|
||||
"posts": "ミンタㇻ ウコピㇱキ",
|
||||
"tags": "シロシ ウコピㇱキ",
|
||||
@@ -301,6 +303,11 @@
|
||||
"axisLimit": "ウコピㇱキ アㇰシㇱ {{max}} パㇰノ ネ。イキㇼ ネワ コラム オルㇱペ ポンノ カㇻ ヤン。",
|
||||
"privateCondition": "ソモ アヌカレ オルㇱペ アシッケウ クス、タン イフナラ イアヌ アニ ウコピㇱキ エアイカㇷ゚。",
|
||||
"errors": {
|
||||
"http": "HTTP {{status}} エラーが発生しました。管理者にサーバーログの確認を依頼してください。",
|
||||
"network": "APIサーバーとの通信に失敗しました。",
|
||||
"internal": "内部処理で問題が発生しました。",
|
||||
"exportStart": "エクスポートを開始できませんでした。",
|
||||
"totalsTimeout": "集計処理が制限時間を超えました。\n\n小計・総計の算出が{{seconds}}秒以内に完了しませんでした。集計期間や行・列の項目を減らして再実行してください。",
|
||||
"queryTimeout": "ウコピㇱキ イカㇻ トキ オケレ。イフナラ イアヌ ネ ヤ ウコピㇱキ アㇰシㇱ ポンノ カㇻ ワ スイ キ ヤン。",
|
||||
"workerUnavailable": "エクスポート ワーカー イカㇻ ソモ キ。",
|
||||
"stopped": "エクスポート イカㇻ オケレ ソモ キ。スイ キ ヤン。",
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
"blank": "空欄",
|
||||
"additionalConditions": "追加条件",
|
||||
"changedField": "変更項目",
|
||||
"eventType": "異動区分",
|
||||
"eventType": "異動等の事実",
|
||||
"count": "件数",
|
||||
"outputTotal": "件数:{{value}} 件",
|
||||
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
|
||||
@@ -246,7 +246,7 @@
|
||||
"sourceLanguage": "翻訳元の言語",
|
||||
"source_language_code": "翻訳元の言語",
|
||||
"source_version_no": "翻訳元の版番号",
|
||||
"event_type": "異動区分",
|
||||
"event_type": "異動等の事実",
|
||||
"body": "本文",
|
||||
"linked_tags": "対応タグ",
|
||||
"description": "説明",
|
||||
@@ -301,6 +301,11 @@
|
||||
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
|
||||
"errors": {
|
||||
"http": "HTTP {{status}} エラーが発生しました。管理者にサーバーログの確認を依頼してください。",
|
||||
"network": "APIサーバーとの通信に失敗しました。",
|
||||
"internal": "内部処理で問題が発生しました。",
|
||||
"exportStart": "エクスポートを開始できませんでした。",
|
||||
"totalsTimeout": "集計処理が制限時間を超えました。\n\n小計・総計の算出が{{seconds}}秒以内に完了しませんでした。集計期間や行・列の項目を減らして再実行してください。",
|
||||
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
|
||||
"workerUnavailable": "エクスポートワーカーが稼働していません。",
|
||||
"stopped": "エクスポート処理が停止しました。再実行してください。",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
@@ -15,7 +16,8 @@ import Pagination from '@/components/common/Pagination'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
|
||||
import { countsResourcePath, countsRequestParams, countsDefinitionParams,
|
||||
countsExportPayload, fieldsForCounts, fetchCounts,
|
||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||
import { countsErrorMessage, countsExportLabels } from '@/lib/countsDisplay'
|
||||
import { isApiError } from '@/lib/api'
|
||||
@@ -52,10 +54,10 @@ const tooManyColumns = (failure: unknown): number | null => {
|
||||
|
||||
|
||||
const CountsResourceContent = (
|
||||
{ resource, definition, definitionError }: {
|
||||
{ resource, definition, definitionError, definitionFailure }: {
|
||||
resource: CountsResource
|
||||
definition?: CountsDefinition
|
||||
definitionError: boolean },
|
||||
definitionError: boolean; definitionFailure?: unknown },
|
||||
) => {
|
||||
const queryClient = useQueryClient ()
|
||||
const [mounted, setMounted] = useState (false)
|
||||
@@ -106,7 +108,7 @@ const CountsResourceContent = (
|
||||
const requestKey = request.toString ()
|
||||
const [exporting, setExporting] = useState (false)
|
||||
const exportInFlight = useRef (false)
|
||||
const [exportError, setExportError] = useState (false)
|
||||
const [exportError, setExportError] = useState<{ failure: unknown } | null> (null)
|
||||
const { data: cachedData, error: queryError, dataUpdatedAt, isFetching: fetching,
|
||||
isPending: pending, isError: errorFlg,
|
||||
isPlaceholderData: placeholder } = useQuery ({
|
||||
@@ -236,7 +238,7 @@ const CountsResourceContent = (
|
||||
const waiting = running || (!definitionError && (definition == null
|
||||
|| pending || fetching || placeholder
|
||||
|| (!params.has ('execution_at') && !errorFlg)))
|
||||
const failure = runError ? runFailure : queryError
|
||||
const failure = definitionError ? definitionFailure : (runError ? runFailure : queryError)
|
||||
const failureMessage = countsErrorMessage (failure, locale)
|
||||
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
|
||||
|
||||
@@ -263,7 +265,7 @@ const CountsResourceContent = (
|
||||
|
||||
exportInFlight.current = true
|
||||
setExporting (true)
|
||||
setExportError (false)
|
||||
setExportError (null)
|
||||
try
|
||||
{
|
||||
const axes = [...readCountState<CountsAxis[]> (params, 'rows', []),
|
||||
@@ -291,9 +293,9 @@ const CountsResourceContent = (
|
||||
handle={handle} controls={controls}/> })
|
||||
|
||||
}
|
||||
catch
|
||||
catch (failure)
|
||||
{
|
||||
setExportError (true)
|
||||
setExportError ({ failure })
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -344,7 +346,7 @@ const CountsResourceContent = (
|
||||
return (
|
||||
<li key={definition}>
|
||||
<PrefetchLink
|
||||
to={`/counts/${target}?${next}`}
|
||||
to={`${countsResourcePath (target)}?${next}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn ('block py-1', active && 'font-bold underline')}>
|
||||
{label (definition)}
|
||||
@@ -385,16 +387,20 @@ const CountsResourceContent = (
|
||||
{label (`exportFormats.${format}`)}</Button>)}
|
||||
</div>}/>}
|
||||
|
||||
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
{exportError != null && <p role="alert">
|
||||
{label ('errors.exportStart')}<br/>
|
||||
{countsErrorMessage (exportError.failure, locale).text}</p>}
|
||||
</div>
|
||||
{columnLimit != null ? <div role="alert">
|
||||
<p>{label ('columnLimit.title')}</p>
|
||||
<p>{label ('columnLimit.message', { max: columnLimit })}</p>
|
||||
</div> : ((definitionError || errorFlg || runError)
|
||||
&& <p role="alert" lang={failureMessage.language}>{failureMessage.text}</p>)}
|
||||
&& <p role="alert" lang={failureMessage.language}
|
||||
className="whitespace-pre-line">{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">
|
||||
<div inert={waiting} aria-hidden={running || undefined}
|
||||
className={cn ('flex min-h-0 flex-1 flex-col', running && 'invisible')}>
|
||||
{mounted && definition != null && data != null && <>
|
||||
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||
@@ -413,10 +419,12 @@ const CountsResourceContent = (
|
||||
</>}
|
||||
</div>
|
||||
{waiting && <div role="status" aria-live="polite"
|
||||
className="pointer-events-none absolute right-2 top-2">
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="rounded border border-slate-300 bg-white px-2 py-1
|
||||
text-sm text-slate-900 dark:border-slate-600
|
||||
inline-flex items-center gap-2 text-sm text-slate-900 dark:border-slate-600
|
||||
dark:bg-slate-950 dark:text-slate-100">
|
||||
<LoaderCircle aria-hidden className={cn (
|
||||
'h-5 w-5', !motion.reduced && 'animate-spin')}/>
|
||||
{label ('shared:messages.loadingEllipsis')}</span>
|
||||
</div>}
|
||||
</div>
|
||||
@@ -428,11 +436,12 @@ const CountsResourceContent = (
|
||||
|
||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||
const locale = useLocale ()
|
||||
const { data, isError: errorFlg } = useQuery ({
|
||||
const { data, error, isError: errorFlg } = useQuery ({
|
||||
queryKey: countsKeys.definition (resource, locale),
|
||||
queryFn: () => fetchCountsDefinition (resource, locale), staleTime: Infinity })
|
||||
return <CountsDefinitionContext.Provider value={data ?? null}>
|
||||
<CountsResourceContent resource={resource} definition={data} definitionError={errorFlg}/>
|
||||
<CountsResourceContent resource={resource} definition={data}
|
||||
definitionError={errorFlg} definitionFailure={error}/>
|
||||
</CountsDefinitionContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする