Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
96 changes: 96 additions & 0 deletions ui/apps/platform/src/utils/searchUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
convertToExactMatch,
convertToRestSearch,
deleteKeysCaseInsensitive,
formatLabelValue,
getListQueryParams,
getPaginationParams,
getSearchFilterFromSearchString,
getViewStateFromSearch,
hasSearchKeyValue,
isLabelSearchTerm,
searchValueAsArray,
wrapInQuotes,
} from './searchUtils';
Expand Down Expand Up @@ -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"');
Expand Down Expand Up @@ -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'],
});
});
});
});
48 changes: 45 additions & 3 deletions ui/apps/platform/src/utils/searchUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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;
}
Expand All @@ -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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the label-specific and generic regex formatting into small helper functions so applyRegexSearchModifiers has a simpler, flatter map callback.

You can flatten applyRegexSearchModifiers by pushing the label-specific branching into a helper and making the quoted/non‑quoted decision once per value.

For example:

function formatRegexValue(val: string): string {
    return isQuotedString(val) ? val : `r/${val}`;
}

function formatLabelRegexValue(val: string): string {
    const quoted = isQuotedString(val);
    const rawValue = quoted ? val.slice(1, -1) : val;
    const formatter = quoted ? wrapInQuotes : (v: string) => `r/${v}`;
    return formatLabelValue(rawValue, formatter);
}

Then applyRegexSearchModifiers becomes:

export function applyRegexSearchModifiers(searchFilter: SearchFilter): SearchFilter {
    const regexSearchFilter = cloneDeep(searchFilter);

    Object.entries(regexSearchFilter).forEach(([key, value]) => {
        if (regexSearchOptions.some((option) => option.toLowerCase() === key.toLowerCase())) {
            const isLabel = isLabelSearchTerm(key);
            regexSearchFilter[key] = searchValueAsArray(value).map((val) =>
                isLabel ? formatLabelRegexValue(val) : formatRegexValue(val)
            );
        }
    });

    return regexSearchFilter;
}

This keeps all existing behavior (including the label key=value semantics and quoted handling) but removes the nested if plus duplicated inline isQuotedString/formatter logic from the .map callback, making the control flow easier to follow.

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}`;
});
}
});

Expand Down
Loading