diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index cd87f198..df5543a4 100644 --- a/galasa-ui/messages/de.json +++ b/galasa-ui/messages/de.json @@ -261,13 +261,14 @@ "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]", + "modalPlaceholderText": "Geben Sie hier ein neues Tag ein", "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." + "updateErrorMessage": "Beim Aktualisieren der Tags ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "editTags": "Tags bearbeiten" }, "3270Tab": { "Terminal": "Terminal", diff --git a/galasa-ui/messages/en.json b/galasa-ui/messages/en.json index d634153b..b64c83d4 100644 --- a/galasa-ui/messages/en.json +++ b/galasa-ui/messages/en.json @@ -240,13 +240,14 @@ "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]", + "modalPlaceholderText": "Type new tag here", "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." + "updateErrorMessage": "An error occurred while updating the tags. Please try again.", + "editTags": "Edit tags" }, "3270Tab": { "Terminal": "Terminal", diff --git a/galasa-ui/src/actions/runsAction.ts b/galasa-ui/src/actions/runsAction.ts index 049520f1..d0169d24 100644 --- a/galasa-ui/src/actions/runsAction.ts +++ b/galasa-ui/src/actions/runsAction.ts @@ -5,7 +5,7 @@ */ 'use server'; -import { ResultArchiveStoreAPIApi } from '@/generated/galasaapi'; +import { ResultArchiveStoreAPIApi, TagsAPIApi } from '@/generated/galasaapi'; import { createAuthenticatedApiConfiguration } from '@/utils/api'; import { CLIENT_API_VERSION } from '@/utils/constants/common'; @@ -69,3 +69,26 @@ export const updateRunTags = async (runId: string, tags: string[]) => { }; } }; + +export const getExistingTagObjects = async () => { + try { + const apiConfig = createAuthenticatedApiConfiguration(); + const tagsApiClient = new TagsAPIApi(apiConfig); + + const tagsResponse = await tagsApiClient.getTags(); + + // Convert to plain objects and extract tag names. + const tagNames = tagsResponse + .map((tag) => tag.metadata?.name) + .filter((name): name is string => name !== undefined && name !== null); + + return { success: true, tags: tagNames }; + } catch (error: any) { + console.error('Error getting existing tags:', error); + return { + success: false, + error: error.message || 'Failed to get existing tags', + tags: [], + }; + } +}; diff --git a/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx b/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx index 3906435d..681b3ead 100644 --- a/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx +++ b/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx @@ -191,7 +191,7 @@ export default function TestRunsTable({ if (value.length === 0) { return N/A; } - const tagsArray = value.split(', '); + const tagsArray = value.split(', ').sort(); return ( 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 90a49002..24559dab 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 @@ -4,21 +4,24 @@ * SPDX-License-Identifier: EPL-2.0 */ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import styles from '@/styles/test-runs/test-run-details/OverviewTab.module.css'; import InlineText from './InlineText'; import { RunMetadata } from '@/utils/interfaces'; import { useTranslations } from 'next-intl'; -import { Link, InlineNotification } from '@carbon/react'; +import { Link, InlineNotification, FilterableMultiSelect, Modal } 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'; +import RenderTags from '@/components/test-runs/test-run-details/RenderTags'; +import { updateRunTags, getExistingTagObjects } from '@/actions/runsAction'; + +type DisplayedTagType = { + id: string; + label: string; +}; const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { const translations = useTranslations('OverviewTab'); @@ -27,9 +30,11 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { const [weekBefore, setWeekBefore] = useState(null); const [tags, setTags] = useState(metadata?.tags || []); + const [existingTagObjectNames, setExistingTagObjectNames] = useState([]); + const [isTagsEditModalOpen, setIsTagsEditModalOpen] = useState(false); - const [newTagInput, setNewTagInput] = useState(''); - const [stagedTags, setStagedTags] = useState>(new Set(tags)); + const [filterInput, setFilterInput] = useState(''); + const [stagedTags, setStagedTags] = useState>(new Set()); const [notification, setNotification] = useState<{ kind: 'success' | 'error'; title: string; @@ -41,6 +46,22 @@ 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!); @@ -68,34 +89,42 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { }); }; - 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); + const handleFilterableMultiSelectChange = (selectedItems: DisplayedTagType[]) => { + // Update staged tags based on selected items. + const newStagedTags = new Set(selectedItems.map((item) => item.label)); + setStagedTags(newStagedTags); + }; - // 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; - }); + // Create items for FilterableMultiSelect. + const filterableItems = useMemo(() => { + const stagedAndExistingTags = new Set(); - // Clear the input after staging - setNewTagInput(''); - }; + // Collect all unique tag names. + stagedTags.forEach((tagName) => stagedAndExistingTags.add(tagName)); + existingTagObjectNames.forEach((tagName) => stagedAndExistingTags.add(tagName)); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleStageNewTags(); + // Add the current filter input if it's not empty and not already in the list. + if (filterInput.trim() && !stagedAndExistingTags.has(filterInput.trim())) { + stagedAndExistingTags.add(filterInput.trim()); } - }; + + const arrayOfStagedAndExistingTags = Array.from(stagedAndExistingTags); + + // Create items with consistent IDs based on sorted order. + return arrayOfStagedAndExistingTags.map((tagName, index) => ({ + id: `tag-${index}-${tagName}`, + label: tagName, + })); + }, [existingTagObjectNames, stagedTags, filterInput]); + + // Get initially selected items based on staged tags. + const initialSelectedItems = useMemo(() => { + return filterableItems.filter((item) => stagedTags.has(item.label)); + }, [filterableItems, stagedTags]); const handleModalClose = () => { setIsTagsEditModalOpen(false); - setNewTagInput(''); + setFilterInput(''); setNotification(null); }; @@ -117,8 +146,8 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { subtitle: translations('updateSuccessMessage'), }); - // Set tags of the component to the staged tags tags. - setTags(Array.from(stagedTags)); + // Set tags of the component to the staged tags. + setTags(Array.from(stagedTags).sort()); // Close modal after a short delay to show success message. setTimeout(() => { @@ -161,13 +190,25 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => {
{ + // Initialise staged tags from current tags when opening modal. + setStagedTags(new Set(tags)); setIsTagsEditModalOpen(true); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setStagedTags(new Set(tags)); + setIsTagsEditModalOpen(true); + } + }} + tabIndex={0} + role="button" + aria-label={translations('editTags')} >
- +
@@ -194,6 +235,7 @@ const OverviewTab = ({ metadata }: { metadata: RunMetadata }) => { secondaryButtonText={translations('modalSecondaryButton')} onRequestSubmit={handleSaveTags} primaryButtonDisabled={isSaving} + className={styles.tagsEditModal} > {notification && ( { onCloseButtonClick={() => setNotification(null)} /> )} - ) => setNewTagInput(e.target.value)} - onKeyDown={handleKeyDown} + items={filterableItems} + initialSelectedItems={initialSelectedItems} + itemToString={(item: DisplayedTagType | null) => (item ? item.label : '')} + selectionFeedback="top" + selectedItems={initialSelectedItems} + onChange={({ selectedItems }: { selectedItems: DisplayedTagType[] }) => { + handleFilterableMultiSelectChange(selectedItems); + }} + onInputValueChange={(inputValue: string) => { + setFilterInput(inputValue); + }} className={styles.tagsTextInput} /> ({ updateRunTags: jest.fn(), + getExistingTagObjects: jest.fn().mockResolvedValue({ + success: true, + tags: ['existing-tag-1', 'existing-tag-2'], + }), })); // Mock RenderTags component @@ -75,6 +79,46 @@ jest.mock('@carbon/react', () => ({ aria-label={labelText} /> ), + FilterableMultiSelect: ({ + id, + titleText, + placeholder, + items, + initialSelectedItems, + selectedItems, + itemToString, + onChange, + onInputValueChange, + }: any) => ( +
+ + onInputValueChange && onInputValueChange(e.target.value)} + /> +
+ {items.map((item: any) => { + const isSelected = selectedItems?.some((selected: any) => selected.id === item.id); + return ( +
+ { + const newSelected = e.target.checked + ? [...(selectedItems || []), item] + : (selectedItems || []).filter((s: any) => s.id !== item.id); + onChange && onChange({ selectedItems: newSelected }); + }} + /> + +
+ ); + })} +
+
+ ), Modal: ({ open, children, @@ -204,15 +248,15 @@ describe('OverviewTab', () => { expect(screen.getByText(completeMetadata.duration)).toBeInTheDocument(); }); - it('renders each tag when tags array is non-empty', () => { + it('renders each tag when tags array is non-empty, sorted', () => { render(); // 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); - expect(tagEls[0]).toHaveTextContent('smoke'); - expect(tagEls[1]).toHaveTextContent('regression'); + expect(tagEls[0]).toHaveTextContent('regression'); + expect(tagEls[1]).toHaveTextContent('smoke'); }); it('shows fallback text when tags is empty or missing', () => { @@ -448,8 +492,8 @@ describe('OverviewTab - Tags Edit Modal', () => { await waitFor(() => { expect(mockUpdateRunTags).toHaveBeenCalledWith(completeMetadata.runId, [ - 'smoke', 'regression', + 'smoke', ]); }); }); @@ -508,7 +552,7 @@ describe('OverviewTab - Tags Edit Modal', () => { }); }); - it('should persist staged tags when modal is closed without saving', async () => { + it('should reset staged tags when modal is closed without saving', async () => { const user = userEvent.setup(); render(); @@ -539,9 +583,9 @@ describe('OverviewTab - Tags Edit 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 + // Tag should be back (staged changes reset on cancel) + expect(screen.getByTestId('remove-tag-smoke')).toBeInTheDocument(); + // And regression tag should still be there expect(screen.getByTestId('remove-tag-regression')).toBeInTheDocument(); }); }); @@ -557,4 +601,217 @@ describe('OverviewTab - Tags Edit Modal', () => { expect(screen.getByText(new RegExp(completeMetadata.runName))).toBeInTheDocument(); }); }); + + it('should initialise staged tags from current tags when modal opens', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + // Both original tags should be present in the modal + expect(screen.getByTestId('remove-tag-smoke')).toBeInTheDocument(); + expect(screen.getByTestId('remove-tag-regression')).toBeInTheDocument(); + }); + }); + + it('should display FilterableMultiSelect with sorted items alphabetically', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Check that items are present (they should be sorted alphabetically) + const items = screen.getByTestId('filterable-multiselect-items'); + expect(items).toBeInTheDocument(); + }); + + it('should maintain alphabetical sorting when adding new tags via filter input', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Type a new tag in the filter input + const filterInput = screen.getByTestId('filterable-multiselect-input'); + await user.type(filterInput, 'alpha-tag'); + + await waitFor(() => { + // The new tag should appear in the items list + expect(screen.getByTestId('multiselect-item-alpha-tag')).toBeInTheDocument(); + }); + }); + + it('should keep selected items ticked in the dropdown', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Check that initial tags are selected (checked) + const smokeCheckbox = screen + .getByTestId('multiselect-item-smoke') + .querySelector('input[type="checkbox"]'); + const regressionCheckbox = screen + .getByTestId('multiselect-item-regression') + .querySelector('input[type="checkbox"]'); + + expect(smokeCheckbox).toBeChecked(); + expect(regressionCheckbox).toBeChecked(); + }); + + it('should update staged tags when selecting items in FilterableMultiSelect', async () => { + const user = userEvent.setup(); + const metadataWithOneTag: RunMetadata = { + ...completeMetadata, + tags: ['smoke'], + }; + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Select an existing tag that wasn't initially selected + const existingTag1Checkbox = screen + .getByTestId('multiselect-item-existing-tag-1') + .querySelector('input[type="checkbox"]'); + if (existingTag1Checkbox) { + await user.click(existingTag1Checkbox); + } + + await waitFor(() => { + // The tag should now appear in the RenderTags component + const tags = screen.getAllByTestId('mock-tag'); + const tagTexts = tags.map((tag) => tag.textContent); + expect(tagTexts.some((text) => text?.includes('existing-tag-1'))).toBe(true); + }); + }); + + it('should clear filter input when modal is closed', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Type something in the filter input + const filterInput = screen.getByTestId('filterable-multiselect-input'); + await user.type(filterInput, 'test-tag'); + + // Close modal + 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(() => { + const filterInputReopened = screen.getByTestId('filterable-multiselect-input'); + // Filter input should be empty + expect(filterInputReopened).toHaveValue(''); + }); + }); + + it('should update main tags display after successful save', async () => { + const user = userEvent.setup(); + mockUpdateRunTags.mockResolvedValueOnce({ + success: true, + tags: ['smoke', 'regression', 'new-tag'], + }); + + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Add a new tag via filter input + const filterInput = screen.getByTestId('filterable-multiselect-input'); + await user.type(filterInput, 'new-tag'); + + await waitFor(async () => { + const newTagCheckbox = screen + .getByTestId('multiselect-item-new-tag') + .querySelector('input[type="checkbox"]'); + if (newTagCheckbox) { + await user.click(newTagCheckbox); + } + }); + + // Save + const primaryButton = screen.getByTestId('modal-primary-button'); + await user.click(primaryButton); + + await waitFor(() => { + expect(mockUpdateRunTags).toHaveBeenCalled(); + }); + + // After modal closes, the main tags display should be updated + await waitFor( + () => { + expect(screen.queryByTestId('mock-modal')).not.toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('should fetch existing tags on component mount', async () => { + 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(); + }); + + it('should include existing system tags in FilterableMultiSelect items', async () => { + const user = userEvent.setup(); + render(); + + const editIcon = screen.getByTestId('edit-icon'); + await user.click(editIcon); + + await waitFor(() => { + expect(screen.getByTestId('mock-filterable-multiselect')).toBeInTheDocument(); + }); + + // Check that existing system tags are available + expect(screen.getByTestId('multiselect-item-existing-tag-1')).toBeInTheDocument(); + expect(screen.getByTestId('multiselect-item-existing-tag-2')).toBeInTheDocument(); + }); });