diff --git a/ui/apps/platform/src/Components/CompoundSearchFilter/components/AutocompleteSelect.tsx b/ui/apps/platform/src/Components/CompoundSearchFilter/components/AutocompleteSelect.tsx index 8d310b49842d0..fb1a9b15a0595 100644 --- a/ui/apps/platform/src/Components/CompoundSearchFilter/components/AutocompleteSelect.tsx +++ b/ui/apps/platform/src/Components/CompoundSearchFilter/components/AutocompleteSelect.tsx @@ -18,7 +18,12 @@ import { SearchIcon, TimesIcon } from '@patternfly/react-icons'; import { useQuery } from '@apollo/client'; import SEARCH_AUTOCOMPLETE_QUERY from 'queries/searchAutocomplete'; import type { SearchAutocompleteQueryResponse } from 'queries/searchAutocomplete'; -import { getRequestQueryStringForSearchFilter, wrapInQuotes } from 'utils/searchUtils'; +import { + formatLabelValue, + getRequestQueryStringForSearchFilter, + isLabelSearchTerm, + wrapInQuotes, +} from 'utils/searchUtils'; import type { SearchFilter } from 'types/search'; import { ensureString } from 'utils/ensure'; @@ -163,10 +168,17 @@ function AutocompleteSelect({ // Wraps the value in quotes if it's an actual autocomplete suggestion from the backend, // otherwise returns it unchanged. This provides exact-match search for suggestions // and regex search for manual/fallback entries. + // Label search terms use special key=value quoting (e.g. "app"="reporting"). const applySelectedText = (rawValue: string | number) => { const value = ensureString(rawValue); const isAutocompleteSuggestion = data.searchAutocomplete.includes(value); - const valueToApply = isAutocompleteSuggestion ? wrapInQuotes(value) : value; + const isLabel = isLabelSearchTerm(searchTerm); + let valueToApply: string; + if (isAutocompleteSuggestion) { + valueToApply = isLabel ? formatLabelValue(value, wrapInQuotes) : wrapInQuotes(value); + } else { + valueToApply = value; + } onChange(valueToApply); onSearch(valueToApply); setFilterValue(''); diff --git a/ui/apps/platform/src/Components/CompoundSearchFilter/components/CompoundSearchFilter.cy.jsx b/ui/apps/platform/src/Components/CompoundSearchFilter/components/CompoundSearchFilter.cy.jsx index de4b7835ade35..d3aa3c738a3c7 100644 --- a/ui/apps/platform/src/Components/CompoundSearchFilter/components/CompoundSearchFilter.cy.jsx +++ b/ui/apps/platform/src/Components/CompoundSearchFilter/components/CompoundSearchFilter.cy.jsx @@ -503,6 +503,47 @@ describe(Cypress.spec.relative, () => { ]); }); + it('should format label autocomplete selections with key=value quoting', () => { + const labelResponseMock = { + data: { + searchAutocomplete: ['app=reporting', 'env=production'], + }, + }; + + cy.intercept('POST', graphqlUrl('autocomplete'), (req) => { + req.reply(labelResponseMock); + }).as('autocomplete'); + + const config = [deploymentSearchFilterConfig]; + const onSearch = cy.stub().as('onSearch'); + const searchFilter = {}; + + setup(config, searchFilter, onSearch); + + // Select the Label attribute + cy.get(selectors.attributeSelectToggle).click(); + cy.get(selectors.attributeSelectItem('Label')).click(); + + const autocompleteMenuToggle = + 'div[aria-labelledby="Filter results menu toggle"] button[aria-label="Menu toggle"]'; + const autocompleteMenuItems = '[aria-label="Filter results select menu"] li'; + + cy.get(autocompleteMenuToggle).click(); + cy.wait('@autocomplete'); + + // Select a label value from autocomplete + cy.get(autocompleteMenuItems).eq(0).click(); + + // Label autocomplete selections should wrap key and value separately + cy.get('@onSearch').should('have.been.calledWithExactly', [ + { + action: 'APPEND', + category: 'Deployment Label', + value: '"app"="reporting"', + }, + ]); + }); + it('should display the default entity and attribute when the selected entity and attribute are not in the config', () => { const onSearch = cy.stub().as('onSearch'); const searchFilter = {}; diff --git a/ui/apps/platform/src/utils/searchUtils.test.ts b/ui/apps/platform/src/utils/searchUtils.test.ts index b59b8be9f611e..aadb2470d3f15 100644 --- a/ui/apps/platform/src/utils/searchUtils.test.ts +++ b/ui/apps/platform/src/utils/searchUtils.test.ts @@ -6,11 +6,13 @@ import { convertToExactMatch, convertToRestSearch, deleteKeysCaseInsensitive, + formatLabelValue, getListQueryParams, getPaginationParams, getSearchFilterFromSearchString, getViewStateFromSearch, hasSearchKeyValue, + isLabelSearchTerm, searchValueAsArray, wrapInQuotes, } from './searchUtils'; @@ -433,6 +435,48 @@ describe('searchUtils', () => { }); }); + describe('isLabelSearchTerm', () => { + it('returns true for label search terms', () => { + expect(isLabelSearchTerm('Deployment Label')).toBe(true); + expect(isLabelSearchTerm('Image Label')).toBe(true); + expect(isLabelSearchTerm('Node Label')).toBe(true); + expect(isLabelSearchTerm('Namespace Label')).toBe(true); + expect(isLabelSearchTerm('Cluster Label')).toBe(true); + }); + + it('returns true for label search terms in any case', () => { + expect(isLabelSearchTerm('deployment label')).toBe(true); + expect(isLabelSearchTerm('DEPLOYMENT LABEL')).toBe(true); + expect(isLabelSearchTerm('dEpLoYmEnT lAbEl')).toBe(true); + }); + + it('returns false for non-label search terms', () => { + expect(isLabelSearchTerm('Cluster')).toBe(false); + expect(isLabelSearchTerm('Deployment')).toBe(false); + expect(isLabelSearchTerm('Image')).toBe(false); + }); + }); + + describe('formatLabelValue', () => { + it('splits on first equals sign and applies formatter to each part', () => { + expect(formatLabelValue('app=reporting', wrapInQuotes)).toBe('"app"="reporting"'); + expect(formatLabelValue('app=reporting', (v) => `r/${v}`)).toBe('r/app=r/reporting'); + }); + + it('handles values with multiple equals signs by splitting on the first', () => { + expect(formatLabelValue('key=val=extra', wrapInQuotes)).toBe('"key"="val=extra"'); + }); + + it('uses the value for both sides when no equals sign is present', () => { + expect(formatLabelValue('reporting', wrapInQuotes)).toBe('"reporting"="reporting"'); + expect(formatLabelValue('reporting', (v) => `r/${v}`)).toBe('r/reporting=r/reporting'); + }); + + it('escapes internal double quotes in key and value', () => { + expect(formatLabelValue('k"ey=v"al', wrapInQuotes)).toBe('"k\\"ey"="v\\"al"'); + }); + }); + describe('wrapInQuotes', () => { it('wraps a string in double quotes', () => { expect(wrapInQuotes('hello')).toBe('"hello"'); @@ -499,5 +543,57 @@ describe('searchUtils', () => { expect(result.Cluster).toEqual(['r/production']); expect(result['Random Field']).toEqual('value'); // should not be modified }); + + it('formats label regex values with equals sign by prefixing both sides', () => { + const searchFilter = { 'Deployment Label': 'app=reporting' }; + const result = applyRegexSearchModifiers(searchFilter); + expect(result).toEqual({ 'Deployment Label': ['r/app=r/reporting'] }); + }); + + it('formats label regex values without equals sign using the value for both sides', () => { + const searchFilter = { 'Deployment Label': 'report' }; + const result = applyRegexSearchModifiers(searchFilter); + expect(result).toEqual({ 'Deployment Label': ['r/report=r/report'] }); + }); + + it('formats quoted label values with equals sign by quoting each side separately', () => { + const searchFilter = { 'Deployment Label': '"app=reporting"' }; + const result = applyRegexSearchModifiers(searchFilter); + expect(result).toEqual({ 'Deployment Label': ['"app"="reporting"'] }); + }); + + it('formats quoted label values without equals sign using the value for both sides', () => { + const searchFilter = { 'Deployment Label': '"reporting"' }; + const result = applyRegexSearchModifiers(searchFilter); + expect(result).toEqual({ 'Deployment Label': ['"reporting"="reporting"'] }); + }); + + it('applies label formatting to all label search terms', () => { + const searchFilter = { + 'Image Label': 'env=prod', + 'Node Label': 'role=worker', + 'Cluster Label': 'team=platform', + 'Namespace Label': 'app=web', + }; + const result = applyRegexSearchModifiers(searchFilter); + expect(result).toEqual({ + 'Image Label': ['r/env=r/prod'], + 'Node Label': ['r/role=r/worker'], + 'Cluster Label': ['r/team=r/platform'], + 'Namespace Label': ['r/app=r/web'], + }); + }); + + it('does not apply label formatting to non-label search terms', () => { + const searchFilter = { + Cluster: 'production', + 'Deployment Label': 'app=reporting', + }; + const result = applyRegexSearchModifiers(searchFilter); + expect(result).toEqual({ + Cluster: ['r/production'], + 'Deployment Label': ['r/app=r/reporting'], + }); + }); }); }); diff --git a/ui/apps/platform/src/utils/searchUtils.ts b/ui/apps/platform/src/utils/searchUtils.ts index de8b0e49c03fe..a8fd157d0142d 100644 --- a/ui/apps/platform/src/utils/searchUtils.ts +++ b/ui/apps/platform/src/utils/searchUtils.ts @@ -428,6 +428,20 @@ const regexSearchOptions = [ .filter(({ inputType }) => inputType === 'text' || inputType === 'autocomplete') .map(({ searchTerm }) => searchTerm); +/* + Search terms that use the key=value label format and need special handling + in both regex and exact-match search modes. +*/ +const labelSearchOptions: Set = new Set( + ['Cluster Label', 'Deployment Label', 'Image Label', 'Namespace Label', 'Node Label'].map( + (label) => label.toLowerCase() + ) +); + +export function isLabelSearchTerm(searchTerm: string): boolean { + return labelSearchOptions.has(searchTerm.toLowerCase()); +} + function isQuotedString(value: string): boolean { return value.startsWith('"') && value.endsWith('"') && value.length >= 2; } @@ -441,18 +455,46 @@ export function wrapInQuotes(value: string): string { return `"${escapedValue}"`; } +/** + * Formats a label value by splitting on the first '=' and applying a formatter + * to each half. If no '=' is present, the value is used for both sides. + * + * Used for both exact-match (formatter = wrapInQuotes) and regex (formatter = r/ prefix) + * label formatting. + */ +export function formatLabelValue(value: string, formatPart: (part: string) => string): string { + const eqIndex = value.indexOf('='); + if (eqIndex !== -1) { + const key = value.slice(0, eqIndex); + const val = value.slice(eqIndex + 1); + return `${formatPart(key)}=${formatPart(val)}`; + } + return `${formatPart(value)}=${formatPart(value)}`; +} + /** * Adds the regex search modifier to the search filter for any search options that support it. * Skips regex wrapping for values that are already quoted (exact-match strings). + * + * Label search terms (e.g. "Deployment Label") receive special formatting because the + * search API treats labels as key=value pairs. If the "=" part is not present, the value is used for both sides. + * - Regex: app=reporting -> r/app=r/reporting, report -> r/report=r/report + * - Exact: app=reporting -> "app"="reporting" */ export function applyRegexSearchModifiers(searchFilter: SearchFilter): SearchFilter { const regexSearchFilter = cloneDeep(searchFilter); Object.entries(regexSearchFilter).forEach(([key, value]) => { if (regexSearchOptions.some((option) => option.toLowerCase() === key.toLowerCase())) { - regexSearchFilter[key] = searchValueAsArray(value).map((val) => - isQuotedString(val) ? val : `r/${val}` - ); + const isLabel = isLabelSearchTerm(key); + regexSearchFilter[key] = searchValueAsArray(value).map((val) => { + if (isLabel) { + const rawValue = isQuotedString(val) ? val.slice(1, -1) : val; + const formatter = isQuotedString(val) ? wrapInQuotes : (v: string) => `r/${v}`; + return formatLabelValue(rawValue, formatter); + } + return isQuotedString(val) ? val : `r/${val}`; + }); } });