このコミットが含まれているのは:
@@ -21,21 +21,36 @@ const ConditionEditor = (
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const fields = countsFilterFields (definition)
|
||||
const [field, setField] = useState (initial?.field ?? 'resource_id')
|
||||
const [operator, setOperator] = useState (initial?.operator ?? 'eq')
|
||||
const [condition, setCondition] = useState<CountsCondition> (() => {
|
||||
const next = { ...initial, field: initial?.field ?? 'resource_id',
|
||||
operator: initial?.operator ?? 'eq' }
|
||||
if (next.operator !== 'range') delete next.end
|
||||
return next
|
||||
})
|
||||
const { field, operator } = condition
|
||||
const operators = definition.fields.find (item => item.key === field)?.operators ?? []
|
||||
useEffect (() => {
|
||||
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
||||
onSelect: () => save ({ ...initial, field, operator }) }])
|
||||
}, [controls, field, operator, initial, label, save])
|
||||
onSelect: () => save (condition) }])
|
||||
}, [controls, condition, initial, label, save])
|
||||
return <div className="space-y-2">
|
||||
<select value={field} aria-label={message ('field').text} className={inputClass ()}
|
||||
onChange={event => { setField (event.target.value); setOperator ('eq') }}>
|
||||
onChange={event => {
|
||||
const field = event.target.value
|
||||
const operator = definition.fields.find (item => item.key === field)?.operators[0]
|
||||
if (operator != null) setCondition ({ field, operator })
|
||||
}}>
|
||||
{Object.keys (fields).filter (key => fields[key] !== 'json').map (key => (
|
||||
<option key={key} value={key}>{message (countsFieldLabel (key)).text}</option>))}
|
||||
</select>
|
||||
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
|
||||
onChange={event => setOperator (event.target.value)}>
|
||||
onChange={event => setCondition (previous => {
|
||||
const operator = event.target.value
|
||||
const next = { ...previous, operator }
|
||||
if (operator !== 'range') delete next.end
|
||||
if ((operator === 'like') !== (previous.operator === 'like')) delete next.value
|
||||
return next
|
||||
})}>
|
||||
{operators.map (key => <option key={key} value={key}>
|
||||
{message (`operators.${key}`).text}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -11,6 +11,7 @@ import TagInput from '@/components/common/TagInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsUseLatest, fieldsForCounts, normaliseCountsParams,
|
||||
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
||||
import { countsNestedIsScalar } from '@/lib/countsDisplay'
|
||||
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
@@ -43,11 +44,13 @@ const CountsControls = (
|
||||
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
|
||||
const mode = draft.get ('mode') ?? 'summary'
|
||||
const columns = selectedCountColumns (resource, draft, definition)
|
||||
const children = selectedCountChildren (draft)
|
||||
const details = mode === 'details'
|
||||
const hasNested = details && fieldsForCounts (resource, definition).some (field =>
|
||||
columns.includes (field.key) && field.children?.some (child =>
|
||||
children.has (`${field.key}.${child.key}`)))
|
||||
columns.includes (field.key) && field.children != null
|
||||
&& !countsNestedIsScalar (field.children, field.key, draft)
|
||||
&& field.children.some (child => selectedCountChildren (draft)
|
||||
.has (`${field.key}.${child.key}`)))
|
||||
// Immediate presentation changes merge into the pending query, never replace it.
|
||||
useEffect (() => {
|
||||
setDraft (previous => {
|
||||
const next = new URLSearchParams (previous)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
export type CountsExportHandle = { cancel: () => Promise<boolean> }
|
||||
type ExportStatus = {
|
||||
status: 'starting' | 'queued' | 'running' | 'cancelling' | 'cancelled' | 'ready' | 'failed'
|
||||
processed: number; total: number | null; token?: string }
|
||||
|
||||
|
||||
const CountsExportProgress = (
|
||||
{ payload, locale, handle, controls }: {
|
||||
payload: Record<string, string>; locale: LocaleCode
|
||||
handle: CountsExportHandle; controls: DialogueFormControls },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const [state, setState] = useState<ExportStatus> ({
|
||||
status: 'starting', processed: 0, total: null })
|
||||
const [cancelError, setCancelError] = useState (false)
|
||||
const terminal = useRef (false)
|
||||
const cancelled = useRef (false)
|
||||
const job = useRef<string | null> (null)
|
||||
const creation = useRef<Promise<{ job: string }> | null> (null)
|
||||
const downloaded = useRef (false)
|
||||
useEffect (() => {
|
||||
handle.cancel = async () => {
|
||||
if (terminal.current) return true
|
||||
cancelled.current = true
|
||||
if (job.current != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
setCancelError (false)
|
||||
const result = await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (job.current)}`, { locale })
|
||||
setState (result)
|
||||
}
|
||||
catch
|
||||
{
|
||||
setCancelError (true)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}, [handle, locale])
|
||||
useEffect (() => {
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const poll = async (token: string) => {
|
||||
try
|
||||
{
|
||||
const response = await apiGet<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale })
|
||||
if (!active) return
|
||||
const result = (cancelled.current && response.status === 'ready')
|
||||
? await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale }) : response
|
||||
if (!active) return
|
||||
setState (result)
|
||||
terminal.current = ['ready', 'failed', 'cancelled'].includes (result.status)
|
||||
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
||||
{
|
||||
downloaded.current = true
|
||||
startAPIDownload ('/counts/export', result.token, () => {
|
||||
if (active) setState (previous => ({ ...previous, status: 'failed' }))
|
||||
})
|
||||
}
|
||||
if (!terminal.current) timer = setTimeout (() => poll (token), 800)
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
if (isApiError (error) && error.response?.status === 404)
|
||||
{
|
||||
terminal.current = true
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
return
|
||||
}
|
||||
setCancelError (true)
|
||||
timer = setTimeout (() => poll (token), 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
creation.current ??= apiPost<{ job: string }> ('/counts/export/jobs', payload, { locale })
|
||||
creation.current.then (async result => {
|
||||
if (!active) return
|
||||
job.current = result.job
|
||||
if (cancelled.current) await handle.cancel ()
|
||||
await poll (result.job)
|
||||
}).catch (() => {
|
||||
if (active)
|
||||
{
|
||||
terminal.current = true
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
if (timer != null) clearTimeout (timer)
|
||||
}
|
||||
}, [handle, locale, payload])
|
||||
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 phase = message (`exportProgress.${state.status}`)
|
||||
return <div className="space-y-3 py-2">
|
||||
<p>{label ('exportProgress.format', {
|
||||
format: message (`exportFormats.${payload.format}`).text })}</p>
|
||||
<p role="status" aria-live="polite">
|
||||
<LocalisedText language={phase.language}>{phase.text}</LocalisedText>
|
||||
</p>
|
||||
<progress className="h-3 w-full" max={100} value={percent ?? undefined}
|
||||
aria-label={phase.text} lang={phase.language}/>
|
||||
<p aria-live="polite">{percent == null
|
||||
? label ('exportProgress.processed', { count: state.processed })
|
||||
: label ('exportProgress.measured', {
|
||||
processed: state.processed, total: state.total ?? 0, percent })}</p>
|
||||
{cancelError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default CountsExportProgress
|
||||
@@ -5,7 +5,8 @@ import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import { changeCountSort, readCountSort, selectedCountChildren,
|
||||
writeCountSort, orderedCountFields } from '@/lib/counts'
|
||||
import { countsLogicalName, countsNestedClipboardTable, countsNestedExport,
|
||||
countsNestedOrder, countsNestedText } from '@/lib/countsDisplay'
|
||||
countsNestedOrder, countsNestedText, countsNestedIsScalar, countsNestedScalarText }
|
||||
from '@/lib/countsDisplay'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
@@ -54,14 +55,15 @@ const CountsNestedTable = (
|
||||
return <LocalisedText language={row.languageCode}>{String (value)}</LocalisedText>
|
||||
return display.text
|
||||
}
|
||||
if (countsNestedIsScalar (fields, path, params))
|
||||
return <span>{countsNestedScalarText (rows, fields, path, params, locale)}</span>
|
||||
return (
|
||||
<div className="counts-nested w-full min-w-0 max-w-full">
|
||||
{params.get ('nested_json') === '1' ? (
|
||||
<pre data-counts-scroll
|
||||
aria-label={title.text} lang={title.language}
|
||||
className="max-h-72 w-full overflow-auto whitespace-pre-wrap
|
||||
break-words text-xs">
|
||||
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale), null, 2)}
|
||||
className="max-h-72 w-full overflow-auto whitespace-pre text-xs">
|
||||
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale))}
|
||||
</pre>) : (columns.length > 0 && rows.length > 0 && (
|
||||
<div data-counts-scroll
|
||||
className="relative isolate max-h-72 w-full min-w-0 overflow-auto">
|
||||
@@ -87,7 +89,7 @@ const CountsNestedTable = (
|
||||
<tr key={index} className="border-t dark:border-slate-700">
|
||||
{columns.map (field => (
|
||||
<td key={field.key}
|
||||
className="overflow-hidden break-words border-t px-2 py-1
|
||||
className="overflow-hidden break-words border-t px-3 py-2
|
||||
align-top dark:border-slate-700"
|
||||
data-counts-copy={
|
||||
field.children
|
||||
|
||||
@@ -156,6 +156,22 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
},
|
||||
[closeRequest, pendingIds])
|
||||
|
||||
const dismiss = async (request: DialogueRequest) => {
|
||||
if (request.kind === 'form' && request.options.onCancel != null)
|
||||
{
|
||||
setPendingIds (current => [...current, request.id])
|
||||
try
|
||||
{
|
||||
if (await request.options.onCancel () === false) return
|
||||
}
|
||||
finally
|
||||
{
|
||||
setPendingIds (current => current.filter (id => id !== request.id))
|
||||
}
|
||||
}
|
||||
closeRequest (request.id, request.kind === 'confirm' ? false : null)
|
||||
}
|
||||
|
||||
const active = queue[0]
|
||||
const startActions =
|
||||
active?.kind === 'form'
|
||||
@@ -179,7 +195,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
nestedConfirm?.parentId === active.id
|
||||
|| pendingIds.includes (active.id)
|
||||
if (!(open) && !(blocked))
|
||||
closeRequest (active.id, active.kind !== 'confirm' && null)
|
||||
dismiss (active)
|
||||
}}>
|
||||
<DialogContent
|
||||
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
|
||||
@@ -244,7 +260,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={() => closeRequest (active.id)}
|
||||
onClick={() => dismiss (active)}
|
||||
disabled={pendingIds.includes (active.id)
|
||||
|| nestedConfirm?.parentId === active.id}>
|
||||
{active.options.cancelText ?? label ('actions.cancel')}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
@@ -37,14 +38,17 @@ const DialogContent = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
& { closeLabel?: React.ReactNode }
|
||||
>(
|
||||
({ className, children, closeLabel, ...props }, ref) => {
|
||||
({ className, children, closeLabel, style, ...props }, ref) => {
|
||||
const { i18n } = useTranslation ('shared')
|
||||
const motion = useClientMotion ()
|
||||
const animationDuration = `${motion.duration ('popover')}ms`
|
||||
const close = translateUIMessage ('dialogue.close', 'shared', i18n.language)
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay/>
|
||||
<DialogOverlay style={{ animationDuration }}/>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
style={{ animationDuration, ...style }}
|
||||
className={cn (
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
|
||||
'translate-x-[-50%] translate-y-[-50%]',
|
||||
|
||||
@@ -211,6 +211,15 @@ export const apiDownload = async (
|
||||
const { token } = await apiPost<{ token: string }> (`${path}/prepare`, body, { locale })
|
||||
if (typeof token !== 'string' || token.length === 0)
|
||||
throw new Error ('Missing download token')
|
||||
startAPIDownload (path, token, onError)
|
||||
}
|
||||
|
||||
|
||||
export const startAPIDownload = (
|
||||
path: string,
|
||||
token: string,
|
||||
onError?: () => void,
|
||||
): void => {
|
||||
const frame = document.createElement ('iframe')
|
||||
frame.hidden = true
|
||||
frame.addEventListener ('load', () => {
|
||||
|
||||
+33
-11
@@ -206,10 +206,16 @@ export const selectedCountColumns = (
|
||||
params: URLSearchParams,
|
||||
definition?: CountsDefinition,
|
||||
): CountColumn[] => {
|
||||
const selected = params.has ('columns') ? params.get ('columns')!.split (',') :
|
||||
columnsForCounts (resource, definition).filter (key => ![
|
||||
'thumbnail', 'original_created_from', 'original_created_before']
|
||||
.includes (key))
|
||||
const common = ['resource_id', 'version_no', 'event_type', 'user', 'created_at']
|
||||
const defaults: Record<CountsResource, string[]> = {
|
||||
posts: ['title', 'url', 'tags', 'parents', 'video_ms'],
|
||||
tags: ['name', 'category', 'aliases', 'parents'],
|
||||
nico_tags: ['name', 'linked_tags'],
|
||||
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
||||
tag_groups: ['name', 'description', 'members'],
|
||||
materials: ['url', 'source_kind', 'tag_id'] }
|
||||
const selected = params.has ('columns') ? params.get ('columns')!.split (',')
|
||||
: [...common, ...defaults[resource]]
|
||||
return orderedCountFields (fieldsForCounts (resource, definition), params)
|
||||
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
||||
}
|
||||
@@ -223,9 +229,8 @@ export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
|
||||
|
||||
|
||||
export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
|
||||
new Set (params.has ('children') ? params.get ('children')!.split (',') :
|
||||
Object.entries (nested).flatMap (([key, fields]) =>
|
||||
countChildPaths (fields ?? [], key)))
|
||||
new Set (params.has ('children') ? params.get ('children')!.split (',')
|
||||
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
|
||||
|
||||
|
||||
export const readCountSort = (value: string | null): CountSort[] =>
|
||||
@@ -342,15 +347,32 @@ export const exportCounts = (
|
||||
headers: string[],
|
||||
labels: Record<string, string>,
|
||||
onError?: () => void,
|
||||
): Promise<void> => {
|
||||
): Promise<void> => apiDownload ('/counts/export', countsExportPayload (
|
||||
resource, params, format, columns, headers, labels), locale, onError)
|
||||
|
||||
|
||||
export const countsExportPayload = (
|
||||
resource: CountsResource,
|
||||
params: URLSearchParams,
|
||||
format: 'json' | 'csv',
|
||||
columns: string[],
|
||||
headers: string[],
|
||||
labels: Record<string, string>,
|
||||
definition?: CountsDefinition,
|
||||
): Record<string, string> => {
|
||||
const payload = new URLSearchParams (params)
|
||||
if (!payload.has ('children'))
|
||||
{
|
||||
const selected = selectedCountChildren (params)
|
||||
payload.set ('children', countChildPaths (fieldsForCounts (resource, definition))
|
||||
.filter (path => selected.has (path)).join (','))
|
||||
}
|
||||
payload.delete ('current')
|
||||
payload.delete ('row_numbers')
|
||||
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
|
||||
return apiDownload ('/counts/export', {
|
||||
...Object.fromEntries (payload), resource, format,
|
||||
return { ...Object.fromEntries (payload), resource, format,
|
||||
columns: columns.join (','), headers: JSON.stringify (headers),
|
||||
labels: JSON.stringify (labels) }, locale, onError)
|
||||
labels: JSON.stringify (labels) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -194,6 +194,38 @@ export const countsNestedExport = (
|
||||
}
|
||||
|
||||
|
||||
// A chain with one selected scalar leaf is displayed and copied as one cell.
|
||||
export const countsNestedIsScalar = (
|
||||
fields: CountField[],
|
||||
path: string,
|
||||
params: URLSearchParams,
|
||||
): boolean => {
|
||||
const selected = selectedCountChildren (params)
|
||||
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
|
||||
if (columns.length !== 1) return false
|
||||
const field = columns[0]
|
||||
return field.children == null
|
||||
|| countsNestedIsScalar (field.children, `${path}.${field.key}`, params)
|
||||
}
|
||||
|
||||
|
||||
export const countsNestedScalarText = (
|
||||
rows: CountRecord[],
|
||||
fields: CountField[],
|
||||
path: string,
|
||||
params: URLSearchParams,
|
||||
locale: LocaleCode,
|
||||
): string => {
|
||||
const selected = selectedCountChildren (params)
|
||||
const field = fields.find (field => selected.has (`${path}.${field.key}`))
|
||||
if (field == null) return ''
|
||||
return countsNestedOrder (rows, path, params, locale).map (row => field.children == null
|
||||
? countsLogicalName (row, field.key, locale).text
|
||||
: countsNestedScalarText (row[field.key] as CountRecord[] ?? [], field.children,
|
||||
`${path}.${field.key}`, params, locale)).join (' ')
|
||||
}
|
||||
|
||||
|
||||
export const countsNestedText = (
|
||||
rows: CountRecord[],
|
||||
fields: CountField[],
|
||||
@@ -201,6 +233,8 @@ export const countsNestedText = (
|
||||
params: URLSearchParams,
|
||||
locale: LocaleCode,
|
||||
): string => {
|
||||
if (countsNestedIsScalar (fields, path, params))
|
||||
return countsNestedScalarText (rows, fields, path, params, locale)
|
||||
const selected = selectedCountChildren (params)
|
||||
const columns = orderedCountFields (fields, params, path)
|
||||
.filter (field => selected.has (`${path}.${field.key}`))
|
||||
@@ -224,9 +258,11 @@ export const countsCellCopy = (
|
||||
): string => {
|
||||
if (field.children == null) return countsCellText (row, field.key as CountColumn, locale, params)
|
||||
const rows = countsNestedRows (row, field.key)
|
||||
if (countsNestedIsScalar (field.children, field.key, params))
|
||||
return countsNestedScalarText (rows, field.children, field.key, params, locale)
|
||||
return params.get ('nested_json') === '1'
|
||||
? JSON.stringify (
|
||||
countsNestedExport (rows, field.children, field.key, params, locale), null, 2)
|
||||
countsNestedExport (rows, field.children, field.key, params, locale))
|
||||
: countsNestedText (rows, field.children, field.key, params, locale)
|
||||
}
|
||||
|
||||
@@ -297,7 +333,9 @@ const clipboardColumns = (
|
||||
return orderedCountFields (fields, params, path)
|
||||
.filter (field => selected.has (`${path}.${field.key}`))
|
||||
.map (field => ({ label: translateUIMessage (field.label, 'counts', locale).text,
|
||||
children: field.children == null ? undefined : clipboardColumns (
|
||||
children: (field.children == null
|
||||
|| countsNestedIsScalar (field.children, `${path}.${field.key}`, params))
|
||||
? undefined : clipboardColumns (
|
||||
field.children, `${path}.${field.key}`, params, locale) }))
|
||||
}
|
||||
|
||||
@@ -316,8 +354,11 @@ export const countsNestedClipboardTable = (
|
||||
columns: clipboardColumns (fields, path, params, locale),
|
||||
rows: countsNestedOrder (rows, path, params, locale).map (row => columns.map (field =>
|
||||
field.children == null ? countsLogicalName (row, field.key, locale).text
|
||||
: countsNestedClipboardTable (row[field.key] as CountRecord[] ?? [],
|
||||
field.children, `${path}.${field.key}`, params, locale))) }
|
||||
: (countsNestedIsScalar (field.children, `${path}.${field.key}`, params)
|
||||
? countsNestedScalarText (row[field.key] as CountRecord[] ?? [],
|
||||
field.children, `${path}.${field.key}`, params, locale)
|
||||
: countsNestedClipboardTable (row[field.key] as CountRecord[] ?? [],
|
||||
field.children, `${path}.${field.key}`, params, locale)))) }
|
||||
}
|
||||
|
||||
|
||||
@@ -330,7 +371,9 @@ export const countsDetailClipboardTable = (
|
||||
): CountsClipboardTable => {
|
||||
const columns = fields.map (field => ({
|
||||
label: translateUIMessage (field.label, 'counts', locale).text,
|
||||
children: field.children == null ? undefined : clipboardColumns (
|
||||
children: (field.children == null
|
||||
|| countsNestedIsScalar (field.children, field.key, params))
|
||||
? undefined : clipboardColumns (
|
||||
field.children, field.key, params, locale) }))
|
||||
if (rowStart != null)
|
||||
columns.unshift ({ label: translateUIMessage ('rowNumbers', 'counts', locale).text,
|
||||
@@ -338,8 +381,11 @@ export const countsDetailClipboardTable = (
|
||||
return { columns, rows: rows.map ((row, index) => {
|
||||
const cells: CountsClipboardCell[] = fields.map (field => field.children == null
|
||||
? countsCellText (row, field.key, locale, params)
|
||||
: countsNestedClipboardTable (countsNestedRows (row, field.key), field.children,
|
||||
field.key, params, locale))
|
||||
: (countsNestedIsScalar (field.children, field.key, params)
|
||||
? countsNestedScalarText (countsNestedRows (row, field.key), field.children,
|
||||
field.key, params, locale)
|
||||
: countsNestedClipboardTable (countsNestedRows (row, field.key), field.children,
|
||||
field.key, params, locale)))
|
||||
return rowStart == null ? cells : [String (rowStart + index), ...cells]
|
||||
}) }
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ type DialogueFormOptions = { title: ReactNode
|
||||
description?: ReactNode
|
||||
body: (controls: DialogueFormControls) => ReactNode
|
||||
cancelText?: ReactNode
|
||||
onCancel?: () => Promise<boolean | void> | boolean | void
|
||||
footerLayout?: 'inline'
|
||||
size?: 'default' | 'large' | 'viewport' }
|
||||
|
||||
|
||||
@@ -13,6 +13,19 @@
|
||||
"output": "出力項目",
|
||||
"filters": "抽出条件",
|
||||
"mode": "表示",
|
||||
"exportProgress": {
|
||||
"title": "エクスポート",
|
||||
"format": "形式:{{format}}",
|
||||
"starting": "開始中",
|
||||
"queued": "待機中",
|
||||
"running": "生成中",
|
||||
"cancelling": "取消処理中",
|
||||
"cancelled": "取消済み",
|
||||
"ready": "ファイル生成完了・ダウンロード開始(保存完了は未確認)",
|
||||
"failed": "エラー",
|
||||
"processed": "{{count}}件処理済み",
|
||||
"measured": "{{processed}} / {{total}}件({{percent}}%)"
|
||||
},
|
||||
"displaySettings": "表示設定",
|
||||
"summary": "集計",
|
||||
"details": "明細",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsExportProgress from '@/components/counts/CountsExportProgress'
|
||||
import CountsControls from '@/components/counts/CountsControls'
|
||||
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
|
||||
import CountsResults from '@/components/counts/CountsResults'
|
||||
@@ -14,7 +15,7 @@ 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, defaultCountsMode, exportCounts, fieldsForCounts, fetchCounts,
|
||||
import { countsRequestParams, defaultCountsMode, countsExportPayload, fieldsForCounts, fetchCounts,
|
||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||
@@ -24,6 +25,7 @@ import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||
import { countsKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
@@ -42,7 +44,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
controlParams.delete ('order')
|
||||
controlParams.delete ('row_numbers')
|
||||
controlParams.set ('columns', selectedCountColumns (resource, params, definition)
|
||||
.filter (column => column !== 'thumbnail').join (','))
|
||||
.filter (column => column !== 'thumbnail').sort ().join (','))
|
||||
const request = countsRequestParams (params)
|
||||
const [open, setOpen] = useState (false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||
@@ -52,12 +54,14 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
setExpanded (previous => new Set ([...previous, resource]))
|
||||
}, [resource])
|
||||
const locale = useLocale ()
|
||||
const dialogue = useDialogue ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const mode = params.get ('mode') ?? 'summary'
|
||||
const state = isCountsState (params)
|
||||
const cacheParams = new URLSearchParams (request)
|
||||
cacheParams.set ('definition', state ? 'latest' : 'history')
|
||||
const [exporting, setExporting] = useState (false)
|
||||
const exportInFlight = useRef (false)
|
||||
const [exportError, setExportError] = useState (false)
|
||||
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
|
||||
enabled: params.has ('timezone'),
|
||||
@@ -103,6 +107,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
setParams (next)
|
||||
}
|
||||
const download = async (format: 'csv' | 'json') => {
|
||||
if (exportInFlight.current) return
|
||||
exportInFlight.current = true
|
||||
setExporting (true)
|
||||
setExportError (false)
|
||||
try
|
||||
@@ -119,12 +125,17 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
? [...axes.map (axis => countsAxisLabel (axis, definition, locale).text),
|
||||
message ('count').text]
|
||||
: fields.map (field => message (field.label).text)
|
||||
await exportCounts (resource, params, locale, format,
|
||||
const payload = countsExportPayload (resource, params, format,
|
||||
fields.map (field => field.key), headers,
|
||||
{ ...countsExportLabels (resource, locale, definition),
|
||||
...Object.fromEntries (countsAxisFields (definition, !state).map (field => [
|
||||
field.key, message (field.label).text])), count: message ('count').text },
|
||||
() => setExportError (true))
|
||||
definition)
|
||||
const handle = { cancel: async () => false }
|
||||
await dialogue.form ({ title: label ('exportProgress.title'),
|
||||
footerLayout: 'inline', onCancel: () => handle.cancel (),
|
||||
body: controls => <CountsExportProgress payload={payload} locale={locale}
|
||||
handle={handle} controls={controls}/> })
|
||||
|
||||
}
|
||||
catch
|
||||
@@ -133,6 +144,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
}
|
||||
finally
|
||||
{
|
||||
exportInFlight.current = false
|
||||
setExporting (false)
|
||||
}
|
||||
}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする