このコミットが含まれているのは:
2026-10-26 21:24:32 +09:00
コミット 2563b3fef3
21個のファイルの変更、703行の追加、54行の削除
+21 -6
ファイルの表示
@@ -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>
+6 -3
ファイルの表示
@@ -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)
+130
ファイルの表示
@@ -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
+7 -5
ファイルの表示
@@ -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
+18 -2
ファイルの表示
@@ -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')}
+6 -2
ファイルの表示
@@ -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%]',