diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index 2d1c0ce4..c82c36a5 100644 --- a/galasa-ui/messages/de.json +++ b/galasa-ui/messages/de.json @@ -256,8 +256,18 @@ "duration": "Dauer", "tags": "Tags", "noTags": "Keine Tags vorhanden.", - "recentRunsLink": "Zeige letzte Ausführungen desselben Tests...", - "runRetriesLink": "Zeige alle Versuche dieses Testlaufs..." + "recentRunsLink": "Zeige letzte Ausführungen desselben Tests", + "runRetriesLink": "Zeige alle Versuche dieses Testlaufs", + "modalHeading": "Tags im Testlauf bearbeiten", + "modalPrimaryButton": "Speichern", + "modalLabelText": "Geben Sie neue Tag-Namen zum Hinzufügen ein oder entfernen Sie vorhandene Tags aus dem Testlauf", + "modalPlaceholderText": "Geben Sie hier einen neuen Tag(s) ein und drücken Sie die [Eingabetaste]", + "removeTag": "Tag entfernen", + "modalSecondaryButton": "Abbrechen", + "updateSuccess": "Tags erfolgreich aktualisiert", + "updateSuccessMessage": "Die Tags wurden für diesen Testlauf aktualisiert.", + "updateError": "Fehler beim Aktualisieren der Tags", + "updateErrorMessage": "Beim Aktualisieren der Tags ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut." }, "3270Tab": { "Terminal": "Terminal", diff --git a/galasa-ui/messages/en.json b/galasa-ui/messages/en.json index e9c30104..23ddae8b 100644 --- a/galasa-ui/messages/en.json +++ b/galasa-ui/messages/en.json @@ -235,8 +235,18 @@ "duration": "Duration", "tags": "Tags", "noTags": "No tags were associated with this test run.", - "recentRunsLink": "View recent runs of the same test...", - "runRetriesLink": "View all attempts of this test run..." + "recentRunsLink": "View recent runs of the same test", + "runRetriesLink": "View all attempts of this test run", + "modalHeading": "Edit tags on test run", + "modalPrimaryButton": "Save", + "modalLabelText": "Type new tag names to add, or remove existing tags from test run", + "modalPlaceholderText": "Type new tag(s) here and hit [enter]", + "removeTag": "Remove tag", + "modalSecondaryButton": "Cancel", + "updateSuccess": "Tags updated successfully", + "updateSuccessMessage": "The tags have been updated for this test run.", + "updateError": "Failed to update tags", + "updateErrorMessage": "An error occurred while updating the tags. Please try again." }, "3270Tab": { "Terminal": "Terminal", @@ -353,7 +363,6 @@ "isloading": "Loading graph...", "errorLoadingGraph": "Something went wrong loading the graph.", "noTestRunsFound": "No test runs found.", - "limitExceeded": { "title": "Limit Exceeded", "subtitle": "Your query returned more than {maxRecords} results. Showing the first {maxRecords} records. To avoid this in the future narrow your time frame or change your search criteria to return fewer results." diff --git a/galasa-ui/src/actions/runsAction.ts b/galasa-ui/src/actions/runsAction.ts index 5bc60b3a..049520f1 100644 --- a/galasa-ui/src/actions/runsAction.ts +++ b/galasa-ui/src/actions/runsAction.ts @@ -49,3 +49,23 @@ export const downloadArtifactFromServer = async (runId: string, artifactUrl: str base64, }; }; + +export const updateRunTags = async (runId: string, tags: string[]) => { + try { + const apiConfig = createAuthenticatedApiConfiguration(); + const rasApiClient = new ResultArchiveStoreAPIApi(apiConfig); + + // Note: Tags are already unique from the Set in the frontend, but is checked again by the rest api. + await rasApiClient.putRasRunTagsOrStatusById(runId, { + tags: tags, + }); + + return { success: true, tags: tags }; + } catch (error: any) { + console.error('Error updating run tags:', error); + return { + success: false, + error: error.message || 'Failed to update tags', + }; + } +}; diff --git a/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx b/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx index 4ae8dc8c..f8824c8a 100644 --- a/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx +++ b/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx @@ -33,6 +33,7 @@ import { useDisappearingNotification } from '@/hooks/useDisappearingNotification import { getTimeframeText } from '@/utils/functions/timeFrameText'; import useResultsTablePageSize from '@/hooks/useResultsTablePageSize'; import Link from 'next/link'; +import RenderTags from '../test-run-details/RenderTags'; interface CustomCellProps { header: string; @@ -134,6 +135,20 @@ export default function TestRunsTable({ ); + if (header === 'tags') { + // Handle tags column with RenderTags component. + if (value.length === 0) { + return N/A; + } + const tagsArray = value.split(', '); + return ( + + + + + ); + } + if (value === 'N/A' || !value) { return N/A; } 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 2c3ff4d9..90a49002 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 @@ -7,22 +7,36 @@ import React, { useEffect, useState } from 'react'; import styles from '@/styles/test-runs/test-run-details/OverviewTab.module.css'; import InlineText from './InlineText'; -import { Tag } from '@carbon/react'; import { RunMetadata } from '@/utils/interfaces'; import { useTranslations } from 'next-intl'; -import { Link } from '@carbon/react'; -import { Launch } from '@carbon/icons-react'; +import { Link, InlineNotification } from '@carbon/react'; +import { Launch, Edit } from '@carbon/icons-react'; import { getAWeekBeforeSubmittedTime } from '@/utils/timeOperations'; import useHistoryBreadCrumbs from '@/hooks/useHistoryBreadCrumbs'; import { TEST_RUNS_QUERY_PARAMS } from '@/utils/constants/common'; +import { TextInput } from '@carbon/react'; +import { Modal } from '@carbon/react'; +import { TIME_TO_WAIT_BEFORE_CLOSING_TAG_EDIT_MODAL_MS } from '@/utils/constants/common'; +import RenderTags from './RenderTags'; +import { updateRunTags } from '@/actions/runsAction'; const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { - const tags = metadata?.tags || []; const translations = useTranslations('OverviewTab'); const { pushBreadCrumb } = useHistoryBreadCrumbs(); const [weekBefore, setWeekBefore] = useState(null); + const [tags, setTags] = useState(metadata?.tags || []); + const [isTagsEditModalOpen, setIsTagsEditModalOpen] = useState(false); + const [newTagInput, setNewTagInput] = useState(''); + const [stagedTags, setStagedTags] = useState>(new Set(tags)); + const [notification, setNotification] = useState<{ + kind: 'success' | 'error'; + title: string; + subtitle: string; + } | null>(null); + const [isSaving, setIsSaving] = useState(false); + const fullTestName = metadata?.testName; 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}`; @@ -39,13 +53,89 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { }, [metadata?.rawSubmittedAt]); const handleNavigationClick = () => { - // Push the current URL to the breadcrumb history + // Push the current URL to the breadcrumb history. pushBreadCrumb({ title: `${metadata.runName}`, route: `/test-runs/${metadata.runId}`, }); }; + const handleTagRemove = (tag: string) => { + setStagedTags((prev) => { + const newSet = new Set(prev); + newSet.delete(tag); + return newSet; + }); + }; + + const handleStageNewTags = () => { + // Parse new tags from input (comma or space separated). + const newTags = newTagInput + .split(/[,\s]+/) + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + + // Add new tags to staged tags Set (automatically handles duplicates). + setStagedTags((prev) => { + const newSet = new Set(prev); + newTags.forEach((tag) => newSet.add(tag)); + return newSet; + }); + + // Clear the input after staging + setNewTagInput(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleStageNewTags(); + } + }; + + const handleModalClose = () => { + setIsTagsEditModalOpen(false); + setNewTagInput(''); + setNotification(null); + }; + + const handleSaveTags = async () => { + setIsSaving(true); + setNotification(null); + + try { + // Call the server action to update tags using the staged tags Set. + const result = await updateRunTags(metadata.runId, Array.from(stagedTags)); + + if (!result.success) { + throw new Error(result.error || 'Failed to update tags'); + } + + setNotification({ + kind: 'success', + title: translations('updateSuccess'), + subtitle: translations('updateSuccessMessage'), + }); + + // Set tags of the component to the staged tags tags. + setTags(Array.from(stagedTags)); + + // Close modal after a short delay to show success message. + setTimeout(() => { + handleModalClose(); + }, TIME_TO_WAIT_BEFORE_CLOSING_TAG_EDIT_MODAL_MS); + } catch (error: any) { + console.error('Failed to update tags:', error); + setNotification({ + kind: 'error', + title: translations('updateError'), + subtitle: error.message || translations('updateErrorMessage'), + }); + } finally { + setIsSaving(false); + } + }; + return ( <> @@ -65,18 +155,19 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => {
-
{translations('tags')}
-
- {tags?.length > 0 ? ( - tags?.map((tag, index) => ( - - {tag} - - )) - ) : ( -

{translations('noTags')}

- )} -
+
+ {translations('tags')} + +
{ + setIsTagsEditModalOpen(true); + }} + > + +
+
+
@@ -94,6 +185,43 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { )}
+ + + {notification && ( + setNotification(null)} + /> + )} + ) => setNewTagInput(e.target.value)} + onKeyDown={handleKeyDown} + className={styles.tagsTextInput} + /> + + ); }; diff --git a/galasa-ui/src/components/test-runs/test-run-details/RenderTags.tsx b/galasa-ui/src/components/test-runs/test-run-details/RenderTags.tsx new file mode 100644 index 00000000..9211bbd2 --- /dev/null +++ b/galasa-ui/src/components/test-runs/test-run-details/RenderTags.tsx @@ -0,0 +1,81 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { DismissibleTag, Tag } from '@carbon/react'; +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { textToHexColour } from '@/utils/functions/textToHexColour'; +import styles from '@/styles/test-runs/test-run-details/RenderTags.module.css'; + +type TagWithColour = { + tag: string; + backgroundColour: string; + foregroundColour: string; +}; + +type TagSize = 'sm' | 'md' | 'lg'; + +const RenderTags = ({ + tags, + isDismissible, + size, + onTagRemove, +}: { + tags: string[]; + isDismissible: boolean; + size: TagSize; + onTagRemove?: (tag: string) => void; +}) => { + const translations = useTranslations('OverviewTab'); + + const tagsWithColours = useMemo( + () => + tags.map((tag) => { + const [backgroundColour, foregroundColour] = textToHexColour(tag); + return { tag, backgroundColour, foregroundColour }; + }), + [tags] + ); + + if (tags.length === 0) { + return

{translations('noTags')}

; + } + + return ( +
+ {tagsWithColours.map((tagWithColour: TagWithColour, index) => { + // Inline styles needed to grab colours from the "tagWithColour" variable. + const style = { + backgroundColor: `${tagWithColour.backgroundColour}`, + color: `${tagWithColour.foregroundColour}`, + }; + + return isDismissible ? ( + { + if (onTagRemove) { + onTagRemove(tagWithColour.tag); + } + }} + size={size} + text={tagWithColour.tag} + title={translations('removeTag')} + style={style} + /> + ) : ( + + {tagWithColour.tag} + + ); + })} +
+ ); +}; + +export default RenderTags; diff --git a/galasa-ui/src/styles/test-runs/test-run-details/OverviewTab.module.css b/galasa-ui/src/styles/test-runs/test-run-details/OverviewTab.module.css index 2a7a5a76..21640ab3 100644 --- a/galasa-ui/src/styles/test-runs/test-run-details/OverviewTab.module.css +++ b/galasa-ui/src/styles/test-runs/test-run-details/OverviewTab.module.css @@ -34,19 +34,49 @@ } .tagsSection h5 { - margin-bottom: 10px; + margin-bottom: 6px; font-weight: 600; } -.tagsContainer { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - .redirectLinks { margin-top: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; } + +.tagsEditButtonWrapper { + top: 0px; + margin-left: 0.4rem; + display: inline-block; + position: relative; + text-decoration: none; + width: 30px; + height: 30px; + line-height: 30px; + border-radius: 50%; + text-align: center; + vertical-align: middle; + overflow: hidden; + font-weight: bold; + transition: 0.2s; +} + +.tagsEditButtonWrapper:hover { + background: #5c5c5c9d; +} + +.tagsEditButton { + position: relative; + width: 22px; + height: 22px; + top: 0.2rem; +} + +.notification { + margin-bottom: 1rem; +} + +.tagsTextInput { + margin-bottom: 1.5rem; +} diff --git a/galasa-ui/src/styles/test-runs/test-run-details/RenderTags.module.css b/galasa-ui/src/styles/test-runs/test-run-details/RenderTags.module.css new file mode 100644 index 00000000..675e888a --- /dev/null +++ b/galasa-ui/src/styles/test-runs/test-run-details/RenderTags.module.css @@ -0,0 +1,22 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +.dismissibleTag { + --cds-layer: #ffffff00; + --cds-text-disabled: #000000; +} + +.tagsContainer { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 5px; + padding-bottom: 5px; +} + +.tagsContainer :global(.cds--tag--filter):hover :global(.cds--tag__close-icon) { + background-color: rgba(0, 0, 0, 0.2); +} diff --git a/galasa-ui/src/tests/actions/runActions.test.ts b/galasa-ui/src/tests/actions/runActions.test.ts index 78fff1da..3117200e 100644 --- a/galasa-ui/src/tests/actions/runActions.test.ts +++ b/galasa-ui/src/tests/actions/runActions.test.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -import { downloadArtifactFromServer } from '@/actions/runsAction'; +import { downloadArtifactFromServer, updateRunTags } from '@/actions/runsAction'; import * as apiUtils from '@/utils/api'; import * as galasaapi from '@/generated/galasaapi'; import { CLIENT_API_VERSION } from '@/utils/constants/common'; @@ -102,3 +102,78 @@ describe('downloadArtifactFromServer', () => { expect(result.data).toBe(text); }); }); + +describe('updateRunTags', () => { + const runId = 'run-123'; + const tags = ['smoke', 'regression']; + let createConfigMock: jest.Mock; + let putTagsMock: jest.Mock; + + beforeEach(() => { + createConfigMock = (apiUtils.createAuthenticatedApiConfiguration as jest.Mock).mockReturnValue({ + basePath: 'https://api.test', + apiKey: 'fake-key', + }); + putTagsMock = jest.fn(); + (galasaapi.ResultArchiveStoreAPIApi as jest.Mock).mockImplementation(() => ({ + putRasRunTagsOrStatusById: putTagsMock, + })); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('successfully updates tags', async () => { + putTagsMock.mockResolvedValue(undefined); + + const result = await updateRunTags(runId, tags); + + expect(createConfigMock).toHaveBeenCalled(); + expect(galasaapi.ResultArchiveStoreAPIApi).toHaveBeenCalledWith( + createConfigMock.mock.results[0].value + ); + expect(putTagsMock).toHaveBeenCalledWith(runId, { tags }); + expect(result).toEqual({ + success: true, + tags, + }); + }); + + it('returns error when API call fails', async () => { + const errorMessage = 'Network error'; + putTagsMock.mockRejectedValue(new Error(errorMessage)); + + const result = await updateRunTags(runId, tags); + + expect(createConfigMock).toHaveBeenCalled(); + expect(putTagsMock).toHaveBeenCalledWith(runId, { tags }); + expect(result).toEqual({ + success: false, + error: errorMessage, + }); + }); + + it('handles error without message', async () => { + putTagsMock.mockRejectedValue(new Error()); + + const result = await updateRunTags(runId, tags); + + expect(result).toEqual({ + success: false, + error: 'Failed to update tags', + }); + }); + + it('handles empty tags array', async () => { + putTagsMock.mockResolvedValue(undefined); + + const result = await updateRunTags(runId, []); + + expect(putTagsMock).toHaveBeenCalledWith(runId, { tags: [] }); + expect(result).toEqual({ + success: true, + tags: [], + }); + }); +}); 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 703cff07..4095f4e6 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 @@ -5,12 +5,43 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import OverviewTab from '@/components/test-runs/test-run-details/OverviewTab'; import { RunMetadata } from '@/utils/interfaces'; import { getAWeekBeforeSubmittedTime } from '@/utils/timeOperations'; +import { updateRunTags } from '@/actions/runsAction'; -// Mock the Carbon Tag component to simplify assertions +// Mock the server action +jest.mock('@/actions/runsAction', () => ({ + updateRunTags: jest.fn(), +})); + +// Mock RenderTags component +jest.mock('@/components/test-runs/test-run-details/RenderTags', () => ({ + __esModule: true, + default: ({ tags, isDismissible, onTagRemove }: any) => { + if (tags.length === 0) { + return

No tags were associated with this test run.

; + } + return ( +
+ {tags.map((tag: string, index: number) => ( + + {tag} + {isDismissible && onTagRemove && ( + + )} + + ))} +
+ ); + }, +})); + +// Mock the Carbon components jest.mock('@carbon/react', () => ({ Tag: ({ children }: { children: React.ReactNode }) => ( {children} @@ -28,6 +59,48 @@ jest.mock('@carbon/react', () => ({ {children} ), + InlineNotification: ({ kind, title, subtitle }: any) => ( +
+
{title}
+
{subtitle}
+
+ ), + TextInput: ({ value, onChange, onKeyDown, labelText, placeholder }: any) => ( + + ), + Modal: ({ + open, + children, + modalHeading, + primaryButtonText, + secondaryButtonText, + onRequestSubmit, + onRequestClose, + }: any) => + open ? ( +
+

{modalHeading}

+ {children} + + +
+ ) : null, +})); + +jest.mock('@carbon/icons-react', () => ({ + Launch: () => Launch, + Edit: () => Edit, })); jest.mock('@/utils/timeOperations', () => ({ @@ -39,6 +112,7 @@ jest.mock('next-intl', () => ({ const translations: Record = { bundle: 'Bundle', testName: 'Test', + testShortName: 'Test Short Name', package: 'Package', group: 'Group', submissionId: 'Submission ID', @@ -50,6 +124,17 @@ jest.mock('next-intl', () => ({ duration: 'Duration', tags: 'Tags', noTags: 'No tags were associated with this test run.', + recentRunsLink: 'View other recent runs of this test', + runRetriesLink: 'View all attempts of this test run', + modalHeading: 'Edit tags for', + modalPrimaryButton: 'Save', + modalSecondaryButton: 'Cancel', + modalLabelText: 'Add tags', + modalPlaceholderText: 'Enter tags separated by commas or spaces', + updateSuccess: 'Tags updated successfully', + updateSuccessMessage: 'The tags have been updated.', + updateError: 'Failed to update tags', + updateErrorMessage: 'An error occurred while updating tags.', }; return translations[key] || key; }, @@ -121,8 +206,8 @@ describe('OverviewTab', () => { it('renders each tag when tags array is non-empty', () => { render(); - // header - expect(screen.getByRole('heading', { level: 5, name: 'Tags' })).toBeInTheDocument(); + // header - use getByText since h5 contains nested elements + expect(screen.getByText('Tags', { selector: 'h5' })).toBeInTheDocument(); // tags const tagEls = screen.getAllByTestId('mock-tag'); expect(tagEls).toHaveLength(2); @@ -261,3 +346,215 @@ describe('OverviewTab - Time and Link Logic', () => { }); }); }); + +describe('OverviewTab - Tags Edit Modal', () => { + const mockUpdateRunTags = updateRunTags as jest.MockedFunction; + + beforeEach(() => { + mockGetAWeekBeforeSubmittedTime.mockReturnValue('2025-06-03T09:00:00Z'); + mockUpdateRunTags.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should open modal when edit icon is clicked', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-modal')).toBeInTheDocument(); + }); + }); + + it('should display RenderTags component with dismissible tags in modal', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + const renderTagsInModal = screen.getAllByTestId('mock-render-tags')[1]; // Second one is in modal + expect(renderTagsInModal).toHaveAttribute('data-dismissible', 'true'); + }); + }); + + it('should close modal when secondary button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-modal')).toBeInTheDocument(); + }); + + const secondaryButton = screen.getByTestId('modal-secondary-button'); + await user.click(secondaryButton); + + await waitFor(() => { + expect(screen.queryByTestId('mock-modal')).not.toBeInTheDocument(); + }); + }); + + it('should handle tag removal in modal', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('remove-tag-smoke')).toBeInTheDocument(); + }); + + const removeButton = screen.getByTestId('remove-tag-smoke'); + await user.click(removeButton); + + await waitFor(() => { + // Tag should be removed from staged tags + expect(screen.queryByTestId('remove-tag-smoke')).not.toBeInTheDocument(); + }); + }); + + it('should successfully save tags via server action', async () => { + const user = userEvent.setup(); + mockUpdateRunTags.mockResolvedValueOnce({ + success: true, + tags: ['smoke', 'regression'], + }); + + // Mock window.location.reload. + delete (window as any).location; + (window as any).location = { reload: jest.fn() }; + + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-modal')).toBeInTheDocument(); + }); + + const primaryButton = screen.getByTestId('modal-primary-button'); + await user.click(primaryButton); + + await waitFor(() => { + expect(mockUpdateRunTags).toHaveBeenCalledWith(completeMetadata.runId, [ + 'smoke', + 'regression', + ]); + }); + }); + + it('should display error notification when server action fails', async () => { + const user = userEvent.setup(); + mockUpdateRunTags.mockResolvedValueOnce({ + success: false, + error: 'Server Error', + }); + + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-modal')).toBeInTheDocument(); + }); + + const primaryButton = screen.getByTestId('modal-primary-button'); + await user.click(primaryButton); + + await waitFor(() => { + const notification = screen.getByTestId('mock-notification'); + expect(notification).toHaveAttribute('data-kind', 'error'); + }); + }); + + it('should display success notification when tags are saved', async () => { + const user = userEvent.setup(); + mockUpdateRunTags.mockResolvedValueOnce({ + success: true, + tags: ['smoke', 'regression'], + }); + + // Mock window.location.reload + delete (window as any).location; + (window as any).location = { reload: jest.fn() }; + + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-modal')).toBeInTheDocument(); + }); + + const primaryButton = screen.getByTestId('modal-primary-button'); + await user.click(primaryButton); + + await waitFor(() => { + const notification = screen.getByTestId('mock-notification'); + expect(notification).toHaveAttribute('data-kind', 'success'); + }); + }); + + it('should persist staged tags when modal is closed without saving', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('remove-tag-smoke')).toBeInTheDocument(); + }); + + // Remove a tag + const removeButton = screen.getByTestId('remove-tag-smoke'); + await user.click(removeButton); + + await waitFor(() => { + expect(screen.queryByTestId('remove-tag-smoke')).not.toBeInTheDocument(); + }); + + // Close modal without saving + const secondaryButton = screen.getByTestId('modal-secondary-button'); + await user.click(secondaryButton); + + await waitFor(() => { + expect(screen.queryByTestId('mock-modal')).not.toBeInTheDocument(); + }); + + // Reopen modal + await user.click(editIcon); + + await waitFor(() => { + // Tag should still be removed (staged changes persist) + expect(screen.queryByTestId('remove-tag-smoke')).not.toBeInTheDocument(); + // But regression tag should still be there + expect(screen.getByTestId('remove-tag-regression')).toBeInTheDocument(); + }); + }); + + it('should display modal heading with run name', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByText(new RegExp(completeMetadata.runName))).toBeInTheDocument(); + }); + }); +}); diff --git a/galasa-ui/src/tests/components/test-runs/test-run-details/RenderTags.test.tsx b/galasa-ui/src/tests/components/test-runs/test-run-details/RenderTags.test.tsx new file mode 100644 index 00000000..cb019850 --- /dev/null +++ b/galasa-ui/src/tests/components/test-runs/test-run-details/RenderTags.test.tsx @@ -0,0 +1,207 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import RenderTags from '@/components/test-runs/test-run-details/RenderTags'; +import { textToHexColour } from '@/utils/functions/textToHexColour'; + +// Mock the textToHexColour function. +jest.mock('@/utils/functions/textToHexColour'); + +// Mock Carbon React components. +jest.mock('@carbon/react', () => ({ + Tag: ({ children, style }: { children: React.ReactNode; style: React.CSSProperties }) => ( + + {children} + + ), + DismissibleTag: ({ + text, + onClose, + style, + size, + title, + }: { + text: string; + onClose: () => void; + style: React.CSSProperties; + size: string; + title: string; + }) => ( + + {text} + + + ), +})); + +// Mock next-intl +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => { + const translations: Record = { + noTags: 'No tags were associated with this test run.', + removeTag: 'Remove tag', + }; + return translations[key] || key; + }, +})); + +const mockTextToHexColour = textToHexColour as jest.MockedFunction; + +describe('RenderTags', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default mock implementation + mockTextToHexColour.mockImplementation((text: string) => { + // Return different colors based on text for testing + if (text === 'smoke') return ['#FF6B6B', '#FFFFFF']; + if (text === 'regression') return ['#4ECDC4', '#000000']; + if (text === 'critical') return ['#45B7D1', '#FFFFFF']; + return ['#000000', '#FFFFFF']; + }); + }); + + describe('Non-dismissible tags', () => { + it('should display fallback message when tags array is empty', () => { + render(); + expect(screen.getByText('No tags were associated with this test run.')).toBeInTheDocument(); + }); + + it('should render single tag correctly', () => { + const tags = ['production']; + mockTextToHexColour.mockReturnValue(['#FF0000', '#FFFFFF']); + + render(); + + const tagElements = screen.getAllByTestId('mock-tag'); + expect(tagElements).toHaveLength(1); + expect(tagElements[0]).toHaveTextContent('production'); + }); + + it('should render regular tags when dismissible is false', () => { + const tags = ['smoke', 'regression']; + render(); + + const tagElements = screen.getAllByTestId('mock-tag'); + expect(tagElements).toHaveLength(2); + expect(tagElements[0]).toHaveTextContent('smoke'); + expect(tagElements[1]).toHaveTextContent('regression'); + }); + + it('should apply correct colors to non-dismissible tags', () => { + const tags = ['smoke', 'regression']; + render(); + + const tagElements = screen.getAllByTestId('mock-tag'); + expect(tagElements[0]).toHaveStyle({ + backgroundColor: '#FF6B6B', + color: '#FFFFFF', + }); + expect(tagElements[1]).toHaveStyle({ + backgroundColor: '#4ECDC4', + color: '#000000', + }); + }); + + it('should handle tags with special characters', () => { + const tags = ['tag-with-dash', 'tag_with_underscore', 'tag.with.dot']; + render(); + + const tagElements = screen.getAllByTestId('mock-tag'); + expect(tagElements).toHaveLength(3); + expect(tagElements[0]).toHaveTextContent('tag-with-dash'); + expect(tagElements[1]).toHaveTextContent('tag_with_underscore'); + expect(tagElements[2]).toHaveTextContent('tag.with.dot'); + }); + }); + + describe('Dismissible tags', () => { + it('should render dismissible tags when dismissible is true', () => { + const tags = ['smoke', 'regression']; + render(); + + const tagElements = screen.getAllByTestId('mock-dismissible-tag'); + expect(tagElements).toHaveLength(2); + expect(tagElements[0]).toHaveTextContent('smoke'); + expect(tagElements[1]).toHaveTextContent('regression'); + }); + + it('should apply correct colors to dismissible tags', () => { + const tags = ['smoke', 'regression']; + render(); + + const tagElements = screen.getAllByTestId('mock-dismissible-tag'); + expect(tagElements[0]).toHaveStyle({ + backgroundColor: '#FF6B6B', + color: '#FFFFFF', + }); + expect(tagElements[1]).toHaveStyle({ + backgroundColor: '#4ECDC4', + color: '#000000', + }); + }); + + it('should call onTagRemove when dismiss button is clicked', () => { + const mockOnTagRemove = jest.fn(); + const tags = ['smoke', 'regression']; + + render( + + ); + + const dismissButtons = screen.getAllByTestId('dismiss-button'); + fireEvent.click(dismissButtons[0]); + + expect(mockOnTagRemove).toHaveBeenCalledTimes(1); + expect(mockOnTagRemove).toHaveBeenCalledWith('smoke'); + }); + + it('should pass correct size prop to dismissible tags', () => { + const tags = ['smoke']; + render(); + + const tagElement = screen.getByTestId('mock-dismissible-tag'); + expect(tagElement).toHaveAttribute('data-size', 'sm'); + }); + + it('should have correct title attribute for dismissible tags', () => { + const tags = ['smoke']; + render(); + + const tagElement = screen.getByTestId('mock-dismissible-tag'); + expect(tagElement).toHaveAttribute('title', 'Remove tag'); + }); + }); + + describe('Tag sizes', () => { + it('should render tags with small size', () => { + const tags = ['smoke']; + render(); + + const tagElement = screen.getByTestId('mock-dismissible-tag'); + expect(tagElement).toHaveAttribute('data-size', 'sm'); + }); + + it('should render tags with medium size', () => { + const tags = ['smoke']; + render(); + + // For non-dismissible tags, size is passed but not visible in our mock + expect(screen.getByTestId('mock-tag')).toBeInTheDocument(); + }); + + it('should render tags with large size', () => { + const tags = ['smoke']; + render(); + + const tagElement = screen.getByTestId('mock-dismissible-tag'); + expect(tagElement).toHaveAttribute('data-size', 'lg'); + }); + }); +}); diff --git a/galasa-ui/src/utils/constants/common.ts b/galasa-ui/src/utils/constants/common.ts index 86e4ee87..9f91183d 100644 --- a/galasa-ui/src/utils/constants/common.ts +++ b/galasa-ui/src/utils/constants/common.ts @@ -165,6 +165,8 @@ const TEST_RUN_PAGE_TABS = ['overview', 'methods', 'runLog', 'artifacts', '3270' const RESULTS_TABLE_PAGE_SIZES = [10, 20, 30, 40, 50]; +const TIME_TO_WAIT_BEFORE_CLOSING_TAG_EDIT_MODAL_MS = 1500; + export { CLIENT_API_VERSION, COLORS, @@ -189,4 +191,5 @@ export { SINGLE_RUN_QUERY_PARAMS, LOCALE_TO_FLATPICKR_FORMAT_MAP, RESULTS_TABLE_PAGE_SIZES, + TIME_TO_WAIT_BEFORE_CLOSING_TAG_EDIT_MODAL_MS, }; diff --git a/galasa-ui/src/utils/functions/textToHexColour.ts b/galasa-ui/src/utils/functions/textToHexColour.ts new file mode 100644 index 00000000..71539077 --- /dev/null +++ b/galasa-ui/src/utils/functions/textToHexColour.ts @@ -0,0 +1,187 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +const WHITE = '#FFFFFF'; +const BLACK = '#000000'; + +// Where the background colour is the first element and the text colour is the second element. +const tagColours: [string, string][] = [ + ['#FF6B6B', WHITE], // Bright Red + ['#4ECDC4', BLACK], // Turquoise + ['#45B7D1', WHITE], // Sky Blue + ['#FFA07A', BLACK], // Light Salmon + ['#98D8C8', BLACK], // Mint + ['#F7DC6F', BLACK], // Yellow + ['#BB8FCE', WHITE], // Lavender + ['#85C1E2', BLACK], // Light Blue + ['#F8B739', BLACK], // Orange + ['#52B788', WHITE], // Green + ['#E63946', WHITE], // Red + ['#06FFA5', BLACK], // Bright Mint + ['#FF006E', WHITE], // Hot Pink + ['#8338EC', WHITE], // Purple + ['#3A86FF', WHITE], // Blue + ['#FB5607', WHITE], // Orange Red + ['#FFBE0B', BLACK], // Amber + ['#06D6A0', BLACK], // Teal + ['#118AB2', WHITE], // Ocean Blue + ['#EF476F', WHITE], // Pink + ['#FFD166', BLACK], // Golden Yellow + ['#26547C', WHITE], // Navy Blue + ['#FF9F1C', BLACK], // Bright Orange + ['#2EC4B6', BLACK], // Cyan + ['#E71D36', WHITE], // Crimson + ['#C9ADA7', BLACK], // Dusty Rose + ['#9D4EDD', WHITE], // Violet + ['#FF5A5F', WHITE], // Coral + ['#00BBF9', BLACK], // Bright Blue + ['#F72585', WHITE], // Magenta + ['#7209B7', WHITE], // Deep Purple + ['#4CC9F0', BLACK], // Light Cyan + ['#F94144', WHITE], // Red Orange + ['#F3722C', WHITE], // Burnt Orange + ['#F8961E', BLACK], // Tangerine + ['#90BE6D', BLACK], // Lime Green + ['#43AA8B', WHITE], // Sea Green + ['#577590', WHITE], // Slate Blue + ['#FF6D00', WHITE], // Deep Orange + ['#00C9A7', BLACK], // Spring Green + ['#845EC2', WHITE], // Purple Blue + ['#FF9671', BLACK], // Peach + ['#FFC75F', BLACK], // Gold + ['#F9F871', BLACK], // Light Yellow + ['#C34A36', WHITE], // Rust + ['#00D2FC', BLACK], // Electric Blue + ['#D65DB1', WHITE], // Orchid + ['#FF6F91', WHITE], // Rose Pink + ['#00F5FF', BLACK], // Aqua + ['#A8DADC', BLACK], // Powder Blue + ['#FF4757', WHITE], // Radical Red + ['#5F27CD', WHITE], // Purple Heart + ['#00D8D6', BLACK], // Robin Egg Blue + ['#FFA502', BLACK], // Mango + ['#2ECC71', WHITE], // Emerald + ['#E84393', WHITE], // Pink Glamour + ['#0984E3', WHITE], // Azure + ['#FDCB6E', BLACK], // Mustard + ['#6C5CE7', WHITE], // Soft Purple + ['#00B894', BLACK], // Mint Leaf + ['#D63031', WHITE], // Pomegranate + ['#FD79A8', BLACK], // Carnation Pink + ['#74B9FF', BLACK], // Baby Blue + ['#A29BFE', BLACK], // Periwinkle + ['#55EFC4', BLACK], // Light Greenish Blue + ['#FF7675', WHITE], // Light Red + ['#FFEAA7', BLACK], // Light Yellow + ['#DFE6E9', BLACK], // Light Grey + ['#FF6348', WHITE], // Sunset Orange + ['#1E3799', WHITE], // Dark Blue + ['#B33771', WHITE], // Magenta Purple + ['#3B3B98', WHITE], // Blue Night + ['#FD7272', WHITE], // Watermelon + ['#9AECDB', BLACK], // Aqua Marine + ['#F8EFBA', BLACK], // Cream + ['#58B19F', WHITE], // Turquoise Green + ['#EE5A6F', WHITE], // Strawberry + ['#F8B500', BLACK], // Selective Yellow + ['#1B9CFC', WHITE], // Bright Cerulean + ['#F97F51', WHITE], // Mandarin + ['#25CCF7', BLACK], // Deep Sky Blue + ['#EAB543', BLACK], // Saffron + ['#55E6C1', BLACK], // Caribbean Green + ['#CAD3C8', BLACK], // Light Grayish + ['#F8EFBA', BLACK], // Pale Yellow + ['#FC427B', WHITE], // Razzmatazz + ['#BDC581', BLACK], // Sage + ['#82589F', WHITE], // Deep Lilac + ['#F53B57', WHITE], // Neon Red + ['#3C40C6', WHITE], // Blue Marguerite + ['#05C46B', BLACK], // Opal + ['#FFA801', BLACK], // Yellow Orange + ['#0FBCF9', BLACK], // Spiro Disco Ball + ['#4B7BEC', WHITE], // Cornflower Blue + ['#A55EEA', WHITE], // Medium Purple + ['#26DE81', BLACK], // UFO Green + ['#FD7272', WHITE], // Brink Pink + ['#FC5C65', WHITE], // Sunset Orange + ['#EB3B5A', WHITE], // Carmine Pink + ['#FA8231', WHITE], // Pumpkin + ['#F7B731', BLACK], // Bright Sun + ['#20BF6B', WHITE], // Shamrock + ['#0FB9B1', BLACK], // Light Sea Green + ['#2D98DA', WHITE], // Curious Blue + ['#3867D6', WHITE], // Royal Blue + ['#8854D0', WHITE], // Purple + ['#A5B1C2', BLACK], // Grayish + ['#4B6584', WHITE], // Fiord + ['#778CA3', WHITE], // Lynch + ['#2C3A47', WHITE], // Mirage + ['#FF3838', WHITE], // Red Orange + ['#FF6B81', WHITE], // Brink Pink + ['#FD9644', BLACK], // Yellow Sea + ['#FEA47F', BLACK], // Tacao + ['#25CCF7', BLACK], // Bright Turquoise + ['#EAB543', BLACK], // Casablanca + ['#55E6C1', BLACK], // Aquamarine + ['#CAD3C8', BLACK], // Pumice + ['#F97F51', WHITE], // Burnt Sienna + ['#1B9CFC', WHITE], // Dodger Blue + ['#F8B500', BLACK], // Amber + ['#58B19F', WHITE], // Puerto Rico + ['#2C3A47', WHITE], // Ebony Clay + ['#B33771', WHITE], // Plum + ['#3B3B98', WHITE], // Jacksons Purple + ['#FD7272', WHITE], // Froly + ['#9AECDB', BLACK], // Riptide + ['#D6A2E8', BLACK], // Plum Light + ['#6A89CC', WHITE], // Ship Cove + ['#82CCDD', BLACK], // Anakiwa + ['#B8E994', BLACK], // Reef + ['#F3A683', BLACK], // Tacao + ['#F7D794', BLACK], // Cherokee + ['#778BEB', WHITE], // Chetwode Blue + ['#E77F67', WHITE], // Apricot + ['#CF6A87', WHITE], // Puce + ['#C44569', WHITE], // Blush + ['#786FA6', WHITE], // Kimberly + ['#F8A5C2', BLACK], // Carnation Pink + ['#63CDDA', BLACK], // Viking + ['#EA8685', WHITE], // Sea Pink + ['#596275', WHITE], // Shuttle Gray + ['#574B90', WHITE], // Deluge + ['#F19066', WHITE], // My Sin + ['#546DE5', WHITE], // Chetwode Blue + ['#E15F41', WHITE], // Flamingo + ['#C44569', WHITE], // Blush + ['#5F27CD', WHITE], // Studio + ['#00D2D3', BLACK], // Bright Turquoise + ['#01A3A4', WHITE], // Persian Green +]; + +// Cache to store computed colours for each text input. +const colourCache = new Map(); + +const computeTextToHexColour = (text: string): [string, string] => { + let hash = 0; + for (let i = 0; i < text.length; i++) { + // hash << 5: Shifting bits left by 5 is the same as multiplying by 2^5 = 32). + // Subtracting the original value results in 32x - x = 31x. 31 is a small prime number, helps distribute hash values uniformly. + hash = text.charCodeAt(i) + ((hash << 5) - hash); + } + const numberBetweenZeroAndLengthOfColoursArray = Math.abs(hash % (tagColours.length - 1)); + return tagColours[numberBetweenZeroAndLengthOfColoursArray]; +}; + +export const textToHexColour = (text: string): [string, string] => { + if (colourCache.has(text)) { + return colourCache.get(text)!; + } + + // If not cached, compute the colour. + const colour = computeTextToHexColour(text); + colourCache.set(text, colour); + return colour; +};