PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / react-src / public / shortcodes / search-modal / SearchModal.js

SearchModal.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/public/shortcodes/search-modal/SearchModal.js

336 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useEffect, useRef, useCallback } from 'react';
2 import { addFilter, applyFilters } from "@wordpress/hooks";
3 import { __ } from "@wordpress/i18n";
4 import FolderIcon from './FolderIcon';
5 import DocsIcon from './DocsIcon';
6 import MagnifyIcon from './MagnifyIcon';
7 import FaqIcon from './FaqIcon';
8 import NoResultIcon from './NoResultIcon';
9 import ClearIcon from './ClearIcon';
10 import ExpandableContent from './ExpandableContent';
11 import Xmark from './Xmark';
12 import apiFetch from '@wordpress/api-fetch';
13
14 const SEARCH_DEBOUNCE_MS = 250;
15 const SEARCH_PER_PAGE = 20;
16
17 // Trailing debounce used for analytics; the search itself is debounced inline via a ref-tracked timer.
18 const debounce = (func, delay) => {
19 let timeout;
20 return (...args) => {
21 if (timeout) clearTimeout(timeout);
22 timeout = setTimeout(() => func(...args), delay);
23 };
24 };
25
26 const SearchModal = ({ initialSearchQuery, closeModal, docCategories, popularDocs, recentFaqs, placeholder, enableFaqSearch, enableDocsSearch, enableAiSearchSuggestions, kbBasedSearch, topWrapperRef = null }) => {
27 const [searchQuery, setSearchQuery] = useState(initialSearchQuery || '');
28 const [selectedCategory, setSelectedCategory] = useState('');
29 const [searchResults, setSearchResults] = useState([]);
30 const [activeTab, setActiveTab] = useState('docs');
31 // 'idle' (showing popular/recent), 'loading', 'success', 'empty', 'error'
32 const [status, setStatus] = useState('idle');
33 const inputRef = useRef(null);
34 const cache = useRef({});
35 const abortRef = useRef(null);
36 const debounceRef = useRef(null);
37 // Last query the user typed — used to reject out-of-order responses even if AbortController is unsupported.
38 const latestQueryRef = useRef('');
39 const decodeHtmlEntities = (str) => {
40 const textArea = document.createElement('textarea');
41 textArea.innerHTML = str;
42 return textArea.value;
43 };
44
45 const storeSearchKeyword = useCallback(
46 debounce(async (query, noResult = false) => {
47 const namespace = 'betterdocs/v1/';
48 const relativePath = `search-insert?s=${query}${noResult ? '&no_result=1' : ''}`;
49 try {
50 await apiFetch({ path: `${namespace}${relativePath}` });
51 } catch (error) {
52 console.error('Error storing search keyword:', error);
53 }
54 }, 300),
55 []
56 );
57
58 // Note: third arg `tab` is retained for backward compatibility with the Pro
59 // `betterdocs_search_modal_popular_keyword` filter, which calls this directly.
60 const fetchSearchResults = useCallback(async (query, category, tab) => {
61 // Cancel any in-flight request — only the most recent query may resolve into state.
62 if (abortRef.current) {
63 abortRef.current.abort();
64 }
65 const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
66 abortRef.current = controller;
67 latestQueryRef.current = query;
68
69 const namespace = 'betterdocs/v1/';
70 let apiPath = `${namespace}search?s=${encodeURIComponent(query)}&per_page=${SEARCH_PER_PAGE}`;
71
72 if (category) {
73 apiPath += `&doc_category=${encodeURIComponent(category)}`;
74 }
75 if (kbBasedSearch) {
76 apiPath += `&knowledge_base=${encodeURIComponent(kbBasedSearch)}`;
77 }
78
79 const commit = (results) => {
80 // Belt-and-suspenders: ignore responses that no longer match the latest query.
81 if (latestQueryRef.current !== query) return;
82 setSearchResults(results);
83 setStatus(results.length === 0 ? 'empty' : 'success');
84 };
85
86 if (cache.current[apiPath]) {
87 commit(cache.current[apiPath]);
88 return;
89 }
90
91 setStatus('loading');
92
93 try {
94 const fetchOptions = {
95 path: apiPath,
96 headers: {
97 'X-WP-Nonce': searchModalConfig?.nonce,
98 },
99 };
100 if (controller) fetchOptions.signal = controller.signal;
101
102 const results = await apiFetch(fetchOptions);
103 if (latestQueryRef.current !== query) return;
104 cache.current[apiPath] = results;
105 commit(results);
106 storeSearchKeyword(query, results.length === 0);
107 } catch (error) {
108 // Aborted requests are expected when the user keeps typing.
109 if (error && (error.name === 'AbortError' || (controller && controller.signal.aborted))) {
110 return;
111 }
112 if (latestQueryRef.current !== query) return;
113 console.error('Error fetching search results:', error);
114 setStatus('error');
115 }
116 }, [storeSearchKeyword, kbBasedSearch]);
117
118 // Backward-compat shim: Pro's popular-keyword chip calls setHasSearched(true) before
119 // firing fetchSearchResults. We no longer track hasSearched in state, but expose a no-op
120 // setter so the filter contract doesn't break.
121 const setHasSearched = useCallback((val) => {
122 if (!val) {
123 setStatus('idle');
124 setSearchResults([]);
125 }
126 }, []);
127
128 useEffect(() => {
129 const shouldSearch = searchQuery.length >= betterdocsSearchModalConfig.search_letter_limit ||
130 (searchQuery.length > 0 && selectedCategory);
131
132 if (debounceRef.current) {
133 clearTimeout(debounceRef.current);
134 debounceRef.current = null;
135 }
136
137 if (!shouldSearch) {
138 if (abortRef.current) {
139 abortRef.current.abort();
140 abortRef.current = null;
141 }
142 latestQueryRef.current = '';
143 setStatus('idle');
144 setSearchResults([]);
145 return;
146 }
147
148 debounceRef.current = setTimeout(() => {
149 fetchSearchResults(searchQuery, selectedCategory, activeTab);
150 }, SEARCH_DEBOUNCE_MS);
151
152 return () => {
153 if (debounceRef.current) {
154 clearTimeout(debounceRef.current);
155 debounceRef.current = null;
156 }
157 };
158 }, [searchQuery, selectedCategory, activeTab, fetchSearchResults]);
159
160 useEffect(() => {
161 return () => {
162 if (abortRef.current) abortRef.current.abort();
163 if (debounceRef.current) clearTimeout(debounceRef.current);
164 };
165 }, []);
166
167 const handleCategoryChange = (selectedOption) => {
168 // Always update the selected category state
169 // The useEffect will handle whether to trigger a search based on searchQuery length
170 setSelectedCategory(selectedOption ? selectedOption.value : '');
171 };
172
173 const handleInsideClick = (e) => {
174 e.stopPropagation(); // Prevents the modal from closing when clicking inside it
175 };
176
177 const handleOutsideClick = () => {
178 closeModal();
179 };
180
181 const handleClear = (e) => {
182 e.stopPropagation(); // Prevent event from bubbling up
183 setSearchQuery('');
184 inputRef.current.focus(); // Refocus the input field
185 };
186
187 // Idle (no search) → fall back to props; otherwise use the live search results.
188 const displayResults = status === 'idle'
189 ? (activeTab === 'docs' ? (popularDocs || []) : (recentFaqs || []))
190 : searchResults;
191
192 const filterResults = (type) => displayResults.filter(result => result.post_type === type);
193
194 // FAQ tab should appear when there are FAQs to show in the current state.
195 const hasFaqResults = status === 'idle'
196 ? (recentFaqs && recentFaqs.length > 0)
197 : searchResults.some(r => r.post_type === 'betterdocs_faq');
198
199 const tabs = [
200 enableDocsSearch ? { label: __('Docs', 'betterdocs'), type: 'docs', icon: <DocsIcon /> } : {},
201 ...(hasFaqResults && enableFaqSearch ? [{ label: __('FAQ', 'betterdocs'), type: 'betterdocs_faq', icon: <FaqIcon /> }] : []), // Conditionally add FAQ tab
202 ];
203
204 useEffect(() => {
205 if (inputRef.current) {
206 inputRef.current.focus(); // Set focus on the input field when the modal opens
207 }
208 }, []);
209
210 useEffect(() => {
211 if ((enableDocsSearch == null && enableFaqSearch == null) || (!enableDocsSearch && !enableFaqSearch)) { // if both are disabled
212 setActiveTab('');
213 } else if (!enableDocsSearch || (enableDocsSearch == null)) { // if docs is disabled
214 setActiveTab('betterdocs_faq');
215 } else if (!enableFaqSearch || (enableFaqSearch == null)) { // if faq is disabled
216 setActiveTab('docs');
217 }
218 }, []);
219
220 const searchNotFoundText = betterdocsSearchModalConfig?.search_not_found_text ? (betterdocsSearchModalConfig?.search_not_found_text
221 ).replace(/\.$/, '') + ' for' : __('No Docs found for', 'betterdocs');
222
223 return (
224 <div className="betterdocs-modal-overlay" onMouseDown={handleOutsideClick}>
225 <div className="betterdocs-search-wrapper" style={{ display: 'block' }}>
226 <div className="betterdocs-search-details" onMouseDown={handleInsideClick}>
227 <div className="xmark">
228 <Xmark onClick={closeModal} width="20" height="20" fill="#475467" />
229 </div>
230 <div className="betterdocs-search-header">
231 <MagnifyIcon />
232 <div className="betterdocs-searchform-input-wrap">
233 <input
234 type="text"
235 className="betterdocs-search-field"
236 aria-label={__('Search Input', 'betterdocs')}
237 placeholder={placeholder}
238 value={searchQuery}
239 onChange={(e) => setSearchQuery(e.target.value)}
240 ref={inputRef}
241 autoComplete="off"
242 data-form-type="other"
243 />
244 {searchQuery ? (
245 <ClearIcon className='clear-icon' onClick={handleClear} />
246 ) : (
247 <span className='esc-button'>{__('ESC', 'betterdocs')}</span>
248 )}
249 </div>
250 {betterdocsSearchModalConfig.advance_search && docCategories.length > 0 && activeTab === 'docs' && applyFilters(
251 "betterdocs_search_modal_category_select",
252 [],
253 [selectedCategory, handleCategoryChange, docCategories, topWrapperRef]
254 )}
255 </div>
256 {applyFilters(
257 "betterdocs_search_modal_after_srarch_header",
258 [],
259 [searchQuery, setSearchQuery, setHasSearched, selectedCategory, activeTab, fetchSearchResults, closeModal, topWrapperRef, enableAiSearchSuggestions]
260 )}
261 {betterdocsSearchModalConfig.advance_search && applyFilters(
262 "betterdocs_search_modal_popular_keyword",
263 [],
264 [setSearchQuery, setHasSearched, selectedCategory, activeTab, fetchSearchResults, topWrapperRef]
265 )}
266
267 <div className="betterdocs-search-content">
268 <div className="betterdocs-search-info-tab betterdocs-search-tabs">
269 {tabs.map(tab => {
270 if (Object.keys(tab)?.length > 0) {
271 return <div
272 key={tab.type}
273 className={`betterdocs-tab-items ${activeTab === tab.type ? 'active' : ''}`}
274 onClick={() => setActiveTab(tab.type)}
275 >
276 {tab.icon && <span className="tab-icon">{tab.icon}</span>}
277 <span>{tab.label}</span>
278 </div>
279 }
280 })}
281 </div>
282 <div className="betterdocs-search-items-wrapper betterdocs-search-tab-content-wrapper">
283 {status === 'loading' ? (
284 <div className="betterdocs-search-skeleton" aria-busy="true" aria-live="polite">
285 {[0, 1, 2, 3, 4].map(i => (
286 <div className="betterdocs-search-skeleton-row" key={i}>
287 <div className="skeleton-icon" />
288 <div className="skeleton-lines">
289 <div className="skeleton-line skeleton-line-title" />
290 <div className="skeleton-line skeleton-line-meta" />
291 </div>
292 </div>
293 ))}
294 </div>
295 ) : filterResults(activeTab).length > 0 ? (
296 filterResults(activeTab).map((result, index) => (
297 <div className="betterdocs-search-item-content" key={result.permalink || index}>
298 <div className="betterdocs-search-item-list">
299 <div className="content-main">
300 <DocsIcon />
301 {result.permalink && result.post_type === 'docs' ? (
302 <a className="hasperma" href={result.permalink}>
303 <h4>{decodeHtmlEntities(result.title)}</h4>
304 </a>
305 ) : (
306 <h4>{decodeHtmlEntities(result.title)}</h4>
307 )}
308 </div>
309 {result.post_type === 'betterdocs_faq' && (
310 <ExpandableContent content={result.content} wordLimit={30} />
311 )}
312 {result.post_type === 'docs' && (
313 <div className="content-sub">
314 <FolderIcon />
315 <h5>{result.taxonomies && result.taxonomies.length > 0 ? decodeHtmlEntities(result.taxonomies) : 'No taxonomy'}</h5>
316 </div>
317 )}
318 </div>
319 </div>
320 ))
321 ) : (status === 'empty' || status === 'error') && (
322 <div className="betterdocs-search-items-not-found">
323 <NoResultIcon />
324 <h4>{activeTab === 'docs' ? searchNotFoundText : __('No FAQ found for', 'betterdocs')} "{searchQuery}"</h4>
325 </div>
326 )}
327 </div>
328 </div>
329 </div>
330 </div>
331 </div>
332 );
333 };
334
335 export default SearchModal;
336