From 2ff48ccdfe0294038c0d9788828cc7554be78a7a Mon Sep 17 00:00:00 2001 From: Linus Pahl Date: Thu, 16 Jul 2026 14:49:06 +0200 Subject: [PATCH] Fix widget auto-scroll race on creation/edit --- .../src/views/components/WidgetGrid.tsx | 5 ++- .../views/components/common/ScrollToHint.tsx | 31 +++++++++++++------ .../src/views/logic/slices/widgetsSlice.ts | 16 +++++++--- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/graylog2-web-interface/src/views/components/WidgetGrid.tsx b/graylog2-web-interface/src/views/components/WidgetGrid.tsx index 25df98e5e12f..c04c3ef6c038 100644 --- a/graylog2-web-interface/src/views/components/WidgetGrid.tsx +++ b/graylog2-web-interface/src/views/components/WidgetGrid.tsx @@ -41,7 +41,7 @@ import useViewsDispatch from 'views/stores/useViewsDispatch'; import { updateWidgetPositions, updateWidgetPosition } from 'views/logic/slices/widgetActions'; import { setIsDirty } from 'views/logic/slices/viewSlice'; import { widgetDragHandleClass } from 'views/components/widgets/Constants'; -import { selectNewWidget } from 'views/logic/slices/widgetsSlice'; +import { selectNewWidget, clearNewWidget } from 'views/logic/slices/widgetsSlice'; import ScrollToHint from 'views/components/common/ScrollToHint'; import WidgetContainer from './WidgetContainer'; @@ -91,8 +91,10 @@ type WidgetsProps = { }; const WidgetGridItem = ({ onPositionsChange, positions, widgetId, focusedWidget, isNewWidget }: WidgetsProps) => { + const dispatch = useViewsDispatch(); const editing = focusedWidget?.id === widgetId && focusedWidget?.editing; const widgetPosition = positions[widgetId]; + const onSettled = useCallback(() => dispatch(clearNewWidget(widgetId)), [dispatch, widgetId]); return ( <> @@ -101,6 +103,7 @@ const WidgetGridItem = ({ onPositionsChange, positions, widgetId, focusedWidget, scrollContainer={{ current: document.body }} title="Scroll to new widget" ifTrue={isNewWidget} + onSettled={onSettled} /> void; }; const ScrollToHint = ({ @@ -62,6 +66,7 @@ const ScrollToHint = ({ title, ifTrue = true, autoScroll = false, + onSettled = undefined, }: Props) => { const scrollTargetRef = useRef(null); const [showHint, setShowHint] = useState(false); @@ -78,22 +83,28 @@ const ScrollToHint = ({ // show the scroll hint if necessary useEffect(() => { + if (!ifTrue || !scrollTargetRef.current || !scrollContainer.current) { + return; + } + if ( - ifTrue && - scrollTargetRef.current && - scrollContainer.current && - !isElementVisibleInContainer( + isElementVisibleInContainer( scrollTargetRef.current.parentElement ?? scrollTargetRef.current, scrollContainer.current, ) ) { - if (autoScroll) { - scrollToTarget(); - } else { - setShowHint(true); - } + onSettled?.(); + + return; + } + + if (autoScroll) { + scrollToTarget(); + onSettled?.(); + } else { + setShowHint(true); } - }, [ifTrue, ifValueChanges, setShowHint, scrollContainer, scrollToTarget, autoScroll]); + }, [ifTrue, ifValueChanges, setShowHint, scrollContainer, scrollToTarget, autoScroll, onSettled]); // hide the hint automatically useEffect(() => { diff --git a/graylog2-web-interface/src/views/logic/slices/widgetsSlice.ts b/graylog2-web-interface/src/views/logic/slices/widgetsSlice.ts index d4fe387a1c38..6f06b815bcc0 100644 --- a/graylog2-web-interface/src/views/logic/slices/widgetsSlice.ts +++ b/graylog2-web-interface/src/views/logic/slices/widgetsSlice.ts @@ -18,6 +18,7 @@ import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; import type { ViewsDispatch } from 'views/stores/useViewsDispatch'; +import type { GetState } from 'views/types'; export type WidgetsState = { newWidget: string | undefined; @@ -28,18 +29,23 @@ const widgetsSlice = createSlice({ newWidget: undefined, }, reducers: { - setNewWidget: (state, action: PayloadAction) => ({ + setNewWidget: (state, action: PayloadAction) => ({ ...state, newWidget: action.payload, }), }, }); export const widgetsSliceReducer = widgetsSlice.reducer; -export const setNewWidget = (id: string) => (dispatch: ViewsDispatch) => { - const result = dispatch(widgetsSlice.actions.setNewWidget(id)); - setTimeout(() => dispatch(widgetsSlice.actions.setNewWidget(undefined)), 1000); +export const setNewWidget = (id: string) => (dispatch: ViewsDispatch) => + dispatch(widgetsSlice.actions.setNewWidget(id)); - return result; +// Widgets stay flagged as "new" until the grid actually scrolls them into view (or finds them +// already visible) rather than after a fixed delay, since the widget-creation/edit round trip +// (including a full search re-execution) can easily take longer than any fixed guess. +export const clearNewWidget = (id: string) => (dispatch: ViewsDispatch, getState: GetState) => { + if (getState().widgets.newWidget === id) { + dispatch(widgetsSlice.actions.setNewWidget(undefined)); + } }; export const selectNewWidget = (state: { widgets: WidgetsState }) => state.widgets.newWidget;