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 / components / CategoryModal.js

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

521 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useEffect, useLayoutEffect } from "react";
2 import { __ } from "@wordpress/i18n";
3 import { decodeEntities } from "@wordpress/html-entities";
4 import { toast } from "react-toastify";
5 import Modal from "react-modal";
6 import ErrorMessage from "../utils/ErrorMessage";
7 import { validateFormFields } from "../utils/validations";
8 import {
9 createFaqCategory,
10 updateFaqCategory,
11 mediaUpload,
12 getProductCategories,
13 searchProducts,
14 } from "../utils/function";
15 import { useFAQScope } from "../context/ScopeContext";
16 import { useFAQ } from "../../components/FAQContext";
17 import GroupMultiSelect from "../woocommerce/GroupMultiSelect";
18
19 const GROUP_PRODUCT_CATS_META = "_betterdocs_faq_group_product_cats";
20 const GROUP_PRODUCTS_META = "_betterdocs_faq_group_products";
21 const GROUP_ALL_PRODUCTS_META = "_betterdocs_faq_group_all_products";
22
23 // WordPress term `name` column is varchar(200); longer names cause a DB insert
24 // error, so cap the FAQ group title input here (server also enforces this).
25 const MAX_GROUP_NAME = 200;
26
27 // Custom modal styles
28 const customStyles = {
29 overlay: {
30 position: "fixed",
31 top: 0,
32 left: 0,
33 right: 0,
34 bottom: 0,
35 backgroundColor: "rgba(35, 40, 45, 0.5)",
36 zIndex: 9990,
37 padding: 50,
38 paddingTop: 70,
39 display: "flex",
40 flexDirection: "column",
41 overflow: "auto",
42 },
43 content: {
44 width: "40rem",
45 maxWidth: "90%",
46 position: "static",
47 height: "auto",
48 overflow: "initial",
49 margin: "auto",
50 },
51 };
52
53 const CategoryModal = ({
54 open,
55 reFetchCategories,
56 setShowCategoryModal,
57 updateCategory,
58 setUpdateCategory,
59 }) => {
60 const { taxonomy, isProduct } = useFAQScope();
61 const { setFocusTarget } = useFAQ();
62 const [formFields, setFormFields] = useState({
63 title: "",
64 icon: "", // Added for storing icon URL
65 });
66 const [errors, setErrors] = useState({
67 title: "",
68 });
69
70 // Product FAQ group assignments (only used when scope is the Product taxonomy).
71 const [catOptions, setCatOptions] = useState([]);
72 const [catOptionsLoading, setCatOptionsLoading] = useState(true);
73 const [assignedCats, setAssignedCats] = useState([]);
74 const [assignedProducts, setAssignedProducts] = useState([]);
75 // Targeting scope: "all" (show on every product page — the store-wide group) or
76 // "specific" (the category/product selects below). Mutually exclusive, so a generated
77 // store-wide group can be switched to specific products and back.
78 const [assignScope, setAssignScope] = useState("specific");
79 // QA-002: guards the Create/Update button against double submission.
80 const [submitting, setSubmitting] = useState(false);
81 // Start in the loading state when the group being edited already has saved
82 // products, so the placeholder chips reserve the row on the very first paint
83 // (matching `catOptionsLoading`) instead of popping in a frame later.
84 const [productsLoading, setProductsLoading] = useState(
85 () => Boolean(updateCategory?.meta?.[GROUP_PRODUCTS_META]?.length)
86 );
87
88 useEffect(() => {
89 if (updateCategory !== null) {
90 setFormFields({
91 ...formFields,
92 title: decodeEntities(updateCategory?.name),
93 icon: updateCategory?.meta?.faq_group_icon[0] || "",
94 });
95 } else {
96 resetFormField();
97 }
98 }, [open, updateCategory]);
99
100 // Load product category options once when the Product modal is in use.
101 useEffect(() => {
102 if (!isProduct) return;
103 setCatOptionsLoading(true);
104 getProductCategories()
105 .then((res) =>
106 setCatOptions(
107 (res?.data || []).map((c) => ({
108 value: c.id,
109 label: decodeEntities(c.name),
110 }))
111 )
112 )
113 .catch(() => setCatOptions([]))
114 .finally(() => setCatOptionsLoading(false));
115 }, [isProduct]);
116
117 // Prefill assigned categories from the group meta (resolved via options).
118 // useLayoutEffect so the resolved chips (or, while options load, the skeleton
119 // driven by `catOptionsLoading`) are in place before the first paint — no
120 // post-paint reflow of the chip row.
121 useLayoutEffect(() => {
122 if (!isProduct || !open) return;
123 if (updateCategory) {
124 const ids = updateCategory?.meta?.[GROUP_PRODUCT_CATS_META] || [];
125 setAssignedCats(catOptions.filter((o) => ids.includes(o.value)));
126 } else {
127 setAssignedCats([]);
128 }
129 }, [open, updateCategory, isProduct, catOptions]);
130
131 // Reflect the group's saved scope so a store-wide group opens on "All products"
132 // (and can be switched to specific), and a new group defaults to "specific".
133 useLayoutEffect(() => {
134 if (!isProduct || !open) return;
135 setAssignScope(updateCategory?.meta?.[GROUP_ALL_PRODUCTS_META] ? "all" : "specific");
136 }, [open, updateCategory, isProduct]);
137
138 // Prefill assigned products by resolving their IDs to { id, name }.
139 // useLayoutEffect so `productsLoading` flips to `true` (and the skeleton chips
140 // reserve their row height + margin) before the browser paints, rather than a
141 // frame after — otherwise the row renders empty first and the modal jumps.
142 useLayoutEffect(() => {
143 if (!isProduct || !open) return;
144 const ids = updateCategory?.meta?.[GROUP_PRODUCTS_META] || [];
145 if (updateCategory && ids.length) {
146 setProductsLoading(true);
147 searchProducts({ include: ids.join(",") })
148 .then((res) =>
149 setAssignedProducts(
150 (res?.data || []).map((p) => ({
151 value: p.id,
152 label: decodeEntities(p.name),
153 }))
154 )
155 )
156 .catch(() => setAssignedProducts([]))
157 .finally(() => setProductsLoading(false));
158 } else {
159 setAssignedProducts([]);
160 setProductsLoading(false);
161 }
162 }, [open, updateCategory, isProduct]);
163
164 const loadProductOptions = (input) =>
165 searchProducts({ q: input })
166 .then((res) =>
167 (res?.data || []).map((p) => ({
168 value: p.id,
169 label: decodeEntities(p.name),
170 }))
171 )
172 .catch(() => []);
173
174 const resetFormField = () => {
175 setFormFields({
176 title: "",
177 icon: "",
178 });
179 setAssignedCats([]);
180 setAssignedProducts([]);
181 };
182
183 const resetErrors = () => {
184 setErrors({
185 title: "",
186 });
187 };
188
189 const handleCancel = () => {
190 resetErrors();
191 setShowCategoryModal(false);
192 setUpdateCategory(null);
193 };
194
195 const validationRules = () => {
196 return {
197 title: {
198 required: {
199 value: true,
200 message: __("Please Insert FAQ Group Title.", "betterdocs"),
201 },
202 },
203 };
204 };
205
206 const handleIconUpload = () => {
207 mediaUpload((imageUrl, id) => {
208 setFormFields({ ...formFields, icon: imageUrl });
209 });
210 };
211
212 const handleIconRemove = () => {
213 setFormFields({ ...formFields, icon: "" });
214 };
215
216 const handleSubmit = (e) => {
217 e.preventDefault();
218 // QA-002: ignore repeat submits while a request is in flight.
219 if (submitting) {
220 return;
221 }
222 const validated = validateFormFields(formFields, validationRules());
223 if (!validated.valid) {
224 setErrors(validated.errors);
225 return;
226 }
227 const data = {
228 title: formFields?.title,
229 icon: formFields?.icon,
230 };
231
232 if (isProduct) {
233 const allProducts = assignScope === "all";
234 data.all_products = allProducts ? 1 : 0;
235 // The scopes are mutually exclusive; don't send category/product targets when the
236 // group is set to show on all products (the server clears them anyway).
237 data.product_cats = allProducts ? [] : assignedCats.map((o) => o.value);
238 data.products = allProducts ? [] : assignedProducts.map((o) => o.value);
239 }
240
241 setSubmitting(true);
242 if (updateCategory !== null) {
243 updateFaqCategory({ ...data, term_id: updateCategory?.id }, taxonomy)
244 .then((res) => {
245 if (res) {
246 resetErrors();
247 reFetchCategories();
248 toast.success(__("FAQ Group updated.", "betterdocs"));
249 }
250 })
251 .catch((err) => {
252 const isDuplicate =
253 err?.code === "term_exists" ||
254 /already exists/i.test(err?.message || "");
255 toast.error(
256 isDuplicate
257 ? __("Name already exists. Try a different one!", "betterdocs")
258 : err?.message || __("Something went wrong!", "betterdocs")
259 );
260 })
261 .finally(() => {
262 setSubmitting(false);
263 setShowCategoryModal(false);
264 setUpdateCategory(null);
265 });
266 } else {
267 createFaqCategory(data, taxonomy)
268 .then((res) => {
269 if (res) {
270 resetErrors();
271 reFetchCategories();
272 toast.success(__("FAQ Group created.", "betterdocs"));
273 // Reveal the new group: jump to the pagination page it lands on
274 // (under the active order) and flash it — same one-shot focus
275 // signal the new-FAQ (?faq_action=create) flow uses.
276 if (res?.term_id) {
277 setFocusTarget({ groupId: res.term_id });
278 }
279 }
280 })
281 .catch((err) => {
282 const isDuplicate =
283 err?.code === "term_exists" ||
284 /already exists/i.test(err?.message || "");
285 toast.error(
286 isDuplicate
287 ? __("Name already exists. Try a different one!", "betterdocs")
288 : err?.message || __("Something went wrong!", "betterdocs")
289 );
290 })
291 .finally(() => {
292 setSubmitting(false);
293 setShowCategoryModal(false);
294 });
295 }
296 };
297
298 return (
299 <Modal
300 isOpen={open}
301 className="bd-faq-ctg-modal"
302 onRequestClose={() => handleCancel()}
303 style={customStyles}
304 >
305 <form onSubmit={handleSubmit}>
306 <div className="bd-faq-ctg-header">
307 <span
308 className="bd-faq-stat-card__icon"
309 style={{ "--bd-icon-tint": "#00B884", "--bd-icon-tint-rgb": "0, 184, 132" }}
310 >
311 <svg
312 width={20}
313 height={20}
314 viewBox="0 0 24 24"
315 fill="none"
316 stroke="currentColor"
317 strokeWidth={1.7}
318 strokeLinecap="round"
319 strokeLinejoin="round"
320 >
321 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5v14zM4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5" />
322 </svg>
323 </span>
324 <div className="bd-faq-ctg-heading">
325 <h3 className="bd-faq-ctg-title">
326 {updateCategory
327 ? (isProduct
328 ? __("Update Product FAQ Group", "betterdocs")
329 : __("Update FAQ Group", "betterdocs"))
330 : (isProduct
331 ? __("Create a New Product FAQ Group", "betterdocs")
332 : __("Create New FAQ Group", "betterdocs"))}
333 </h3>
334 <p className="bd-faq-ctg-subtitle">
335 {__("Organize related FAQs into a group.", "betterdocs")}
336 </p>
337 </div>
338 </div>
339 <div className="bd-faq-ctg-body">
340 <div className="bd-faq-cgt-field">
341 <label className="bd-faq-label">
342 {__("FAQ Group Title", "betterdocs")}
343 </label>
344 <input
345 type="text"
346 className="bd-faq-input"
347 maxLength={MAX_GROUP_NAME}
348 value={formFields?.title}
349 onChange={(e) =>
350 setFormFields({
351 ...formFields,
352 title: e.target.value.replace(/^\s+/, "").slice(0, MAX_GROUP_NAME),
353 })
354 }
355 placeholder={__("FAQ Group Title...", "betterdocs")}
356 />
357 {formFields?.title?.length >= MAX_GROUP_NAME && (
358 <p className="bd-faq-max-length-note">
359 {__("Maximum character limit reached", "betterdocs")}
360 </p>
361 )}
362 <ErrorMessage error={errors.title} />
363 </div>
364
365 {/* Icon Upload Section */}
366 <div className="bd-faq-cgt-field bd-faq-icon-field">
367 <label className="bd-faq-label">{__("FAQ Group Icon", "betterdocs")}</label>
368 {formFields.icon ? (
369 <div className="bd-faq-control bd-faq-media">
370 <div className="bd-faq-image-preview">
371 <img src={formFields.icon} alt="Selected Icon" />
372 </div>
373 <div className="bd-faq-image-uploader uploaded">
374 <div className="bd-faq_image_overlay">
375 <button
376 type="button"
377 className="bd-faq-btn bd-faq-image-change-btn"
378 onClick={handleIconUpload}
379 >
380 <i className="btd-icon btd-upload"></i>
381 </button>
382 <button
383 type="button"
384 className="bd-faq-btn bd-faq-image-remove-btn"
385 onClick={handleIconRemove}
386 >
387 <i className="btd-icon btd-delete"></i>
388 </button>
389 </div>
390 </div>
391 </div>
392 ) : (
393 <div className="bd-faq-control bd-faq-media">
394 <div className="bd-faq-image-uploader">
395 <button
396 type="button"
397 className="bd-faq-btn bd-faq-image-upload-btn"
398 onClick={handleIconUpload}
399 >
400 <span className="icon">
401 <i className="btd-icon btd-upload"></i>
402 </span>
403 <span className="title">{__("Click to upload", "betterdocs")}</span>
404 <span className="info">
405 {__("SVG, PNG, JPG or GIF", "betterdocs")}
406 </span>
407 </button>
408 </div>
409 </div>
410 )}
411 </div>
412
413 {isProduct && (
414 <>
415 <div className="bd-faq-cgt-field">
416 <label className="bd-faq-label">
417 {__("Where should this group show?", "betterdocs")}
418 </label>
419 <div className="bd-faq-scope-choices">
420 <label className="bd-faq-scope-choice">
421 <input
422 type="radio"
423 name="bd-faq-assign-scope"
424 value="all"
425 checked={assignScope === "all"}
426 onChange={() => setAssignScope("all")}
427 />
428 <span>
429 <strong>{__("All products", "betterdocs")}</strong>
430 <em>{__("Show this FAQ on every product page.", "betterdocs")}</em>
431 </span>
432 </label>
433 <label className="bd-faq-scope-choice">
434 <input
435 type="radio"
436 name="bd-faq-assign-scope"
437 value="specific"
438 checked={assignScope === "specific"}
439 onChange={() => setAssignScope("specific")}
440 />
441 <span>
442 <strong>{__("Specific products", "betterdocs")}</strong>
443 <em>{__("Only the product categories or products you choose.", "betterdocs")}</em>
444 </span>
445 </label>
446 </div>
447 </div>
448
449 {assignScope === "specific" && (
450 <>
451 <div className="bd-faq-cgt-field">
452 <label className="bd-faq-label">
453 {__("Assign to Product Categories", "betterdocs")}
454 </label>
455 <GroupMultiSelect
456 options={catOptions}
457 value={assignedCats}
458 onChange={setAssignedCats}
459 loading={catOptionsLoading}
460 loadingCount={
461 (updateCategory?.meta?.[GROUP_PRODUCT_CATS_META] || []).length
462 }
463 placeholder={__("Select product categories…", "betterdocs")}
464 noOptionsMessage={() =>
465 __("No product categories found", "betterdocs")
466 }
467 />
468 </div>
469
470 <div className="bd-faq-cgt-field">
471 <label className="bd-faq-label">
472 {__("Assign to Products", "betterdocs")}
473 </label>
474 <GroupMultiSelect
475 loadOptions={loadProductOptions}
476 value={assignedProducts}
477 onChange={setAssignedProducts}
478 loading={productsLoading}
479 loadingCount={
480 (updateCategory?.meta?.[GROUP_PRODUCTS_META] || []).length
481 }
482 placeholder={__("Search products…", "betterdocs")}
483 noOptionsMessage={() => __("No products found", "betterdocs")}
484 />
485 </div>
486 </>
487 )}
488 </>
489 )}
490 </div>
491 <div className="bd-faq-ctg-footer">
492 <button type="submit" className="bd-faq-button button-success" disabled={submitting}>
493 <span className="bd-faq-text">
494 {submitting
495 ? __("Saving…", "betterdocs")
496 : updateCategory
497 ? (isProduct
498 ? __("Update Product FAQ Group", "betterdocs")
499 : __("Update FAQ Group", "betterdocs"))
500 : (isProduct
501 ? __("Create Product FAQ Group", "betterdocs")
502 : __("Create FAQ Group", "betterdocs"))}
503 </span>
504 </button>
505 </div>
506 </form>
507 <button
508 type="button"
509 className="bd-faq-modal-close-button"
510 onClick={() => handleCancel()}
511 >
512 <svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
513 <path d="M1.69687 14L0.296875 12.6L5.89687 7L0.296875 1.4L1.69687 0L7.29688 5.6L12.8969 0L14.2969 1.4L8.69688 7L14.2969 12.6L12.8969 14L7.29688 8.4L1.69687 14Z" fill="currentColor" />
514 </svg>
515 </button>
516 </Modal>
517 );
518 };
519
520 export default CategoryModal;
521