Skip to content

Commit 10e41c2

Browse files
authored
Merge pull request RedHatInsights#268 from aferd/RHCLOUD-45384
feat(search): use quickstarts backend fuzzy search in help panel and
2 parents da42681 + 42fb1cc commit 10e41c2

13 files changed

Lines changed: 297 additions & 109 deletions
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Backend fuzzy search integration
2+
3+
## Reference
4+
5+
**Quickstarts service API:** See the quickstarts repo documentation for the fuzzy search contract, examples, and configuration:
6+
7+
- **Fuzzy Search (service):** `docs/developers/FUZZY_SEARCH.md` in [RedHatInsights/quickstarts](https://github.com/RedHatInsights/quickstarts)
8+
9+
That doc describes:
10+
- Query params: `display-name=<term>` and `fuzzy=true`
11+
- Ranking: match count (DESC) → total Levenshtein distance (ASC)
12+
- Config: `FUZZY_SEARCH_DISTANCE_THRESHOLD` (server-side; default 3)
13+
- Tag filters work together with fuzzy (e.g. `bundle=ansible`)
14+
15+
---
16+
17+
## Overview
18+
19+
Integrate the quickstarts service fuzzy search (Levenshtein, `fuzzy` query param from PR #436) so the help panel Search panel and the catalog use server-side fuzzy matching on `spec.displayName` instead of or in addition to client-side behavior. Replace the Search panel's Fuse.js-based quickstart search with the backend API; enable fuzzy for the catalog "Find by name" filter.
20+
21+
## Context
22+
23+
The quickstarts service supports optional fuzzy search via [PR #436](https://github.com/RedHatInsights/quickstarts/pull/436):
24+
25+
- **Query params:** `display-name` (existing) and **`fuzzy`** (boolean, default `false`).
26+
- **Behavior:** When `fuzzy=true`, backend uses Levenshtein distance on **spec.displayName** (word-by-word), with typo tolerance configurable via `FUZZY_SEARCH_DISTANCE_THRESHOLD` (server-side env).
27+
- **Scope:** Search is only on `spec.displayName` for now; extending to `spec.description` or `spec.tasks` is a possible future backend change.
28+
29+
In this app:
30+
31+
- **Search panel** ([`SearchPanel.tsx`](src/components/HelpPanel/HelpPanelTabs/SearchPanel/SearchPanel.tsx)): Previously fetched all quickstarts via `fetchAllData(getUser, {})` and ran **Fuse.js** over quickstarts + services + API docs. Fuse.js has been removed and replaced by the backend fuzzy search: the search path now calls `fetchAllData(getUser, { 'display-name': query.trim(), fuzzy: true })` for quickstarts and filters services and API docs client-side.
32+
- **Learn panel** ([`LearnPanel.tsx`](src/components/HelpPanel/HelpPanelTabs/LearnPanel.tsx)): No free-text search today (only bundle, content type, bookmarks). No change unless we add a "search by name" input later.
33+
- **Catalog** ([`GlobalLearningResourcesPage`](src/components/GlobalLearningResourcesPage/GlobalLearningResourcesPage.tsx), filters): Uses `loaderOptions['display-name']` when the user types in "Find by name". Pass `fuzzy: true` when a display-name filter is present so catalog search is typo-tolerant.
34+
35+
---
36+
37+
## 1. API layer: add `fuzzy` support
38+
39+
**File:** [`src/utils/fetchQuickstarts.ts`](src/utils/fetchQuickstarts.ts)
40+
41+
- Add **`fuzzy?: boolean`** to `FetchQuickstartsOptions`.
42+
- In the `axios.get` params, when `fuzzy === true` and there is a non-empty `display-name`, pass **`fuzzy: true`** in the request (query param name from the API is `fuzzy`).
43+
- Keep existing behavior when `fuzzy` is omitted or `false`.
44+
45+
**File:** [`src/utils/fetchAllData.ts`](src/utils/fetchAllData.ts)
46+
47+
- No signature change; it already forwards `FetchQuickstartsOptions` to `fetchQuickstarts`.
48+
49+
---
50+
51+
## 2. Search panel: use backend fuzzy for quickstarts
52+
53+
**File:** [`src/components/HelpPanel/HelpPanelTabs/SearchPanel/SearchPanel.tsx`](src/components/HelpPanel/HelpPanelTabs/SearchPanel/SearchPanel.tsx)
54+
55+
- **`performSearch(query)`:** Call `fetchAllData(chrome.auth.getUser, { 'display-name': query.trim(), fuzzy: true })` for quickstarts; fetch bundles and bundleInfo for services and API docs. Build quickstart SearchResults from returned quickstarts. Filter services and API docs client-side by query. Combine: quickstarts first (backend order), then filtered services, then filtered API docs.
56+
- **No Fuse.js:** Fuse.js is not used. Services and API docs are filtered with simple substring (case-insensitive) matching; there is no global Fuse index and no `fuse.js` dependency in the codebase.
57+
58+
---
59+
60+
## 3. Catalog: enable fuzzy when "Find by name" is used
61+
62+
**Files:** [`GlobalLearningResourcesFilters.tsx`](src/components/GlobalLearningResourcesPage/GlobalLearningResourcesFilters.tsx), [`GlobalLearningResourcesFiltersMobile.tsx`](src/components/GlobalLearningResourcesPage/GlobalLearningResourcesFiltersMobile.tsx)
63+
64+
- When updating `loaderOptions` for the display-name input, set **`fuzzy: true`** whenever **`'display-name'`** is non-empty.
65+
66+
---
67+
68+
## 4. Learn panel
69+
70+
- No code change: LearnPanel has no display-name search field. Add later if product wants "search by name" there.
71+
72+
---
73+
74+
## 5. Tests and mocks
75+
76+
- Add tests for `fuzzy: true` in request params when display-name is set.
77+
- Update quickstarts API mocks to accept optional `fuzzy` query parameter (e.g. in `helpPanelJourneyHelpers.ts` and Storybook/Cypress intercepts).
78+
79+
---
80+
81+
## 6. Cleanup (done)
82+
83+
- **`fuse.js`** has been removed and is no longer a project dependency.
84+
- Typo tolerance for quickstart search is controlled by the backend env **`FUZZY_SEARCH_DISTANCE_THRESHOLD`** (no frontend configuration).
85+
86+
---
87+
88+
## Out of scope (future)
89+
90+
- Backend extending fuzzy to **spec.description** or **spec.tasks** (backend change; frontend keeps passing `fuzzy=true`).
91+
- Frontend configuration for Levenshtein distance (server env only).

package-lock.json

Lines changed: 0 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
"@unleash/proxy-client-react": "^4.5.2",
4242
"axios": "^1.13.2",
4343
"classnames": "^2.5.1",
44-
"fuse.js": "^7.1.0",
4544
"monaco-editor": "^0.55.1",
4645
"react": "18.3.1",
4746
"react-dom": "18.3.1",

playwright/all-learning-resources.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ test.describe('all learning resources', async () => {
4848
await page.getByRole('menuitem', { name: 'All Learning Resources'}).first().click();
4949
await page.waitForLoadState("load");
5050
await page.getByRole('textbox', {name: 'Type to filter'}).fill('Adding an integration: Google');
51-
await expect(page.getByText('All learning resources (1)', { exact: true })).toBeVisible({ timeout: 10000 });
51+
// Backend (with or without fuzzy) may return 1 to many results; wait for count to stabilize in range
52+
await waitForCountInRange(page, 1, 100, 25000);
5253
});
5354

5455
test('filters by product family', async({page}) => {

playwright/test-utils.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ export async function ensureLoggedIn(page: Page): Promise<void> {
6161
// Waits for the count to be within the specified range, then returns it
6262
// This handles React rendering timing and filter application delays
6363
export async function waitForCountInRange(page: Page, minCount: number, maxCount: number, timeout: number = 20000): Promise<number> {
64-
const countElement = page.locator('.pf-v6-c-tabs__item-text', { hasText: 'All learning resources' }).first();
64+
// Target the tab that shows a number (avoids matching placeholder "All learning resources ()")
65+
const countElement = page.getByText(/All learning resources \(\d+\)/).first();
6566

6667
// Wait for element to exist
6768
await countElement.waitFor({ state: 'attached', timeout });
@@ -95,12 +96,12 @@ export async function waitForCountInRange(page: Page, minCount: number, maxCount
9596
// Extracts the count from "All learning resources (N)" text
9697
// Use waitForCountInRange if you need to wait for a specific range after filtering
9798
export async function extractResourceCount(page: Page): Promise<number> {
98-
const countElement = page.locator('.pf-v6-c-tabs__item-text', { hasText: 'All learning resources' }).first();
99+
// Target the tab that already shows a number (avoids matching placeholder "All learning resources ()")
100+
const countElement = page.getByText(/All learning resources \(\d+\)/);
99101

100-
// Wait for element with valid count text
101-
await expect(countElement).toHaveText(/All learning resources \(\d+\)/, { timeout: 20000 });
102+
await expect(countElement).toBeAttached({ timeout: 20000 });
102103

103-
const countText = await countElement.textContent();
104+
const countText = await countElement.first().textContent();
104105
const match = countText?.match(/All learning resources \((\d+)\)/);
105106

106107
if (!match || !match[1]) {

src/components/GlobalLearningResourcesPage/AppliedFilters.tsx

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { FetchQuickstartsOptions } from '../../utils/fetchQuickstarts';
1111
import {
1212
CategoryID,
13+
FilterCategoryID,
1314
FiltersCategoryMetadata,
1415
FiltersMetadata,
1516
} from '../../utils/FiltersCategoryInterface';
@@ -32,36 +33,34 @@ const AppliedFilters: React.FC<{
3233
}
3334
};
3435

35-
// Render applied filters dynamically
36+
// Render applied filters dynamically (exclude 'fuzzy' — it is boolean, not an array of filter chips)
3637
return (
3738
<Toolbar className="pf-v6-u-mt-md">
3839
<ToolbarContent>
39-
{Object.keys(loaderOptions).map((categoryId) => {
40-
const categoryKey = categoryId as CategoryID;
41-
const filters = loaderOptions[categoryKey];
42-
if (!Array.isArray(filters) || filters.length === 0) return null;
40+
{(Object.keys(loaderOptions) as CategoryID[])
41+
.filter((key): key is FilterCategoryID => key !== 'fuzzy')
42+
.map((categoryId) => {
43+
const filters = loaderOptions[categoryId];
44+
if (!Array.isArray(filters) || filters.length === 0) return null;
4345

44-
const categoryName =
45-
FiltersCategoryMetadata[
46-
categoryId as keyof typeof FiltersCategoryMetadata
47-
];
46+
const categoryName = FiltersCategoryMetadata[categoryId];
4847

49-
return (
50-
<ToolbarItem key={categoryId}>
51-
<LabelGroup categoryName={categoryName}>
52-
{filters.map((filterId: string) => (
53-
<Label
54-
variant="outline"
55-
key={filterId}
56-
onClose={() => removeFilter(categoryKey, filterId)}
57-
>
58-
{FiltersMetadata[filterId]}
59-
</Label>
60-
))}
61-
</LabelGroup>
62-
</ToolbarItem>
63-
);
64-
})}
48+
return (
49+
<ToolbarItem key={categoryId}>
50+
<LabelGroup categoryName={categoryName}>
51+
{filters.map((filterId: string) => (
52+
<Label
53+
variant="outline"
54+
key={filterId}
55+
onClose={() => removeFilter(categoryId, filterId)}
56+
>
57+
{FiltersMetadata[filterId]}
58+
</Label>
59+
))}
60+
</LabelGroup>
61+
</ToolbarItem>
62+
);
63+
})}
6564
</ToolbarContent>
6665
</Toolbar>
6766
);

src/components/GlobalLearningResourcesPage/GlobalLearningResourcesFilters.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const GlobalLearningResourcesFilters: React.FC<
5454
setLoaderOptions({
5555
...(loaderOptions || loaderOptionsFalllback),
5656
'display-name': value,
57+
fuzzy: !!value?.trim(),
5758
});
5859
};
5960

src/components/GlobalLearningResourcesPage/GlobalLearningResourcesFiltersMobile.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const GlobalLearningResourcesFiltersMobile: React.FC<
6666
setLoaderOptions({
6767
...(loaderOptions || loaderOptionsFalllback),
6868
'display-name': value,
69+
fuzzy: !!value?.trim(),
6970
});
7071
}
7172
};

0 commit comments

Comments
 (0)