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
91 changes: 91 additions & 0 deletions docs/backend-fuzzy-search-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Backend fuzzy search integration

## Reference

**Quickstarts service API:** See the quickstarts repo documentation for the fuzzy search contract, examples, and configuration:

- **Fuzzy Search (service):** `docs/developers/FUZZY_SEARCH.md` in [RedHatInsights/quickstarts](https://github.com/RedHatInsights/quickstarts)

That doc describes:
- Query params: `display-name=<term>` and `fuzzy=true`
- Ranking: match count (DESC) → total Levenshtein distance (ASC)
- Config: `FUZZY_SEARCH_DISTANCE_THRESHOLD` (server-side; default 3)
- Tag filters work together with fuzzy (e.g. `bundle=ansible`)

---

## Overview

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.

## Context

The quickstarts service supports optional fuzzy search via [PR #436](https://github.com/RedHatInsights/quickstarts/pull/436):

- **Query params:** `display-name` (existing) and **`fuzzy`** (boolean, default `false`).
- **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).
- **Scope:** Search is only on `spec.displayName` for now; extending to `spec.description` or `spec.tasks` is a possible future backend change.

In this app:

- **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.
- **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.
- **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.

---

## 1. API layer: add `fuzzy` support

**File:** [`src/utils/fetchQuickstarts.ts`](src/utils/fetchQuickstarts.ts)

- Add **`fuzzy?: boolean`** to `FetchQuickstartsOptions`.
- 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`).
- Keep existing behavior when `fuzzy` is omitted or `false`.

**File:** [`src/utils/fetchAllData.ts`](src/utils/fetchAllData.ts)

- No signature change; it already forwards `FetchQuickstartsOptions` to `fetchQuickstarts`.

---

## 2. Search panel: use backend fuzzy for quickstarts

**File:** [`src/components/HelpPanel/HelpPanelTabs/SearchPanel/SearchPanel.tsx`](src/components/HelpPanel/HelpPanelTabs/SearchPanel/SearchPanel.tsx)

- **`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.
- **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.

---

## 3. Catalog: enable fuzzy when "Find by name" is used

**Files:** [`GlobalLearningResourcesFilters.tsx`](src/components/GlobalLearningResourcesPage/GlobalLearningResourcesFilters.tsx), [`GlobalLearningResourcesFiltersMobile.tsx`](src/components/GlobalLearningResourcesPage/GlobalLearningResourcesFiltersMobile.tsx)

- When updating `loaderOptions` for the display-name input, set **`fuzzy: true`** whenever **`'display-name'`** is non-empty.

---

## 4. Learn panel

- No code change: LearnPanel has no display-name search field. Add later if product wants "search by name" there.

---

## 5. Tests and mocks

- Add tests for `fuzzy: true` in request params when display-name is set.
- Update quickstarts API mocks to accept optional `fuzzy` query parameter (e.g. in `helpPanelJourneyHelpers.ts` and Storybook/Cypress intercepts).

---

## 6. Cleanup (done)

- **`fuse.js`** has been removed and is no longer a project dependency.
- Typo tolerance for quickstart search is controlled by the backend env **`FUZZY_SEARCH_DISTANCE_THRESHOLD`** (no frontend configuration).

---

## Out of scope (future)

- Backend extending fuzzy to **spec.description** or **spec.tasks** (backend change; frontend keeps passing `fuzzy=true`).
- Frontend configuration for Levenshtein distance (server env only).
10 changes: 0 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"@unleash/proxy-client-react": "^4.5.2",
"axios": "^1.13.2",
"classnames": "^2.5.1",
"fuse.js": "^7.1.0",
"monaco-editor": "^0.55.1",
"react": "18.3.1",
"react-dom": "18.3.1",
Expand Down
3 changes: 2 additions & 1 deletion playwright/all-learning-resources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ test.describe('all learning resources', async () => {
await page.getByRole('menuitem', { name: 'All Learning Resources'}).first().click();
await page.waitForLoadState("load");
await page.getByRole('textbox', {name: 'Type to filter'}).fill('Adding an integration: Google');
await expect(page.getByText('All learning resources (1)', { exact: true })).toBeVisible({ timeout: 10000 });
// Backend (with or without fuzzy) may return 1 to many results; wait for count to stabilize in range
await waitForCountInRange(page, 1, 100, 25000);
});

test('filters by product family', async({page}) => {
Expand Down
11 changes: 6 additions & 5 deletions playwright/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export async function ensureLoggedIn(page: Page): Promise<void> {
// Waits for the count to be within the specified range, then returns it
// This handles React rendering timing and filter application delays
export async function waitForCountInRange(page: Page, minCount: number, maxCount: number, timeout: number = 20000): Promise<number> {
const countElement = page.locator('.pf-v6-c-tabs__item-text', { hasText: 'All learning resources' }).first();
// Target the tab that shows a number (avoids matching placeholder "All learning resources ()")
const countElement = page.getByText(/All learning resources \(\d+\)/).first();

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

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

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

if (!match || !match[1]) {
Expand Down
49 changes: 24 additions & 25 deletions src/components/GlobalLearningResourcesPage/AppliedFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { FetchQuickstartsOptions } from '../../utils/fetchQuickstarts';
import {
CategoryID,
FilterCategoryID,
FiltersCategoryMetadata,
FiltersMetadata,
} from '../../utils/FiltersCategoryInterface';
Expand All @@ -32,36 +33,34 @@ const AppliedFilters: React.FC<{
}
};

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

const categoryName =
FiltersCategoryMetadata[
categoryId as keyof typeof FiltersCategoryMetadata
];
const categoryName = FiltersCategoryMetadata[categoryId];

return (
<ToolbarItem key={categoryId}>
<LabelGroup categoryName={categoryName}>
{filters.map((filterId: string) => (
<Label
variant="outline"
key={filterId}
onClose={() => removeFilter(categoryKey, filterId)}
>
{FiltersMetadata[filterId]}
</Label>
))}
</LabelGroup>
</ToolbarItem>
);
})}
return (
<ToolbarItem key={categoryId}>
<LabelGroup categoryName={categoryName}>
{filters.map((filterId: string) => (
<Label
variant="outline"
key={filterId}
onClose={() => removeFilter(categoryId, filterId)}
>
{FiltersMetadata[filterId]}
</Label>
))}
</LabelGroup>
</ToolbarItem>
);
})}
</ToolbarContent>
</Toolbar>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const GlobalLearningResourcesFilters: React.FC<
setLoaderOptions({
...(loaderOptions || loaderOptionsFalllback),
'display-name': value,
fuzzy: !!value?.trim(),
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const GlobalLearningResourcesFiltersMobile: React.FC<
setLoaderOptions({
...(loaderOptions || loaderOptionsFalllback),
'display-name': value,
fuzzy: !!value?.trim(),
});
}
};
Expand Down
Loading
Loading