fix(web/channel): stabilize inline priority updates (#6415)

* fix(channel): debounce inline priority updates

* fix(channel): commit spinner edits only on Enter or focus leave

Blurring the inline edit input to the +/- buttons flushed the pending
priority update immediately, bypassing the container focus-containment
check and defeating the debounce. Commit now happens via the container
blur handler or explicitly on Enter. Add unit tests for the priority
update scheduler.

* fix(channel): preserve row identity when priority updates reorder channels
This commit is contained in:
RedwindA
2026-07-25 15:59:59 +08:00
committed by GitHub
parent bf8cfcc512
commit a0d0e5049e
8 changed files with 327 additions and 41 deletions
@@ -27,7 +27,7 @@ import {
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
import { useState, useMemo, useContext } from 'react'
import { useState, useMemo, useContext, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -71,6 +71,7 @@ import {
handleUpdateChannelField,
handleUpdateTagField,
handleUpdateChannelBalance,
createChannelPriorityUpdateScheduler,
isTagAggregateRow,
type TagRow,
} from '../lib'
@@ -172,55 +173,77 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
* Priority cell component with inline editing
*/
function PriorityCell({ channel }: { channel: Channel }) {
if (isTagAggregateRow(channel)) {
return <TagPriorityCell channel={channel} />
}
return <ChannelPriorityCell channel={channel} />
}
function TagPriorityCell({ channel }: { channel: TagRow }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const isTagRow = isTagAggregateRow(channel)
const priority = channel.priority
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingValue, setPendingValue] = useState<number | null>(null)
const tag = channel.tag || ''
const channelCount = channel.children?.length || 0
// Tag row - editable with confirmation for all tag channels
if (isTagRow) {
const tag = channel.tag || ''
const channelCount = channel.children?.length || 0
return (
<>
<NumericSpinnerInput
value={priority ?? 0}
onChange={(value) => {
setPendingValue(value)
setConfirmOpen(true)
}}
min={-999}
/>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Confirm Batch Update')}
desc={t(
'This will update the priority to {{value}} for all {{count}} channel(s) with tag "{{tag}}". Continue?',
{ value: pendingValue, count: channelCount, tag }
)}
confirmText={t('Update')}
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
}
setConfirmOpen(false)
}}
/>
</>
)
}
return (
<>
<NumericSpinnerInput
value={priority ?? 0}
onChange={(value) => {
setPendingValue(value)
setConfirmOpen(true)
}}
min={-999}
/>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Confirm Batch Update')}
desc={t(
'This will update the priority to {{value}} for all {{count}} channel(s) with tag "{{tag}}". Continue?',
{ value: pendingValue, count: channelCount, tag }
)}
confirmText={t('Update')}
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
}
setConfirmOpen(false)
}}
/>
</>
)
}
function ChannelPriorityCell({ channel }: { channel: Channel }) {
const queryClient = useQueryClient()
const priorityUpdateScheduler = useMemo(
() =>
createChannelPriorityUpdateScheduler((value) => {
void handleUpdateChannelField(
channel.id,
'priority',
value,
queryClient
)
}),
[channel.id, queryClient]
)
useEffect(
() => () => priorityUpdateScheduler.flush(),
[priorityUpdateScheduler]
)
// Regular channel row - editable
return (
<NumericSpinnerInput
value={priority ?? 0}
onChange={(value) => {
handleUpdateChannelField(channel.id, 'priority', value, queryClient)
}}
value={channel.priority ?? 0}
onChange={priorityUpdateScheduler.schedule}
onCommit={priorityUpdateScheduler.flush}
min={-999}
/>
)
@@ -55,6 +55,7 @@ import {
import {
channelsQueryKeys,
aggregateChannelsByTag,
getChannelTableRowId,
isTagAggregateRow,
getChannelTypeIcon,
getChannelTypeLabel,
@@ -330,6 +331,7 @@ export function ChannelsTable() {
onColumnFiltersChange: handleColumnFiltersChange,
onPaginationChange,
onGlobalFilterChange,
getRowId: getChannelTableRowId,
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true,
manualSorting: true,
@@ -25,6 +25,7 @@ import { cn } from '@/lib/utils'
interface NumericSpinnerInputProps {
value: number | null | undefined
onChange: (value: number) => void
onCommit?: () => void
min?: number
max?: number
step?: number
@@ -36,6 +37,7 @@ interface NumericSpinnerInputProps {
export function NumericSpinnerInput({
value,
onChange,
onCommit,
min = 0,
max,
step = 1,
@@ -96,7 +98,7 @@ export function NumericSpinnerInput({
const commitValue = () => {
setEditing(false)
const num = Number(localValue)
if (isNaN(num) || localValue === '' || localValue === '-') {
if (Number.isNaN(num) || localValue === '' || localValue === '-') {
setLocalValue(String(value ?? 0))
return
}
@@ -107,10 +109,21 @@ export function NumericSpinnerInput({
}
}
const handleControlBlur = (e: React.FocusEvent<HTMLDivElement>) => {
if (
e.relatedTarget instanceof Node &&
e.currentTarget.contains(e.relatedTarget)
) {
return
}
onCommit?.()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
commitValue()
onCommit?.()
} else if (e.key === 'Escape') {
setEditing(false)
setLocalValue(String(value ?? 0))
@@ -126,6 +139,7 @@ export function NumericSpinnerInput({
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
)}
<div
onBlur={handleControlBlur}
className={cn(
'group/spinner border-input inline-flex h-7 items-center gap-0 rounded-md border transition-colors',
!disabled && 'hover:bg-muted/60',
@@ -0,0 +1,56 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { Channel } from '../../types'
import { getChannelTableRowId, type TagRow } from '../channel-utils'
function channel(id: number): Channel {
return { id } as Channel
}
describe('channel table row identity', () => {
test('keeps each channel identity when priority updates reorder the rows', () => {
const first = channel(101)
const updated = channel(202)
const third = channel(303)
const beforeUpdate = [first, updated, third].map(getChannelTableRowId)
const afterUpdate = [updated, first, third].map(getChannelTableRowId)
assert.deepEqual(beforeUpdate, [
'channel:101',
'channel:202',
'channel:303',
])
assert.deepEqual(afterUpdate, ['channel:202', 'channel:101', 'channel:303'])
})
test('uses separate namespaces for tag and channel rows', () => {
const tagRow = {
id: '202' as unknown as number,
tag: '202',
children: [channel(202)],
} as TagRow
assert.equal(getChannelTableRowId(tagRow), 'tag:202')
assert.equal(getChannelTableRowId(channel(202)), 'channel:202')
})
})
@@ -0,0 +1,117 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import {
CHANNEL_PRIORITY_UPDATE_DELAY_MS,
createChannelPriorityUpdateScheduler,
} from './channel-priority-update'
function createFakeTimers() {
const pending = new Map<number, () => void>()
let nextId = 1
return {
timers: {
setTimeout: (callback: () => void, delay: number) => {
assert.equal(delay, CHANNEL_PRIORITY_UPDATE_DELAY_MS)
const id = nextId++
pending.set(id, callback)
return id
},
clearTimeout: (id: number) => {
pending.delete(id)
},
},
fireAll() {
const callbacks = [...pending.values()]
pending.clear()
for (const callback of callbacks) callback()
},
get pendingCount() {
return pending.size
},
}
}
describe('channel priority update scheduler', () => {
test('coalesces rapid schedules into one update with the latest value', () => {
const fake = createFakeTimers()
const updates: number[] = []
const scheduler = createChannelPriorityUpdateScheduler(
(value) => updates.push(value),
fake.timers
)
scheduler.schedule(1)
scheduler.schedule(2)
scheduler.schedule(3)
assert.deepEqual(updates, [])
assert.equal(fake.pendingCount, 1)
fake.fireAll()
assert.deepEqual(updates, [3])
})
test('flush commits the pending value immediately and cancels the timer', () => {
const fake = createFakeTimers()
const updates: number[] = []
const scheduler = createChannelPriorityUpdateScheduler(
(value) => updates.push(value),
fake.timers
)
scheduler.schedule(7)
scheduler.flush()
assert.deepEqual(updates, [7])
assert.equal(fake.pendingCount, 0)
fake.fireAll()
assert.deepEqual(updates, [7])
})
test('flush without a pending value does nothing', () => {
const fake = createFakeTimers()
const updates: number[] = []
const scheduler = createChannelPriorityUpdateScheduler(
(value) => updates.push(value),
fake.timers
)
scheduler.flush()
scheduler.schedule(5)
scheduler.flush()
scheduler.flush()
assert.deepEqual(updates, [5])
})
test('preserves a pending value of 0', () => {
const fake = createFakeTimers()
const updates: number[] = []
const scheduler = createChannelPriorityUpdateScheduler(
(value) => updates.push(value),
fake.timers
)
scheduler.schedule(0)
scheduler.flush()
assert.deepEqual(updates, [0])
})
})
@@ -0,0 +1,65 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export const CHANNEL_PRIORITY_UPDATE_DELAY_MS = 800
interface ChannelPriorityUpdateTimers {
setTimeout: (callback: () => void, delay: number) => number
clearTimeout: (id: number) => void
}
const browserTimers: ChannelPriorityUpdateTimers = {
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
clearTimeout: (id) => window.clearTimeout(id),
}
export function createChannelPriorityUpdateScheduler(
onUpdate: (value: number) => void,
timers: ChannelPriorityUpdateTimers = browserTimers
) {
let timeoutId: number | undefined
let pendingValue: number | undefined
const clearPendingTimer = () => {
if (timeoutId === undefined) return
timers.clearTimeout(timeoutId)
timeoutId = undefined
}
const commitPendingValue = () => {
clearPendingTimer()
if (pendingValue === undefined) return
const value = pendingValue
pendingValue = undefined
onUpdate(value)
}
return {
schedule(value: number) {
clearPendingTimer()
pendingValue = value
timeoutId = timers.setTimeout(
commitPendingValue,
CHANNEL_PRIORITY_UPDATE_DELAY_MS
)
},
flush: commitPendingValue,
}
}
@@ -616,6 +616,14 @@ export function isTagAggregateRow(row: Channel | TagRow): row is TagRow {
return Array.isArray((row as TagRow).children)
}
export function getChannelTableRowId(row: Channel | TagRow): string {
if (isTagAggregateRow(row)) {
return `tag:${row.tag || ''}`
}
return `channel:${row.id}`
}
/**
* Aggregate channels by tag for tag mode display
* Converts flat array into tree structure grouped by tag
+1
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
// Re-export all library functions
export * from './channel-actions'
export * from './channel-priority-update'
export * from './advanced-custom'
export * from './channel-form-errors'
export * from './channel-form'