diff --git a/packages/components/src/components/Log/Log.jsx b/packages/components/src/components/Log/Log.jsx index f26a0b139..3505b1d5b 100644 --- a/packages/components/src/components/Log/Log.jsx +++ b/packages/components/src/components/Log/Log.jsx @@ -13,17 +13,15 @@ limitations under the License. /* istanbul ignore file */ import { Component, createRef } from 'react'; -import { Button, PrefixContext, SkeletonText } from '@carbon/react'; +import { PrefixContext, SkeletonText } from '@carbon/react'; import { FixedSizeList as List } from 'react-window'; import { injectIntl, useIntl } from 'react-intl'; import { getStepStatusReason, isRunning } from '@tektoncd/dashboard-utils'; -import { DownToBottom, Information, UpToTop } from '@carbon/react/icons'; +import { Information } from '@carbon/react/icons'; import { hasElementPositiveVerticalScrollBottom, - hasElementPositiveVerticalScrollTop, - isElementEndBelowViewBottom, - isElementStartAboveViewTop + isElementEndBelowViewBottom } from './domUtils'; import DotSpinner from '../DotSpinner'; import LogFormat from '../LogFormat'; @@ -89,7 +87,7 @@ export class LogContainer extends Component { componentDidMount() { this.loadLog(); - if (this.props.enableLogAutoScroll || this.props.enableLogScrollButtons) { + if (this.props.enableLogAutoScroll) { this.wasRunningAfterMounting(); window.addEventListener('scroll', this.handleLogScroll, true); this.handleLogScroll(); @@ -98,7 +96,7 @@ export class LogContainer extends Component { componentDidUpdate(prevProps, prevState) { if ( - (this.props.enableLogAutoScroll || this.props.enableLogScrollButtons) && + this.props.enableLogAutoScroll && (prevState.logs?.length !== this.state.logs?.length || prevProps.isLogsMaximized !== this.props.isLogsMaximized) ) { @@ -128,18 +126,9 @@ export class LogContainer extends Component { handleLogScroll = () => { if (!this.state.loading) { const isLogBottomUnseen = this.isLogBottomUnseen(); - const isLogTopUnseen = - this.props.enableLogScrollButtons && this.isLogTopUnseen(); - this.updateScrollButtonCoordinates(); - - if ( - isLogBottomUnseen !== this.state.isLogBottomUnseen || - (this.props.enableLogScrollButtons && - isLogTopUnseen !== this.state.isLogTopUnseen) - ) { + if (isLogBottomUnseen !== this.state.isLogBottomUnseen) { this.setState({ - isLogBottomUnseen, - isLogTopUnseen + isLogBottomUnseen }); } } @@ -163,15 +152,6 @@ export class LogContainer extends Component { ); }; - isLogTopUnseen = () => { - return ( - isElementStartAboveViewTop(this.logRef?.current) || - hasElementPositiveVerticalScrollTop( - this.textRef?.current?.firstElementChild - ) - ); - }; - scrollToBottomLog = () => { const longTextElement = this.textRef?.current?.firstElementChild; if (longTextElement) { @@ -182,14 +162,6 @@ export class LogContainer extends Component { rootElement.scrollTop = rootElement.scrollHeight - rootElement.clientHeight; }; - scrollToTopLog = () => { - const longTextElement = this.textRef?.current?.firstElementChild; - if (longTextElement) { - longTextElement.scrollTop = 0; - } - document.documentElement.scrollTop = 0; - }; - wasRunningAfterMounting = () => { if (this.alreadyWasRunningAfterMounting) { return true; @@ -203,98 +175,6 @@ export class LogContainer extends Component { return false; }; - updateScrollButtonCoordinates = () => { - if (this.props.enableLogScrollButtons) { - const logRectangle = this.logRef.current?.getBoundingClientRect(); - const logElementRight = - document.documentElement.clientWidth - logRectangle.right; - - const scrollButtonTop = Math.max(0, logRectangle.top); - - const scrollButtonBottom = Math.max( - 0, - document.documentElement.clientHeight - logRectangle.bottom - ); - - this.updateCssStyleProperty(logElementRight, '--tkn-log-element-right'); - this.updateCssStyleProperty(scrollButtonTop, '--tkn-scroll-button-top'); - this.updateCssStyleProperty( - scrollButtonBottom, - '--tkn-scroll-button-bottom' - ); - } - }; - - updateCssStyleProperty = (computedVariable, variableName) => { - // instead of using a state variable + inline styling for the button vertical position, - // a class variable + css custom property are used (avoiding unnecessary re-rendering of entire component) - if ( - !Number.isNaN(computedVariable) && - this[variableName] !== computedVariable - ) { - this[variableName] = computedVariable; - document.documentElement.style.setProperty( - variableName, - `${computedVariable.toString()}px` - ); - } - }; - - getScrollButtons = () => { - const carbonPrefix = this.context; - const { enableLogScrollButtons, intl } = this.props; - const { isLogBottomUnseen, isLogTopUnseen, loading } = this.state; - - if (!enableLogScrollButtons || loading) { - return null; - } - const scrollButtonTopMessage = intl.formatMessage({ - id: 'dashboard.logs.scrollToTop', - defaultMessage: 'Scroll to start of logs' - }); - const scrollButtonBottomMessage = intl.formatMessage({ - id: 'dashboard.logs.scrollToBottom', - defaultMessage: 'Scroll to end of logs' - }); - - return ( -
- {isLogTopUnseen ? ( -
- ); - }; - getLogList = () => { const { intl, @@ -610,7 +490,6 @@ export class LogContainer extends Component { {this.getLogList()} {this.logTrailer()} - {this.getScrollButtons()} )} diff --git a/packages/components/src/components/Log/Log.test.jsx b/packages/components/src/components/Log/Log.test.jsx index 874916875..0770b15d4 100644 --- a/packages/components/src/components/Log/Log.test.jsx +++ b/packages/components/src/components/Log/Log.test.jsx @@ -11,7 +11,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { fireEvent, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import Log, { logFormatRegex } from './Log'; import { render } from '../../utils/test'; @@ -193,70 +193,6 @@ describe('Log', () => { ); await waitFor(() => expect(spiedFn).not.toHaveBeenCalled()); }); - - it('displays the scroll to top button when the virtualized list scroll is all the way down', async () => { - const long = Array.from( - { length: 20000 }, - (_v, i) => `Line ${i + 1}\n` - ).join(''); - vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ - bottom: 0, - top: 0, - right: 0 - }); - vi.spyOn(Element.prototype, 'scrollTop', 'get').mockReturnValue(1); - const spiedFn = vi.spyOn(Element.prototype, 'scrollTop', 'set'); // the scrollTop value is changed in scrollToBottomLog - - const { container } = render( - long} - /> - ); - await waitFor(() => { - expect( - container.querySelector('#log-scroll-to-start-btn') - ).not.toBeNull(); - }); - expect(container.querySelector('#log-scroll-to-end-btn')).toBeNull(); - fireEvent.click(container.querySelector('#log-scroll-to-start-btn')); - - await waitFor(() => expect(spiedFn).toHaveBeenCalled()); - }); - - it('displays both scroll buttons when the virtualized list scrolled is neither all the way up nor down', async () => { - const long = Array.from( - { length: 20000 }, - (_v, i) => `Line ${i + 1}\n` - ).join(''); - vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ - bottom: 0, - top: 0, - right: 0 - }); - vi.spyOn(Element.prototype, 'scrollTop', 'get').mockReturnValue(1); - vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(2); - const spiedFn = vi.spyOn(Element.prototype, 'scrollTop', 'set'); // the scrollTop value is changed in scrollToBottomLog - - const { container } = render( - long} - /> - ); - await waitFor(() => { - expect( - container.querySelector('#log-scroll-to-start-btn') - ).not.toBeNull(); - }); - expect(container.querySelector('#log-scroll-to-end-btn')).not.toBeNull(); - fireEvent.click(container.querySelector('#log-scroll-to-end-btn')); - - await waitFor(() => expect(spiedFn).toHaveBeenCalled()); - }); - it('renders the provided content when streaming logs', async () => { const { getByText } = render( { + const handleScroll = () => { + //Check for maximized container + const maximizedContainer = maximizedContainerRef.current; + setIsMaximized(!!maximizedContainer); + let scrollTop, scrollHeight, clientHeight; + + if (maximizedContainer) { + scrollTop = maximizedContainer.scrollTop; // how far scrolled down + scrollHeight = maximizedContainer.scrollHeight; // content inside container + clientHeight = maximizedContainer.clientHeight; // + } else { + scrollTop = window.scrollY; //scrolled down + scrollHeight = document.documentElement.scrollHeight; // height of page + clientHeight = window.innerHeight; + } + + // Show scroll-to-top when not at top + setShowScrollTop(scrollTop > 100); + + // Show scroll-to-bottom when not at bottom + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 100; + const isScrollable = scrollHeight > clientHeight; + setShowScrollBottom(!isAtBottom && isScrollable); + }; + + // Initialize the ref with current maximized container + maximizedContainerRef.current = document.querySelector( + '.tkn--taskrun--maximized' + ); + + window.addEventListener('scroll', handleScroll, { passive: true }); + if (maximizedContainerRef.current) { + maximizedContainerRef.current.addEventListener('scroll', handleScroll, { + passive: true + }); + } + + //Watch for maximize/minimize changes + const observer = new MutationObserver(() => { + const newMaximizedContainer = document.querySelector( + '.tkn--taskrun--maximized' + ); + + // Remove listener from previous maximized container + if (maximizedContainerRef.current) { + maximizedContainerRef.current.removeEventListener( + 'scroll', + handleScroll + ); + } + + // Add listener to new maximized container + if (newMaximizedContainer) { + newMaximizedContainer.addEventListener('scroll', handleScroll, { + passive: true + }); + maximizedContainerRef.current = newMaximizedContainer; + } else { + maximizedContainerRef.current = null; + } + + handleScroll(); + }); + + observer.observe(document.body, { + attributes: true, + subtree: true, + attributeFilter: ['class'] + }); + + handleScroll(); + + return () => { + window.removeEventListener('scroll', handleScroll); + observer.disconnect(); + if (maximizedContainerRef.current) { + maximizedContainerRef.current.removeEventListener( + 'scroll', + handleScroll + ); + } + }; + }, []); + + const getScrollBehavior = () => { + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + return prefersReducedMotion ? 'auto' : 'smooth'; + }; + + const scrollToTop = () => { + if (maximizedContainerRef.current) { + maximizedContainerRef.current.scrollTo({ + top: 0, + behavior: getScrollBehavior() + }); + } else { + window.scrollTo({ top: 0, behavior: getScrollBehavior() }); + } + }; + + const scrollToBottom = () => { + if (maximizedContainerRef.current) { + maximizedContainerRef.current.scrollTo({ + top: maximizedContainerRef.current.scrollHeight, + behavior: getScrollBehavior() + }); + } else { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: getScrollBehavior() + }); + } + }; + + const scrollTopMessage = intl.formatMessage({ + id: 'dashboard.app.scrollToTop', + defaultMessage: 'Scroll to top' + }); + + const scrollBottomMessage = intl.formatMessage({ + id: 'dashboard.app.scrollToBottom', + defaultMessage: 'Scroll to bottom' + }); + + if (!showScrollTop && !showScrollBottom) { + return null; + } + + return ( +
+ {showScrollTop && ( +
+ ); +} + +export default ScrollButtons; diff --git a/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx b/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx new file mode 100644 index 000000000..f12b7e971 --- /dev/null +++ b/packages/components/src/components/ScrollButtons/ScrollButtons.test.jsx @@ -0,0 +1,272 @@ +/* +Copyright 2019-2025 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../utils/test'; +import ScrollButtons from './ScrollButtons'; + +describe('ScrollButtons', () => { + beforeEach(() => { + // Reset scroll position + window.scrollY = 0; + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800 + }); + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 2000 + }); + }); + + afterEach(() => { + // Clean up any maximized containers + const maximizedContainers = document.querySelectorAll( + '.tkn--taskrun--maximized' + ); + maximizedContainers.forEach(container => container.remove()); + }); + + it('renders nothing when at top of non-scrollable page', () => { + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 800 + }); + + const { container } = render(); + expect(container.querySelector('.tkn--scroll-buttons')).toBeFalsy(); + }); + + it('shows scroll-to-bottom button when at the top of scrollable page', async () => { + const { queryByLabelText } = render(); + + await waitFor(() => { + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); + }); + expect(queryByLabelText('Scroll to top')).toBeFalsy(); + }); + + it('shows scroll-to-top button when at the bottom of scrollable page', async () => { + const { queryByLabelText } = render(); + + // Simulate scrolling down + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 200 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + }); + }); + + it('shows both buttons when in middle of page', async () => { + const { queryByLabelText } = render(); + + // Simulate scrolling to middle + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); + }); + }); + + it('hides scroll-to-bottom button when at bottom', async () => { + const { queryByLabelText } = render(); + + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 1200 // scrollHeight (2000) - innerHeight (800) + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeFalsy(); + }); + }); + + it('scrolls to top when scroll to top button is clicked', async () => { + const scrollToSpy = vi.fn(); + window.scrollTo = scrollToSpy; + + const { queryByLabelText } = render(); + + // scroll down first + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + }); + + const scrollTopButton = queryByLabelText('Scroll to top'); + fireEvent.click(scrollTopButton); + + expect(scrollToSpy).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth' + }); + }); + + it('scrolls to bottom when scroll-to-bottom button is clicked', async () => { + const scrollToSpy = vi.fn(); + window.scrollTo = scrollToSpy; + + const { queryByLabelText } = render(); + + await waitFor(() => { + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); + }); + + const scrollBottomButton = queryByLabelText('Scroll to bottom'); + fireEvent.click(scrollBottomButton); + + expect(scrollToSpy).toHaveBeenCalledWith({ + top: document.documentElement.scrollHeight, + behavior: 'smooth' + }); + }); + + it('handles maximized container scroll events', async () => { + const { queryByLabelText, container } = render(); + + // Create container first, then add the maximized class to trigger MutationObserver later + const maximizedContainer = document.createElement('div'); + document.body.appendChild(maximizedContainer); + + const containerScrollToSpy = vi.fn(); + maximizedContainer.scrollTo = containerScrollToSpy; + Object.defineProperty(maximizedContainer, 'scrollTop', { + writable: true, + configurable: true, + value: 0 + }); + Object.defineProperty(maximizedContainer, 'scrollHeight', { + writable: true, + configurable: true, + value: 2000 + }); + Object.defineProperty(maximizedContainer, 'clientHeight', { + writable: true, + configurable: true, + value: 500 + }); + + // Add the maximized class to trigger the MutationObserver + maximizedContainer.className = 'tkn--taskrun--maximized'; + + // Wait for MutationObserver to detect the class change and apply maximized class + await waitFor(() => { + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); + const scrollButtonsContainer = container.querySelector( + '.tkn--scroll-buttons' + ); + expect( + scrollButtonsContainer?.classList.contains( + 'tkn--scroll-buttons--maximized' + ) + ).toBe(true); + }); + + // scroll down the maximized container + Object.defineProperty(maximizedContainer, 'scrollTop', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(maximizedContainer); + + // Both buttons should be visible + await waitFor(() => { + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeTruthy(); + }); + + // test scroll-to-top button in maximized mode + const scrollTopButton = queryByLabelText('Scroll to top'); + fireEvent.click(scrollTopButton); + + expect(containerScrollToSpy).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth' + }); + + // test scroll-to-bottom button in maximized mode + const scrollBottomButton = queryByLabelText('Scroll to bottom'); + fireEvent.click(scrollBottomButton); + + expect(containerScrollToSpy).toHaveBeenCalledWith({ + top: 2000, // scrollHeight of maximized container + behavior: 'smooth' + }); + + // scroll to bottom - only scroll-to-top should be visible + Object.defineProperty(maximizedContainer, 'scrollTop', { + writable: true, + configurable: true, + value: 1500 // 2000 - 500 = 1500 (at bottom) + }); + fireEvent.scroll(maximizedContainer); + + await waitFor(() => { + expect(queryByLabelText('Scroll to top')).toBeTruthy(); + expect(queryByLabelText('Scroll to bottom')).toBeFalsy(); + }); + }); + + it('renders correct button IDs', async () => { + const { container } = render(); + + // scroll to middle - both buttons should be visible + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 500 + }); + fireEvent.scroll(window); + + await waitFor(() => { + expect(container.querySelector('#log-scroll-to-start-btn')).toBeTruthy(); + expect(container.querySelector('#log-scroll-to-end-btn')).toBeTruthy(); + }); + }); + + it('cleans up event listeners on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + const { unmount } = render(); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'scroll', + expect.any(Function) + ); + }); +}); diff --git a/packages/components/src/components/ScrollButtons/index.js b/packages/components/src/components/ScrollButtons/index.js new file mode 100644 index 000000000..4065e3c37 --- /dev/null +++ b/packages/components/src/components/ScrollButtons/index.js @@ -0,0 +1,14 @@ +/* +Copyright 2019-2025 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { default } from './ScrollButtons'; diff --git a/packages/components/src/components/index.js b/packages/components/src/components/index.js index 95e7e4b7a..5cdaf52f2 100644 --- a/packages/components/src/components/index.js +++ b/packages/components/src/components/index.js @@ -39,6 +39,7 @@ export { default as ResourceDetails } from './ResourceDetails'; export { default as RunHeader } from './RunHeader'; export { default as RunMetadataColumn } from './RunMetadataColumn'; export { default as RunTimeMetadata } from './RunTimeMetadata'; +export { default as ScrollButtons } from './ScrollButtons'; export { default as Spinner } from './Spinner'; export { default as StatusFilterDropdown } from './StatusFilterDropdown'; export { default as StatusIcon } from './StatusIcon'; diff --git a/src/containers/App/App.jsx b/src/containers/App/App.jsx index 262c95ea5..9ce1c1363 100644 --- a/src/containers/App/App.jsx +++ b/src/containers/App/App.jsx @@ -24,7 +24,10 @@ import { } from 'react-router-dom'; import { IntlProvider, useIntl } from 'react-intl'; import { Content, HeaderContainer, InlineNotification } from '@carbon/react'; -import { PageErrorBoundary } from '@tektoncd/dashboard-components'; +import { + PageErrorBoundary, + ScrollButtons +} from '@tektoncd/dashboard-components'; import { ALL_NAMESPACES, getErrorMessage, @@ -151,6 +154,7 @@ function Root() { + ); } diff --git a/src/containers/PipelineRun/PipelineRun.jsx b/src/containers/PipelineRun/PipelineRun.jsx index 98b71892b..870e8774e 100644 --- a/src/containers/PipelineRun/PipelineRun.jsx +++ b/src/containers/PipelineRun/PipelineRun.jsx @@ -521,7 +521,6 @@ export /* istanbul ignore next */ function PipelineRunContainer({