Skip to content
Merged
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
@@ -1,5 +1,5 @@
import type { ReactNode, FC } from 'react';
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { useMemo, useState, useRef, useCallback, useEffect, startTransition } from 'react';
import * as _ from 'lodash';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
Expand All @@ -8,13 +8,7 @@ import type { CatalogItem } from '@console/dynamic-plugin-sdk/src/extensions';
import { isModalOpen } from '@console/internal/components/modals';
import { useQueryParams } from '../../../hooks/useQueryParams';
import PaneBody from '../../layout/PaneBody';
import {
setURLParams,
updateURLParams,
getCatalogTypeCounts,
calculateCatalogItemRelevanceScore,
getRedHatPriority,
} from '../utils/catalog-utils';
import { setURLParams, updateURLParams, getCatalogTypeCounts } from '../utils/catalog-utils';
import {
categorize,
findActiveCategory,
Expand Down Expand Up @@ -102,8 +96,6 @@ const CatalogView: FC<CatalogViewProps> = ({
}, [filterGroups, filters, queryParams]);

const [filterGroupsShowAll, setFilterGroupsShowAll] = useState<Record<string, boolean>>({});
const [filterGroupCounts, setFilterGroupCounts] = useState<CatalogFilterCounts>({});
const [catalogTypeCounts, setCatalogTypeCounts] = useState<CatalogTypeCounts>({});

const isGrouped = _.has(groupings, activeGrouping);

Expand Down Expand Up @@ -137,7 +129,9 @@ const CatalogView: FC<CatalogViewProps> = ({

const handleSearchKeywordChange = useCallback(
(searchKeyword) => {
updateURLParams(CatalogQueryParams.KEYWORD, searchKeyword, navigate);
startTransition(() => {
updateURLParams(CatalogQueryParams.KEYWORD, searchKeyword, navigate);
});
},
[navigate],
);
Expand Down Expand Up @@ -185,100 +179,25 @@ const CatalogView: FC<CatalogViewProps> = ({
[activeCategoryId, catalogCategories],
);

const filteredItems: CatalogItem[] = useMemo(() => {
const filteredBySearchItems = useMemo(() => {
const filteredByCategoryItems = filterByCategory(items, activeCategoryId, categorizedIds);
const filteredBySearchItems = filterBySearchKeyword(
filteredByCategoryItems,
activeSearchKeyword,
sortOrder,
);
const filteredByAttributes = filterByAttributes(filteredBySearchItems, activeFilters);

const filterCounts = getFilterGroupCounts(filteredBySearchItems, activeFilters, filterGroups);
setFilterGroupCounts(filterCounts);

const typeCounts = getCatalogTypeCounts(filteredBySearchItems, catalogTypes);
setCatalogTypeCounts(typeCounts);

// Console table for final filtered results (only for operators)
if (filteredByAttributes.length > 0) {
// Check if we have active filters beyond just search and category
const hasAttributeFilters = Object.values(activeFilters).some((filterGroup) =>
Object.values(filterGroup).some((filter) => filter.active),
);

// Only show console.table if we have search term or attribute filters
if (activeSearchKeyword || hasAttributeFilters) {
const REDHAT_PRIORITY = {
EXACT_MATCH: 2,
CONTAINS_REDHAT: 1,
NON_REDHAT: 0,
};

const tableData = filteredByAttributes.map((item) => {
// Ensure we have the scoring properties, calculate them if missing
const relevanceScore = activeSearchKeyword
? (item as any).relevanceScore ??
calculateCatalogItemRelevanceScore(activeSearchKeyword, item)
: 'N/A (No search)';
const redHatPriority = (item as any).redHatPriority ?? getRedHatPriority(item);

return {
Title: item.name || 'N/A',
'Search Relevance Score': relevanceScore,
'Is Red Hat Provider (Priority)':
redHatPriority === REDHAT_PRIORITY.EXACT_MATCH
? `Exact Match (${REDHAT_PRIORITY.EXACT_MATCH})`
: redHatPriority === REDHAT_PRIORITY.CONTAINS_REDHAT
? `Contains Red Hat (${REDHAT_PRIORITY.CONTAINS_REDHAT})`
: `Non-Red Hat (${REDHAT_PRIORITY.NON_REDHAT})`,
Provider: item.attributes?.provider || item.provider || 'N/A',
Type: item.type || 'N/A',
};
});

// Build filter description
const activeFilterDescriptions = [];
if (activeSearchKeyword) activeFilterDescriptions.push(`Search: "${activeSearchKeyword}"`);
if (activeCategoryId !== 'all')
activeFilterDescriptions.push(`Category: ${activeCategoryId}`);

Object.entries(activeFilters).forEach(([filterType, filterGroup]) => {
const activeFilterValues = Object.entries(filterGroup)
.filter(([, filter]) => filter.active)
.map(([, filter]) => filter.label || filter.value);
if (activeFilterValues.length > 0) {
activeFilterDescriptions.push(`${filterType}: [${activeFilterValues.join(', ')}]`);
}
});

const filterDescription =
activeFilterDescriptions.length > 0 ? activeFilterDescriptions.join(' + ') : 'No filters';
return filterBySearchKeyword(filteredByCategoryItems, activeSearchKeyword, sortOrder);
}, [activeCategoryId, activeSearchKeyword, categorizedIds, items, sortOrder]);

// eslint-disable-next-line no-console
console.log(
`\n🎯 FINAL Catalog Results: ${filterDescription} (${filteredByAttributes.length} matches)`,
);
// eslint-disable-next-line no-console
console.table(tableData);
}
}
const filteredItems: CatalogItem[] = useMemo(
() => filterByAttributes(filteredBySearchItems, activeFilters),
[activeFilters, filteredBySearchItems],
);

// Always use filteredByAttributes since keywordCompare handles both cases:
// - With search terms: relevance scoring + Red Hat prioritization + filtering
// - Without search terms: Red Hat prioritization + alphabetical sorting (no filtering)
// The keywordCompare function is called by filterBySearchKeyword regardless of search term presence
return filteredByAttributes;
}, [
activeCategoryId,
activeFilters,
activeSearchKeyword,
catalogTypes,
categorizedIds,
filterGroups,
items,
sortOrder,
]);
const filterGroupCounts = useMemo<CatalogFilterCounts>(
() => getFilterGroupCounts(filteredBySearchItems, activeFilters, filterGroups),
[filteredBySearchItems, activeFilters, filterGroups],
);

const catalogTypeCounts = useMemo<CatalogTypeCounts>(
() => getCatalogTypeCounts(filteredBySearchItems, catalogTypes),
[filteredBySearchItems, catalogTypes],
);

const totalItems = filteredItems.length;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,11 @@ const InputField = forwardRef<HTMLInputElement, BaseInputFieldProps>(
{(props) => (
<div className="oc-inputfield">
<TextInput ref={ref} {...props} />
{props.validated && props.validated !== ValidatedOptions.default ? (
<div
className={`oc-inputfield__validation-icon ${props.validated}`}
// The BaseInputField will show an description (helper-text) below
// the input field that describes the validation error.
aria-hidden="true"
/>
) : null}
<div
className={`oc-inputfield__validation-icon ${props.validated}`}
aria-hidden="true"
hidden={!props.validated || props.validated === ValidatedOptions.default}
/>
</div>
)}
</BaseInputField>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# Disabled due to createRoot concurrent rendering failures (OCPBUGS-82505)
@add-flow @smoke @to-do
@add-flow @smoke
Feature: Create the different workloads from Add page
As a user, I should be able to create an Application, component or service from one of the options provided on Add page

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export const catalogPage = {
},
search: (keyword: string) => {
cy.get('.skeleton-catalog--grid').should('not.exist');
cy.get(catalogPO.search).clear().type(keyword);
// Split clear/type so Cypress re-queries the DOM between operations.
// Under React 18 createRoot, clear() can trigger a re-render that
// replaces the input node, detaching it before type() runs.
cy.get(catalogPO.search).clear();
cy.get(catalogPO.search).type(keyword);
},
verifyDialog: () => cy.get(catalogPO.sidePane.dialog).should('be.visible'),
verifyCreateHelmReleasePage: () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@ import { gitPage } from './git-page';

export const containerImagePage = {
enterExternalRegistryImageName: (imageName: string) => {
cy.get(containerImagePO.imageSection.externalRegistry.imageName).type(imageName);
// Set the value atomically via native setter to avoid React 18 createRoot
// concurrent rendering stealing focus during character-by-character typing.
cy.get(containerImagePO.imageSection.externalRegistry.imageName)
.should('be.visible')
.then(($input) => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value',
).set;
nativeInputValueSetter.call($input[0], imageName);
$input[0].dispatchEvent(new Event('input', { bubbles: true }));
})
.should('have.value', imageName);
containerImagePage.verifyValidatedMessage();
},
selectProject: (projectName: string) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FC } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from 'react';
import {
TextInputTypes,
Alert,
Expand Down Expand Up @@ -261,10 +261,13 @@ const ImageSearch: FC = () => {
helpTextInvalid={helpText}
validated={validated}
onChange={(e: KeyboardEvent) => {
resetFields();
setFieldValue('isi', {});
const { value } = e.target as HTMLInputElement;
startTransition(() => {
resetFields();
setFieldValue('isi', {});
});
setValidated(ValidatedOptions.default);
debouncedHandleSearch((e.target as HTMLInputElement).value);
debouncedHandleSearch(value);
}}
aria-label={t('devconsole~Image name')}
data-test-id="deploy-image-search-term"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const ImageSearchSection: FC<{ disabled?: boolean }> = ({ disabled = false }) =>
initialValues.searchTerm,
registry,
setFieldValue,
values,
values.registry,
]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { topologyPO } from '@console/topology/integration-tests/support/page-obj
import { topologyPage } from '@console/topology/integration-tests/support/pages/topology/topology-page';

export const topologyHelper = {
search: (name: string) => cy.get(topologyPO.search).clear().type(name),
search: (name: string) => {
cy.get(topologyPO.search).clear();
cy.get(topologyPO.search).type(name);
},
verifyWorkloadInTopologyPage: (appName: string, options?: { timeout: number }) => {
topologyPage.verifyToplogyPageNotEmpty();
topologyHelper.search(appName);
Expand Down
9 changes: 6 additions & 3 deletions frontend/public/components/utils/console-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,15 @@ export const ConsoleSelect: FC<ConsoleSelectProps> = ({
[autocompleteFilter],
);

// Update state when props change
// One-way prop→state sync. Do NOT include `selectedKey` in deps — it
// creates a feedback loop under React 18 createRoot where the effect
// reverts user selections before the prop update from onChange propagates.
useEffect(() => {
if (props.selectedKey && props.selectedKey !== selectedKey) {
if (props.selectedKey !== undefined) {
setSelectedKey(props.selectedKey);
}
}, [props.selectedKey, selectedKey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.selectedKey]);

useEffect(() => {
applyTextFilter(autocompleteText, props.items);
Expand Down