Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
852d4fd
Button and modal added
James-Cocker Jan 8, 2026
110ae85
Merge branch 'JamesC-iss2466-upgrade-carbon-react-package' into James…
James-Cocker Jan 8, 2026
02b6e75
Separated out tag rendering functionality and made new text to hex he…
James-Cocker Jan 8, 2026
778f614
Formatted code and removed comments
James-Cocker Jan 8, 2026
2ab86b5
Added another 100 new colours for the tags and added a size attribute…
James-Cocker Jan 8, 2026
70a5fec
Main functionality completed, no tests yet
James-Cocker Jan 13, 2026
d8fe7fb
Small prettier fixes
James-Cocker Jan 13, 2026
c169efc
Merge branch 'main' into JamesC-iss2466-update-tags-on-existing-test-run
James-Cocker Jan 13, 2026
9c3172a
Merge branch 'JamesC-iss2466-update-tags-on-existing-test-run' of git…
James-Cocker Jan 13, 2026
62e9fa6
Added staging via enter key and tags now stored as a set compared to …
James-Cocker Jan 14, 2026
3c99ecb
Removed helper function
James-Cocker Jan 14, 2026
59a0845
Added unit tests and small comment change
James-Cocker Jan 14, 2026
25feecf
Rendered tags within RenderTags on test runs results table and some s…
James-Cocker Jan 14, 2026
81df4b1
First set of changes suggested by Eamonn, all minor changes such as c…
James-Cocker Jan 15, 2026
906a16d
Cached computed colour values and stopped colour inconsistencies
James-Cocker Jan 15, 2026
9968ab5
Added server component rendering, removed screen refresh if tags upda…
James-Cocker Jan 15, 2026
a9450b9
Merge branch 'main' into JamesC-iss2466-update-tags-on-existing-test-run
James-Cocker Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions galasa-ui/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions galasa-ui/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
Expand Down
20 changes: 20 additions & 0 deletions galasa-ui/src/actions/runsAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}
};
15 changes: 15 additions & 0 deletions galasa-ui/src/components/test-runs/results/TestRunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -134,6 +135,20 @@ export default function TestRunsTable({
</TableCell>
);

if (header === 'tags') {
// Handle tags column with RenderTags component.
if (value.length === 0) {
return <TableCell>N/A</TableCell>;
}
const tagsArray = value.split(', ');
return (
<TableCell className={styles.linkCell}>
<RenderTags tags={tagsArray} isDismissible={false} size="sm" />
<Link href={href} prefetch={false} className={styles.linkOverlay} />
</TableCell>
);
}

if (value === 'N/A' || !value) {
return <TableCell>N/A</TableCell>;
}
Expand Down
162 changes: 145 additions & 17 deletions galasa-ui/src/components/test-runs/test-run-details/OverviewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);

const [tags, setTags] = useState<string[]>(metadata?.tags || []);
const [isTagsEditModalOpen, setIsTagsEditModalOpen] = useState<boolean>(false);
const [newTagInput, setNewTagInput] = useState<string>('');
const [stagedTags, setStagedTags] = useState<Set<string>>(new Set(tags));
const [notification, setNotification] = useState<{
kind: 'success' | 'error';
title: string;
subtitle: string;
} | null>(null);
const [isSaving, setIsSaving] = useState<boolean>(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}`;
Expand All @@ -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<HTMLInputElement>) => {
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 (
<>
<InlineText title={`${translations('bundle')}:`} value={metadata?.bundle} />
Expand All @@ -65,18 +155,19 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => {
</div>

<div className={styles.tagsSection}>
<h5>{translations('tags')}</h5>
<div className={styles.tagsContainer}>
{tags?.length > 0 ? (
tags?.map((tag, index) => (
<Tag size="md" key={index}>
{tag}
</Tag>
))
) : (
<p>{translations('noTags')}</p>
)}
</div>
<h5>
{translations('tags')}

<div
className={styles.tagsEditButtonWrapper}
onClick={() => {
setIsTagsEditModalOpen(true);
}}
>
<Edit className={styles.tagsEditButton} />
</div>
</h5>
<RenderTags tags={tags} isDismissible={false} size="md" />

<div className={styles.redirectLinks}>
<div className={styles.linkWrapper} onClick={handleNavigationClick}>
Expand All @@ -94,6 +185,43 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => {
)}
</div>
</div>

<Modal
open={isTagsEditModalOpen}
onRequestClose={handleModalClose}
modalHeading={`${translations('modalHeading')} ${metadata?.runName || ''}`}
primaryButtonText={translations('modalPrimaryButton')}
secondaryButtonText={translations('modalSecondaryButton')}
onRequestSubmit={handleSaveTags}
primaryButtonDisabled={isSaving}
>
{notification && (
<InlineNotification
className={styles.notification}
kind={notification.kind}
title={notification.title}
subtitle={notification.subtitle}
lowContrast
hideCloseButton={false}
onCloseButtonClick={() => setNotification(null)}
/>
)}
<TextInput
data-modal-primary-focus
labelText={translations('modalLabelText')}
placeholder={translations('modalPlaceholderText')}
value={newTagInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewTagInput(e.target.value)}
onKeyDown={handleKeyDown}
className={styles.tagsTextInput}
/>
<RenderTags
tags={Array.from(stagedTags)}
isDismissible={true}
size="lg"
onTagRemove={handleTagRemove}
/>
</Modal>
</>
);
};
Expand Down
81 changes: 81 additions & 0 deletions galasa-ui/src/components/test-runs/test-run-details/RenderTags.tsx
Original file line number Diff line number Diff line change
@@ -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 <p>{translations('noTags')}</p>;
}

return (
<div className={styles.tagsContainer}>
{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 ? (
<DismissibleTag
key={index}
className={styles.dismissibleTag}
dismissTooltipAlignment="bottom"
onClose={() => {
if (onTagRemove) {
onTagRemove(tagWithColour.tag);
}
}}
size={size}
text={tagWithColour.tag}
title={translations('removeTag')}
style={style}
/>
) : (
<Tag size={size} key={index} style={style}>
{tagWithColour.tag}
</Tag>
);
})}
</div>
);
};

export default RenderTags;
Loading