From d0d17381928662b58d0f64e80f74e1e27a88dd6b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 28 Oct 2026 21:54:35 +0900 Subject: [PATCH] #434 --- frontend/src/components/TopNav.tsx | 6 ++-- .../src/components/common/SortableList.tsx | 22 +++++++++----- frontend/src/components/counts/CountsAxes.tsx | 6 ++-- .../counts/CountsSummaryResults.tsx | 3 +- frontend/src/locales/ja/counts.json | 4 +++ frontend/src/pages/CountsResourcePage.tsx | 30 ++++++++++++++++--- 6 files changed, 51 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 7ffd24c..4904b96 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -12,9 +12,7 @@ import TopNavUser from '@/components/TopNavUser' import TranslatedText from '@/components/TranslatedText' import { toast } from '@/components/ui/use-toast' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' -import { languageUIMessage } from '@/lib/i18n' import { isLocaleCode, - localeLanguage, localeFromPath, switchLocalePath, unlocalisedPath } from '@/lib/locales' @@ -134,7 +132,7 @@ export const menuOutline = ( const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { const { message, label } = useUITranslation ('shared') const locale = useLocale () - const localeName = languageUIMessage (localeLanguage (locale), locale).text + const localeName = locales.find (entry => entry.code === locale)?.name const navigate = useLocaleNavigate () const location = useLocation () const currentLocation = useRef (location) @@ -449,7 +447,7 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { }}> {locales.map (entry => isLocaleCode (entry.code) && ( ))} ) diff --git a/frontend/src/components/common/SortableList.tsx b/frontend/src/components/common/SortableList.tsx index 521c540..eb86fde 100644 --- a/frontend/src/components/common/SortableList.tsx +++ b/frontend/src/components/common/SortableList.tsx @@ -1,5 +1,6 @@ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, - closestCenter, useDroppable, useSensor, useSensors } from '@dnd-kit/core' + closestCenter, pointerWithin, useDroppable, useSensor, useSensors } + from '@dnd-kit/core' import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useId, useRef, useState } from 'react' @@ -25,7 +26,7 @@ const sameGroups = (left: Groups, right: Groups): boolean => const Group = ({ id, children }: { id: string; children: ReactNode }) => { const drop = useDroppable ({ id: `group:${id}` }) return
{children}
} @@ -86,7 +87,12 @@ const SortableList = ( drag.current = null setActive (null) } - return { + const hits = pointerWithin (args) + const available = hits.find (hit => hit.id === 'group:available') + return owner (String (args.active.id), groups) !== 'available' + && available != null ? [available] : closestCenter (args) + }} accessibility={{ announcements: { onDragStart: event => accessibleName (String (event.active.id)).text, onDragOver: event => event.over == null ? undefined : ( @@ -110,15 +116,17 @@ const SortableList = ( }}> {/* Keep indexes and droppable geometry stable until the drop is committed. */} - {Object.entries (groups).map (([group, ids]) =>
+ {Object.entries (groups).map (([group, ids]) => {renderGroup (group, - {ids.map (id => + {ids.map (id => moveGroup (id, direction)}> {renderItem (id, group)} - )} + )} +
)} - )} + )} {typeof document !== 'undefined' && createPortal ( { const { label, message } = useUITranslation ('counts') const definition = useCountsDefinition () - const locale = useLocale () const motion = useClientMotion () const available = countsAxisFields (definition, params.get ('definition') === 'history') const rows = readCountState (params, 'rows', []) @@ -31,7 +29,7 @@ const CountsAxes = ({ params, update }: { const name = (id: string) => { const axis = axes.find (item => item.field === id) return axis == null ? message (available.find (field => field.key === id)?.label ?? 'axis') - : countsAxisLabel (axis, definition, locale) + : message (countsFieldLabel (axis.field)) } const commit = (next: Record) => { for (const group of ['rows', 'cols'] as const) diff --git a/frontend/src/components/counts/CountsSummaryResults.tsx b/frontend/src/components/counts/CountsSummaryResults.tsx index 3deae0f..5f21f07 100644 --- a/frontend/src/components/counts/CountsSummaryResults.tsx +++ b/frontend/src/components/counts/CountsSummaryResults.tsx @@ -226,7 +226,8 @@ const CountsSummaryTable = ( return output } return
- key !== 'row_number').length > 1} copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })), headerRows: headingRows, rows: matrix })} identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`} diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json index 6be0f0b..ef157fd 100644 --- a/frontend/src/locales/ja/counts.json +++ b/frontend/src/locales/ja/counts.json @@ -13,6 +13,10 @@ "localeAliases": "別名({{language}})", "localeAliasesWithScript": "別名({{language}}・{{script}})", "run": "集計実行", + "columnLimit": { + "title": "列数が上限を超えてゐます", + "message": "集計表の列は最大{{max}}列です。列の項目を変更するか、抽出条件で対象を絞り込んでください。" + }, "conditions": "集計条件", "output": "出力項目", "filters": "抽出条件", diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 29b8d0d..882ff28 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button' import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts, isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts' import { countsExportLabels } from '@/lib/countsDisplay' +import { isApiError } from '@/lib/api' import { CountsDefinitionContext, countsAxisFields, countsAxisLabel, visibleCountsResources, countsFieldLabel, fetchCountsDefinition } @@ -42,6 +43,16 @@ type PendingCountsRun = { target?: string } +const tooManyColumns = (failure: unknown): number | null => { + if (!isApiError (failure) || failure.response?.status !== 422) return null + const body = (failure.response.data ?? { }) as { + error?: unknown; maxColumns?: unknown; max_columns?: unknown } + const maximum = body.maxColumns ?? body.max_columns + return body.error === 'too_many_columns' && typeof maximum === 'number' + ? maximum : null +} + + const CountsResourceContent = ( { resource, definition, definitionError }: { resource: CountsResource; definition?: CountsDefinition; definitionError: boolean }, @@ -85,6 +96,7 @@ const CountsResourceContent = ( cacheParams.set ('definition', state ? 'latest' : 'history') const [running, setRunning] = useState (false) const [runError, setRunError] = useState (false) + const [runColumnLimit, setRunColumnLimit] = useState (null) const pendingRun = useRef (null) const applied = useRef (params) const retainedParams = useRef (params) @@ -92,7 +104,8 @@ const CountsResourceContent = ( const [exporting, setExporting] = useState (false) const exportInFlight = useRef (false) const [exportError, setExportError] = useState (false) - const { data, dataUpdatedAt, isFetching: fetching, isPending: pending, isError: errorFlg, + const { data, error: queryError, dataUpdatedAt, isFetching: fetching, + isPending: pending, isError: errorFlg, isPlaceholderData: placeholder } = useQuery ({ enabled: definition != null && params.has ('timezone') && !running, staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0, @@ -124,6 +137,7 @@ const CountsResourceContent = ( useEffect (() => { setRunning (false) setRunError (false) + setRunColumnLimit (null) return () => { const pending = pendingRun.current pendingRun.current = null @@ -162,6 +176,7 @@ const CountsResourceContent = ( const previous = new URLSearchParams (params) pendingRun.current = pending setRunError (false) + setRunColumnLimit (null) setRunning (true) try { @@ -199,11 +214,12 @@ const CountsResourceContent = ( } else setParams (committed, { preventScrollReset: true }) } - catch + catch (failure) { if (pendingRun.current !== pending) return pendingRun.current = null setRunning (false) + setRunColumnLimit (tooManyColumns (failure)) setRunError (true) } } @@ -212,9 +228,11 @@ const CountsResourceContent = ( const waiting = running || (!definitionError && (definition == null || pending || fetching || placeholder || (!params.has ('execution_at') && !errorFlg))) + const columnLimit = runColumnLimit ?? tooManyColumns (queryError) const update = (key: string, value: string, extra?: Record) => { + setRunColumnLimit (null) const next = new URLSearchParams (params) if (value || ['sort', 'columns', 'children', 'conditions', 'drill'].includes (key)) setCountState (next, key, value) @@ -275,7 +293,7 @@ const CountsResourceContent = (
- + @@ -347,7 +365,11 @@ const CountsResourceContent = ( {exportError &&

{label ('common:fetchError')}

}
- {(definitionError || errorFlg || runError) &&

{label ('common:fetchError')}

} + {columnLimit != null ?
+

{label ('columnLimit.title')}

+

{label ('columnLimit.message', { max: columnLimit })}

+
: ((definitionError || errorFlg || runError) + &&

{label ('common:fetchError')}

)}