diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json
index eec759e51c3..40485586337 100644
--- a/client/i18n/locales/english/translations.json
+++ b/client/i18n/locales/english/translations.json
@@ -668,7 +668,8 @@
"see-results": "See all results for {{searchQuery}}",
"no-tutorials": "No tutorials found",
"try": "Looking for something? Try the search bar on this page.",
- "no-results": "We could not find anything relating to <0>{{query}}0>"
+ "no-results": "We could not find anything relating to <0>{{query}}0>",
+ "result-list": "Search results"
},
"misc": {
"offline": "You appear to be offline, your progress may not be saved",
diff --git a/client/src/components/search/searchBar/search-hits.tsx b/client/src/components/search/searchBar/search-hits.tsx
index 3ed927d6db9..da2413206ab 100644
--- a/client/src/components/search/searchBar/search-hits.tsx
+++ b/client/src/components/search/searchBar/search-hits.tsx
@@ -63,7 +63,11 @@ const CustomHits = connectHits(
return (
-
+
{allHits.map((hit: Hit, i: number) => (
- Article 1",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 2",
+ "objectID": "Article 2",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 2",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 3",
+ "objectID": "Article 3",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 3",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 4",
+ "objectID": "Article 4",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 4",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 5",
+ "objectID": "Article 5",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 5",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 6",
+ "objectID": "Article 6",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 6",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 7",
+ "objectID": "Article 7",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 7",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 8",
+ "objectID": "Article 8",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 8",
+ "matchLevel": "full"
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/e2e/fixtures/algolia-five-hits.json b/e2e/fixtures/algolia-five-hits.json
new file mode 100644
index 00000000000..ef3f7ea5d5f
--- /dev/null
+++ b/e2e/fixtures/algolia-five-hits.json
@@ -0,0 +1,58 @@
+{
+ "results": [
+ {
+ "hits": [
+ {
+ "title": "Article 1",
+ "objectID": "Article 1",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 1",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 2",
+ "objectID": "Article 2",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 2",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 3",
+ "objectID": "Article 3",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 3",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 4",
+ "objectID": "Article 4",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 4",
+ "matchLevel": "full"
+ }
+ }
+ },
+ {
+ "title": "Article 5",
+ "objectID": "Article 5",
+ "_highlightResult": {
+ "title": {
+ "value": "Article 5",
+ "matchLevel": "full"
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/e2e/search-bar.spec.ts b/e2e/search-bar.spec.ts
new file mode 100644
index 00000000000..6370a03bfc2
--- /dev/null
+++ b/e2e/search-bar.spec.ts
@@ -0,0 +1,236 @@
+import { test, expect, type Page } from '@playwright/test';
+import translations from '../client/i18n/locales/english/translations.json';
+import algoliaEightHits from './fixtures/algolia-eight-hits.json';
+import algoliaFiveHits from './fixtures/algolia-five-hits.json';
+
+const haveApiKeys =
+ process.env.ALGOLIA_APP_ID !== 'app_id_from_algolia_dashboard' &&
+ process.env.ALGOLIA_API_KEY !== 'api_key_from_algolia_dashboard';
+
+const getSearchInput = async ({
+ page,
+ isMobile
+}: {
+ page: Page;
+ isMobile: boolean;
+}) => {
+ if (isMobile) {
+ const menuButton = page.getByRole('button', {
+ name: translations.buttons.menu
+ });
+ await expect(menuButton).toBeVisible();
+ await menuButton.click();
+ }
+
+ return page.getByLabel('Search');
+};
+
+const search = async ({
+ page,
+ isMobile,
+ query
+}: {
+ page: Page;
+ isMobile: boolean;
+ query: string;
+}) => {
+ const searchInput = await getSearchInput({ page, isMobile });
+ await searchInput.fill(query);
+};
+
+const mockAlgolia = async ({
+ page,
+ hitsPerPage
+}: {
+ page: Page;
+ hitsPerPage: number;
+}) => {
+ if (hitsPerPage === 8) {
+ await page.route(/\w+(\.algolia\.net|\.algolianet\.com)/, async route => {
+ await route.fulfill({ json: algoliaEightHits });
+ });
+ } else if (hitsPerPage === 5) {
+ await page.route(/\w+(\.algolia\.net|\.algolianet\.com)/, async route => {
+ await route.fulfill({ json: algoliaFiveHits });
+ });
+ } else if (hitsPerPage === 0) {
+ await page.route(/\w+(\.algolia\.net|\.algolianet\.com)/, async route => {
+ await route.fulfill({ json: {} });
+ });
+ }
+};
+
+test.describe('Search bar', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/learn');
+
+ // Mock Algolia requests to prevent hitting Algolia server unnecessarily.
+ // Comment out this line if you want to test against the real server.
+ await mockAlgolia({ page, hitsPerPage: 8 });
+ });
+
+ test('should display correctly', async ({ page, isMobile }) => {
+ const searchInput = await getSearchInput({ page, isMobile });
+
+ await expect(searchInput).toBeVisible();
+ await expect(searchInput).toHaveAttribute(
+ 'placeholder',
+ translations.search.placeholder
+ );
+ await expect(
+ page.getByRole('button', { name: 'Submit search terms' })
+ ).toBeVisible();
+ });
+
+ test('should return the search results when the user presses Enter', async ({
+ page,
+ isMobile
+ }) => {
+ test.skip(!haveApiKeys, 'This test requires Algolia API keys');
+
+ await search({ page, isMobile, query: 'article' });
+
+ // Wait for the search results to show up
+ const resultList = page.getByRole('list', { name: 'Search results' });
+ // Initially, the dropdown contains an `li` with the text "No tutorials found",
+ // so we need to check the text content to ensure the correct `li` is displayed.
+ await expect(resultList.getByRole('listitem').first()).toContainText(
+ /article/i
+ );
+
+ await page.keyboard.press('Enter');
+
+ await page.waitForURL(
+ 'https://www.freecodecamp.org/news/search/?query=article'
+ );
+ const title = await page.title();
+ expect(title).toBe('Search - freeCodeCamp.org');
+ });
+
+ test('should return the search results when the user clicks the search button', async ({
+ page,
+ isMobile
+ }) => {
+ test.skip(!haveApiKeys, 'This test requires Algolia API keys');
+
+ await search({ page, isMobile, query: 'article' });
+
+ // Wait for the search results to show up
+ const resultList = page.getByRole('list', { name: 'Search results' });
+ // Initially, the dropdown contains an `li` with the text "No tutorials found",
+ // so we need to check the text content to ensure the correct `li` is displayed.
+ await expect(resultList.getByRole('listitem').first()).toContainText(
+ /article/i
+ );
+
+ await page.getByRole('button', { name: 'Submit search terms' }).click();
+
+ await page.waitForURL(
+ 'https://www.freecodecamp.org/news/search/?query=article'
+ );
+ const title = await page.title();
+ expect(title).toBe('Search - freeCodeCamp.org');
+ });
+
+ test('should show an empty result list if no results found', async ({
+ page,
+ isMobile
+ }) => {
+ await mockAlgolia({ page, hitsPerPage: 0 });
+ await search({ page, isMobile, query: '!@#$%^' });
+
+ const resultList = page.getByRole('list', { name: 'Search results' });
+ await expect(resultList.getByRole('listitem')).toHaveCount(1);
+ await expect(resultList.getByRole('listitem')).toHaveText(
+ 'No tutorials found'
+ );
+ });
+
+ test('should clear the input and hide the result dropdown when the user clicks the clear button', async ({
+ page,
+ isMobile
+ }) => {
+ const searchInput = await getSearchInput({ page, isMobile });
+ await expect(searchInput).toBeVisible();
+
+ await searchInput.fill('test');
+ await page.getByRole('button', { name: 'Clear search terms' }).click();
+
+ await expect(searchInput).toHaveValue('');
+ await expect(
+ page.getByRole('list', { name: 'Search results' })
+ ).toBeHidden();
+ });
+});
+
+test.describe('Search results when viewport height is greater than 768px', () => {
+ test.use({
+ viewport: { width: 1600, height: 1200 }
+ });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/learn');
+
+ // Mock Algolia requests to prevent hitting Algolia server unnecessarily.
+ // Comment out this line if you want to test against the real server.
+ await mockAlgolia({ page, hitsPerPage: 8 });
+ });
+
+ test('should display 8 items', async ({ page, isMobile }) => {
+ test.skip(!haveApiKeys, 'This test requires Algolia API keys');
+
+ await search({ page, isMobile, query: 'article' });
+
+ // Wait for the search results to show up
+ const results = page.getByRole('list', { name: 'Search results' });
+ await expect(results.getByRole('listitem')).toHaveCount(9); // 8 results + the footer
+ });
+});
+
+test.describe('Search results when viewport height is equal to 768px', () => {
+ test.use({
+ viewport: { width: 1600, height: 768 }
+ });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/learn');
+
+ // Mock Algolia requests to prevent hitting Algolia server unnecessarily.
+ // Comment out this line if you want to test against the real server.
+ await mockAlgolia({ page, hitsPerPage: 8 });
+ });
+
+ test('should display 8 items', async ({ page, isMobile }) => {
+ test.skip(!haveApiKeys, 'This test requires Algolia API keys');
+
+ await search({ page, isMobile, query: 'article' });
+
+ // Wait for the search results to show up
+ const results = page.getByRole('list', { name: 'Search results' });
+ await expect(results.getByRole('listitem')).toHaveCount(9); // 8 results + the footer
+ });
+});
+
+test.describe('Search results when viewport height is less than 768px', () => {
+ test.use({
+ viewport: { width: 1600, height: 500 }
+ });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/learn');
+
+ // Mock Algolia requests to prevent hitting Algolia server unnecessarily.
+ // Comment out this line if you want to test against the real server.
+ await mockAlgolia({ page, hitsPerPage: 5 });
+ });
+
+ test('should display 5 items', async ({ page, isMobile }) => {
+ test.skip(!haveApiKeys, 'This test requires Algolia API keys');
+
+ await search({ page, isMobile, query: 'article' });
+
+ // Wait for the search results to show up
+ const results = page.getByRole('list', { name: 'Search results' });
+ await expect(results.getByRole('listitem')).toHaveCount(6); // 5 results + the footer
+ });
+});