このコミットが含まれているのは:
@@ -25,7 +25,7 @@ class CountsDefaults
|
|||||||
elsif resource == 'tags' && token.start_with?('aliases.')
|
elsif resource == 'tags' && token.start_with?('aliases.')
|
||||||
[]
|
[]
|
||||||
elsif resource == 'posts' && token == 'tags.name'
|
elsif resource == 'posts' && token == 'tags.name'
|
||||||
["tags.#{ name }", "tags.#{ aliases }"]
|
"tags.#{ name }"
|
||||||
else token
|
else token
|
||||||
end
|
end
|
||||||
end.join(' ')
|
end.join(' ')
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ const sameGroups = (left: Groups, right: Groups): boolean =>
|
|||||||
|
|
||||||
const Group = ({ id, children }: { id: string; children: ReactNode }) => {
|
const Group = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||||
const drop = useDroppable ({ id: `group:${id}` })
|
const drop = useDroppable ({ id: `group:${id}` })
|
||||||
return <div ref={drop.setNodeRef} className={cn (
|
return <div ref={drop.setNodeRef} className="min-w-0 rounded">{children}</div>
|
||||||
'min-w-0 rounded',
|
|
||||||
drop.isOver && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{children}</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -36,12 +34,13 @@ const SortableList = (
|
|||||||
groups: Groups; name: (id: string) => Name
|
groups: Groups; name: (id: string) => Name
|
||||||
handleName?: (id: string) => Name
|
handleName?: (id: string) => Name
|
||||||
renderItem: (id: string, group: string) => ReactNode
|
renderItem: (id: string, group: string) => ReactNode
|
||||||
renderGroup: (id: string, content: ReactNode) => ReactNode
|
renderGroup: (id: string, content: ReactNode, over: boolean) => ReactNode
|
||||||
onChange: (groups: Groups) => void; fixed?: string[] },
|
onChange: (groups: Groups) => void; fixed?: string[] },
|
||||||
) => {
|
) => {
|
||||||
const drag = useRef<{ id: string; groups: Groups } | null> (null)
|
const drag = useRef<{ id: string; groups: Groups } | null> (null)
|
||||||
const scope = useId ()
|
const scope = useId ()
|
||||||
const [active, setActive] = useState<string | null> (null)
|
const [active, setActive] = useState<string | null> (null)
|
||||||
|
const [overGroup, setOverGroup] = useState<string | null> (null)
|
||||||
const motion = useClientMotion ()
|
const motion = useClientMotion ()
|
||||||
const accessibleName = handleName ?? name
|
const accessibleName = handleName ?? name
|
||||||
const sensors = useSensors (
|
const sensors = useSensors (
|
||||||
@@ -86,12 +85,15 @@ const SortableList = (
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
drag.current = null
|
drag.current = null
|
||||||
setActive (null)
|
setActive (null)
|
||||||
|
setOverGroup (null)
|
||||||
}
|
}
|
||||||
return <DndContext sensors={sensors} collisionDetection={args => {
|
return <DndContext sensors={sensors} collisionDetection={args => {
|
||||||
const hits = pointerWithin (args)
|
const hits = pointerWithin (args)
|
||||||
const available = hits.find (hit => hit.id === 'group:available')
|
const group = hits.find (hit => String (hit.id).startsWith ('group:'))
|
||||||
return owner (String (args.active.id), groups) !== 'available'
|
const groupName = group?.id.toString ().slice (6)
|
||||||
&& available != null ? [available] : closestCenter (args)
|
const item = groupName != null && hits.some (hit =>
|
||||||
|
owner (String (hit.id), groups) === groupName)
|
||||||
|
return group != null && !item ? [group] : closestCenter (args)
|
||||||
}}
|
}}
|
||||||
accessibility={{ announcements: {
|
accessibility={{ announcements: {
|
||||||
onDragStart: event => accessibleName (String (event.active.id)).text,
|
onDragStart: event => accessibleName (String (event.active.id)).text,
|
||||||
@@ -106,6 +108,12 @@ const SortableList = (
|
|||||||
const id = String (event.active.id)
|
const id = String (event.active.id)
|
||||||
drag.current = { id, groups }
|
drag.current = { id, groups }
|
||||||
setActive (id)
|
setActive (id)
|
||||||
|
setOverGroup (owner (id, groups) ?? null)
|
||||||
|
}} onDragOver={event => {
|
||||||
|
const id = event.over?.id.toString ()
|
||||||
|
const group = id == null ? null
|
||||||
|
: (id.startsWith ('group:') ? id.slice (6) : owner (id, groups) ?? null)
|
||||||
|
setOverGroup (previous => previous === group ? previous : group)
|
||||||
}} onDragCancel={reset} onDragEnd={event => {
|
}} onDragCancel={reset} onDragEnd={event => {
|
||||||
const session = drag.current
|
const session = drag.current
|
||||||
reset ()
|
reset ()
|
||||||
@@ -125,7 +133,7 @@ const SortableList = (
|
|||||||
{renderItem (id, group)}
|
{renderItem (id, group)}
|
||||||
</SortableItem>)}
|
</SortableItem>)}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>)}
|
</SortableContext>, overGroup === group)}
|
||||||
</Group>)}
|
</Group>)}
|
||||||
{typeof document !== 'undefined' && createPortal (
|
{typeof document !== 'undefined' && createPortal (
|
||||||
<DragOverlay adjustScale={false}
|
<DragOverlay adjustScale={false}
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ const CountsAxes = ({ params, update }: {
|
|||||||
commit (next)
|
commit (next)
|
||||||
}
|
}
|
||||||
return <SortableList groups={groups} name={name} onChange={commit}
|
return <SortableList groups={groups} name={name} onChange={commit}
|
||||||
renderGroup={(id, content) => <fieldset className="mb-2 min-w-0">
|
renderGroup={(id, content, over) => <fieldset className="mb-2 min-w-0">
|
||||||
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
|
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
|
||||||
<div className="rounded border dark:border-slate-700">{content}</div>
|
<div className={cn ('rounded border dark:border-slate-700',
|
||||||
|
over && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{content}</div>
|
||||||
</fieldset>} renderItem={(id, group) => {
|
</fieldset>} renderItem={(id, group) => {
|
||||||
const axis = axes.find (axis => axis.field === id)
|
const axis = axes.find (axis => axis.field === id)
|
||||||
return <div className={cn ('flex items-center gap-2 text-sm',
|
return <div className={cn ('flex items-center gap-2 text-sm',
|
||||||
|
|||||||
@@ -83,14 +83,13 @@ const scrollToCell = (table: HTMLTableElement, point: Point, previous: Point) =>
|
|||||||
|
|
||||||
|
|
||||||
const CountsSelectableTable = (
|
const CountsSelectableTable = (
|
||||||
{ family, columns, identity, children, className, name, copyTable, stretched = false }: {
|
{ family, columns, identity, children, className, name, copyTable }: {
|
||||||
family: string
|
family: string
|
||||||
columns: string[]
|
columns: string[]
|
||||||
identity: string
|
identity: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
name?: { text: string; language: string }
|
name?: { text: string; language: string }
|
||||||
stretched?: boolean
|
|
||||||
copyTable?: () => CountsClipboardTable },
|
copyTable?: () => CountsClipboardTable },
|
||||||
) => {
|
) => {
|
||||||
const id = useId ()
|
const id = useId ()
|
||||||
@@ -383,15 +382,13 @@ const CountsSelectableTable = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
|
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
|
||||||
const flexible = stretched ? columns.filter (key => !layout.custom (family, key)).length : 0
|
|
||||||
return (
|
return (
|
||||||
<CountsTableScope.Provider value={family}>
|
<CountsTableScope.Provider value={family}>
|
||||||
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
||||||
aria-label={name?.text ?? message ('selection.table').text}
|
aria-label={name?.text ?? message ('selection.table').text}
|
||||||
aria-describedby={`${id}-hint`}
|
aria-describedby={`${id}-hint`}
|
||||||
|
|
||||||
style={{ tableLayout: 'fixed', minWidth: totalWidth,
|
style={{ tableLayout: 'fixed', minWidth: totalWidth, width: totalWidth }}
|
||||||
width: flexible > 0 ? '100%' : totalWidth }}
|
|
||||||
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
|
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
|
||||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
|
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
|
||||||
'dark:focus-visible:outline-blue-400',
|
'dark:focus-visible:outline-blue-400',
|
||||||
@@ -618,10 +615,8 @@ const CountsSelectableTable = (
|
|||||||
<span id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</span>
|
<span id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</span>
|
||||||
<span role="status">{textCell != null && label ('selection.textMode')}</span>
|
<span role="status">{textCell != null && label ('selection.textMode')}</span>
|
||||||
</caption>
|
</caption>
|
||||||
<colgroup>{columns.map (key => (
|
<colgroup>{columns.map (key => <col key={key}
|
||||||
<col key={key} style={{ width: flexible > 0 && !layout.custom (family, key)
|
style={{ width: layout.width (family, key) }}/>)}</colgroup>
|
||||||
? `calc(${layout.width (family, key)}px + (100% - ${totalWidth}px) / ${flexible})`
|
|
||||||
: layout.width (family, key) }}/>))}</colgroup>
|
|
||||||
{children}
|
{children}
|
||||||
</table>
|
</table>
|
||||||
{textCell != null && caret != null && caret.cell === textCell && createPortal (
|
{textCell != null && caret != null && caret.cell === textCell && createPortal (
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ const CountsSummaryTable = (
|
|||||||
const columnHeaders: CountsPivotHeader[] = data.columnHeaders
|
const columnHeaders: CountsPivotHeader[] = data.columnHeaders
|
||||||
?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' }))
|
?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' }))
|
||||||
const sorts = readCountSort (params.get ('sort'))
|
const sorts = readCountSort (params.get ('sort'))
|
||||||
|
const timezone = params.get ('timezone') ?? 'UTC'
|
||||||
const change = (key: string, additive: boolean) => update (
|
const change = (key: string, additive: boolean) => update (
|
||||||
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||||
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
|
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
|
||||||
countsSummaryValue (value, axis, index, data, locale)
|
countsSummaryValue (value, axis, index, data, locale, timezone)
|
||||||
const aggregateLabel = (header: CountsPivotHeader) => message (
|
const aggregateLabel = (header: CountsPivotHeader) => message (
|
||||||
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
|
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
|
||||||
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
|
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
|
||||||
@@ -227,7 +228,6 @@ const CountsSummaryTable = (
|
|||||||
}
|
}
|
||||||
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||||
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
|
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
|
||||||
stretched={keys.filter (key => key !== 'row_number').length > 1}
|
|
||||||
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
|
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
|
||||||
headerRows: headingRows, rows: matrix })}
|
headerRows: headingRows, rows: matrix })}
|
||||||
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
|
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
|
||||||
@@ -295,7 +295,8 @@ const CountsSummaryResults = (props: Parameters<typeof CountsSummaryTable>[0]) =
|
|||||||
const dragIds = Object.fromEntries (groups.map ((group, index) => [group, ids[index]]))
|
const dragIds = Object.fromEntries (groups.map ((group, index) => [group, ids[index]]))
|
||||||
return <SortableColumns ids={ids} name={id => ({
|
return <SortableColumns ids={ids} name={id => ({
|
||||||
text: countsSummaryValue (JSON.parse (groups[ids.indexOf (id)])[0], columns[0],
|
text: countsSummaryValue (JSON.parse (groups[ids.indexOf (id)])[0], columns[0],
|
||||||
props.data.rows?.length ?? 0, props.data, locale), language: message ('count').language })}
|
props.data.rows?.length ?? 0, props.data, locale,
|
||||||
|
props.params.get ('timezone') ?? 'UTC'), language: message ('count').language })}
|
||||||
onChange={order => props.update ('column_order', encodeCountState ('column_order',
|
onChange={order => props.update ('column_order', encodeCountState ('column_order',
|
||||||
order.map (id => ({ value: JSON.parse (groups[ids.indexOf (id)])[0] }))))}>
|
order.map (id => ({ value: JSON.parse (groups[ids.indexOf (id)])[0] }))))}>
|
||||||
{order => {
|
{order => {
|
||||||
|
|||||||
@@ -392,12 +392,66 @@ export const countsDetailClipboardTable = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const zonedCalendarDate = (
|
||||||
|
year: number, month: number, day: number, hour: number, timezone: string,
|
||||||
|
): Date => {
|
||||||
|
const target = Date.UTC (year, month - 1, day, hour)
|
||||||
|
const formatter = new Intl.DateTimeFormat ('en-US-u-ca-gregory-nu-latn', {
|
||||||
|
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })
|
||||||
|
const align = (date: Date) => {
|
||||||
|
const parts = formatter.formatToParts (date)
|
||||||
|
const part = (type: string) => Number (
|
||||||
|
parts.find (entry => entry.type === type)?.value ?? 0)
|
||||||
|
const actual = Date.UTC (part ('year'), part ('month') - 1, part ('day'),
|
||||||
|
part ('hour'), part ('minute'), part ('second'))
|
||||||
|
return new Date (date.getTime () + target - actual)
|
||||||
|
}
|
||||||
|
return align (align (new Date (target)))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const countsDateValue = (
|
||||||
|
value: string, grain: string, locale: LocaleCode, timezone: string,
|
||||||
|
): string | null => {
|
||||||
|
const patterns = {
|
||||||
|
year: /^(\d{4})$/, quarter: /^(\d{4})-Q([1-4])$/,
|
||||||
|
month: /^(\d{4})-(\d{2})$/, week: /^(\d{4})-W(\d{2})$/,
|
||||||
|
day: /^(\d{4})-(\d{2})-(\d{2})$/,
|
||||||
|
hour: /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):00$/ }
|
||||||
|
const pattern = patterns[grain as keyof typeof patterns]
|
||||||
|
const match = pattern == null ? null : value.match (pattern)
|
||||||
|
if (match == null) return null
|
||||||
|
|
||||||
|
const year = Number (match[1])
|
||||||
|
const weekStart = grain === 'week' ? new Date (Date.UTC (year, 0, 4)
|
||||||
|
- ((new Date (Date.UTC (year, 0, 4)).getUTCDay () + 6) % 7) * 86_400_000
|
||||||
|
+ (Number (match[2]) - 1) * 7 * 86_400_000) : null
|
||||||
|
const calendarYear = weekStart?.getUTCFullYear () ?? year
|
||||||
|
const month = weekStart == null
|
||||||
|
? (grain === 'quarter' ? (Number (match[2]) - 1) * 3 + 1
|
||||||
|
: (grain === 'month' || grain === 'day' || grain === 'hour'
|
||||||
|
? Number (match[2]) : 1))
|
||||||
|
: weekStart.getUTCMonth () + 1
|
||||||
|
const day = weekStart?.getUTCDate ()
|
||||||
|
?? (grain === 'day' || grain === 'hour' ? Number (match[3]) : 1)
|
||||||
|
const hour = grain === 'hour' ? Number (match[4]) : 12
|
||||||
|
const date = zonedCalendarDate (calendarYear, month, day, hour, timezone)
|
||||||
|
const unknown = ({ year: 'month', quarter: 'day', month: 'day', week: 'hour',
|
||||||
|
day: 'hour', hour: 'minute' } as const)[grain as keyof typeof patterns]
|
||||||
|
const formatted = localisedDateString (date, locale, unknown, timezone)
|
||||||
|
return grain === 'quarter' ? `${formatted} Q${match[2]}`
|
||||||
|
: (grain === 'week' ? `${formatted} W${match[2]}` : formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const countsSummaryValue = (
|
export const countsSummaryValue = (
|
||||||
value: string | number | null,
|
value: string | number | null,
|
||||||
axis: CountsAxis,
|
axis: CountsAxis,
|
||||||
index: number,
|
index: number,
|
||||||
data: CountsResult,
|
data: CountsResult,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
|
timezone = 'UTC',
|
||||||
): string => {
|
): string => {
|
||||||
const message = (key: string, values?: Record<string, string | number>) =>
|
const message = (key: string, values?: Record<string, string | number>) =>
|
||||||
translateUIMessage (key, 'counts', locale, values).text
|
translateUIMessage (key, 'counts', locale, values).text
|
||||||
@@ -414,5 +468,13 @@ export const countsSummaryValue = (
|
|||||||
if (axis.field === 'event_type') return message (`events.${value}`)
|
if (axis.field === 'event_type') return message (`events.${value}`)
|
||||||
if (axis.field === 'category' || axis.field === 'tag_category')
|
if (axis.field === 'category' || axis.field === 'tag_category')
|
||||||
return message (`common:categories.${value}`)
|
return message (`common:categories.${value}`)
|
||||||
|
const dateAxis = axis.field.endsWith ('_at')
|
||||||
|
|| axis.field.startsWith ('original_created_')
|
||||||
|
const grain = axis.grain ?? (dateAxis ? 'month' : null)
|
||||||
|
if (grain != null)
|
||||||
|
{
|
||||||
|
const date = countsDateValue (String (value), grain, locale, timezone)
|
||||||
|
if (date != null) return date
|
||||||
|
}
|
||||||
return String (value)
|
return String (value)
|
||||||
}
|
}
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする