diff --git a/packages/ui-tests/cypress/e2e/catalog.cy.ts b/packages/ui-tests/cypress/e2e/catalog.cy.ts index 2e0070ab4..668ae80c4 100644 --- a/packages/ui-tests/cypress/e2e/catalog.cy.ts +++ b/packages/ui-tests/cypress/e2e/catalog.cy.ts @@ -10,15 +10,19 @@ describe('Catalog related tests', () => { cy.get('.pf-v5-c-text-input-group__text-input').type('timer'); cy.get('div[id="timer"]').should('be.visible'); cy.get('button[aria-label="Reset"]').click(); + cy.get('.pf-v5-c-text-input-group__text-input').should('have.value', ''); cy.get('[data-testid="processor-catalog-tab"]').click(); cy.get('.pf-v5-c-text-input-group__text-input').type('choice'); cy.get('div[id="choice"]').should('be.visible'); cy.get('button[aria-label="Reset"]').click(); + cy.get('.pf-v5-c-text-input-group__text-input').should('have.value', ''); cy.get('[data-testid="kamelet-catalog-tab"]').click(); cy.get('.pf-v5-c-text-input-group__text-input').type('google'); cy.get('div[id="google-storage-source"]').should('be.visible'); + cy.get('button[aria-label="Reset"]').click(); + cy.get('.pf-v5-c-text-input-group__text-input').should('have.value', ''); }); it('Catalog filtering using tags', () => { diff --git a/packages/ui/package.json b/packages/ui/package.json index bce29f6f8..19009bd37 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -68,6 +68,7 @@ "simple-zustand-devtools": "^1.1.0", "uniforms": "4.0.0-alpha.5", "uniforms-bridge-json-schema": "4.0.0-alpha.5", + "usehooks-ts": "^2.15.1", "uuid": "^9.0.0", "yaml": "^2.3.2", "zustand": "^4.3.9" diff --git a/packages/ui/src/components/Catalog/Catalog.tsx b/packages/ui/src/components/Catalog/Catalog.tsx index f055e1715..c271ab021 100644 --- a/packages/ui/src/components/Catalog/Catalog.tsx +++ b/packages/ui/src/components/Catalog/Catalog.tsx @@ -1,10 +1,12 @@ import { FunctionComponent, PropsWithChildren, useCallback, useMemo, useState } from 'react'; +import { useDebounceValue } from 'usehooks-ts'; import { useLocalStorage } from '../../hooks'; import { LocalStorageKeys } from '../../models'; import { BaseCatalog } from './BaseCatalog'; import { CatalogLayout, ITile } from './Catalog.models'; import './Catalog.scss'; import { CatalogFilter } from './CatalogFilter'; +import { filterTiles } from './filter-tiles'; interface CatalogProps { /** Tiles list */ @@ -14,51 +16,31 @@ interface CatalogProps { onTileClick?: (tile: ITile) => void; } -const checkThatArrayContainsAllTags = (arr: string[], tags: string[]) => tags.every((v) => arr.includes(v)); - export const Catalog: FunctionComponent> = (props) => { - /** Set the tiles groups */ - const tilesGroups = useMemo(() => { - return Array.from(new Set(props.tiles.map((tile) => tile.type))); - }, [props.tiles]); - /** Selected Group */ - const [activeGroup, setActiveGroup] = useState(tilesGroups[0]); - const [searchTerm, setSearchTerm] = useState(''); - const [activeLayout, setActiveLayout] = useLocalStorage(LocalStorageKeys.CatalogLayout, CatalogLayout.Gallery); + const [searchTerm, setSearchTerm] = useDebounceValue('', 500, { trailing: true }); const [filterTags, setFilterTags] = useState([]); /** Filter by selected group */ - const tilesFromGroup = useMemo(() => { - return !activeGroup ? props.tiles : props.tiles.filter((tile) => activeGroup === tile.type); - }, [activeGroup, props.tiles]); + const filteredTilesByGroup = useMemo(() => { + return filterTiles(props.tiles, { searchTerm, searchTags: filterTags }); + }, [filterTags, props.tiles, searchTerm]); - const filteredTiles = useMemo(() => { - let toBeFiltered: ITile[] = []; - // filter by selected tags - toBeFiltered = filterTags - ? tilesFromGroup.filter((tile) => { - return checkThatArrayContainsAllTags(tile.tags, filterTags); - }) - : tilesFromGroup; - // filter by search term ( name, description, tag ) - toBeFiltered = searchTerm - ? toBeFiltered?.filter((tile) => { - return ( - tile.name.toLowerCase().includes(searchTerm.toLowerCase()) || - tile.title.toLowerCase().includes(searchTerm.toLowerCase()) || - tile.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - tile.tags.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase())) - ); - }) - : toBeFiltered; + /** Set the tiles groups */ + const tilesGroups = useMemo(() => { + return Object.entries(filteredTilesByGroup).map(([group, tiles]) => ({ name: group, count: tiles.length })); + }, [filteredTilesByGroup]); - return toBeFiltered; - }, [filterTags, searchTerm, tilesFromGroup]); + const [activeGroup, setActiveGroup] = useState(tilesGroups[0].name); + const [activeLayout, setActiveLayout] = useLocalStorage(LocalStorageKeys.CatalogLayout, CatalogLayout.Gallery); + const filteredTiles = useMemo(() => filteredTilesByGroup[activeGroup] ?? [], [activeGroup, filteredTilesByGroup]); - const onFilterChange = useCallback((_event: unknown, value = '') => { - setSearchTerm(value); - }, []); + const onFilterChange = useCallback( + (_event: unknown, value = '') => { + setSearchTerm(value); + }, + [setSearchTerm], + ); const onTileClick = useCallback( (tile: ITile) => { diff --git a/packages/ui/src/components/Catalog/CatalogFilter.tsx b/packages/ui/src/components/Catalog/CatalogFilter.tsx index 5a622ccc9..705473fc9 100644 --- a/packages/ui/src/components/Catalog/CatalogFilter.tsx +++ b/packages/ui/src/components/Catalog/CatalogFilter.tsx @@ -1,4 +1,5 @@ import { + Badge, Form, FormGroup, Grid, @@ -17,7 +18,7 @@ import { CatalogLayoutIcon } from './CatalogLayoutIcon'; interface CatalogFilterProps { className?: string; searchTerm: string; - groups: string[]; + groups: { name: string; count: number }[]; layouts: CatalogLayout[]; activeGroup: string; activeLayout: CatalogLayout; @@ -58,15 +59,19 @@ export const CatalogFilter: FunctionComponent = (props) => { - {props.groups.map((key) => ( + {props.groups.map((tileGroup) => ( + {capitalize(tileGroup.name)} {tileGroup.count} + + } + key={tileGroup.name} + data-testid={`${tileGroup.name}-catalog-tab`} + buttonId={`toggle-group-button-${tileGroup.name}`} + isSelected={props.activeGroup === tileGroup.name} onChange={() => { - props.setActiveGroup(key); + props.setActiveGroup(tileGroup.name); inputRef.current?.focus(); }} /> diff --git a/packages/ui/src/components/Catalog/filter-tiles.test.ts b/packages/ui/src/components/Catalog/filter-tiles.test.ts new file mode 100644 index 000000000..90a88462c --- /dev/null +++ b/packages/ui/src/components/Catalog/filter-tiles.test.ts @@ -0,0 +1,107 @@ +import { CatalogKind } from '../../models/catalog-kind'; +import { ITile } from './Catalog.models'; +import { filterTiles } from './filter-tiles'; + +describe('filterTiles', () => { + const tilesMap: Record = { + activemq: { + name: 'activemq', + title: 'ActiveMQ', + description: 'Send messages to (or consume from) Apache ActiveMQ.', + tags: ['messaging'], + type: CatalogKind.Component, + }, + cometd: { + name: 'cometd', + title: 'CometD', + description: + 'Offers publish/subscribe, peer-to-peer (via a server), and RPC style messaging using the CometD/Bayeux protocol.', + tags: ['networking', 'messaging'], + type: CatalogKind.Component, + }, + cron: { + name: 'cron', + title: 'Cron', + description: 'Schedule a task to run at a specific time.', + tags: ['scheduling'], + type: CatalogKind.Component, + }, + hazelcast: { + name: 'hazelcast', + title: 'Hazelcast', + description: 'Perform operations on Hazelcast distributed queue.', + tags: ['cache', 'clustering', 'messaging'], + type: CatalogKind.Component, + }, + setBody: { + name: 'setBody', + title: 'Set Body', + description: 'Set the message body.', + tags: ['eip', 'transformation'], + type: CatalogKind.Pattern, + }, + split: { + name: 'split', + title: 'Split', + description: 'Split a message into parts.', + tags: ['eip', 'routing'], + type: CatalogKind.Pattern, + }, + beerSource: { + name: 'beerSource', + title: 'Beer Source', + description: 'A source that emits beer.', + tags: ['beer', 'source'], + type: CatalogKind.Kamelet, + }, + slackSource: { + name: 'slackSource', + title: 'Slack Source', + description: 'A source that emits messages from Slack.', + tags: ['slack', 'source'], + type: CatalogKind.Kamelet, + }, + }; + const tiles = Object.values(tilesMap); + + it('should filter tiles by search term', () => { + const options = { searchTerm: 'message' }; + const result = filterTiles(tiles, options); + + expect(result).toEqual({ + [CatalogKind.Component]: [tilesMap.activemq], + [CatalogKind.Pattern]: [tilesMap.setBody, tilesMap.split], + [CatalogKind.Kamelet]: [tilesMap.slackSource], + }); + }); + + it('should filter tiles by a single tag', () => { + const options = { searchTags: ['messaging'] }; + const result = filterTiles(tiles, options); + expect(result).toEqual({ + [CatalogKind.Component]: [tilesMap.activemq, tilesMap.cometd, tilesMap.hazelcast], + [CatalogKind.Pattern]: [], + [CatalogKind.Kamelet]: [], + }); + }); + + it('should filter tiles by multiple tags', () => { + const options = { searchTags: ['messaging', 'clustering'] }; + const result = filterTiles(tiles, options); + expect(result).toEqual({ + [CatalogKind.Component]: [tilesMap.hazelcast], + [CatalogKind.Pattern]: [], + [CatalogKind.Kamelet]: [], + }); + }); + + it('should filter tiles by search term and tags', () => { + const options = { searchTerm: 'cr', searchTags: ['scheduling'] }; + const result = filterTiles(tiles, options); + expect(result).toEqual({ + [CatalogKind.Component]: [tilesMap.cron], + [CatalogKind.Pattern]: [], + [CatalogKind.Kamelet]: [], + }); + }); +}); diff --git a/packages/ui/src/components/Catalog/filter-tiles.ts b/packages/ui/src/components/Catalog/filter-tiles.ts new file mode 100644 index 000000000..351d6ca61 --- /dev/null +++ b/packages/ui/src/components/Catalog/filter-tiles.ts @@ -0,0 +1,36 @@ +import { ITile } from './Catalog.models'; + +const checkThatArrayContainsAllTags = (tileTags: string[], searchTags: string[]) => + searchTags.every((v) => tileTags.includes(v)); + +export const filterTiles = ( + tiles: ITile[], + options?: { searchTerm?: string; searchTags?: string[] }, +): Record => { + const { searchTerm = '', searchTags = [] } = options ?? {}; + const searchTermLowercase = searchTerm.toLowerCase(); + + return tiles.reduce( + (acc, tile) => { + /** Filter by selected tags */ + const doesTagsMatches = searchTags.length ? checkThatArrayContainsAllTags(tile.tags, searchTags) : true; + + /** Determine whether the tile should be included in the filtered list */ + const shouldInclude = + doesTagsMatches && + (!searchTermLowercase || + tile.name.toLowerCase().includes(searchTermLowercase) || + tile.title.toLowerCase().includes(searchTermLowercase) || + tile.description?.toLowerCase().includes(searchTermLowercase) || + tile.tags.some((tag) => tag.toLowerCase().includes(searchTermLowercase))); + + acc[tile.type] = acc[tile.type] ?? []; + if (shouldInclude) { + acc[tile.type].push(tile); + } + + return acc; + }, + {} as Record, + ); +}; diff --git a/yarn.lock b/yarn.lock index 04a964ff3..b51f92221 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2517,6 +2517,7 @@ __metadata: typescript: ^5.0.2 uniforms: 4.0.0-alpha.5 uniforms-bridge-json-schema: 4.0.0-alpha.5 + usehooks-ts: ^2.15.1 uuid: ^9.0.0 vite: ^4.4.5 vite-plugin-dts: ^3.5.1 @@ -18949,6 +18950,17 @@ __metadata: languageName: node linkType: hard +"usehooks-ts@npm:^2.15.1": + version: 2.15.1 + resolution: "usehooks-ts@npm:2.15.1" + dependencies: + lodash.debounce: ^4.0.8 + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + checksum: fe5f88780ec37cb9c00b5b456831e550e5e0d420031b88aab5e536cc42d927cfeaf39fa209d0829e45e1f8342446c149377ccbf11f0e922ef185b16986de7860 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2"