From a0d0e5049e2d0db326ee36864906f43cfefb0c05 Mon Sep 17 00:00:00 2001 From: RedwindA <128586631+RedwindA@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:59:59 +0800 Subject: [PATCH] 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 --- .../channels/components/channels-columns.tsx | 103 +++++++++------ .../channels/components/channels-table.tsx | 2 + .../components/numeric-spinner-input.tsx | 16 ++- .../__tests__/channel-table-row-id.test.ts | 56 +++++++++ .../lib/channel-priority-update.test.ts | 117 ++++++++++++++++++ .../channels/lib/channel-priority-update.ts | 65 ++++++++++ .../features/channels/lib/channel-utils.ts | 8 ++ web/src/features/channels/lib/index.ts | 1 + 8 files changed, 327 insertions(+), 41 deletions(-) create mode 100644 web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts create mode 100644 web/src/features/channels/lib/channel-priority-update.test.ts create mode 100644 web/src/features/channels/lib/channel-priority-update.ts diff --git a/web/src/features/channels/components/channels-columns.tsx b/web/src/features/channels/components/channels-columns.tsx index f538296d..8d91485c 100644 --- a/web/src/features/channels/components/channels-columns.tsx +++ b/web/src/features/channels/components/channels-columns.tsx @@ -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 + } + + return +} + +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(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 ( + <> + { + setPendingValue(value) + setConfirmOpen(true) + }} + min={-999} + /> + { + if (pendingValue !== null) { + handleUpdateTagField(tag, 'priority', pendingValue, queryClient) + } + setConfirmOpen(false) + }} + /> + + ) +} - return ( - <> - { - setPendingValue(value) - setConfirmOpen(true) - }} - min={-999} - /> - { - 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 ( { - handleUpdateChannelField(channel.id, 'priority', value, queryClient) - }} + value={channel.priority ?? 0} + onChange={priorityUpdateScheduler.schedule} + onCommit={priorityUpdateScheduler.flush} min={-999} /> ) diff --git a/web/src/features/channels/components/channels-table.tsx b/web/src/features/channels/components/channels-table.tsx index e9d058bb..51f3f3ec 100644 --- a/web/src/features/channels/components/channels-table.tsx +++ b/web/src/features/channels/components/channels-table.tsx @@ -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, diff --git a/web/src/features/channels/components/numeric-spinner-input.tsx b/web/src/features/channels/components/numeric-spinner-input.tsx index ab5736c4..cd53f45a 100644 --- a/web/src/features/channels/components/numeric-spinner-input.tsx +++ b/web/src/features/channels/components/numeric-spinner-input.tsx @@ -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) => { + 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({ )}
. + +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') + }) +}) diff --git a/web/src/features/channels/lib/channel-priority-update.test.ts b/web/src/features/channels/lib/channel-priority-update.test.ts new file mode 100644 index 00000000..f77efd7c --- /dev/null +++ b/web/src/features/channels/lib/channel-priority-update.test.ts @@ -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 . + +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 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]) + }) +}) diff --git a/web/src/features/channels/lib/channel-priority-update.ts b/web/src/features/channels/lib/channel-priority-update.ts new file mode 100644 index 00000000..746de26b --- /dev/null +++ b/web/src/features/channels/lib/channel-priority-update.ts @@ -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 . + +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, + } +} diff --git a/web/src/features/channels/lib/channel-utils.ts b/web/src/features/channels/lib/channel-utils.ts index dc143a66..d30c4f7a 100644 --- a/web/src/features/channels/lib/channel-utils.ts +++ b/web/src/features/channels/lib/channel-utils.ts @@ -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 diff --git a/web/src/features/channels/lib/index.ts b/web/src/features/channels/lib/index.ts index 9c41eb68..71059a81 100644 --- a/web/src/features/channels/lib/index.ts +++ b/web/src/features/channels/lib/index.ts @@ -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'