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,
|
Shuffle,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useMemo, useContext } from 'react'
|
import { useState, useMemo, useContext, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
handleUpdateChannelField,
|
handleUpdateChannelField,
|
||||||
handleUpdateTagField,
|
handleUpdateTagField,
|
||||||
handleUpdateChannelBalance,
|
handleUpdateChannelBalance,
|
||||||
|
createChannelPriorityUpdateScheduler,
|
||||||
isTagAggregateRow,
|
isTagAggregateRow,
|
||||||
type TagRow,
|
type TagRow,
|
||||||
} from '../lib'
|
} from '../lib'
|
||||||
@@ -172,55 +173,77 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
|
|||||||
* Priority cell component with inline editing
|
* Priority cell component with inline editing
|
||||||
*/
|
*/
|
||||||
function PriorityCell({ channel }: { channel: Channel }) {
|
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 { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const isTagRow = isTagAggregateRow(channel)
|
|
||||||
const priority = channel.priority
|
const priority = channel.priority
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
const [pendingValue, setPendingValue] = useState<number | null>(null)
|
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
|
return (
|
||||||
if (isTagRow) {
|
<>
|
||||||
const tag = channel.tag || ''
|
<NumericSpinnerInput
|
||||||
const channelCount = channel.children?.length || 0
|
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 (
|
function ChannelPriorityCell({ channel }: { channel: Channel }) {
|
||||||
<>
|
const queryClient = useQueryClient()
|
||||||
<NumericSpinnerInput
|
const priorityUpdateScheduler = useMemo(
|
||||||
value={priority ?? 0}
|
() =>
|
||||||
onChange={(value) => {
|
createChannelPriorityUpdateScheduler((value) => {
|
||||||
setPendingValue(value)
|
void handleUpdateChannelField(
|
||||||
setConfirmOpen(true)
|
channel.id,
|
||||||
}}
|
'priority',
|
||||||
min={-999}
|
value,
|
||||||
/>
|
queryClient
|
||||||
<ConfirmDialog
|
)
|
||||||
open={confirmOpen}
|
}),
|
||||||
onOpenChange={setConfirmOpen}
|
[channel.id, queryClient]
|
||||||
title={t('Confirm Batch Update')}
|
)
|
||||||
desc={t(
|
|
||||||
'This will update the priority to {{value}} for all {{count}} channel(s) with tag "{{tag}}". Continue?',
|
useEffect(
|
||||||
{ value: pendingValue, count: channelCount, tag }
|
() => () => priorityUpdateScheduler.flush(),
|
||||||
)}
|
[priorityUpdateScheduler]
|
||||||
confirmText={t('Update')}
|
)
|
||||||
handleConfirm={() => {
|
|
||||||
if (pendingValue !== null) {
|
|
||||||
handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
|
|
||||||
}
|
|
||||||
setConfirmOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular channel row - editable
|
|
||||||
return (
|
return (
|
||||||
<NumericSpinnerInput
|
<NumericSpinnerInput
|
||||||
value={priority ?? 0}
|
value={channel.priority ?? 0}
|
||||||
onChange={(value) => {
|
onChange={priorityUpdateScheduler.schedule}
|
||||||
handleUpdateChannelField(channel.id, 'priority', value, queryClient)
|
onCommit={priorityUpdateScheduler.flush}
|
||||||
}}
|
|
||||||
min={-999}
|
min={-999}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
channelsQueryKeys,
|
channelsQueryKeys,
|
||||||
aggregateChannelsByTag,
|
aggregateChannelsByTag,
|
||||||
|
getChannelTableRowId,
|
||||||
isTagAggregateRow,
|
isTagAggregateRow,
|
||||||
getChannelTypeIcon,
|
getChannelTypeIcon,
|
||||||
getChannelTypeLabel,
|
getChannelTypeLabel,
|
||||||
@@ -330,6 +331,7 @@ export function ChannelsTable() {
|
|||||||
onColumnFiltersChange: handleColumnFiltersChange,
|
onColumnFiltersChange: handleColumnFiltersChange,
|
||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
onGlobalFilterChange,
|
onGlobalFilterChange,
|
||||||
|
getRowId: getChannelTableRowId,
|
||||||
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
|
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
manualSorting: true,
|
manualSorting: true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { cn } from '@/lib/utils'
|
|||||||
interface NumericSpinnerInputProps {
|
interface NumericSpinnerInputProps {
|
||||||
value: number | null | undefined
|
value: number | null | undefined
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
|
onCommit?: () => void
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
step?: number
|
step?: number
|
||||||
@@ -36,6 +37,7 @@ interface NumericSpinnerInputProps {
|
|||||||
export function NumericSpinnerInput({
|
export function NumericSpinnerInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onCommit,
|
||||||
min = 0,
|
min = 0,
|
||||||
max,
|
max,
|
||||||
step = 1,
|
step = 1,
|
||||||
@@ -96,7 +98,7 @@ export function NumericSpinnerInput({
|
|||||||
const commitValue = () => {
|
const commitValue = () => {
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
const num = Number(localValue)
|
const num = Number(localValue)
|
||||||
if (isNaN(num) || localValue === '' || localValue === '-') {
|
if (Number.isNaN(num) || localValue === '' || localValue === '-') {
|
||||||
setLocalValue(String(value ?? 0))
|
setLocalValue(String(value ?? 0))
|
||||||
return
|
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) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
commitValue()
|
commitValue()
|
||||||
|
onCommit?.()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
setLocalValue(String(value ?? 0))
|
setLocalValue(String(value ?? 0))
|
||||||
@@ -126,6 +139,7 @@ export function NumericSpinnerInput({
|
|||||||
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
|
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
onBlur={handleControlBlur}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/spinner border-input inline-flex h-7 items-center gap-0 rounded-md border transition-colors',
|
'group/spinner border-input inline-flex h-7 items-center gap-0 rounded-md border transition-colors',
|
||||||
!disabled && 'hover:bg-muted/60',
|
!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)
|
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
|
* Aggregate channels by tag for tag mode display
|
||||||
* Converts flat array into tree structure grouped by tag
|
* 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
|
// Re-export all library functions
|
||||||
export * from './channel-actions'
|
export * from './channel-actions'
|
||||||
|
export * from './channel-priority-update'
|
||||||
export * from './advanced-custom'
|
export * from './advanced-custom'
|
||||||
export * from './channel-form-errors'
|
export * from './channel-form-errors'
|
||||||
export * from './channel-form'
|
export * from './channel-form'
|
||||||
|
|||||||
Reference in New Issue
Block a user