-
{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) => (
+
+ ),
+ 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;
+};