diff --git a/src/web/components/folding/Foldable.tsx b/src/web/components/folding/Foldable.tsx new file mode 100644 index 0000000000..aa6dd88ab5 --- /dev/null +++ b/src/web/components/folding/Foldable.tsx @@ -0,0 +1,79 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import React from 'react'; +import styled, {css, keyframes} from 'styled-components'; +import {FoldState, type FoldStateType} from 'web/components/folding/Folding'; + +interface FoldableStyleProps { + $foldState: FoldStateType; +} + +export interface FoldableProps extends Omit< + React.HTMLAttributes, + 'onAnimationEnd' | 'onTransitionEnd' +> { + children?: React.ReactNode; + foldState?: FoldStateType; + onFoldStepEnd?: () => void; +} + +const foldDelay = keyframes` + 0% { + min-width: 0px; + } + 100% { + min-width: 1px; + } +`; + +const FoldableDiv = styled.div` + overflow: hidden; + transition: height 0.4s; + + display: ${({$foldState}) => + $foldState === FoldState.FOLDED ? 'none' : 'block'}; + + height: ${({$foldState}) => { + switch ($foldState) { + case FoldState.FOLDED: + case FoldState.FOLDING: + return '0px'; + case FoldState.FOLDING_START: + case FoldState.UNFOLDING: + return `${Math.ceil(window.innerHeight * 1.2)}px`; + case FoldState.UNFOLDING_START: + return '1px'; + default: + return 'auto'; + } + }}; + + animation: ${({$foldState}) => + $foldState === FoldState.UNFOLDING_START || + $foldState === FoldState.FOLDING_START + ? css` + ${foldDelay} 0.01s + ` + : 'none'}; +`; + +const Foldable = ({ + children, + foldState = FoldState.UNFOLDED, + onFoldStepEnd, + ...props +}: FoldableProps) => ( + + {children} + +); + +export default Foldable; diff --git a/src/web/components/folding/Folding.tsx b/src/web/components/folding/Folding.tsx index d2a9725c31..4fd44773ed 100644 --- a/src/web/components/folding/Folding.tsx +++ b/src/web/components/folding/Folding.tsx @@ -3,31 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; -import styled, {keyframes, css} from 'styled-components'; -import {updateDisplayName} from 'web/utils/display-name'; - -interface FoldStatefulProps { - foldState?: FoldStateType; - onFoldStepEnd?: () => void; - onFoldToggle?: () => void; - style?: React.CSSProperties; -} - -interface FoldToggleProps { - initialFoldState?: FoldStateType; -} - -export interface FoldToggleComponentProps { - foldState: FoldStateType; - onFoldStepEnd: () => void; - onFoldToggle: () => void; -} - -interface FoldableDivProps { - $foldState: FoldStateType; -} - export type FoldStateType = (typeof FoldState)[keyof typeof FoldState]; /** @@ -41,170 +16,3 @@ export const FoldState = { FOLDING: 'FOLDING', UNFOLDING: 'UNFOLDING', } as const; - -const foldDelay = keyframes` - 0% { - min-width: 0px; - } - 100% { - min-width: 1px; - } -`; - -const FoldableDiv = styled.div` - overflow: hidden; - transition: height 0.4s; - - display: ${({$foldState}) => - $foldState === FoldState.FOLDED ? 'none' : 'block'}; - - height: ${({$foldState}) => { - switch ($foldState) { - case FoldState.FOLDED: - case FoldState.FOLDING: - return '0px'; - case FoldState.FOLDING_START: - case FoldState.UNFOLDING: - return `${Math.ceil(window.innerHeight * 1.2)}px`; - case FoldState.UNFOLDING_START: - return '1px'; - default: - return 'auto'; - } - }}; - - animation: ${({$foldState}) => - $foldState === FoldState.UNFOLDING_START || - $foldState === FoldState.FOLDING_START - ? css` - ${foldDelay} 0.01s - ` - : 'none'}; -`; - -/** - * HOC for making a container content component foldable - */ -export const withFolding = ( - Component: React.ComponentType, -) => { - const FoldingWrapper = ({ - foldState = FoldState.UNFOLDED, - onFoldStepEnd, - ...props - }: TProps & FoldStatefulProps) => ( - - - - ); - - return updateDisplayName(FoldingWrapper, Component, 'withFolding'); -}; - -/** - * HOC to add fold parent functionality to a component. - */ - -export const withFoldToggle = ( - Component: React.ComponentType, -) => { - type PublicProps = Omit & - FoldToggleProps; - - class FoldToggleWrapper extends React.Component< - PublicProps, - {foldState: FoldStateType} - > { - constructor(props: PublicProps) { - super(props); - - const {initialFoldState = FoldState.UNFOLDED} = props; - - this.state = { - foldState: initialFoldState, - }; - - this.handleFoldStepEnd = this.handleFoldStepEnd.bind(this); - this.handleFoldToggle = this.handleFoldToggle.bind(this); - } - - handleFoldToggle() { - this.setState(({foldState}) => { - let newFoldState: FoldStateType; - - switch (foldState) { - case FoldState.FOLDED: - newFoldState = FoldState.UNFOLDING_START; - break; - case FoldState.UNFOLDED: - newFoldState = FoldState.FOLDING_START; - break; - case FoldState.UNFOLDING_START: - newFoldState = FoldState.FOLDED; - break; - case FoldState.FOLDING_START: - newFoldState = FoldState.UNFOLDED; - break; - case FoldState.UNFOLDING: - newFoldState = FoldState.FOLDING; - break; - case FoldState.FOLDING: - newFoldState = FoldState.UNFOLDING; - break; - default: - newFoldState = FoldState.UNFOLDED; - } - return {foldState: newFoldState}; - }); - } - - handleFoldStepEnd() { - this.setState(({foldState}) => { - let newFoldState: FoldStateType; - - switch (foldState) { - case FoldState.FOLDED: - newFoldState = FoldState.FOLDED; - break; - case FoldState.UNFOLDED: - newFoldState = FoldState.UNFOLDED; - break; - case FoldState.UNFOLDING_START: - newFoldState = FoldState.UNFOLDING; - break; - case FoldState.FOLDING_START: - newFoldState = FoldState.FOLDING; - break; - case FoldState.UNFOLDING: - newFoldState = FoldState.UNFOLDED; - break; - case FoldState.FOLDING: - newFoldState = FoldState.FOLDED; - break; - default: - newFoldState = FoldState.UNFOLDED; - } - return {foldState: newFoldState}; - }); - } - - render() { - const {...other} = this.props; - const {foldState} = this.state; - const componentProps = { - ...(other as Omit), - foldState, - onFoldStepEnd: this.handleFoldStepEnd, - onFoldToggle: this.handleFoldToggle, - } as TProps; - - return ; - } - } - - return FoldToggleWrapper; -}; diff --git a/src/web/components/folding/__tests__/Foldable.test.tsx b/src/web/components/folding/__tests__/Foldable.test.tsx new file mode 100644 index 0000000000..4c2ae522ab --- /dev/null +++ b/src/web/components/folding/__tests__/Foldable.test.tsx @@ -0,0 +1,68 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import {fireEvent, render, screen} from 'web/testing'; +import Foldable from 'web/components/folding/Foldable'; +import {FoldState} from 'web/components/folding/Folding'; + +describe('Foldable', () => { + test('should use the default fold state and forward props', () => { + const {element} = render( + + Dummy Component + , + ); + + expect(element).toHaveStyle({display: 'block', height: 'auto'}); + expect(screen.getByTestId('dummy')).toHaveClass('dummy-component'); + expect(screen.getByText('Dummy Component')).toBeVisible(); + }); + + test.each([ + [FoldState.FOLDED, 'none', '0px'], + [FoldState.FOLDING, 'block', '0px'], + [ + FoldState.FOLDING_START, + 'block', + `${Math.ceil(window.innerHeight * 1.2)}px`, + ], + [FoldState.UNFOLDING, 'block', `${Math.ceil(window.innerHeight * 1.2)}px`], + [FoldState.UNFOLDING_START, 'block', '1px'], + [FoldState.UNFOLDED, 'block', 'auto'], + ])('should apply %s folding styles', (foldState, display, height) => { + const {element, rerender} = render(); + + expect(element).toHaveStyle({display, height}); + + rerender(); + }); + + test('should hide folded content and show it when unfolded', () => { + const {rerender} = render( + Dummy Component, + ); + expect(screen.getByText('Dummy Component')).not.toBeVisible(); + + rerender( + Dummy Component, + ); + expect(screen.getByText('Dummy Component')).toBeVisible(); + }); + + test('should call onFoldStepEnd after a transition', () => { + const onFoldStepEnd = testing.fn(); + const {element} = render( + , + ); + + fireEvent.transitionEnd(element); + + expect(onFoldStepEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web/components/folding/__tests__/Folding.test.tsx b/src/web/components/folding/__tests__/Folding.test.tsx deleted file mode 100644 index cd27a141bc..0000000000 --- a/src/web/components/folding/__tests__/Folding.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import {describe, test, expect} from '@gsa/testing'; -import {render, screen, fireEvent} from 'web/testing'; -import { - withFolding, - FoldState, - withFoldToggle, - type FoldStateType, -} from 'web/components/folding/Folding'; - -describe('withFolding', () => { - const DummyComponent = (props: React.HTMLAttributes) => ( -
Dummy Component
- ); - const FoldableComponent = withFolding(DummyComponent); - - test('hides content when foldState is Folded', () => { - const {rerender} = render( - , - ); - expect(screen.getByText('Dummy Component')).not.toBeVisible(); - - rerender(); - expect(screen.getByText('Dummy Component')).toBeVisible(); - }); -}); - -describe('withFoldToggle', () => { - test('toggles foldState when onFolded isCalled', () => { - interface DummyProps { - foldState: FoldStateType; - onFoldToggle: () => void; - } - - const DummyComponent = ({foldState, onFoldToggle}: DummyProps) => ( -
- {foldState} - -
- ); - - const FoldToggleComponent = withFoldToggle(DummyComponent); - - render(); - - expect(screen.getByTestId('foldState')).toHaveTextContent( - FoldState.UNFOLDED, - ); - - fireEvent.click(screen.getByText('Toggle')); - expect(screen.getByTestId('foldState')).toHaveTextContent( - FoldState.FOLDING_START, - ); - }); -}); diff --git a/src/web/components/folding/__tests__/useFoldToggle.test.tsx b/src/web/components/folding/__tests__/useFoldToggle.test.tsx new file mode 100644 index 0000000000..3b6c7c0fbb --- /dev/null +++ b/src/web/components/folding/__tests__/useFoldToggle.test.tsx @@ -0,0 +1,83 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test} from '@gsa/testing'; +import {act, renderHook} from 'web/testing'; +import {FoldState, type FoldStateType} from 'web/components/folding/Folding'; +import useFoldToggle from 'web/components/folding/useFoldToggle'; + +describe('useFoldToggle', () => { + test('should use the default fold state', () => { + const {result} = renderHook(() => useFoldToggle()); + + expect(result.current.foldState).toBe(FoldState.UNFOLDED); + }); + + test('should use the initial fold state', () => { + const {result} = renderHook(() => + useFoldToggle({initialFoldState: FoldState.FOLDED}), + ); + + expect(result.current.foldState).toBe(FoldState.FOLDED); + }); + + test.each([ + [FoldState.FOLDED, FoldState.UNFOLDING_START], + [FoldState.UNFOLDED, FoldState.FOLDING_START], + [FoldState.UNFOLDING_START, FoldState.FOLDED], + [FoldState.FOLDING_START, FoldState.UNFOLDED], + [FoldState.UNFOLDING, FoldState.FOLDING], + [FoldState.FOLDING, FoldState.UNFOLDING], + ])('should toggle %s to %s', (initialFoldState, expectedFoldState) => { + const {result} = renderHook(() => useFoldToggle({initialFoldState})); + + act(() => result.current.onFoldToggle()); + expect(result.current.foldState).toBe(expectedFoldState); + }); + + test.each([ + [FoldState.FOLDED, FoldState.FOLDED], + [FoldState.UNFOLDED, FoldState.UNFOLDED], + [FoldState.UNFOLDING_START, FoldState.UNFOLDING], + [FoldState.FOLDING_START, FoldState.FOLDING], + [FoldState.UNFOLDING, FoldState.UNFOLDED], + [FoldState.FOLDING, FoldState.FOLDED], + ])( + 'should handle fold step end from %s to %s', + (initialFoldState, expectedFoldState) => { + const {result} = renderHook(() => useFoldToggle({initialFoldState})); + + act(() => result.current.onFoldStepEnd()); + expect(result.current.foldState).toBe(expectedFoldState); + }, + ); + + test('should not update the initial state after rerender', () => { + const {result, rerender} = renderHook( + ({initialFoldState}: {initialFoldState: FoldStateType}) => + useFoldToggle({initialFoldState}), + { + initialProps: { + initialFoldState: FoldState.FOLDED as FoldStateType, + }, + }, + ); + + rerender({initialFoldState: FoldState.UNFOLDED}); + + expect(result.current.foldState).toBe(FoldState.FOLDED); + }); + + test('should keep handlers stable after state updates', () => { + const {result, rerender} = renderHook(() => useFoldToggle()); + const {onFoldStepEnd, onFoldToggle} = result.current; + + act(() => result.current.onFoldToggle()); + rerender(); + + expect(result.current.onFoldStepEnd).toBe(onFoldStepEnd); + expect(result.current.onFoldToggle).toBe(onFoldToggle); + }); +}); diff --git a/src/web/components/folding/useFoldToggle.ts b/src/web/components/folding/useFoldToggle.ts new file mode 100644 index 0000000000..8056b425b0 --- /dev/null +++ b/src/web/components/folding/useFoldToggle.ts @@ -0,0 +1,69 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useCallback, useState} from 'react'; +import {FoldState, type FoldStateType} from 'web/components/folding/Folding'; + +export interface UseFoldToggleProps { + initialFoldState?: FoldStateType; +} + +export interface UseFoldToggleResult { + foldState: FoldStateType; + onFoldStepEnd: () => void; + onFoldToggle: () => void; +} + +const useFoldToggle = ({ + initialFoldState = FoldState.UNFOLDED, +}: UseFoldToggleProps = {}): UseFoldToggleResult => { + const [foldState, setFoldState] = useState(initialFoldState); + + const onFoldToggle = useCallback(() => { + setFoldState(currentFoldState => { + switch (currentFoldState) { + case FoldState.FOLDED: + return FoldState.UNFOLDING_START; + case FoldState.UNFOLDED: + return FoldState.FOLDING_START; + case FoldState.UNFOLDING_START: + return FoldState.FOLDED; + case FoldState.FOLDING_START: + return FoldState.UNFOLDED; + case FoldState.UNFOLDING: + return FoldState.FOLDING; + case FoldState.FOLDING: + return FoldState.UNFOLDING; + default: + return FoldState.UNFOLDED; + } + }); + }, []); + + const onFoldStepEnd = useCallback(() => { + setFoldState(currentFoldState => { + switch (currentFoldState) { + case FoldState.FOLDED: + return FoldState.FOLDED; + case FoldState.UNFOLDED: + return FoldState.UNFOLDED; + case FoldState.UNFOLDING_START: + return FoldState.UNFOLDING; + case FoldState.FOLDING_START: + return FoldState.FOLDING; + case FoldState.UNFOLDING: + return FoldState.UNFOLDED; + case FoldState.FOLDING: + return FoldState.FOLDED; + default: + return FoldState.UNFOLDED; + } + }); + }, []); + + return {foldState, onFoldStepEnd, onFoldToggle}; +}; + +export default useFoldToggle; diff --git a/src/web/components/section/Section.tsx b/src/web/components/section/Section.tsx index cbadddb522..c8eaaf3f75 100644 --- a/src/web/components/section/Section.tsx +++ b/src/web/components/section/Section.tsx @@ -6,16 +6,15 @@ import React from 'react'; import styled from 'styled-components'; import {isDefined} from 'gmp/utils/identity'; -import { - type FoldToggleComponentProps, - withFolding, - withFoldToggle, -} from 'web/components/folding/Folding'; +import Foldable from 'web/components/folding/Foldable'; +import useFoldToggle, { + type UseFoldToggleProps, +} from 'web/components/folding/useFoldToggle'; import FoldStateIcon from 'web/components/icon/FoldStateIcon'; import Layout from 'web/components/layout/Layout'; import SectionHeader from 'web/components/section/SectionHeader'; -interface SectionProps extends FoldToggleComponentProps { +interface SectionProps extends UseFoldToggleProps { children?: React.ReactNode; className?: string; 'data-testid'?: string; @@ -26,8 +25,6 @@ interface SectionProps extends FoldToggleComponentProps { title?: string; } -const FoldableLayout = withFolding(Layout); - const FoldLayout = styled(Layout)` margin-left: 3px; margin-top: -2px; @@ -38,14 +35,16 @@ const Section = ({ className, extra, foldable, - foldState, header, img, title, ['data-testid']: dataTestId, - onFoldToggle, - onFoldStepEnd, + initialFoldState, }: SectionProps) => { + const {foldState, onFoldToggle, onFoldStepEnd} = useFoldToggle({ + initialFoldState, + }); + if (!isDefined(header)) { header = ( @@ -68,13 +67,9 @@ const Section = ({
{header} {foldable ? ( - - {children} - + + {children} + ) : ( children )} @@ -82,4 +77,4 @@ const Section = ({ ); }; -export default withFoldToggle(Section); +export default Section; diff --git a/src/web/entity/EntityPage.tsx b/src/web/entity/EntityPage.tsx index fc4f93f0ec..03ab64846e 100644 --- a/src/web/entity/EntityPage.tsx +++ b/src/web/entity/EntityPage.tsx @@ -129,7 +129,6 @@ const EntityPage = ({ const renderSection = () => { if (!isDefined(SectionComponent)) { - // @ts-expect-error SectionComponent = Section; } if (SectionComponent === false) { @@ -142,7 +141,6 @@ const EntityPage = ({ } return ( - // @ts-expect-error