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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user