From f36223ff657e5b6531c9cf46111c4e18add6ff8b Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 10:15:22 +0200 Subject: [PATCH 01/16] feat(search): add frontend-private popular searches API endpoint --- .../Api/PopularSearchesController.php | 57 ++++++++++++ .../PopularSearchesControllerDirectTest.php | 92 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PopularSearchesController.php create mode 100644 tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerDirectTest.php diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PopularSearchesController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PopularSearchesController.php new file mode 100644 index 0000000000..108d4edb44 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PopularSearchesController.php @@ -0,0 +1,57 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Controller\Frontend\Api; + +use phpMyFAQ\Controller\AbstractController; +use phpMyFAQ\Search; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +final class PopularSearchesController extends AbstractController +{ + public function __construct( + private readonly Search $faqSearch, + ) { + parent::__construct(); + } + + /** + * @throws \Exception + */ + #[Route(path: 'searches/popular', name: 'api.private.searches.popular', methods: ['GET'])] + public function popular(): JsonResponse + { + $numberOfResults = (int) $this->configuration->get('search.numberSearchTerms'); + if ($numberOfResults <= 0) { + $numberOfResults = 7; + } + + // Aggregate across all languages (no withLang), consistent with the existing + // frontend "most popular searches" display in SearchController/search.twig. + $result = $this->faqSearch->getMostPopularSearches($numberOfResults); + + if ($result === []) { + return $this->json([], Response::HTTP_NOT_FOUND); + } + + return $this->json($result, Response::HTTP_OK); + } +} diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerDirectTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerDirectTest.php new file mode 100644 index 0000000000..95f22b1e09 --- /dev/null +++ b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerDirectTest.php @@ -0,0 +1,92 @@ +createMock(Search::class); + $search + ->expects($this->once()) + ->method('getMostPopularSearches') + ->willReturn([['id' => 1, 'searchterm' => 'mac', 'number' => '18']]); + + $controller = new PopularSearchesController($search); + $currentUser = $this->createAuthenticatedUserMock(); + $this->injectControllerState($controller, $currentUser, $this->createSession()); + + $response = $controller->popular(); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('mac', $payload[0]['searchterm']); + self::assertSame('18', $payload[0]['number']); + } + + public function testPopularReturnsNotFoundWhenEmpty(): void + { + $search = $this->createMock(Search::class); + $search->expects($this->once())->method('getMostPopularSearches')->willReturn([]); + + $controller = new PopularSearchesController($search); + $currentUser = $this->createAuthenticatedUserMock(); + $this->injectControllerState($controller, $currentUser, $this->createSession()); + + $response = $controller->popular(); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + self::assertSame('[]', (string) $response->getContent()); + } + + public function testPopularPassesConfiguredCountToSearch(): void + { + $search = $this->createMock(Search::class); + $search + ->expects($this->once()) + ->method('getMostPopularSearches') + ->with(5) + ->willReturn([['id' => 1, 'searchterm' => 'php', 'number' => '3']]); + + $controller = new PopularSearchesController($search); + $this->injectControllerState($controller, $this->createAuthenticatedUserMock(), $this->createSession()); + + // Override after controller construction: the AbstractController constructor reads + // template settings via getAll(), which reloads the whole config from the database. + $this->overrideConfigurationValues(['search.numberSearchTerms' => '5']); + + $response = $controller->popular(); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testPopularFallsBackToSevenWhenConfiguredCountIsZero(): void + { + $search = $this->createMock(Search::class); + $search + ->expects($this->once()) + ->method('getMostPopularSearches') + ->with(7) + ->willReturn([['id' => 1, 'searchterm' => 'php', 'number' => '3']]); + + $controller = new PopularSearchesController($search); + $this->injectControllerState($controller, $this->createAuthenticatedUserMock(), $this->createSession()); + + // Override after controller construction: the AbstractController constructor reads + // template settings via getAll(), which reloads the whole config from the database. + $this->overrideConfigurationValues(['search.numberSearchTerms' => '0']); + + $response = $controller->popular(); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } +} From 94d77c866f3542e6e03fd1a4ad5213d1282bb44a Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 10:35:57 +0200 Subject: [PATCH 02/16] feat(search): add translation keys for search experience labels --- phpmyfaq/translations/language_de.php | 4 ++++ phpmyfaq/translations/language_en.php | 3 +++ 2 files changed, 7 insertions(+) diff --git a/phpmyfaq/translations/language_de.php b/phpmyfaq/translations/language_de.php index 6ba3bae889..375054776f 100755 --- a/phpmyfaq/translations/language_de.php +++ b/phpmyfaq/translations/language_de.php @@ -807,6 +807,10 @@ // added 2.5.0-alpha2 - 2009-01-24 by Thorsten $PMF_LANG['msgMostPopularSearches'] = "Beliebte Suchbegriffe"; +$PMF_LANG['msgRecentSearches'] = 'Letzte Suchanfragen'; +$PMF_LANG['msgPopularSearches'] = 'Beliebte Suchanfragen'; +$PMF_LANG['msgNoSearchResults'] = 'Keine Ergebnisse gefunden'; +$PMF_LANG['msgAskQuestionInstead'] = 'Stattdessen eine Frage stellen'; $LANG_CONF['main.enableWysiwygEditor'] = ['checkbox', "Aktivierung des WYSIWYG Editors"]; // added 2.5.0-beta - 2009-03-30 by Anatoliy diff --git a/phpmyfaq/translations/language_en.php b/phpmyfaq/translations/language_en.php index 4ba620affc..d82c68d6c9 100644 --- a/phpmyfaq/translations/language_en.php +++ b/phpmyfaq/translations/language_en.php @@ -804,6 +804,9 @@ // added 2.5.0-alpha2 - 2009-01-24 by Thorsten $PMF_LANG['msgMostPopularSearches'] = 'Most popular searches'; +$PMF_LANG['msgRecentSearches'] = 'Recent searches'; +$PMF_LANG['msgNoSearchResults'] = 'No results found'; +$PMF_LANG['msgAskQuestionInstead'] = 'Ask a question instead'; $LANG_CONF['main.enableWysiwygEditor'] = ["checkbox", "Enable bundled WYSIWYG editor"]; // added 2.5.0-beta - 2009-03-30 by Anatoliy From 5aecbcbe025f1ded970c5a3a7de177a2b009f311 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 10:45:12 +0200 Subject: [PATCH 03/16] feat(search): extend Suggestion type with item kind and metadata --- phpmyfaq/assets/src/interfaces/suggestionItem.ts | 9 +++++++-- phpmyfaq/assets/src/search/autocomplete.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/phpmyfaq/assets/src/interfaces/suggestionItem.ts b/phpmyfaq/assets/src/interfaces/suggestionItem.ts index 7aaf06a6f5..1113f20075 100644 --- a/phpmyfaq/assets/src/interfaces/suggestionItem.ts +++ b/phpmyfaq/assets/src/interfaces/suggestionItem.ts @@ -1,9 +1,14 @@ import { AutocompleteItem } from 'autocompleter'; +export type SuggestionType = 'result' | 'recent' | 'popular' | 'empty'; + export interface SuggestionItem { - question: string; - category: string; + type?: SuggestionType; url: string; + question?: string; + category?: string; + searchTerm?: string; + count?: number; } export type Suggestion = SuggestionItem & AutocompleteItem; diff --git a/phpmyfaq/assets/src/search/autocomplete.test.ts b/phpmyfaq/assets/src/search/autocomplete.test.ts index 3b4a3c8644..29ae197673 100644 --- a/phpmyfaq/assets/src/search/autocomplete.test.ts +++ b/phpmyfaq/assets/src/search/autocomplete.test.ts @@ -21,7 +21,7 @@ vi.mock('../utils', () => ({ import { handleAutoComplete } from './autocomplete'; import { fetchAutoCompleteData } from '../api'; -import { Suggestion } from '../interfaces'; +import { AutocompleteSearchResponse, Suggestion } from '../interfaces'; import autocomplete from 'autocompleter'; const mockAutocomplete = vi.mocked(autocomplete); @@ -74,7 +74,7 @@ describe('handleAutoComplete', () => { { question: 'How to install?', category: 'Setup', url: '/faq/1' } as Suggestion, { question: 'How to configure?', category: 'Config', url: '/faq/2' } as Suggestion, ]; - vi.mocked(fetchAutoCompleteData).mockResolvedValue(mockResults); + vi.mocked(fetchAutoCompleteData).mockResolvedValue(mockResults as AutocompleteSearchResponse); handleAutoComplete(); From e89f65980fee4cb6e3ff6910a8c65082bac17375 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 10:53:11 +0200 Subject: [PATCH 04/16] feat(search): add popular searches API client --- phpmyfaq/assets/src/api/index.ts | 1 + .../assets/src/api/popularSearches.test.ts | 44 +++++++++++++++++++ phpmyfaq/assets/src/api/popularSearches.ts | 38 ++++++++++++++++ .../assets/src/interfaces/PopularSearch.ts | 7 +++ phpmyfaq/assets/src/interfaces/index.ts | 1 + 5 files changed, 91 insertions(+) create mode 100644 phpmyfaq/assets/src/api/popularSearches.test.ts create mode 100644 phpmyfaq/assets/src/api/popularSearches.ts create mode 100644 phpmyfaq/assets/src/interfaces/PopularSearch.ts diff --git a/phpmyfaq/assets/src/api/index.ts b/phpmyfaq/assets/src/api/index.ts index a69a648fec..70a44ab5b7 100644 --- a/phpmyfaq/assets/src/api/index.ts +++ b/phpmyfaq/assets/src/api/index.ts @@ -3,6 +3,7 @@ export * from './bookmarks'; export * from './comment'; export * from './contact'; export * from './faq'; +export * from './popularSearches'; export * from './question'; export * from './register'; export * from './share'; diff --git a/phpmyfaq/assets/src/api/popularSearches.test.ts b/phpmyfaq/assets/src/api/popularSearches.test.ts new file mode 100644 index 0000000000..ae74c85eb2 --- /dev/null +++ b/phpmyfaq/assets/src/api/popularSearches.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { fetchPopularSearches } from './popularSearches'; +import createFetchMock, { FetchMock } from 'vitest-fetch-mock'; + +const fetchMocker: FetchMock = createFetchMock(vi); +fetchMocker.enableMocks(); + +describe('fetchPopularSearches', (): void => { + beforeEach((): void => { + fetchMocker.resetMocks(); + }); + + test('returns the parsed list on success', async (): Promise => { + const mockData = [{ id: 1, searchterm: 'mac', number: '18' }]; + fetchMocker.mockResponseOnce(JSON.stringify(mockData)); + + const data = await fetchPopularSearches(); + + expect(data).toEqual(mockData); + expect(fetch).toHaveBeenCalledWith('api/searches/popular', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('returns an empty array on a non-ok response', async (): Promise => { + fetchMocker.mockResponseOnce('[]', { status: 404 }); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); + + test('returns an empty array when fetch rejects', async (): Promise => { + fetchMocker.mockRejectOnce(new Error('API is down')); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); +}); diff --git a/phpmyfaq/assets/src/api/popularSearches.ts b/phpmyfaq/assets/src/api/popularSearches.ts new file mode 100644 index 0000000000..4c63fd654a --- /dev/null +++ b/phpmyfaq/assets/src/api/popularSearches.ts @@ -0,0 +1,38 @@ +/** + * Popular searches API functionality + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { PopularSearchResponse } from '../interfaces'; + +export const fetchPopularSearches = async (): Promise => { + try { + const response: Response = await fetch('api/searches/popular', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + return []; + } + + return (await response.json()) as PopularSearchResponse; + } catch { + return []; + } +}; diff --git a/phpmyfaq/assets/src/interfaces/PopularSearch.ts b/phpmyfaq/assets/src/interfaces/PopularSearch.ts new file mode 100644 index 0000000000..cdf2dcc4fb --- /dev/null +++ b/phpmyfaq/assets/src/interfaces/PopularSearch.ts @@ -0,0 +1,7 @@ +export interface PopularSearch { + id: number; + searchterm: string; + number: number | string; +} + +export type PopularSearchResponse = PopularSearch[]; diff --git a/phpmyfaq/assets/src/interfaces/index.ts b/phpmyfaq/assets/src/interfaces/index.ts index 3a806039b7..e240e3b0c4 100644 --- a/phpmyfaq/assets/src/interfaces/index.ts +++ b/phpmyfaq/assets/src/interfaces/index.ts @@ -4,3 +4,4 @@ export * from './Bookmark'; export * from './authenticatorResponse'; export * from './Chat'; export * from './suggestionItem'; +export * from './PopularSearch'; From 62b773303f7e471f2fdb2b344cc0d114441af868 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 11:00:47 +0200 Subject: [PATCH 05/16] feat(search): add recent searches localStorage helper --- .../assets/src/search/recentSearches.test.ts | 63 +++++++++++++++++++ phpmyfaq/assets/src/search/recentSearches.ts | 53 ++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 phpmyfaq/assets/src/search/recentSearches.test.ts create mode 100644 phpmyfaq/assets/src/search/recentSearches.ts diff --git a/phpmyfaq/assets/src/search/recentSearches.test.ts b/phpmyfaq/assets/src/search/recentSearches.test.ts new file mode 100644 index 0000000000..9412c51222 --- /dev/null +++ b/phpmyfaq/assets/src/search/recentSearches.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addRecentSearch, clearRecentSearches, getRecentSearches } from './recentSearches'; + +describe('recentSearches', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('returns an empty array when nothing is stored', () => { + expect(getRecentSearches()).toEqual([]); + }); + + it('stores and returns a term', () => { + addRecentSearch('mac'); + expect(getRecentSearches()).toEqual(['mac']); + }); + + it('puts the most recent term first', () => { + addRecentSearch('one'); + addRecentSearch('two'); + expect(getRecentSearches()).toEqual(['two', 'one']); + }); + + it('deduplicates case-insensitively and moves the term to the front', () => { + addRecentSearch('Mac'); + addRecentSearch('linux'); + addRecentSearch('mac'); + expect(getRecentSearches()).toEqual(['mac', 'linux']); + }); + + it('caps the list at five entries', () => { + ['a', 'b', 'c', 'd', 'e', 'f'].forEach(addRecentSearch); + expect(getRecentSearches()).toEqual(['f', 'e', 'd', 'c', 'b']); + }); + + it('ignores blank terms', () => { + addRecentSearch(' '); + addRecentSearch(''); + expect(getRecentSearches()).toEqual([]); + }); + + it('clears stored terms', () => { + addRecentSearch('mac'); + clearRecentSearches(); + expect(getRecentSearches()).toEqual([]); + }); + + it('returns an empty array when localStorage.getItem throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + expect(getRecentSearches()).toEqual([]); + vi.restoreAllMocks(); + }); + + it('does not throw when localStorage.setItem throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + expect(() => addRecentSearch('mac')).not.toThrow(); + vi.restoreAllMocks(); + }); +}); diff --git a/phpmyfaq/assets/src/search/recentSearches.ts b/phpmyfaq/assets/src/search/recentSearches.ts new file mode 100644 index 0000000000..98056abe41 --- /dev/null +++ b/phpmyfaq/assets/src/search/recentSearches.ts @@ -0,0 +1,53 @@ +/** + * Recent searches storage (localStorage) + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +const STORAGE_KEY = 'pmf-recent-searches'; +const MAX_ENTRIES = 5; + +export const getRecentSearches = (): string[] => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + } catch { + return []; + } +}; + +export const addRecentSearch = (term: string): void => { + const trimmed = term.trim(); + if (trimmed === '') { + return; + } + + try { + const existing = getRecentSearches().filter((item) => item.toLowerCase() !== trimmed.toLowerCase()); + const updated = [trimmed, ...existing].slice(0, MAX_ENTRIES); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch { + // Storage unavailable (e.g. private mode) — silently ignore. + } +}; + +export const clearRecentSearches = (): void => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Storage unavailable — silently ignore. + } +}; From fc48596761ad5b7aeee8b988709f966dc637e87f Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 11:09:07 +0200 Subject: [PATCH 06/16] feat(search): add injection-safe matched-term highlighting helper --- phpmyfaq/assets/src/search/highlight.test.ts | 34 +++++++++++++++ phpmyfaq/assets/src/search/highlight.ts | 45 ++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 phpmyfaq/assets/src/search/highlight.test.ts create mode 100644 phpmyfaq/assets/src/search/highlight.ts diff --git a/phpmyfaq/assets/src/search/highlight.test.ts b/phpmyfaq/assets/src/search/highlight.test.ts new file mode 100644 index 0000000000..dbb6ad07a7 --- /dev/null +++ b/phpmyfaq/assets/src/search/highlight.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { highlightMatch } from './highlight'; + +const toHtml = (fragment: DocumentFragment): string => { + const wrapper = document.createElement('div'); + wrapper.appendChild(fragment); + return wrapper.innerHTML; +}; + +describe('highlightMatch', () => { + it('wraps the matched substring in ', () => { + expect(toHtml(highlightMatch('Install phpMyFAQ', 'install'))).toBe('Install phpMyFAQ'); + }); + + it('matches case-insensitively in the middle of the text', () => { + expect(toHtml(highlightMatch('How to MAC setup', 'mac'))).toBe('How to MAC setup'); + }); + + it('returns the plain text when there is no match', () => { + expect(toHtml(highlightMatch('Hello world', 'xyz'))).toBe('Hello world'); + }); + + it('returns the plain text for an empty query', () => { + expect(toHtml(highlightMatch('Hello world', ''))).toBe('Hello world'); + }); + + it('escapes HTML so it cannot inject markup', () => { + expect(toHtml(highlightMatch(' term', 'term'))).toBe('<img src=x> term'); + }); + + it('treats regex metacharacters in the query as literals', () => { + expect(toHtml(highlightMatch('a.b.c', '.'))).toBe('a.b.c'); + }); +}); diff --git a/phpmyfaq/assets/src/search/highlight.ts b/phpmyfaq/assets/src/search/highlight.ts new file mode 100644 index 0000000000..c15e7bf9bc --- /dev/null +++ b/phpmyfaq/assets/src/search/highlight.ts @@ -0,0 +1,45 @@ +/** + * Matched-term highlighting for search suggestions + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const highlightMatch = (text: string, query: string): DocumentFragment => { + const fragment = document.createDocumentFragment(); + const trimmedQuery = query.trim(); + + if (trimmedQuery === '') { + fragment.appendChild(document.createTextNode(text)); + return fragment; + } + + const regex = new RegExp(`(${escapeRegExp(trimmedQuery)})`, 'gi'); + const parts = text.split(regex); + + parts.forEach((part, index) => { + if (part === '') { + return; + } + // split() with a capturing group yields matches at odd indices. + if (index % 2 === 1) { + const strong = document.createElement('strong'); + strong.textContent = part; + fragment.appendChild(strong); + } else { + fragment.appendChild(document.createTextNode(part)); + } + }); + + return fragment; +}; From ad33bcbadc80eb4d3587e411bbda46900c2094b5 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 11:18:03 +0200 Subject: [PATCH 07/16] feat(search): add platform detection for keyboard shortcut hint --- phpmyfaq/assets/src/utils/index.ts | 1 + phpmyfaq/assets/src/utils/platform.test.ts | 38 ++++++++++++++++++++++ phpmyfaq/assets/src/utils/platform.ts | 26 +++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 phpmyfaq/assets/src/utils/platform.test.ts create mode 100644 phpmyfaq/assets/src/utils/platform.ts diff --git a/phpmyfaq/assets/src/utils/index.ts b/phpmyfaq/assets/src/utils/index.ts index 4193bd1a77..aa075891e7 100644 --- a/phpmyfaq/assets/src/utils/index.ts +++ b/phpmyfaq/assets/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './helper'; export * from './login'; export * from './notifications'; export * from './password'; +export * from './platform'; export * from './reading-time'; export * from './tooltip'; export * from './TranslationService'; diff --git a/phpmyfaq/assets/src/utils/platform.test.ts b/phpmyfaq/assets/src/utils/platform.test.ts new file mode 100644 index 0000000000..297d990144 --- /dev/null +++ b/phpmyfaq/assets/src/utils/platform.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getShortcutHintLabel, isMacPlatform } from './platform'; + +const setPlatform = (value: string): void => { + Object.defineProperty(window.navigator, 'platform', { value, configurable: true }); +}; + +describe('platform helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + setPlatform(''); + }); + + it('detects macOS from navigator.platform', () => { + setPlatform('MacIntel'); + expect(isMacPlatform()).toBe(true); + }); + + it('returns false for Windows', () => { + setPlatform('Win32'); + expect(isMacPlatform()).toBe(false); + }); + + it('defaults to non-Mac for an unknown platform', () => { + setPlatform('Linux x86_64'); + expect(isMacPlatform()).toBe(false); + }); + + it('returns the Cmd label on macOS', () => { + setPlatform('MacIntel'); + expect(getShortcutHintLabel()).toBe('⌘ K'); + }); + + it('returns the Ctrl label off macOS', () => { + setPlatform('Win32'); + expect(getShortcutHintLabel()).toBe('Ctrl K'); + }); +}); diff --git a/phpmyfaq/assets/src/utils/platform.ts b/phpmyfaq/assets/src/utils/platform.ts new file mode 100644 index 0000000000..c7ba8b1f49 --- /dev/null +++ b/phpmyfaq/assets/src/utils/platform.ts @@ -0,0 +1,26 @@ +/** + * Platform detection for keyboard shortcuts + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +interface UserAgentDataLike { + platform?: string; +} + +export const isMacPlatform = (): boolean => { + const uaData = (navigator as Navigator & { userAgentData?: UserAgentDataLike }).userAgentData; + const platform = uaData?.platform ?? navigator.platform ?? ''; + return /mac/i.test(platform); +}; + +export const getShortcutHintLabel = (): string => (isMacPlatform() ? '⌘ K' : 'Ctrl K'); From c62ae6785c982b42226ea27d7cfd697d4253b1a5 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 11:28:59 +0200 Subject: [PATCH 08/16] feat(search): reusable attachAutocomplete with recent/popular/highlight/no-results --- .../assets/src/search/autocomplete.test.ts | 196 +++++++++++------- phpmyfaq/assets/src/search/autocomplete.ts | 147 ++++++++++--- 2 files changed, 237 insertions(+), 106 deletions(-) diff --git a/phpmyfaq/assets/src/search/autocomplete.test.ts b/phpmyfaq/assets/src/search/autocomplete.test.ts index 29ae197673..3086921ea5 100644 --- a/phpmyfaq/assets/src/search/autocomplete.test.ts +++ b/phpmyfaq/assets/src/search/autocomplete.test.ts @@ -6,142 +6,180 @@ vi.mock('autocompleter', () => ({ vi.mock('../api', () => ({ fetchAutoCompleteData: vi.fn(), + fetchPopularSearches: vi.fn(), +})); + +vi.mock('./recentSearches', () => ({ + getRecentSearches: vi.fn(() => []), + addRecentSearch: vi.fn(), })); vi.mock('../utils', () => ({ - addElement: vi.fn((tag: string, props: Record, children: Node[] = []) => { + addElement: vi.fn((tag: string, props: Record = {}, children: Node[] = []) => { const el = document.createElement(tag); if (props.classList) el.className = props.classList; - if (props.innerText) el.innerText = props.innerText; + // jsdom does not reflect innerText into textContent, so treat it as text content. + if (props.innerText) el.textContent = props.innerText; if (props.textContent) el.textContent = props.textContent; children.forEach((child) => el.appendChild(child)); return el; }), + TranslationService: class { + async loadTranslations(): Promise {} + translate(key: string): string { + return key; + } + }, })); -import { handleAutoComplete } from './autocomplete'; -import { fetchAutoCompleteData } from '../api'; +import { attachAutocomplete, handleAutoComplete } from './autocomplete'; +import { fetchAutoCompleteData, fetchPopularSearches } from '../api'; +import { getRecentSearches, addRecentSearch } from './recentSearches'; import { AutocompleteSearchResponse, Suggestion } from '../interfaces'; import autocomplete from 'autocompleter'; const mockAutocomplete = vi.mocked(autocomplete); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const getConfig = (): any => mockAutocomplete.mock.calls[0][0]; -describe('handleAutoComplete', () => { +describe('attachAutocomplete', () => { beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = ''; + vi.mocked(getRecentSearches).mockReturnValue([]); + vi.mocked(fetchPopularSearches).mockResolvedValue([]); + vi.mocked(fetchAutoCompleteData).mockResolvedValue([]); }); - it('should do nothing when autocomplete input is missing', () => { - document.body.innerHTML = '
'; - - handleAutoComplete(); - - expect(mockAutocomplete).not.toHaveBeenCalled(); - }); - - it('should initialize autocomplete when input element exists', () => { - document.body.innerHTML = ''; - - handleAutoComplete(); - + it('initialises autocomplete on the given input', () => { + const input = document.createElement('input'); + attachAutocomplete(input); expect(mockAutocomplete).toHaveBeenCalledTimes(1); + expect(getConfig().input).toBe(input); + expect(getConfig().showOnFocus).toBe(true); + expect(getConfig().debounceWaitMs).toBe(200); }); - it('should pass the input element to autocomplete', () => { - document.body.innerHTML = ''; + it('fetches live results mapped to result items when typing', async () => { + vi.mocked(fetchAutoCompleteData).mockResolvedValue([ + { question: 'How to install?', category: 'Setup', url: '/faq/1' }, + ] as AutocompleteSearchResponse); + attachAutocomplete(document.createElement('input')); - handleAutoComplete(); + const update = vi.fn(); + await getConfig().fetch('Install', update); - expect(getConfig().input).toBe(document.getElementById('pmf-search-autocomplete')); + expect(fetchAutoCompleteData).toHaveBeenCalledWith('install'); + const items = update.mock.calls[0][0] as Suggestion[]; + expect(items[0].type).toBe('result'); }); - it('should configure debounce wait time', () => { - document.body.innerHTML = ''; + it('shows a no-results helper when a non-empty query returns nothing', async () => { + vi.mocked(fetchAutoCompleteData).mockResolvedValue([]); + attachAutocomplete(document.createElement('input')); - handleAutoComplete(); + const update = vi.fn(); + await getConfig().fetch('zzz', update); - expect(getConfig().debounceWaitMs).toBe(200); + const items = update.mock.calls[0][0] as Suggestion[]; + expect(items).toHaveLength(1); + expect(items[0].type).toBe('empty'); }); - it('should fetch data and call update in the fetch callback', async () => { - document.body.innerHTML = ''; - - const mockResults: Suggestion[] = [ - { question: 'How to install?', category: 'Setup', url: '/faq/1' } as Suggestion, - { question: 'How to configure?', category: 'Config', url: '/faq/2' } as Suggestion, - ]; - vi.mocked(fetchAutoCompleteData).mockResolvedValue(mockResults as AutocompleteSearchResponse); - - handleAutoComplete(); + it('composes recent then popular items on empty input', async () => { + vi.mocked(getRecentSearches).mockReturnValue(['mac']); + vi.mocked(fetchPopularSearches).mockResolvedValue([{ id: 1, searchterm: 'linux', number: '9' }]); + attachAutocomplete(document.createElement('input')); const update = vi.fn(); - await getConfig().fetch('Test Query', update); + await getConfig().fetch('', update); - expect(fetchAutoCompleteData).toHaveBeenCalledWith('test query'); - expect(update).toHaveBeenCalledWith(mockResults); + const items = update.mock.calls[0][0] as Suggestion[]; + expect(items.map((i) => i.type)).toEqual(['recent', 'popular']); + expect(items[0].searchTerm).toBe('mac'); + expect(items[1].searchTerm).toBe('linux'); }); - it('should convert search string to lowercase in fetch callback', async () => { - document.body.innerHTML = ''; - - vi.mocked(fetchAutoCompleteData).mockResolvedValue([]); + it('returns no empty-state items when there are no recent or popular searches', async () => { + attachAutocomplete(document.createElement('input')); + const update = vi.fn(); + await getConfig().fetch('', update); + expect(update).toHaveBeenCalledWith([]); + }); - handleAutoComplete(); + it('records the term and navigates on select of a result', () => { + const input = document.createElement('input'); + input.value = 'install'; + const mockLocation = { href: '' }; + Object.defineProperty(window, 'location', { value: mockLocation, writable: true }); + attachAutocomplete(input); - await getConfig().fetch('UPPERCASE Query', vi.fn()); + getConfig().onSelect({ type: 'result', url: '/faq/42' } as Suggestion); - expect(fetchAutoCompleteData).toHaveBeenCalledWith('uppercase query'); + expect(addRecentSearch).toHaveBeenCalledWith('install'); + expect(mockLocation.href).toBe('/faq/42'); }); - it('should navigate to item URL on select', () => { - document.body.innerHTML = ''; - + it('records the search term and navigates on select of a popular item', () => { const mockLocation = { href: '' }; Object.defineProperty(window, 'location', { value: mockLocation, writable: true }); + attachAutocomplete(document.createElement('input')); - handleAutoComplete(); + getConfig().onSelect({ type: 'popular', searchTerm: 'linux', url: 'search.html?search=linux' } as Suggestion); - const item: Suggestion = { question: 'FAQ', category: 'General', url: '/faq/42' } as Suggestion; - getConfig().onSelect(item); + expect(addRecentSearch).toHaveBeenCalledWith('linux'); + expect(mockLocation.href).toBe('search.html?search=linux'); + }); - expect(mockLocation.href).toBe('/faq/42'); + it('handleAutoComplete does nothing without the inline input', () => { + document.body.innerHTML = '
'; + handleAutoComplete(); + expect(mockAutocomplete).not.toHaveBeenCalled(); }); - it('should render suggestion items with category and question', () => { + it('handleAutoComplete attaches to the inline input when present', () => { document.body.innerHTML = ''; - handleAutoComplete(); + expect(mockAutocomplete).toHaveBeenCalledTimes(1); + expect(getConfig().input).toBe(document.getElementById('pmf-search-autocomplete')); + }); - const item: Suggestion = { - question: 'How to install phpMyFAQ?', - category: 'Installation', - url: '/faq/1', - } as Suggestion; - - const rendered = getConfig().render(item) as HTMLElement; - - expect(rendered.tagName).toBe('LI'); - expect(rendered.classList.contains('list-group-item')).toBe(true); - - const categoryEl = rendered.querySelector('.fw-bold') as HTMLElement | null; - expect(categoryEl?.innerText).toBe('Installation'); - - const questionEl = rendered.querySelector('.pmf-searched-question'); - expect(questionEl?.textContent).toBe('How to install phpMyFAQ?'); + it('renders a no-results helper for an empty item', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render({ type: 'empty', url: 'search.html?search=x' } as Suggestion, '') as HTMLElement; + expect(el.classList.contains('pmf-search-empty')).toBe(true); + expect(el.textContent).toContain('msgNoSearchResults'); + expect(el.textContent).toContain('msgAskQuestionInstead'); }); - it('should create a list-group container', () => { - document.body.innerHTML = ''; + it('renders a popular item with a count badge', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render( + { type: 'popular', searchTerm: 'linux', count: 9, url: 'search.html?search=linux' } as Suggestion, + '' + ) as HTMLElement; + expect(el.textContent).toContain('linux'); + expect(el.textContent).toContain('9x'); + }); - handleAutoComplete(); + it('does not render a badge when the popular count is NaN', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render( + { type: 'popular', searchTerm: 'linux', count: Number('abc'), url: 'search.html?search=linux' } as Suggestion, + '' + ) as HTMLElement; + expect(el.textContent).toContain('linux'); + expect(el.textContent).not.toContain('NaN'); + }); - const container = getConfig().container as HTMLElement; - expect(container).toBeDefined(); - expect(container.tagName).toBe('UL'); - expect(container.classList.contains('list-group')).toBe(true); + it('renders a result item and highlights the matched query', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render( + { type: 'result', question: 'Install phpMyFAQ', category: 'Setup', url: '/faq/1' } as Suggestion, + 'install' + ) as HTMLElement; + expect(el.textContent).toContain('Setup'); + const strong = el.querySelector('strong'); + expect(strong?.textContent).toBe('Install'); }); }); diff --git a/phpmyfaq/assets/src/search/autocomplete.ts b/phpmyfaq/assets/src/search/autocomplete.ts index bc24b4749e..6ece94c295 100644 --- a/phpmyfaq/assets/src/search/autocomplete.ts +++ b/phpmyfaq/assets/src/search/autocomplete.ts @@ -14,36 +14,129 @@ */ import autocomplete from 'autocompleter'; -import { fetchAutoCompleteData } from '../api'; -import { addElement } from '../utils'; +import { fetchAutoCompleteData, fetchPopularSearches } from '../api'; +import { addElement, TranslationService } from '../utils'; import { Suggestion } from '../interfaces'; +import { addRecentSearch, getRecentSearches } from './recentSearches'; +import { highlightMatch } from './highlight'; -export const handleAutoComplete = (): void => { - const autoCompleteInput = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; +const buildEmptyStateItems = async (translate: (key: string) => string): Promise => { + const recent: Suggestion[] = getRecentSearches().map( + (term): Suggestion => + ({ + type: 'recent', + searchTerm: term, + url: `search.html?search=${encodeURIComponent(term)}`, + group: translate('msgRecentSearches'), + }) as Suggestion + ); - if (autoCompleteInput) { - autocomplete({ - debounceWaitMs: 200, - preventSubmit: undefined, - disableAutoSelect: false, - input: autoCompleteInput, - container: addElement('ul', { classList: 'list-group bg-dark' }) as HTMLDivElement, - fetch: async (searchString: string, update: (items: Suggestion[]) => void): Promise => { - searchString = searchString.toLowerCase(); - const fetchedData: Suggestion[] = await fetchAutoCompleteData(searchString); - update(fetchedData); - }, - onSelect: (item: Suggestion): void => { - window.location.href = item.url; - }, - render: (item: Suggestion): HTMLDivElement => { - return addElement('li', { classList: 'list-group-item d-flex justify-content-between align-items-start' }, [ - addElement('div', { classList: 'ms-2 me-auto' }, [ - addElement('div', { classList: 'fw-bold', innerText: item.category }), - addElement('span', { classList: 'pmf-searched-question', textContent: item.question }), - ]), - ]) as HTMLDivElement; + const popular: Suggestion[] = (await fetchPopularSearches()).map( + (item): Suggestion => + ({ + type: 'popular', + searchTerm: item.searchterm, + count: Number(item.number), + url: `search.html?search=${encodeURIComponent(item.searchterm)}`, + group: translate('msgPopularSearches'), + }) as Suggestion + ); + + return [...recent, ...popular]; +}; + +const renderItem = (item: Suggestion, currentValue: string, translate: (key: string) => string): HTMLDivElement => { + if (item.type === 'empty') { + return addElement('li', { classList: 'list-group-item pmf-search-empty' }, [ + addElement('div', { classList: 'text-muted', innerText: translate('msgNoSearchResults') }), + addElement('div', { classList: 'fw-bold text-primary' }, [ + addElement('i', { classList: 'bi bi-question-circle me-1' }), + document.createTextNode(translate('msgAskQuestionInstead')), + ]), + ]) as HTMLDivElement; + } + + if (item.type === 'recent' || item.type === 'popular') { + const icon = item.type === 'recent' ? 'bi-clock-history' : 'bi-graph-up-arrow'; + const children: Node[] = [ + addElement('span', {}, [ + addElement('i', { classList: `bi ${icon} me-2 text-muted` }), + document.createTextNode(item.searchTerm ?? ''), + ]), + ]; + if (item.type === 'popular' && typeof item.count === 'number' && !Number.isNaN(item.count)) { + children.push(addElement('span', { classList: 'badge bg-info', innerText: `${item.count}x` })); + } + return addElement( + 'li', + { + classList: 'list-group-item d-flex justify-content-between align-items-center', }, - }); + children + ) as HTMLDivElement; + } + + // type === 'result' (or undefined, treated as a result) + const questionEl = addElement('span', { classList: 'pmf-searched-question' }); + questionEl.appendChild(highlightMatch(item.question ?? '', currentValue)); + + return addElement('li', { classList: 'list-group-item d-flex justify-content-between align-items-start' }, [ + addElement('div', { classList: 'ms-2 me-auto' }, [ + addElement('div', { classList: 'fw-bold', innerText: item.category ?? '' }), + questionEl, + ]), + ]) as HTMLDivElement; +}; + +export const attachAutocomplete = (input: HTMLInputElement): void => { + const translator = new TranslationService(); + const translate = (key: string): string => translator.translate(key); + void translator.loadTranslations(document.documentElement.lang); + + autocomplete({ + debounceWaitMs: 200, + preventSubmit: undefined, + disableAutoSelect: false, + showOnFocus: true, + input, + container: addElement('ul', { classList: 'list-group bg-dark' }) as HTMLDivElement, + fetch: async (searchString: string, update: (items: Suggestion[]) => void): Promise => { + const query = searchString.trim().toLowerCase(); + + if (query === '') { + update(await buildEmptyStateItems(translate)); + return; + } + + const results: Suggestion[] = (await fetchAutoCompleteData(query)).map( + (result): Suggestion => + ({ + type: 'result', + url: result.url, + question: result.question, + category: result.category, + }) as Suggestion + ); + + if (results.length === 0) { + update([{ type: 'empty', url: `search.html?search=${encodeURIComponent(query)}` } as Suggestion]); + return; + } + + update(results); + }, + onSelect: (item: Suggestion): void => { + const term = item.type === 'result' || item.type === undefined ? input.value.trim() : (item.searchTerm ?? ''); + addRecentSearch(term); + window.location.href = item.url; + }, + render: (item: Suggestion, currentValue: string): HTMLDivElement => renderItem(item, currentValue, translate), + }); +}; + +export const handleAutoComplete = (): void => { + const autoCompleteInput = document.getElementById('pmf-search-autocomplete') as HTMLInputElement | null; + if (autoCompleteInput) { + attachAutocomplete(autoCompleteInput); } }; From 150934d547f894306a253a397678984510fc7194 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 11:52:53 +0200 Subject: [PATCH 09/16] feat(search): add fallback search modal palette --- .../assets/src/search/searchModal.test.ts | 53 ++++++++++++++ phpmyfaq/assets/src/search/searchModal.ts | 69 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 phpmyfaq/assets/src/search/searchModal.test.ts create mode 100644 phpmyfaq/assets/src/search/searchModal.ts diff --git a/phpmyfaq/assets/src/search/searchModal.test.ts b/phpmyfaq/assets/src/search/searchModal.test.ts new file mode 100644 index 0000000000..a95533fe1b --- /dev/null +++ b/phpmyfaq/assets/src/search/searchModal.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const showSpy = vi.fn(); +const hideSpy = vi.fn(); +vi.mock('bootstrap', () => ({ + Modal: vi.fn(function () { + return { show: showSpy, hide: hideSpy }; + }), +})); + +vi.mock('./autocomplete', () => ({ + attachAutocomplete: vi.fn(), +})); + +describe('openSearchModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('creates the modal markup with a search input on first open', async () => { + const { openSearchModal } = await import('./searchModal'); + openSearchModal(); + const input = document.getElementById('pmf-search-modal-input'); + expect(input).not.toBeNull(); + expect(input?.tagName).toBe('INPUT'); + }); + + it('wires the modal input with attachAutocomplete', async () => { + const { openSearchModal } = await import('./searchModal'); + const { attachAutocomplete } = await import('./autocomplete'); + openSearchModal(); + expect(attachAutocomplete).toHaveBeenCalledTimes(1); + }); + + it('shows the Bootstrap modal', async () => { + const { openSearchModal } = await import('./searchModal'); + const { Modal } = await import('bootstrap'); + openSearchModal(); + expect(Modal).toHaveBeenCalled(); + expect(showSpy).toHaveBeenCalled(); + }); + + it('reuses the same modal markup on subsequent opens', async () => { + const { openSearchModal } = await import('./searchModal'); + const { attachAutocomplete } = await import('./autocomplete'); + openSearchModal(); + openSearchModal(); + expect(document.querySelectorAll('#pmf-search-modal').length).toBe(1); + expect(attachAutocomplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/phpmyfaq/assets/src/search/searchModal.ts b/phpmyfaq/assets/src/search/searchModal.ts new file mode 100644 index 0000000000..6d42115e4a --- /dev/null +++ b/phpmyfaq/assets/src/search/searchModal.ts @@ -0,0 +1,69 @@ +/** + * Search modal palette (fallback when the inline search bar is absent) + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { Modal } from 'bootstrap'; +import { addElement } from '../utils'; +import { attachAutocomplete } from './autocomplete'; + +let modalInstance: Modal | null = null; + +const buildModal = (): void => { + const input = addElement('input', { + type: 'text', + id: 'pmf-search-modal-input', + classList: 'form-control form-control-lg', + name: 'search', + autocomplete: 'off', + maxLength: 255, + placeholder: 'Search …', + 'aria-label': 'Search', + }) as HTMLInputElement; + + const dialog = addElement('div', { classList: 'modal-dialog modal-lg modal-dialog-scrollable' }, [ + addElement('div', { classList: 'modal-content' }, [ + addElement('div', { classList: 'modal-body pmf-search-modal-body' }, [ + addElement('div', { classList: 'search' }, [addElement('i', { classList: 'bi bi-search' }), input]), + ]), + ]), + ]); + + const modalEl = addElement( + 'div', + { + classList: 'modal fade', + id: 'pmf-search-modal', + tabIndex: -1, + 'aria-hidden': 'true', + }, + [dialog] + ); + + document.body.appendChild(modalEl); + + attachAutocomplete(input); + + modalEl.addEventListener('shown.bs.modal', (): void => { + input.focus(); + }); + + modalInstance = new Modal(modalEl); +}; + +export const openSearchModal = (): void => { + if (modalInstance === null) { + buildModal(); + } + modalInstance?.show(); +}; From ef07130804c23cfda90372913da3aee31c9e8b3b Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 12:02:08 +0200 Subject: [PATCH 10/16] feat(search): add global Cmd+K / Ctrl+K shortcut --- .../assets/src/search/searchShortcut.test.ts | 71 +++++++++++++++++++ phpmyfaq/assets/src/search/searchShortcut.ts | 64 +++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 phpmyfaq/assets/src/search/searchShortcut.test.ts create mode 100644 phpmyfaq/assets/src/search/searchShortcut.ts diff --git a/phpmyfaq/assets/src/search/searchShortcut.test.ts b/phpmyfaq/assets/src/search/searchShortcut.test.ts new file mode 100644 index 0000000000..d0fb0719d1 --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcut.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./searchModal', () => ({ + openSearchModal: vi.fn(), +})); + +import { initSearchShortcut } from './searchShortcut'; +import { openSearchModal } from './searchModal'; + +const pressK = (): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { + key: 'k', + metaKey: true, + ctrlKey: true, + cancelable: true, + bubbles: true, + }); + document.dispatchEvent(event); + return event; +}; + +describe('initSearchShortcut', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('focuses the inline input when it exists', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + const focusSpy = vi.spyOn(input, 'focus'); + + initSearchShortcut(); + const event = pressK(); + + expect(focusSpy).toHaveBeenCalled(); + expect(openSearchModal).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + }); + + it('opens the modal when the inline input is absent', () => { + initSearchShortcut(); + pressK(); + expect(openSearchModal).toHaveBeenCalledTimes(1); + }); + + it('ignores the shortcut when typing in another text field', () => { + document.body.innerHTML = ''; + const other = document.getElementById('other') as HTMLTextAreaElement; + other.focus(); + const focusSpy = vi.spyOn(document.getElementById('pmf-search-autocomplete') as HTMLInputElement, 'focus'); + + initSearchShortcut(); + pressK(); + + expect(focusSpy).not.toHaveBeenCalled(); + expect(openSearchModal).not.toHaveBeenCalled(); + }); + + it('blurs the inline input on Escape', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + input.focus(); + const blurSpy = vi.spyOn(input, 'blur'); + + initSearchShortcut(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(blurSpy).toHaveBeenCalled(); + }); +}); diff --git a/phpmyfaq/assets/src/search/searchShortcut.ts b/phpmyfaq/assets/src/search/searchShortcut.ts new file mode 100644 index 0000000000..06a9953a4b --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcut.ts @@ -0,0 +1,64 @@ +/** + * Global keyboard shortcut for search (Cmd+K / Ctrl+K) + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { openSearchModal } from './searchModal'; + +const INLINE_INPUT_ID = 'pmf-search-autocomplete'; + +let initialized = false; + +const isEditableTarget = (target: EventTarget | null): boolean => { + const element = target instanceof HTMLElement ? target : document.activeElement; + if (!(element instanceof HTMLElement)) { + return false; + } + if (element.id === INLINE_INPUT_ID) { + return false; + } + const tag = element.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable; +}; + +export const initSearchShortcut = (): void => { + if (initialized) { + return; + } + initialized = true; + + document.addEventListener('keydown', (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + if (isEditableTarget(event.target)) { + return; + } + event.preventDefault(); + + const inlineInput = document.getElementById(INLINE_INPUT_ID) as HTMLInputElement | null; + if (inlineInput) { + inlineInput.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); + inlineInput.focus(); + } else { + openSearchModal(); + } + return; + } + + if (event.key === 'Escape') { + const inlineInput = document.getElementById(INLINE_INPUT_ID) as HTMLInputElement | null; + if (inlineInput && document.activeElement === inlineInput) { + inlineInput.blur(); + } + } + }); +}; From b1e62222c69195170c5741de53db99bdea89e3ec Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 12:20:56 +0200 Subject: [PATCH 11/16] feat(search): wire up shortcut init, hint badge, and styles --- .../assets/scss/layout/_autocomplete.scss | 27 ++++++++ phpmyfaq/assets/src/frontend.test.ts | 4 ++ phpmyfaq/assets/src/frontend.ts | 10 ++- phpmyfaq/assets/src/search/index.ts | 3 + .../src/search/searchShortcutBadge.test.ts | 65 +++++++++++++++++++ .../assets/src/search/searchShortcutBadge.ts | 39 +++++++++++ phpmyfaq/assets/templates/default/index.twig | 1 + 7 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 phpmyfaq/assets/src/search/searchShortcutBadge.test.ts create mode 100644 phpmyfaq/assets/src/search/searchShortcutBadge.ts diff --git a/phpmyfaq/assets/scss/layout/_autocomplete.scss b/phpmyfaq/assets/scss/layout/_autocomplete.scss index aa4e46c44c..664bb8d4ec 100644 --- a/phpmyfaq/assets/scss/layout/_autocomplete.scss +++ b/phpmyfaq/assets/scss/layout/_autocomplete.scss @@ -25,12 +25,39 @@ right: 5px; height: 50px; } + + .pmf-search-hint { + position: absolute; + top: 16px; + right: 130px; + padding: 2px 8px; + font-size: 12px; + color: var(--bs-secondary-color); + background-color: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 4px; + pointer-events: none; + } +} + +.pmf-search-modal-body { + .search { + box-shadow: none; + } } .autocomplete { @extend .list-group; z-index: 1000; + + .pmf-search-empty { + cursor: pointer; + } + + .pmf-searched-question strong { + color: var(--bs-primary); + } } .autocomplete > div { diff --git a/phpmyfaq/assets/src/frontend.test.ts b/phpmyfaq/assets/src/frontend.test.ts index ae130714f8..2b41308f4b 100644 --- a/phpmyfaq/assets/src/frontend.test.ts +++ b/phpmyfaq/assets/src/frontend.test.ts @@ -45,11 +45,15 @@ vi.mock('./faq', () => ({ const mockHandleAutoComplete = vi.fn(); const mockHandleCategorySelection = vi.fn(); const mockHandleQuestion = vi.fn(); +const mockInitSearchShortcut = vi.fn(); +const mockInitSearchShortcutBadge = vi.fn(); vi.mock('./search', () => ({ handleAutoComplete: () => mockHandleAutoComplete(), handleCategorySelection: () => mockHandleCategorySelection(), handleQuestion: () => mockHandleQuestion(), + initSearchShortcut: () => mockInitSearchShortcut(), + initSearchShortcutBadge: () => mockInitSearchShortcutBadge(), })); const mockHandleDeleteBookmarks = vi.fn(); diff --git a/phpmyfaq/assets/src/frontend.ts b/phpmyfaq/assets/src/frontend.ts index 55ce8b4c80..17843fbc39 100644 --- a/phpmyfaq/assets/src/frontend.ts +++ b/phpmyfaq/assets/src/frontend.ts @@ -25,7 +25,13 @@ import { handleUserVoting, renderFaqEditor, } from './faq'; -import { handleAutoComplete, handleCategorySelection, handleQuestion } from './search'; +import { + handleAutoComplete, + handleCategorySelection, + handleQuestion, + initSearchShortcut, + initSearchShortcutBadge, +} from './search'; import { handleDeleteBookmarks, handleRegister, @@ -121,6 +127,8 @@ document.addEventListener('DOMContentLoaded', (): void => { // AutoComplete handleAutoComplete(); handleCategorySelection(); + initSearchShortcut(); + initSearchShortcutBadge(); // Handle Chat handleChat(); diff --git a/phpmyfaq/assets/src/search/index.ts b/phpmyfaq/assets/src/search/index.ts index 9040469c91..0917e58577 100644 --- a/phpmyfaq/assets/src/search/index.ts +++ b/phpmyfaq/assets/src/search/index.ts @@ -1,3 +1,6 @@ export * from './autocomplete'; export * from './category'; export * from './question'; +export * from './searchModal'; +export * from './searchShortcut'; +export * from './searchShortcutBadge'; diff --git a/phpmyfaq/assets/src/search/searchShortcutBadge.test.ts b/phpmyfaq/assets/src/search/searchShortcutBadge.test.ts new file mode 100644 index 0000000000..489bd91b05 --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcutBadge.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../utils', () => ({ + getShortcutHintLabel: vi.fn(() => '⌘ K'), +})); + +import { initSearchShortcutBadge } from './searchShortcutBadge'; + +describe('initSearchShortcutBadge', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('does nothing when the hint element is absent', () => { + expect(() => initSearchShortcutBadge()).not.toThrow(); + }); + + it('sets the hint label from getShortcutHintLabel', () => { + document.body.innerHTML = ''; + initSearchShortcutBadge(); + expect(document.getElementById('pmf-search-hint')?.textContent).toBe('⌘ K'); + }); + + it('hides the hint initially when the input is pre-filled', () => { + document.body.innerHTML = + ''; + initSearchShortcutBadge(); + expect(document.getElementById('pmf-search-hint')?.classList.contains('d-none')).toBe(true); + }); + + it('shows the hint initially when the input is empty and not focused', () => { + document.body.innerHTML = ''; + initSearchShortcutBadge(); + expect(document.getElementById('pmf-search-hint')?.classList.contains('d-none')).toBe(false); + }); + + it('hides the hint on focus and shows it again on blur', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + const hint = document.getElementById('pmf-search-hint') as HTMLElement; + initSearchShortcutBadge(); + + input.dispatchEvent(new Event('focus')); + // jsdom focus event does not set document.activeElement; call focus() too + input.focus(); + input.dispatchEvent(new Event('focus')); + expect(hint.classList.contains('d-none')).toBe(true); + + input.blur(); + input.dispatchEvent(new Event('blur')); + expect(hint.classList.contains('d-none')).toBe(false); + }); + + it('hides the hint when the user types', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + const hint = document.getElementById('pmf-search-hint') as HTMLElement; + initSearchShortcutBadge(); + + input.value = 'mac'; + input.dispatchEvent(new Event('input')); + expect(hint.classList.contains('d-none')).toBe(true); + }); +}); diff --git a/phpmyfaq/assets/src/search/searchShortcutBadge.ts b/phpmyfaq/assets/src/search/searchShortcutBadge.ts new file mode 100644 index 0000000000..4f55169d71 --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcutBadge.ts @@ -0,0 +1,39 @@ +/** + * Keyboard shortcut hint badge for the inline search bar + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { getShortcutHintLabel } from '../utils'; + +export const initSearchShortcutBadge = (): void => { + const searchHint: HTMLElement | null = document.getElementById('pmf-search-hint'); + if (searchHint === null) { + return; + } + + searchHint.textContent = getShortcutHintLabel(); + + const searchInput: HTMLInputElement | null = document.getElementById( + 'pmf-search-autocomplete' + ) as HTMLInputElement | null; + + const toggleHint = (): void => { + const hide = searchInput !== null && (document.activeElement === searchInput || searchInput.value !== ''); + searchHint.classList.toggle('d-none', hide); + }; + + toggleHint(); + searchInput?.addEventListener('focus', toggleHint); + searchInput?.addEventListener('blur', toggleHint); + searchInput?.addEventListener('input', toggleHint); +}; diff --git a/phpmyfaq/assets/templates/default/index.twig b/phpmyfaq/assets/templates/default/index.twig index 53506f62bb..19c0335613 100644 --- a/phpmyfaq/assets/templates/default/index.twig +++ b/phpmyfaq/assets/templates/default/index.twig @@ -128,6 +128,7 @@ + From 607e87896093c4dc2940930e74369ffc31d65d3b Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 13:11:01 +0200 Subject: [PATCH 12/16] fix(search): register PopularSearchesController in DI container --- phpmyfaq/src/services.php | 4 +++ .../Api/PopularSearchesControllerWebTest.php | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerWebTest.php diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index 6f482225ad..b403ad9bba 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -137,6 +137,7 @@ use phpMyFAQ\Controller\Frontend\Api\CommentController as FrontendApiCommentController; use phpMyFAQ\Controller\Frontend\Api\ContactController as FrontendApiContactController; use phpMyFAQ\Controller\Frontend\Api\FaqController as FrontendApiFaqController; +use phpMyFAQ\Controller\Frontend\Api\PopularSearchesController as FrontendApiPopularSearchesController; use phpMyFAQ\Controller\Frontend\Api\PushController as FrontendApiPushController; use phpMyFAQ\Controller\Frontend\Api\QuestionController as FrontendApiQuestionController; use phpMyFAQ\Controller\Frontend\Api\UserController as FrontendApiUserController; @@ -809,6 +810,9 @@ service('phpmyfaq.helper.search'), service('phpmyfaq.language.plurals'), ]); + $services->set(FrontendApiPopularSearchesController::class, FrontendApiPopularSearchesController::class)->args([ + service('phpmyfaq.search'), + ]); $services->set(FrontendApiCaptchaController::class, FrontendApiCaptchaController::class)->args([ service('phpmyfaq.captcha'), ]); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerWebTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerWebTest.php new file mode 100644 index 0000000000..78d0dbc67e --- /dev/null +++ b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerWebTest.php @@ -0,0 +1,34 @@ +requestApi('GET', '/searches/popular'); + + self::assertContains( + $response->getStatusCode(), + [Response::HTTP_OK, Response::HTTP_NOT_FOUND], + 'The popular searches endpoint must resolve through the container and ' + . 'return a valid HTTP status, not a 500 from an unregistered controller.', + ); + self::assertStringContainsString('json', (string) $response->headers->get('Content-Type')); + self::assertJson((string) $response->getContent()); + } +} From b5a121fe97343c05f7309d1a4f7ffca723a8d3b6 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 13:26:25 +0200 Subject: [PATCH 13/16] docs: add coding patterns guidance to AGENTS.md --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 340333d90c..880494dfcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,26 @@ It is built using HTML5, CSS, TypeScript, and PHP and supports various databases - Use single quotes for strings. - Use arrow functions for callbacks. +# Coding Patterns + +- Prefer guard clauses and early returns over deep conditional nesting +- Name classes and methods after domain concepts, not technical mechanics (e.g. `Subscription.renew()`, not `DataRecord.update()`) +- Isolate external dependencies behind an interface you own, so they can be swapped or mocked at the boundary +- Make illegal states unrepresentable — encode invariants in types and constructors instead of re-validating them everywhere +- Separate decisions (pure logic that computes *what* to do) from actions (side effects that carry it out) +- Keep functions small and single-purpose +- Fail loudly and specifically: errors state what went wrong, where, and what to do next — structured enough for machines to parse, readable enough for humans to act on + +## Making illegal states unrepresentable in PHP + +- Use `enum` for fixed sets instead of string/int constants or magic values (e.g. `enum FaqStatus { case Draft; case Published; case Archived; }`) +- Push validation into the constructor and throw on bad input, so an object cannot exist in an invalid state — never construct first and validate later +- Use named constructors (private `__construct` + static `fromString()`, `create()`) when there are several distinct, valid ways to build an object +- Make value objects `readonly` so invariants checked at construction can't be mutated away afterward (`final readonly class EmailAddress`) +- Prefer required, typed, non-nullable constructor arguments over nullable properties with setters; if a value is optional, model that explicitly rather than leaning on `null` +- Replace pervasive `null` checks with a Null Object or a typed result/option where it removes branching +- Mark classes `final` by default; open them for extension only when you mean to + ## Agent Workflow When implementing changes: From c68ca385085818b63264ddbc23911943cecd558c Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 13:42:03 +0200 Subject: [PATCH 14/16] test: rebuild test DB when database.php path does not match current env --- tests/bootstrap.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index bb49ce2b78..5b0964e617 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -93,6 +93,18 @@ function canReusePreparedTestDatabase(string $databasePath, string $databaseConf return false; } + // The recorded server path in database.php must match the current environment's + // database path. A previously generated config may carry a different absolute path + // — e.g. the same checkout accessed under a different case on a case-insensitive + // filesystem, or a moved/shared working copy. Reusing it would leave database.php + // pointing at the wrong path string and break path-sensitive assertions. A mismatch + // forces a rebuild below, which regenerates database.php with the current path. + $DB = []; + include $databaseConfigPath; + if (!isset($DB['server']) || $DB['server'] !== $databasePath) { + return false; + } + try { $pdo = new \PDO('sqlite:' . $databasePath); $statement = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'"); From 44d8ae0d0daa11b5414fd512da65490cd047fca1 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 14:06:47 +0200 Subject: [PATCH 15/16] fix(search): validate and normalize popular searches API response --- .../assets/src/api/popularSearches.test.ts | 36 ++++++++++++++-- phpmyfaq/assets/src/api/popularSearches.ts | 43 ++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/phpmyfaq/assets/src/api/popularSearches.test.ts b/phpmyfaq/assets/src/api/popularSearches.test.ts index ae74c85eb2..abf906e8e6 100644 --- a/phpmyfaq/assets/src/api/popularSearches.test.ts +++ b/phpmyfaq/assets/src/api/popularSearches.test.ts @@ -10,13 +10,12 @@ describe('fetchPopularSearches', (): void => { fetchMocker.resetMocks(); }); - test('returns the parsed list on success', async (): Promise => { - const mockData = [{ id: 1, searchterm: 'mac', number: '18' }]; - fetchMocker.mockResponseOnce(JSON.stringify(mockData)); + test('returns the parsed list on success and coerces the count to a number', async (): Promise => { + fetchMocker.mockResponseOnce(JSON.stringify([{ id: 1, searchterm: 'mac', number: '18' }])); const data = await fetchPopularSearches(); - expect(data).toEqual(mockData); + expect(data).toEqual([{ id: 1, searchterm: 'mac', number: 18 }]); expect(fetch).toHaveBeenCalledWith('api/searches/popular', { method: 'GET', cache: 'no-cache', @@ -26,6 +25,35 @@ describe('fetchPopularSearches', (): void => { }); }); + test('drops malformed entries and keeps only well-formed ones', async (): Promise => { + fetchMocker.mockResponseOnce( + JSON.stringify([ + { id: 1, searchterm: 'mac', number: 18 }, + { id: 2, searchterm: '', number: 5 }, // empty searchterm + { id: 3, number: 7 }, // missing searchterm + { id: 4, searchterm: 'linux', number: 'not-a-number' }, // non-coercible + { id: 5, searchterm: 'php', number: null }, // invalid number type + 'nonsense', // not an object + { id: 6, searchterm: 'sqlite', number: '9' }, // valid, coerced + ]) + ); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([ + { id: 1, searchterm: 'mac', number: 18 }, + { id: 6, searchterm: 'sqlite', number: 9 }, + ]); + }); + + test('returns an empty array when the payload is not an array', async (): Promise => { + fetchMocker.mockResponseOnce(JSON.stringify({ unexpected: 'shape' })); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); + test('returns an empty array on a non-ok response', async (): Promise => { fetchMocker.mockResponseOnce('[]', { status: 404 }); diff --git a/phpmyfaq/assets/src/api/popularSearches.ts b/phpmyfaq/assets/src/api/popularSearches.ts index 4c63fd654a..81191ad7e6 100644 --- a/phpmyfaq/assets/src/api/popularSearches.ts +++ b/phpmyfaq/assets/src/api/popularSearches.ts @@ -13,7 +13,46 @@ * @since 2026-06-20 */ -import { PopularSearchResponse } from '../interfaces'; +import { PopularSearch, PopularSearchResponse } from '../interfaces'; + +/** + * Validates the raw JSON payload and returns only well-formed entries. + * The backend is trusted but not guaranteed; malformed items are dropped so they + * never reach the autocomplete rendering. Numeric fields are coerced to numbers. + */ +const normalizePopularSearches = (data: unknown): PopularSearchResponse => { + if (!Array.isArray(data)) { + return []; + } + + const result: PopularSearch[] = []; + + for (const entry of data) { + if (typeof entry !== 'object' || entry === null) { + continue; + } + + const record = entry as Record; + const { searchterm, number } = record; + + if ( + typeof searchterm !== 'string' || + searchterm.trim() === '' || + (typeof number !== 'number' && typeof number !== 'string') + ) { + continue; + } + + const count = Number(number); + if (!Number.isFinite(count)) { + continue; + } + + result.push({ id: Number(record.id) || 0, searchterm, number: count }); + } + + return result; +}; export const fetchPopularSearches = async (): Promise => { try { @@ -31,7 +70,7 @@ export const fetchPopularSearches = async (): Promise => return []; } - return (await response.json()) as PopularSearchResponse; + return normalizePopularSearches(await response.json()); } catch { return []; } From 79c86c48a234e2fa2198494dc3d77092a11b4ad6 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 20 Jun 2026 14:18:27 +0200 Subject: [PATCH 16/16] fix(search): harden search edge cases from code review --- .../assets/src/api/popularSearches.test.ts | 8 ++++++ .../assets/src/search/autocomplete.test.ts | 8 ++++++ phpmyfaq/assets/src/search/autocomplete.ts | 26 ++++++++++++------- .../assets/src/search/recentSearches.test.ts | 8 ++++++ phpmyfaq/assets/src/search/recentSearches.ts | 11 +++++++- phpmyfaq/assets/src/search/searchModal.ts | 13 +++++++--- phpmyfaq/assets/src/utils/platform.test.ts | 26 +++++++++++++++++++ tests/bootstrap.php | 4 +++ 8 files changed, 91 insertions(+), 13 deletions(-) diff --git a/phpmyfaq/assets/src/api/popularSearches.test.ts b/phpmyfaq/assets/src/api/popularSearches.test.ts index abf906e8e6..8a30954b4f 100644 --- a/phpmyfaq/assets/src/api/popularSearches.test.ts +++ b/phpmyfaq/assets/src/api/popularSearches.test.ts @@ -54,6 +54,14 @@ describe('fetchPopularSearches', (): void => { expect(data).toEqual([]); }); + test('returns an empty array when a 200 response has an invalid JSON body', async (): Promise => { + fetchMocker.mockResponseOnce('not valid json', { status: 200 }); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); + test('returns an empty array on a non-ok response', async (): Promise => { fetchMocker.mockResponseOnce('[]', { status: 404 }); diff --git a/phpmyfaq/assets/src/search/autocomplete.test.ts b/phpmyfaq/assets/src/search/autocomplete.test.ts index 3086921ea5..a694fd43a8 100644 --- a/phpmyfaq/assets/src/search/autocomplete.test.ts +++ b/phpmyfaq/assets/src/search/autocomplete.test.ts @@ -107,6 +107,14 @@ describe('attachAutocomplete', () => { expect(update).toHaveBeenCalledWith([]); }); + it('clears suggestions when fetchAutoCompleteData rejects', async () => { + vi.mocked(fetchAutoCompleteData).mockRejectedValueOnce(new Error('Network response was not ok.')); + attachAutocomplete(document.createElement('input')); + const update = vi.fn(); + await getConfig().fetch('install', update); + expect(update).toHaveBeenCalledWith([]); + }); + it('records the term and navigates on select of a result', () => { const input = document.createElement('input'); input.value = 'install'; diff --git a/phpmyfaq/assets/src/search/autocomplete.ts b/phpmyfaq/assets/src/search/autocomplete.ts index 6ece94c295..15c2963271 100644 --- a/phpmyfaq/assets/src/search/autocomplete.ts +++ b/phpmyfaq/assets/src/search/autocomplete.ts @@ -108,15 +108,23 @@ export const attachAutocomplete = (input: HTMLInputElement): void => { return; } - const results: Suggestion[] = (await fetchAutoCompleteData(query)).map( - (result): Suggestion => - ({ - type: 'result', - url: result.url, - question: result.question, - category: result.category, - }) as Suggestion - ); + let results: Suggestion[]; + try { + results = (await fetchAutoCompleteData(query)).map( + (result): Suggestion => + ({ + type: 'result', + url: result.url, + question: result.question, + category: result.category, + }) as Suggestion + ); + } catch { + // A transient network/backend failure must not leave the dropdown stuck: + // recover by clearing the suggestions so the UI stays responsive. + update([]); + return; + } if (results.length === 0) { update([{ type: 'empty', url: `search.html?search=${encodeURIComponent(query)}` } as Suggestion]); diff --git a/phpmyfaq/assets/src/search/recentSearches.test.ts b/phpmyfaq/assets/src/search/recentSearches.test.ts index 9412c51222..c378451ab2 100644 --- a/phpmyfaq/assets/src/search/recentSearches.test.ts +++ b/phpmyfaq/assets/src/search/recentSearches.test.ts @@ -10,6 +10,14 @@ describe('recentSearches', () => { expect(getRecentSearches()).toEqual([]); }); + it('trims, drops blank/non-string entries, and caps externally modified storage on read', () => { + localStorage.setItem( + 'pmf-recent-searches', + JSON.stringify([' mac ', '', ' ', 42, 'linux', 'a', 'b', 'c', 'd']) + ); + expect(getRecentSearches()).toEqual(['mac', 'linux', 'a', 'b', 'c']); + }); + it('stores and returns a term', () => { addRecentSearch('mac'); expect(getRecentSearches()).toEqual(['mac']); diff --git a/phpmyfaq/assets/src/search/recentSearches.ts b/phpmyfaq/assets/src/search/recentSearches.ts index 98056abe41..76339a419d 100644 --- a/phpmyfaq/assets/src/search/recentSearches.ts +++ b/phpmyfaq/assets/src/search/recentSearches.ts @@ -23,7 +23,16 @@ export const getRecentSearches = (): string[] => { return []; } const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + if (!Array.isArray(parsed)) { + return []; + } + // Normalize defensively: externally modified localStorage could contain + // non-strings, blank entries, or an arbitrarily large array. + return parsed + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item !== '') + .slice(0, MAX_ENTRIES); } catch { return []; } diff --git a/phpmyfaq/assets/src/search/searchModal.ts b/phpmyfaq/assets/src/search/searchModal.ts index 6d42115e4a..f0f740e824 100644 --- a/phpmyfaq/assets/src/search/searchModal.ts +++ b/phpmyfaq/assets/src/search/searchModal.ts @@ -14,7 +14,7 @@ */ import { Modal } from 'bootstrap'; -import { addElement } from '../utils'; +import { addElement, TranslationService } from '../utils'; import { attachAutocomplete } from './autocomplete'; let modalInstance: Modal | null = null; @@ -27,10 +27,17 @@ const buildModal = (): void => { name: 'search', autocomplete: 'off', maxLength: 255, - placeholder: 'Search …', - 'aria-label': 'Search', }) as HTMLInputElement; + // Localize the placeholder / accessible label via the translation system. + // Loading is async; set the labels once translations are available. + const translator = new TranslationService(); + void translator.loadTranslations(document.documentElement.lang).then((): void => { + const label = translator.translate('msgSearch'); + input.placeholder = `${label} …`; + input.setAttribute('aria-label', label); + }); + const dialog = addElement('div', { classList: 'modal-dialog modal-lg modal-dialog-scrollable' }, [ addElement('div', { classList: 'modal-content' }, [ addElement('div', { classList: 'modal-body pmf-search-modal-body' }, [ diff --git a/phpmyfaq/assets/src/utils/platform.test.ts b/phpmyfaq/assets/src/utils/platform.test.ts index 297d990144..2361de63f9 100644 --- a/phpmyfaq/assets/src/utils/platform.test.ts +++ b/phpmyfaq/assets/src/utils/platform.test.ts @@ -5,10 +5,36 @@ const setPlatform = (value: string): void => { Object.defineProperty(window.navigator, 'platform', { value, configurable: true }); }; +const setUserAgentData = (platform: string | undefined): void => { + Object.defineProperty(window.navigator, 'userAgentData', { + value: platform === undefined ? undefined : { platform }, + configurable: true, + }); +}; + describe('platform helpers', () => { afterEach(() => { vi.restoreAllMocks(); setPlatform(''); + setUserAgentData(undefined); + }); + + it('detects macOS from navigator.userAgentData.platform', () => { + setUserAgentData('macOS'); + setPlatform('Win32'); // userAgentData must take precedence + expect(isMacPlatform()).toBe(true); + }); + + it('returns false when userAgentData.platform is not macOS even if navigator.platform is Mac', () => { + setUserAgentData('Windows'); + setPlatform('MacIntel'); // userAgentData must take precedence over the legacy value + expect(isMacPlatform()).toBe(false); + }); + + it('falls back to non-Mac when userAgentData is absent and platform is empty', () => { + setUserAgentData(undefined); + setPlatform(''); + expect(isMacPlatform()).toBe(false); }); it('detects macOS from navigator.platform', () => { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5b0964e617..ffc86086cc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -99,6 +99,10 @@ function canReusePreparedTestDatabase(string $databasePath, string $databaseConf // filesystem, or a moved/shared working copy. Reusing it would leave database.php // pointing at the wrong path string and break path-sensitive assertions. A mismatch // forces a rebuild below, which regenerates database.php with the current path. + if (!is_readable($databaseConfigPath)) { + return false; + } + $DB = []; include $databaseConfigPath; if (!isset($DB['server']) || $DB['server'] !== $databasePath) {