diff --git a/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TabFor3270.tsx b/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TabFor3270.tsx index 972d9544..06a01225 100644 --- a/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TabFor3270.tsx +++ b/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TabFor3270.tsx @@ -19,14 +19,17 @@ export default function TabFor3270({ zos3270TerminalData, is3270CurrentlySelected, handleNavigateTo3270, + isLoading, + setIsLoading, }: { runId: string; zos3270TerminalData: TreeNodeData[]; is3270CurrentlySelected: boolean; handleNavigateTo3270: (highlightedRowId: string) => void; + isLoading: boolean; + setIsLoading: React.Dispatch>; }) { const [isError, setIsError] = useState(false); - const [isLoading, setIsLoading] = useState(true); const [imageData, setImageData] = useState(); const [moveImageSelection, setMoveImageSelection] = useState(0); const [cannotSwitchToPreviousImage, setCannotSwitchToPreviousImage] = useState(true); @@ -44,17 +47,6 @@ export default function TabFor3270({ // eslint-disable-next-line }, [highlightedRowId, is3270CurrentlySelected]); - // Get the 'terminalScreen' parameter - useEffect(() => { - if (is3270CurrentlySelected && highlightedRowId === '') { - const url = new URL(window.location.href); - setHighlightedRowId(url.searchParams.get('terminalScreen') || ''); - } - - // If you're adding extra state to this hook, make sure to review the dependency array due to the warning suppression: - // eslint-disable-next-line - }, [is3270CurrentlySelected]); - if (isError) { return ; } diff --git a/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TableOfScreenshots.tsx b/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TableOfScreenshots.tsx index d5ed8cf1..2be93d90 100644 --- a/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TableOfScreenshots.tsx +++ b/galasa-ui/src/components/test-runs/test-run-details/3270Tab/TableOfScreenshots.tsx @@ -78,7 +78,6 @@ export default function TableOfScreenshots({ const [searchTerm, setSearchTerm] = useState(''); const [selectedTerminal, setSelectedTerminal] = useState(null); const [allImageData, setAllImageData] = useState([]); - const [initialHighlightedRowSet, setInitialHighlightedRowSet] = useState(false); const screenshotsCollected = useRef(false); @@ -116,12 +115,16 @@ export default function TableOfScreenshots({ useEffect(() => { // Highlight and display first element when the page loads, unless already set. const highlightFirstRowOnPageLoad = () => { - if (!initialHighlightedRowSet && filteredRows[0]) { - setInitialHighlightedRowSet(true); + if (!highlightedRowId && filteredRows[0]) { + const url = new URL(window.location.href); + const terminalScreen = url.searchParams.get('terminalScreen'); + if ( - highlightedRowId === '' || - !filteredRows.find((filteredRow) => filteredRow.id === highlightedRowId) + terminalScreen && + filteredRows.find((filteredRow) => filteredRow.id === terminalScreen) ) { + setHighlightedRowId(terminalScreen); + } else { setHighlightedRowId(filteredRows[0].id); } } @@ -147,7 +150,6 @@ export default function TableOfScreenshots({ // Ensure screenshots are only collected once. if (!screenshotsCollected.current?.valueOf() && flattenedZos3270TerminalData.length === 0) { screenshotsCollected.current = true; - setIsLoading(true); const fetchData = async () => { try { setFlattenedZos3270TerminalData([]); @@ -285,7 +287,7 @@ export default function TableOfScreenshots({ getRowProps: (options: any) => TableRowProps; getTableProps: () => TableBodyProps; }) => ( - +
{headers.map((header) => ( diff --git a/galasa-ui/src/components/test-runs/test-run-details/LogTab.tsx b/galasa-ui/src/components/test-runs/test-run-details/LogTab.tsx index eb7c358a..42911cbb 100644 --- a/galasa-ui/src/components/test-runs/test-run-details/LogTab.tsx +++ b/galasa-ui/src/components/test-runs/test-run-details/LogTab.tsx @@ -536,7 +536,7 @@ export default function LogTab({ logs, initialLine, runId }: LogTabProps) { lineElement.scrollIntoView({ behavior: ANIMATION_BEHAVIOUR, block: 'start' }); } } - }, [initialLine, processedLines]); + }, [ANIMATION_BEHAVIOUR, initialLine, processedLines]); // Scroll to current match useEffect(() => { @@ -549,7 +549,7 @@ export default function LogTab({ logs, initialLine, runId }: LogTabProps) { }); } } - }, [currentMatchIndex]); + }, [ANIMATION_BEHAVIOUR, currentMatchIndex]); useEffect(() => { const matchCount = searchMatches.length; diff --git a/galasa-ui/src/components/test-runs/test-run-details/OverviewTab.tsx b/galasa-ui/src/components/test-runs/test-run-details/OverviewTab.tsx index 24559dab..f47d1474 100644 --- a/galasa-ui/src/components/test-runs/test-run-details/OverviewTab.tsx +++ b/galasa-ui/src/components/test-runs/test-run-details/OverviewTab.tsx @@ -16,21 +16,26 @@ import useHistoryBreadCrumbs from '@/hooks/useHistoryBreadCrumbs'; import { TEST_RUNS_QUERY_PARAMS } from '@/utils/constants/common'; import { TIME_TO_WAIT_BEFORE_CLOSING_TAG_EDIT_MODAL_MS } from '@/utils/constants/common'; import RenderTags from '@/components/test-runs/test-run-details/RenderTags'; -import { updateRunTags, getExistingTagObjects } from '@/actions/runsAction'; +import { updateRunTags } from '@/actions/runsAction'; type DisplayedTagType = { id: string; label: string; }; -const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { +const OverviewTab = ({ + metadata, + existingTagObjectNames, +}: { + metadata: RunMetadata; + existingTagObjectNames: string[]; +}) => { const translations = useTranslations('OverviewTab'); const { pushBreadCrumb } = useHistoryBreadCrumbs(); const [weekBefore, setWeekBefore] = useState(null); const [tags, setTags] = useState(metadata?.tags || []); - const [existingTagObjectNames, setExistingTagObjectNames] = useState([]); const [isTagsEditModalOpen, setIsTagsEditModalOpen] = useState(false); const [filterInput, setFilterInput] = useState(''); @@ -46,22 +51,6 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { const OTHER_RECENT_RUNS = `/test-runs?${TEST_RUNS_QUERY_PARAMS.TEST_NAME}=${fullTestName}&${TEST_RUNS_QUERY_PARAMS.BUNDLE}=${metadata?.bundle}&${TEST_RUNS_QUERY_PARAMS.PACKAGE}=${metadata?.package}&${TEST_RUNS_QUERY_PARAMS.DURATION}=60,0,0&${TEST_RUNS_QUERY_PARAMS.TAB}=results&${TEST_RUNS_QUERY_PARAMS.QUERY_NAME}=Recent runs of test ${metadata?.testName}`; const RETRIES_FOR_THIS_TEST_RUN = `/test-runs?${TEST_RUNS_QUERY_PARAMS.SUBMISSION_ID}=${metadata?.submissionId}&${TEST_RUNS_QUERY_PARAMS.FROM}=${weekBefore}&${TEST_RUNS_QUERY_PARAMS.TAB}=results&${TEST_RUNS_QUERY_PARAMS.QUERY_NAME}=All attempts of test run ${metadata?.runName}`; - useEffect(() => { - const fetchExistingTags = async () => { - try { - const result = await getExistingTagObjects(); - setExistingTagObjectNames(result.tags || []); - - if (!result.success) { - console.error('Failed to fetch existing tags:', result.error); - } - } catch (error) { - console.error('Error fetching existing tags:', error); - } - }; - fetchExistingTags(); - }, []); - useEffect(() => { const validateTime = () => { const validatedTime = getAWeekBeforeSubmittedTime(metadata?.rawSubmittedAt!); diff --git a/galasa-ui/src/components/test-runs/test-run-details/TestRunDetails.tsx b/galasa-ui/src/components/test-runs/test-run-details/TestRunDetails.tsx index d84d062b..08d59b3a 100644 --- a/galasa-ui/src/components/test-runs/test-run-details/TestRunDetails.tsx +++ b/galasa-ui/src/components/test-runs/test-run-details/TestRunDetails.tsx @@ -45,6 +45,7 @@ import { NotificationType } from '@/utils/types/common'; import { TreeNodeData } from '@/utils/functions/artifacts'; import { TEST_RUNS } from '@/utils/constants/breadcrumb'; import TestRunsSearch from '../TestRunsSearch'; +import { getExistingTagObjects } from '@/actions/runsAction'; interface TestRunDetailsProps { runId: string; @@ -74,9 +75,11 @@ const TestRunDetails = ({ const [isError, setIsError] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [notification, setNotification] = useState(null); + const [existingTagObjectNames, setExistingTagObjectNames] = useState([]); const { formatDate } = useDateTimeFormat(); const indexOf3270Tab = TEST_RUN_PAGE_TABS.indexOf('3270'); + const [is3270TabLoading, setIs3270TabLoading] = useState(true); const [is3270TabSelectedInURL, setIs3270TabSelectedInURL] = useState(false); const [zos3270TerminalFolderExists, setZos3270TerminalFolderExists] = useState(false); const [zos3270TerminalData, setZos3270TerminalData] = useState([]); @@ -100,12 +103,16 @@ const TestRunDetails = ({ const handleZos3270TerminalFolderCheck = (newZos3270TerminalFolderExists: boolean) => { setZos3270TerminalFolderExists(newZos3270TerminalFolderExists); + }; + useEffect(() => { // If 3270 tab has been selected in the URL, move them to the 3270 pannel from the overview page redirection - if (is3270TabSelectedInURL && newZos3270TerminalFolderExists) { + if (is3270TabSelectedInURL && zos3270TerminalFolderExists && !is3270TabLoading) { setSelectedTabIndex(indexOf3270Tab); } - }; + // Ignore missing dependecies as they will be finalised by the time is3270TabLoading switches to false + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [is3270TabLoading]); const handleSetZos3270TerminalData = (newZos3270TerminalData: TreeNodeData[]) => { setZos3270TerminalData(newZos3270TerminalData); @@ -182,6 +189,23 @@ const TestRunDetails = ({ loadRunDetails(); }, [run, runDetailsPromise, runArtifactsPromise, runLogPromise, extractRunDetails]); + // Fetch existing tags once on component mount (persists across tab changes) + useEffect(() => { + const fetchExistingTags = async () => { + try { + const result = await getExistingTagObjects(); + if (result.success) { + setExistingTagObjectNames(result.tags || []); + } else { + console.error('Failed to fetch existing tags:', result.error); + } + } catch (error) { + console.error('Error fetching existing tags:', error); + } + }; + fetchExistingTags(); + }, []); + useEffect(() => { // If the 'Test Runs' breadcrumb is already in the items, skip. if (breadCrumbItems.length > 1) return; @@ -383,7 +407,7 @@ const TestRunDetails = ({ - + @@ -407,6 +431,8 @@ const TestRunDetails = ({ zos3270TerminalData={zos3270TerminalData} is3270CurrentlySelected={indexOf3270Tab === selectedTabIndex} handleNavigateTo3270={handleNavigateTo3270} + isLoading={is3270TabLoading} + setIsLoading={setIs3270TabLoading} /> )} diff --git a/galasa-ui/src/styles/test-runs/test-run-details/Tab3270.module.css b/galasa-ui/src/styles/test-runs/test-run-details/Tab3270.module.css index 842cb011..8c2a2ce2 100644 --- a/galasa-ui/src/styles/test-runs/test-run-details/Tab3270.module.css +++ b/galasa-ui/src/styles/test-runs/test-run-details/Tab3270.module.css @@ -5,14 +5,14 @@ */ .tab3270Container { - height: min(calc(100vh - 400px), 550px); + height: min(calc(100vh - 400px), 600px); } .clickableRow { cursor: pointer; } -.innerScreenshotTable { +#innerScreenshotTable { max-height: min(calc(100vh - 450px), 550px); overflow-y: auto; /* Stops sticky header overlapping table elements when scrollToTop() is called. */ diff --git a/galasa-ui/src/tests/components/test-runs/test-run-details/OverviewTab.test.tsx b/galasa-ui/src/tests/components/test-runs/test-run-details/OverviewTab.test.tsx index ccdcef39..57766a22 100644 --- a/galasa-ui/src/tests/components/test-runs/test-run-details/OverviewTab.test.tsx +++ b/galasa-ui/src/tests/components/test-runs/test-run-details/OverviewTab.test.tsx @@ -219,7 +219,12 @@ const mockGetAWeekBeforeSubmittedTime = getAWeekBeforeSubmittedTime as jest.Mock describe('OverviewTab', () => { it('renders all top-level InlineText entries', () => { - render(); + render( + + ); // check each label/value pair [ @@ -240,7 +245,12 @@ describe('OverviewTab', () => { }); it('renders the timing fields in the infoContainer', () => { - render(); + render( + + ); ['Submitted:', 'Started:', 'Finished:', 'Duration:'].forEach((label) => { expect(screen.getByText(label as string, { selector: 'p' })).toBeInTheDocument(); @@ -249,7 +259,12 @@ describe('OverviewTab', () => { }); it('renders each tag when tags array is non-empty, sorted', () => { - render(); + render( + + ); // header - use getByText since h5 contains nested elements expect(screen.getByText('Tags', { selector: 'h5' })).toBeInTheDocument(); @@ -261,7 +276,12 @@ describe('OverviewTab', () => { it('shows fallback text when tags is empty or missing', () => { const noTags: RunMetadata = { ...completeMetadata, tags: [] }; - render(); + render( + + ); expect(screen.getByText('No tags were associated with this test run.')).toBeInTheDocument(); }); }); @@ -274,7 +294,12 @@ describe('OverviewTab - Time and Link Logic', () => { it('renders recent runs link with correct href', async () => { mockGetAWeekBeforeSubmittedTime.mockReturnValue('2025-06-03T09:00:00Z'); - render(); + render( + + ); await screen.findAllByTestId('mock-link'); @@ -292,7 +317,12 @@ describe('OverviewTab - Time and Link Logic', () => { mockGetAWeekBeforeSubmittedTime.mockReturnValue(mockWeekBefore); - render(); + render( + + ); await screen.findAllByTestId('mock-link'); @@ -310,7 +340,12 @@ describe('OverviewTab - Time and Link Logic', () => { it('renders only recent runs link when weekBefore is invalid', async () => { mockGetAWeekBeforeSubmittedTime.mockReturnValue(null); - render(); + render( + + ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -329,7 +364,12 @@ describe('OverviewTab - Time and Link Logic', () => { mockGetAWeekBeforeSubmittedTime.mockReturnValue('2025-06-03T09:00:00Z'); - render(); + render( + + ); expect(mockGetAWeekBeforeSubmittedTime).toHaveBeenCalledWith('2025-06-10T09:00:00Z'); expect(mockGetAWeekBeforeSubmittedTime).toHaveBeenCalledTimes(1); @@ -343,7 +383,12 @@ describe('OverviewTab - Time and Link Logic', () => { mockGetAWeekBeforeSubmittedTime.mockReturnValue('Invalid date'); - render(); + render( + + ); expect(mockGetAWeekBeforeSubmittedTime).toHaveBeenCalledWith(undefined); }); @@ -353,7 +398,12 @@ describe('OverviewTab - Time and Link Logic', () => { mockGetAWeekBeforeSubmittedTime.mockReturnValue(mockWeekBefore); - render(); + render( + + ); await screen.findAllByTestId('mock-link'); @@ -366,7 +416,12 @@ describe('OverviewTab - Time and Link Logic', () => { it('updates weekBefore state correctly when time is invalid', async () => { mockGetAWeekBeforeSubmittedTime.mockReturnValue(null); - render(); + render( + + ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -378,7 +433,12 @@ describe('OverviewTab - Time and Link Logic', () => { }); it('push link bread crumb when any of the links is clicked', () => { - render(); + render( + + ); const links = screen.getAllByTestId('mock-link'); links.forEach((link) => { @@ -405,7 +465,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should open modal when edit icon is clicked', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -417,7 +482,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should display RenderTags component with dismissible tags in modal', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -430,7 +500,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should close modal when secondary button is clicked', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -449,7 +524,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should handle tag removal in modal', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -478,7 +558,12 @@ describe('OverviewTab - Tags Edit Modal', () => { delete (window as any).location; (window as any).location = { reload: jest.fn() }; - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -505,7 +590,12 @@ describe('OverviewTab - Tags Edit Modal', () => { error: 'Server Error', }); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -534,7 +624,12 @@ describe('OverviewTab - Tags Edit Modal', () => { delete (window as any).location; (window as any).location = { reload: jest.fn() }; - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -554,7 +649,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should reset staged tags when modal is closed without saving', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -592,7 +692,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should display modal heading with run name', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -604,7 +709,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should initialise staged tags from current tags when modal opens', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -618,7 +728,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should display FilterableMultiSelect with sorted items alphabetically', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -634,7 +749,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should maintain alphabetical sorting when adding new tags via filter input', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -655,7 +775,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should keep selected items ticked in the dropdown', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -682,7 +807,12 @@ describe('OverviewTab - Tags Edit Modal', () => { ...completeMetadata, tags: ['smoke'], }; - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -709,7 +839,12 @@ describe('OverviewTab - Tags Edit Modal', () => { it('should clear filter input when modal is closed', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -747,7 +882,12 @@ describe('OverviewTab - Tags Edit Modal', () => { tags: ['smoke', 'regression', 'new-tag'], }); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon); @@ -786,22 +926,37 @@ describe('OverviewTab - Tags Edit Modal', () => { ); }); - it('should fetch existing tags on component mount', async () => { - render(); + it('should receive existing tags as props', async () => { + const existingTags = ['existing-tag-1', 'existing-tag-2']; + render(); // Wait for the component to render await waitFor(() => { expect(screen.getByText('Tags', { selector: 'h5' })).toBeInTheDocument(); }); - // The getExistingTagObjects should have been called - const { getExistingTagObjects } = require('@/actions/runsAction'); - expect(getExistingTagObjects).toHaveBeenCalled(); + // Open the modal to verify existing tags are available + const user = userEvent.setup(); + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Verify that the existing tags passed as props are available in the dropdown + expect(screen.getByTestId('multiselect-item-existing-tag-1')).toBeInTheDocument(); + expect(screen.getByTestId('multiselect-item-existing-tag-2')).toBeInTheDocument(); }); it('should include existing system tags in FilterableMultiSelect items', async () => { const user = userEvent.setup(); - render(); + render( + + ); const editIcon = screen.getByTestId('edit-icon'); await user.click(editIcon);