Skip to content

Commit

Permalink
feat(catalog): Add tile counters
Browse files Browse the repository at this point in the history
Currently, when looking for a `saga` component, we see a `Saga` component, but there's also a `Saga` pattern and there's no visual feedback that it exists, until we click on the `Pattern` toggle.

This commit adds counters to each toggle, so we can monitor the
occurrences in other groups as well.

In addition to that, the lookup is debounced until the user stop
writing, in a 500ms window. This helps when writing the first letters of
the search term, it doesn't get stuck until the UI renders.

The e2e tests for the catalog were updated to include a check after
resetting the search field.

fix: #745
  • Loading branch information
lordrip committed Feb 27, 2024
1 parent 3b96387 commit 1c01516
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 45 deletions.
4 changes: 4 additions & 0 deletions packages/ui-tests/cypress/e2e/catalog.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 19 additions & 37 deletions packages/ui/src/components/Catalog/Catalog.tsx
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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<PropsWithChildren<CatalogProps>> = (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<string>(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<string[]>([]);

/** 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<string>(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) => {
Expand Down
21 changes: 13 additions & 8 deletions packages/ui/src/components/Catalog/CatalogFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Badge,
Form,
FormGroup,
Grid,
Expand All @@ -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;
Expand Down Expand Up @@ -58,15 +59,19 @@ export const CatalogFilter: FunctionComponent<CatalogFilterProps> = (props) => {
<GridItem className="pf-v5-u-text-align-right" md={5}>
<FormGroup label="Type" fieldId="element-type">
<ToggleGroup aria-label="Select element type">
{props.groups.map((key) => (
{props.groups.map((tileGroup) => (
<ToggleGroupItem
text={capitalize(key)}
key={key}
data-testid={`${key}-catalog-tab`}
buttonId={`toggle-group-button-${key}`}
isSelected={props.activeGroup === key}
text={
<>
<span>{capitalize(tileGroup.name)}</span> <Badge isRead>{tileGroup.count}</Badge>
</>
}
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();
}}
/>
Expand Down
107 changes: 107 additions & 0 deletions packages/ui/src/components/Catalog/filter-tiles.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ITile> = {
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]: [],
});
});
});
36 changes: 36 additions & 0 deletions packages/ui/src/components/Catalog/filter-tiles.ts
Original file line number Diff line number Diff line change
@@ -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<string, ITile[]> => {
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<ITile['type'], ITile[]>,
);
};
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 1c01516

Please sign in to comment.