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 / admin / faq-builder / utils / function.js

function.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/admin/faq-builder/utils/function.js

522 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import apiFetch from "@wordpress/api-fetch";
2 import sampleData from "../sample-faq.json";
3 import React, { useEffect, useState } from "react";
4
5 // Default taxonomy for the General FAQ Builder. Product FAQ Groups pass
6 // `betterdocs_product_faq_category` so the same data layer serves both tabs.
7 const DEFAULT_TAXONOMY = "betterdocs_faq_category";
8
9
10
11 const ORDER_MAP = {
12 default: { order: 'asc', orderby: 'id' },
13 most_recent: { order: 'desc', orderby: 'id' },
14 least_recent: { order: 'asc', orderby: 'id' },
15 a_to_z: { order: 'asc', orderby: 'name' },
16 z_to_a: { order: 'desc', orderby: 'name' },
17 most_questions: { order: 'desc', orderby: 'count' },
18 };
19
20 // Generate an FAQ answer via the internal endpoint (server-side OpenAI call).
21 // The API key never leaves the server. Returns { success, data: { answer, model } }.
22 export const generateFaqAnswer = ({ question, keywords = "" }) => {
23 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
24 return apiFetch({
25 path: `betterdocs/v1/ai-faq`,
26 method: "POST",
27 data: {
28 question,
29 keywords,
30 nonce: betterdocs_admin.nonce,
31 },
32 });
33 };
34
35 // this function work for fetch all categories.
36 export const getAllCategories = ({ queryKey }) => {
37 const [_, perPage, currentPage, orderKey, taxonomy = DEFAULT_TAXONOMY] = queryKey;
38 const { order, orderby } = ORDER_MAP[orderKey] || ORDER_MAP.most_recent;
39 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
40 let response = apiFetch({
41 // betterdocs_faq_order drives the server-side ordering (and pagination
42 // boundaries) via FAQBuilder::faq_category_orderby_meta, so the whole
43 // list — not just the current page — is sorted in the chosen order.
44 path: `wp/v2/${taxonomy}?per_page=${perPage}&page=${currentPage}&order=${order}&orderby=${orderby}&betterdocs_faq_order=${orderKey || 'default'}`,
45 method: "GET",
46 parse: false,
47 });
48
49 let total = 0;
50 let totalPages = 0;
51
52 return response
53 .then((res) => {
54 totalPages = res?.headers?.get("X-WP-TotalPages");
55 total = res?.headers?.get("X-WP-Total");
56 return res.json();
57 })
58 .then((res) => {
59 return {
60 data: res,
61 totalPages,
62 total,
63 };
64 });
65 };
66
67 // Find which pagination page a given FAQ group falls on, under the active sort
68 // order. The group list is server-paginated, so the current page alone can't
69 // tell us where an off-page group lives — we fetch the full ordered id list
70 // (ids only, in batches) and compute the page from the target's index. Pages
71 // are bounded by the server `orderby` (the same one getAllCategories uses), so
72 // this matches where the group will actually render. Returns a 1-based page
73 // (1 if the group isn't found).
74 export const findCategoryPage = ({ taxonomy = DEFAULT_TAXONOMY, orderKey = 'default', perPage = 10, targetId }) => {
75 const { order, orderby } = ORDER_MAP[orderKey] || ORDER_MAP.most_recent;
76 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
77 const BATCH = 100;
78
79 const collect = (page, acc) =>
80 apiFetch({
81 path: `wp/v2/${taxonomy}?per_page=${BATCH}&page=${page}&order=${order}&orderby=${orderby}&betterdocs_faq_order=${orderKey || 'default'}&_fields=id`,
82 method: "GET",
83 parse: false,
84 }).then((res) => {
85 const totalPages = Number(res?.headers?.get("X-WP-TotalPages") || 1);
86 return res.json().then((rows) => {
87 const ids = acc.concat((rows || []).map((r) => r?.id));
88 return page >= totalPages ? ids : collect(page + 1, ids);
89 });
90 });
91
92 return collect(1, []).then((ids) => {
93 const idx = ids.findIndex((id) => String(id) === String(targetId));
94 return idx < 0 ? 1 : Math.floor(idx / perPage) + 1;
95 });
96 };
97
98 export const getSearchedFaqCategories = ({queryKey}) => {
99 const [_, faqKeyword, taxonomy = DEFAULT_TAXONOMY] = queryKey;
100 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
101 let response = apiFetch({
102 path: `betterdocs/v1/faq-terms-by-keyword-search?search=${encodeURIComponent(faqKeyword)}&taxonomy=${encodeURIComponent(taxonomy)}`,
103 method: "GET",
104 parse: false,
105 });
106
107 return response
108 .then((res) => {
109 return res.json();
110 }).then((res) => {
111 return res;
112 });
113 }
114
115
116 // this function work for fetch all categories.
117 export const searchCategories = (searchString, taxonomy = DEFAULT_TAXONOMY) => {
118 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
119 return apiFetch({
120 path: `betterdocs/faq/category_search/?title=${searchString}&taxonomy=${taxonomy}`,
121 method: "GET",
122 });
123 };
124
125 // this function work for create new faq category
126 export const createFaqCategory = (data, taxonomy = DEFAULT_TAXONOMY) => {
127 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
128 return apiFetch({
129 path: `betterdocs/faq/create_category`,
130 method: "POST",
131 data: {
132 title: data?.title,
133 group_icon_url:data?.icon,
134 // slug: data?.slug,
135 slug: null,
136 // description: data?.description ? data?.description : "undefined",
137 description: null,
138 taxonomy: taxonomy,
139 product_cats: data?.product_cats || [],
140 products: data?.products || [],
141 all_products: data?.all_products ? 1 : 0,
142 nonce: betterdocs_admin.nonce,
143 },
144 });
145 };
146
147 // this function work for update faq category
148 export const updateFaqCategory = (data, taxonomy = DEFAULT_TAXONOMY) => {
149 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
150 return apiFetch({
151 path: `betterdocs/faq/update_category`,
152 method: "POST",
153 data: {
154 term_id: data?.term_id,
155 title: data?.title,
156 group_icon_url:data?.icon,
157 // slug: data?.slug,
158 slug: null,
159 // description: data?.description ? data?.description : "undefined",
160 description: null,
161 taxonomy: taxonomy,
162 product_cats: data?.product_cats || [],
163 products: data?.products || [],
164 all_products: data?.all_products ? 1 : 0,
165 nonce: betterdocs_admin.nonce,
166 },
167 });
168 };
169
170 // this function work for delete faq category
171 export const deleteFaqCategory = (id, with_all_post, taxonomy = DEFAULT_TAXONOMY) => {
172 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
173 return apiFetch({
174 path: `betterdocs/faq/delete_category`,
175 method: "POST",
176 data: {
177 term_id: id,
178 with_all_post: with_all_post,
179 taxonomy: taxonomy,
180 nonce: betterdocs_admin.nonce,
181 },
182 });
183 };
184
185 // this function work for change category active status
186 export const categoryStatusChange = (term_id, status) => {
187 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
188 return apiFetch({
189 path: `betterdocs/faq/category_status`,
190 method: "POST",
191 data: {
192 term_id: term_id,
193 status: status,
194 nonce: betterdocs_admin.nonce,
195 },
196 });
197 };
198
199 // this function work for change category order
200 export const reOrderCategories = (data) => {
201 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
202 const faq_category_order = JSON.stringify(data);
203 return apiFetch({
204 path: `betterdocs/faq/category_order`,
205 method: "POST",
206 data: {
207 faq_category_order: faq_category_order,
208 nonce: betterdocs_admin.nonce,
209 },
210 });
211 };
212
213 // Persist the FAQ Builder header order dropdown so the front end mirrors it.
214 // Stored as a single global preference (option `betterdocs_faq_order`).
215 export const saveFaqOrder = (order) => {
216 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
217 return apiFetch({
218 path: `betterdocs/faq/order`,
219 method: "POST",
220 data: { order: order, nonce: betterdocs_admin.nonce },
221 });
222 };
223
224 // this function work for fetch all faqs based on category.
225 export const getAllFaqBasedOnCategory = async ({ queryKey }) => {
226 const [_, id, taxonomy = DEFAULT_TAXONOMY] = queryKey;
227 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
228 return apiFetch({
229 path: `wp/v2/betterdocs_faq?${taxonomy}=${id}&status=publish,draft&posts_per_page=-1`,
230 method: "GET",
231 });
232 };
233
234 // this function work for fetch all Uncategorized faqs.
235 // Ungrouped FAQs for the active tab. The taxonomy scopes the bucket: the Product tab
236 // gets the FAQs scoped to Product that have no Product group, the General tab gets the
237 // rest — so an ungrouped Product FAQ no longer surfaces under General.
238 export const getAllUncategorizedFaq = async ({ queryKey }) => {
239 const [_, taxonomy = DEFAULT_TAXONOMY] = queryKey;
240 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
241 return apiFetch({
242 path: `betterdocs/faq/uncategorised?status=publish,draft&taxonomy=${taxonomy}`,
243 method: "GET",
244 });
245 };
246
247 // this function work for create new faq under category
248 export const createFAQ = (data, taxonomy = DEFAULT_TAXONOMY) => {
249 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
250 return apiFetch({
251 path: `betterdocs/faq/create_post`,
252 method: "POST",
253 data: {
254 post_title: data?.post_title,
255 post_content: data?.post_content,
256 term_id: data?.term_id ? data?.term_id : null,
257 taxonomy: taxonomy,
258 nonce: betterdocs_admin.nonce,
259 },
260 });
261 };
262
263 // this function work for update faq under category
264 export const updateFaq = (data, taxonomy = DEFAULT_TAXONOMY) => {
265 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
266 return apiFetch({
267 path: `betterdocs/faq/update_post`,
268 method: "POST",
269 data: {
270 post_id: data?.post_id,
271 post_title: data?.post_title,
272 post_content: data?.post_content,
273 term_id: data?.term_id,
274 taxonomy: taxonomy,
275 nonce: betterdocs_admin.nonce,
276 },
277 });
278 };
279
280 // this function work for delate faq under category
281 export const deleteFaq = (id) => {
282 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
283 return apiFetch({
284 path: `betterdocs/faq/delete_post`,
285 method: "POST",
286 data: {
287 post_id: id,
288 nonce: betterdocs_admin.nonce,
289 },
290 });
291 };
292
293 // this function work for change faq active status
294 export const faqStatusChange = (post_id, status) => {
295 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
296 return apiFetch({
297 path: `wp/v2/betterdocs_faq/${post_id}`,
298 method: "POST",
299 data: { status: status, nonce: betterdocs_admin.nonce },
300 });
301 };
302
303 // this function work for change faq order
304 export const reOrderFaqList = (category, updatedFaqsList) => {
305 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
306 return apiFetch({
307 path: `betterdocs/faq/update_order_by_category?term_id=${category}&posts=${updatedFaqsList}`,
308 method: "POST",
309 data: {
310 nonce: betterdocs_admin.nonce,
311 },
312 });
313 };
314
315 // ---------------------------------------------------------------------------
316 // WooCommerce Product FAQ — Category Assignments
317 // ---------------------------------------------------------------------------
318
319 // Fetch WooCommerce product categories as { id, name } options for the
320 // Product FAQ Group assignment selector. Optional search narrows by name.
321 export const getProductCategories = async (search = "") => {
322 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
323 return apiFetch({
324 path: `betterdocs/v1/woo-product-faq/product-categories?search=${encodeURIComponent(
325 search
326 )}`,
327 method: "GET",
328 });
329 };
330
331 // Search products by title, or resolve specific products by id (include is a
332 // comma-separated id list) to prefill the Products selector when editing.
333 export const searchProducts = async ({ q = "", include = "" } = {}) => {
334 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
335 return apiFetch({
336 path: `betterdocs/v1/woo-product-faq/assignable-products?q=${encodeURIComponent(
337 q
338 )}&include=${encodeURIComponent(include)}`,
339 method: "GET",
340 });
341 };
342
343 export const getWooFaqDisplaySettings = async () => {
344 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
345 return apiFetch({
346 path: `betterdocs/v1/woo-product-faq/display-settings`,
347 method: "GET",
348 });
349 };
350
351 export const saveWooFaqDisplaySettings = (settings) => {
352 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
353 return apiFetch({
354 path: `betterdocs/v1/woo-product-faq/display-settings`,
355 method: "POST",
356 data: { settings: settings, nonce: betterdocs_admin.nonce },
357 });
358 };
359
360 // this function work for create sample data
361 export const generateSampleData = () => {
362 const data = JSON.stringify(sampleData);
363 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
364 return apiFetch({
365 path: `betterdocs/faq/sample_data`,
366 method: "POST",
367 data: { sample_data: data, nonce: betterdocs_admin.nonce },
368 });
369 };
370
371
372 // submit toggle response data
373 export const getToggleResponse = (id, toggle) => {
374 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
375 return apiFetch({
376 path: `betterdocs/v1/faq-accordion-toggle`,
377 method: "POST",
378 body: JSON.stringify({ faq_id:id, nonce: betterdocs_admin.nonce, toggle: toggle}),
379 });
380 };
381
382 export const getSettingsData = async () => {
383 try {
384 apiFetch.use(apiFetch.createNonceMiddleware(betterdocs_admin.nonce));
385
386 const response = await apiFetch({
387 path: 'betterdocs/v1/settings',
388 });
389
390 if (!response || response.error) {
391 throw new Error('Failed to fetch settings data');
392 }
393
394 return response;
395 } catch (error) {
396 console.error('Error fetching settings data:', error);
397 throw error;
398 }
399 };
400
401 export const mediaUpload = (callback) => {
402 // Ensure wp.media is available
403 if (typeof wp === "undefined" || typeof wp.media === "undefined") {
404 console.error("WordPress Media Library is not loaded.");
405 return;
406 }
407
408 // Create the media frame
409 const mediaFrame = wp.media({
410 title: "Select or Upload Media",
411 button: {
412 text: "Use this media",
413 },
414 multiple: false, // Single file selection
415 });
416
417 // On select, retrieve the selected media
418 mediaFrame.on("select", () => {
419 const attachment = mediaFrame.state().get("selection").first().toJSON();
420 if (callback && typeof callback === "function") {
421 callback(attachment.url, attachment.id); // Return the URL of the uploaded/selected image
422 }
423 });
424
425 // Open the media frame
426 mediaFrame.open();
427 };
428
429 // Wait out the 0.32s slide-open transition (see _category-list.scss /
430 // _faq-list.scss) before measuring, so the scroll lands on the panel's FINAL
431 // position instead of a mid-animation one. Measuring too early made the page
432 // scroll while the panel was still growing — reading as a "scroll, then jump".
433 export const EXPAND_ANIM_MS = 340;
434 // Gap left above the item head when scrolling it to the top of its section.
435 const SCROLL_PAD = 12;
436
437 // Height of the fixed/sticky chrome at the top of the viewport: the WP admin bar
438 // plus the now-sticky BetterDocs dashboard header (.betterdocs-dashboard-header-top).
439 // Page-level smooth scrolls must subtract this so the target lands BELOW the
440 // sticky header instead of underneath it. `pad` is extra breathing room.
441 export const getStickyTopOffset = (pad = 16) => {
442 const adminBar = document.getElementById("wpadminbar");
443 const stickyHeader = document.querySelector(".betterdocs-dashboard-header-top");
444 return (adminBar?.offsetHeight || 32) + (stickyHeader?.offsetHeight || 0) + pad;
445 };
446
447 // Walk up from `el` to the nearest ancestor that actually scrolls vertically
448 // (overflow-y auto/scroll AND content taller than the box). Returns null when
449 // nothing between the element and the document scrolls — e.g. a FAQ group row,
450 // or a FAQ inside a short, un-capped list — so the caller falls back to the page.
451 const getScrollableParent = (el) => {
452 let node = el.parentElement;
453 while (node && node !== document.body && node !== document.documentElement) {
454 const overflowY = getComputedStyle(node).overflowY;
455 if (
456 (overflowY === "auto" || overflowY === "scroll") &&
457 node.scrollHeight > node.clientHeight
458 ) {
459 return node;
460 }
461 node = node.parentElement;
462 }
463 return null;
464 };
465
466 /**
467 * After an accordion item (FAQ group / FAQ / glossary) is expanded, bring it into
468 * view — but only when it isn't already fully visible, so on-screen items never
469 * jump. Call this ONLY from the open branch of a toggle, never on close.
470 *
471 * If the item lives inside a capped, independently-scrollable section (e.g.
472 * `.bd-faq-category-body-inner`, max-height 575px), it scrolls WITHIN that
473 * section — moving the page would be the wrong axis. Otherwise it falls back to a
474 * page scroll that lands the header just below the WP admin bar.
475 *
476 * The element is found by a namespaced `data-bd-expand-id` attribute on its root
477 * node (e.g. `ctg-12`, `faq-34`) rather than a ref, because every row's root
478 * already carries react-beautiful-dnd's `provided.innerRef`.
479 *
480 * @param {string} id Namespaced expand id, e.g. `ctg-${termId}` or `faq-${postId}`.
481 */
482 export const scrollExpandedIntoView = (id) => {
483 if (id === undefined || id === null) return;
484 // rAF defers past the state-commit/paint that starts the open transition; the
485 // timeout lets the panel reach (near) final height before we measure.
486 requestAnimationFrame(() => {
487 setTimeout(() => {
488 const el = document.querySelector(`[data-bd-expand-id="${id}"]`);
489 if (!el) return;
490 const rect = el.getBoundingClientRect();
491 const container = getScrollableParent(el);
492
493 if (container) {
494 // Scroll inside the section so the item head sits at its top.
495 const cRect = container.getBoundingClientRect();
496 // Already fully visible within the section → don't move.
497 if (rect.top >= cRect.top && rect.bottom <= cRect.bottom) return;
498 container.scrollTo({
499 top: container.scrollTop + (rect.top - cRect.top) - SCROLL_PAD,
500 behavior: "smooth",
501 });
502 return;
503 }
504
505 // Page-level fallback: land the head just below the admin bar AND the
506 // sticky dashboard header. Panels can exceed the viewport, so align the
507 // head (not the whole element).
508 const offset = getStickyTopOffset();
509 if (rect.top >= offset && rect.bottom <= window.innerHeight) return;
510 window.scrollTo({
511 top: window.scrollY + rect.top - offset,
512 behavior: "smooth",
513 });
514 }, EXPAND_ANIM_MS);
515 });
516 };
517
518
519
520
521
522