| 1 |
import { useQuery, useQueryClient, QueryCache, useMutation } from "@tanstack/react-query"; |
| 2 |
import React, { useEffect, useState, useRef } from "react"; |
| 3 |
import { DragDropContext } from "react-beautiful-dnd"; |
| 4 |
import { __ } from "@wordpress/i18n"; |
| 5 |
import { decodeEntities } from "@wordpress/html-entities"; |
| 6 |
import useFetch from "../hooks/useFetch"; |
| 7 |
import AllDocsGridItem from "./AllDocsGridItem"; |
| 8 |
import NoDocsBody from "./NoDocsBody"; |
| 9 |
import { useOutletContext } from "react-router-dom"; |
| 10 |
import { ajaxRequest, constructFormData } from "../../utils/ajaxRequest"; |
| 11 |
import apiFetch from "@wordpress/api-fetch"; |
| 12 |
import GridLoader from '../loaders/GridLoader'; |
| 13 |
import NoDocsLoader from '../loaders/NoDocsLoader'; |
| 14 |
import { addQueryArgs } from "@wordpress/url"; |
| 15 |
import GridQuickEdit from "./GridQuickEdit"; |
| 16 |
import Pagination from "rc-pagination"; |
| 17 |
import "rc-pagination/assets/index.css"; |
| 18 |
import { IconNext, IconPrev } from "../../../icons"; |
| 19 |
|
| 20 |
const AllDocsGrid = () => { |
| 21 |
const queryClient = useQueryClient(); |
| 22 |
const [quickEdit, setQuickEdit] = useState({isOpen:false}); |
| 23 |
const [mkbFilterValue, refetch] = useOutletContext(); |
| 24 |
const [isDragging, setIsDragging] = useState(false); |
| 25 |
const dragTimeoutRef = useRef(null); |
| 26 |
const [stateManager, setStateManager] = useState({ |
| 27 |
docCategoriesQueryParams:{ |
| 28 |
per_page:100, |
| 29 |
hide_empty:false, |
| 30 |
orderby:'doc_category_order', |
| 31 |
...(mkbFilterValue != '' ? {'knowledge_base': mkbFilterValue} : {}), |
| 32 |
page:1 |
| 33 |
}, |
| 34 |
}); |
| 35 |
|
| 36 |
const { docCategoriesQueryParams } = stateManager; |
| 37 |
let options = []; |
| 38 |
|
| 39 |
// Pass the admin UI language so the REST read path resolves to the same |
| 40 |
// language-specific meta key the drag-and-drop AJAX writes to. Without this, |
| 41 |
// WPML resolves REST requests to the site default language and the dashboard |
| 42 |
// appears to revert ordering after reload when the admin language is a secondary one. |
| 43 |
const langParam = betterdocs_admin?.current_admin_language ? { lang: betterdocs_admin.current_admin_language } : {}; |
| 44 |
|
| 45 |
const { |
| 46 |
isLoading: docsCategoriesLoading, |
| 47 |
data: docsCategoriesData, |
| 48 |
} = useQuery(["docsCategories", docCategoriesQueryParams], ({queryKey}) => { |
| 49 |
let [_,params] = queryKey; |
| 50 |
return apiFetch({ |
| 51 |
path: addQueryArgs('betterdocs/v1/doc-categories', { ...params, ...langParam }), |
| 52 |
method: "GET", |
| 53 |
parse: false, |
| 54 |
}).then((data) => { |
| 55 |
return data.json(); |
| 56 |
}).then((result) => { |
| 57 |
return result; |
| 58 |
}); |
| 59 |
},{ |
| 60 |
refetchOnMount:false, |
| 61 |
cacheTime:0 |
| 62 |
}); |
| 63 |
|
| 64 |
const { isLoading:categoriesLoading, data:categories } = useQuery(['docCategories',docCategoriesQueryParams], ({queryKey}) => { |
| 65 |
let [_,params] = queryKey; |
| 66 |
return apiFetch({ |
| 67 |
path: addQueryArgs(`wp/v2/doc_category`, { ...params, ...langParam }), |
| 68 |
method: "GET", |
| 69 |
parse: false, |
| 70 |
}).then((data) => { |
| 71 |
return data.json(); |
| 72 |
}).then((result) => { |
| 73 |
return result; |
| 74 |
}); |
| 75 |
},{ |
| 76 |
refetchOnMount:false, |
| 77 |
cacheTime:0 |
| 78 |
}); |
| 79 |
|
| 80 |
const { mutate, data, isLoading, variables } = useMutation({ |
| 81 |
mutationKey:['deleteDocs'], |
| 82 |
mutationFn:(deleteIds) => { |
| 83 |
for( let termId of Object.keys(deleteIds) ) { |
| 84 |
const { post_id_index, term_id, post_id } = deleteIds[termId]; |
| 85 |
return apiFetch({ path: `/wp/v2/docs/${post_id}`, method:'DELETE' } ); |
| 86 |
} |
| 87 |
}, |
| 88 |
onMutate:async(deleteIds) => { |
| 89 |
return deleteIds; |
| 90 |
}, |
| 91 |
onSettled:(data) => { |
| 92 |
queryClient.invalidateQueries(['docsCategories', docCategoriesQueryParams]) |
| 93 |
}, |
| 94 |
onSuccess:(serverResponse, deleteIds) => { |
| 95 |
const previousQueryData = queryClient.getQueryData(['docsCategories', docCategoriesQueryParams]); |
| 96 |
for( let termId of Object.keys(deleteIds) ) { |
| 97 |
const { post_id_index, term_id, post_id } = deleteIds[termId]; |
| 98 |
previousQueryData?.data[term_id]?.splice(post_id_index, 1); |
| 99 |
delete deleteIds[termId]; |
| 100 |
if( typeof refetch == 'function'){ |
| 101 |
refetch(true); |
| 102 |
} |
| 103 |
queryClient.setQueryData(['docsCategories'], {...previousQueryData}); |
| 104 |
} |
| 105 |
} |
| 106 |
}); |
| 107 |
|
| 108 |
const { mutate:dragDrop } = useMutation({ |
| 109 |
mutationKey:['sortDocsDragDrop'], |
| 110 |
mutationFn:(data) => { |
| 111 |
return null; //not needed |
| 112 |
}, |
| 113 |
onMutate:async(updatedData) => { |
| 114 |
await queryClient.cancelQueries(['docsCategories', docCategoriesQueryParams]); |
| 115 |
queryClient.setQueryData(['docsCategories', docCategoriesQueryParams], {...updatedData}); |
| 116 |
}, |
| 117 |
onSettled:(serverResponse) => { |
| 118 |
return null; //not needed |
| 119 |
} |
| 120 |
}); |
| 121 |
|
| 122 |
const itemRender = (current, type, originalElement, totalItems) => { |
| 123 |
if (type === 'prev') { |
| 124 |
if (current === 0) { |
| 125 |
return React.cloneElement(originalElement, { title: '' }, <IconPrev />); |
| 126 |
} |
| 127 |
return React.cloneElement(originalElement, { title: __("Previous Page", "betterdocs") }, <IconPrev />); |
| 128 |
} |
| 129 |
if (type === 'next') { |
| 130 |
if (current === Math.ceil(totalItems / 20)) { |
| 131 |
return React.cloneElement(originalElement, { title: '' }, <IconNext />); |
| 132 |
} |
| 133 |
return React.cloneElement(originalElement, { title: __("Next Page", "betterdocs") }, <IconNext />); |
| 134 |
} |
| 135 |
return originalElement; |
| 136 |
}; |
| 137 |
|
| 138 |
useEffect(() => { |
| 139 |
if( ! categoriesLoading && ! docsCategoriesLoading ) { |
| 140 |
if( mkbFilterValue != '' ) { |
| 141 |
setStateManager({...stateManager, docCategoriesQueryParams:{...docCategoriesQueryParams, page:1, knowledge_base:mkbFilterValue}}); |
| 142 |
} else { |
| 143 |
if( docCategoriesQueryParams['knowledge_base'] != undefined ) { |
| 144 |
delete docCategoriesQueryParams['knowledge_base']; |
| 145 |
} |
| 146 |
setStateManager({...stateManager, docCategoriesQueryParams:{ ...docCategoriesQueryParams, page:1, per_page:10, hide_empty:false, orderby:'doc_category_order'}}); |
| 147 |
} |
| 148 |
} |
| 149 |
}, [mkbFilterValue]); |
| 150 |
|
| 151 |
// Cleanup timeout on unmount |
| 152 |
useEffect(() => { |
| 153 |
return () => { |
| 154 |
if (dragTimeoutRef.current) { |
| 155 |
clearTimeout(dragTimeoutRef.current); |
| 156 |
} |
| 157 |
}; |
| 158 |
}, []); |
| 159 |
|
| 160 |
|
| 161 |
const onDragStart = () => { |
| 162 |
setIsDragging(true); |
| 163 |
// Clear any existing timeout |
| 164 |
if (dragTimeoutRef.current) { |
| 165 |
clearTimeout(dragTimeoutRef.current); |
| 166 |
} |
| 167 |
}; |
| 168 |
|
| 169 |
const onDragEnd = (result) => { |
| 170 |
setIsDragging(false); |
| 171 |
|
| 172 |
const { destination, source } = result; |
| 173 |
|
| 174 |
if( !destination ) { |
| 175 |
return null; |
| 176 |
} |
| 177 |
|
| 178 |
// Prevent rapid successive drag operations |
| 179 |
if (dragTimeoutRef.current) { |
| 180 |
clearTimeout(dragTimeoutRef.current); |
| 181 |
} |
| 182 |
|
| 183 |
const {droppableId:destinationCategoryId, index:destinationPostIndex} = destination; |
| 184 |
const {droppableId:sourceCategoryId, index:sourcePostIndex} = source; |
| 185 |
|
| 186 |
// Debounce the drag operation to prevent multiple rapid calls |
| 187 |
dragTimeoutRef.current = setTimeout(() => { |
| 188 |
if( sourceCategoryId == 'uncategorized' || destinationCategoryId == 'uncategorized' ){ |
| 189 |
let sourcePost = docsCategoriesData?.data[sourceCategoryId]?.splice(sourcePostIndex, 1); |
| 190 |
docsCategoriesData?.data[destinationCategoryId]?.splice(destinationPostIndex, 0, sourcePost[0]); |
| 191 |
dragDrop(docsCategoriesData, docsCategoriesData[parseInt(sourceCategoryId)], docsCategoriesData[parseInt(destinationCategoryId)], sourceCategoryId, destinationCategoryId, sourcePost[0]?.id); |
| 192 |
sendAjaxRequest(docsCategoriesData?.data[sourceCategoryId], docsCategoriesData?.data[destinationCategoryId], sourceCategoryId == 'uncategorized' ? '' : sourceCategoryId , destinationCategoryId == 'uncategorized' ? '' : destinationCategoryId, sourcePost[0]?.id); |
| 193 |
} else { |
| 194 |
let sourcePost = docsCategoriesData?.data[parseInt(sourceCategoryId)]?.splice(sourcePostIndex, 1); |
| 195 |
docsCategoriesData?.data[parseInt(destinationCategoryId)]?.splice(destinationPostIndex, 0, sourcePost[0]); |
| 196 |
dragDrop(docsCategoriesData, docsCategoriesData[parseInt(sourceCategoryId)], docsCategoriesData[parseInt(destinationCategoryId)], sourceCategoryId, destinationCategoryId, sourcePost[0]?.id); |
| 197 |
sendAjaxRequest(docsCategoriesData?.data[parseInt(sourceCategoryId)], docsCategoriesData?.data[parseInt(destinationCategoryId)], sourceCategoryId, destinationCategoryId, sourcePost[0]?.id); |
| 198 |
} |
| 199 |
}, 300); // 300ms debounce delay |
| 200 |
}; |
| 201 |
|
| 202 |
const sendAjaxRequest = async (sourceCategories, destinationCategories, sourceCategoryId, destinationCategoryId, sourcePostId) => { |
| 203 |
console.log('Starting drag and drop AJAX requests:', { |
| 204 |
sourcePostId, |
| 205 |
sourceCategoryId, |
| 206 |
destinationCategoryId, |
| 207 |
sourceCategories: sourceCategories?.length, |
| 208 |
destinationCategories: destinationCategories?.length |
| 209 |
}); |
| 210 |
|
| 211 |
try { |
| 212 |
// Step 1: Update document category assignment first |
| 213 |
console.log('Step 1: Updating document category assignment'); |
| 214 |
let orderObject = constructFormData({ |
| 215 |
action: 'update_docs_term', |
| 216 |
object_id: sourcePostId, |
| 217 |
prev_term_id: sourceCategoryId, |
| 218 |
list_term_id: destinationCategoryId, |
| 219 |
'doc_cat_order_nonce': betterdocs_admin?.doc_cat_order_nonce, |
| 220 |
// Pass language for WPML/multilingual compatibility |
| 221 |
...(betterdocs_admin?.current_admin_language ? { lang: betterdocs_admin.current_admin_language } : {}) |
| 222 |
}); |
| 223 |
|
| 224 |
const termUpdateResult = await ajaxRequest(orderObject, betterdocs_admin?.ajaxurl, 'POST', true); |
| 225 |
console.log('Step 1 completed:', termUpdateResult); |
| 226 |
|
| 227 |
// Step 2: Update source category order (only if different from destination) |
| 228 |
if (sourceCategoryId !== destinationCategoryId && sourceCategories && sourceCategories.length > 0) { |
| 229 |
console.log('Step 2: Updating source category order'); |
| 230 |
let sourceDataObject = constructFormData({ |
| 231 |
'action': 'update_doc_order_by_category', |
| 232 |
'doc_cat_order_nonce': betterdocs_admin?.doc_cat_order_nonce, |
| 233 |
'list_term_id': sourceCategoryId, |
| 234 |
'docs_ordering_data': sourceCategories, |
| 235 |
// Pass language for WPML/multilingual compatibility |
| 236 |
...(betterdocs_admin?.current_admin_language ? { lang: betterdocs_admin.current_admin_language } : {}) |
| 237 |
}); |
| 238 |
|
| 239 |
const sourceUpdateResult = await ajaxRequest(sourceDataObject, betterdocs_admin?.ajaxurl, 'POST', true); |
| 240 |
console.log('Step 2 completed:', sourceUpdateResult); |
| 241 |
} |
| 242 |
|
| 243 |
// Step 3: Update destination category order |
| 244 |
if (destinationCategories && destinationCategories.length > 0) { |
| 245 |
console.log('Step 3: Updating destination category order'); |
| 246 |
let destinationDataObject = constructFormData({ |
| 247 |
'action': 'update_doc_order_by_category', |
| 248 |
'doc_cat_order_nonce': betterdocs_admin?.doc_cat_order_nonce, |
| 249 |
'list_term_id': destinationCategoryId, |
| 250 |
'docs_ordering_data': destinationCategories, |
| 251 |
// Pass language for WPML/multilingual compatibility |
| 252 |
...(betterdocs_admin?.current_admin_language ? { lang: betterdocs_admin.current_admin_language } : {}) |
| 253 |
}); |
| 254 |
|
| 255 |
const destUpdateResult = await ajaxRequest(destinationDataObject, betterdocs_admin?.ajaxurl, 'POST', true); |
| 256 |
console.log('Step 3 completed:', destUpdateResult); |
| 257 |
} |
| 258 |
|
| 259 |
console.log('All drag and drop AJAX requests completed successfully'); |
| 260 |
} catch (error) { |
| 261 |
console.error('Drag and drop AJAX request failed:', error); |
| 262 |
// Refresh the data to restore correct state |
| 263 |
console.log('Refreshing data due to error...'); |
| 264 |
queryClient.invalidateQueries(['docsCategories', docCategoriesQueryParams]); |
| 265 |
} |
| 266 |
} |
| 267 |
|
| 268 |
for( let i = 10; i <= 100; i+=10 ) { // set the number of terms per page option for select field |
| 269 |
options.push(<option value={i}>{i}</option>) |
| 270 |
} |
| 271 |
// console.log(docsCategoriesData?.total_terms); |
| 272 |
const hasUncategorizedDocs = docsCategoriesData?.data && docsCategoriesData.data['uncategorized'] != undefined && docsCategoriesData.data['uncategorized']?.length > 0; |
| 273 |
const loading = categoriesLoading || docsCategoriesLoading; |
| 274 |
// Genuinely-empty KB (no categories, no docs) is known server-side at page |
| 275 |
// load, so on the first paint we can show a skeleton shaped like the empty |
| 276 |
// card instead of the category-grid skeleton it would immediately replace. |
| 277 |
const kbEmpty = Boolean(window.betterdocs_admin?.kb_is_empty); |
| 278 |
// Hide the (otherwise empty) category grid section in the no-docs empty state. |
| 279 |
const showCategoryGrid = loading || categories?.length > 0 || hasUncategorizedDocs; |
| 280 |
return ( |
| 281 |
<> |
| 282 |
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}> |
| 283 |
{loading && kbEmpty && <NoDocsLoader />} |
| 284 |
{showCategoryGrid && !(loading && kbEmpty) && ( |
| 285 |
<section className="betterdocs-dashboard-body betterdocs-dashboard-category-grid"> |
| 286 |
<div className="betterdocs-dashboard-container"> |
| 287 |
<div className="betterdocs-row"> |
| 288 |
{( categoriesLoading || docsCategoriesLoading ) && <GridLoader/>} |
| 289 |
{(!categoriesLoading && !docsCategoriesLoading && categories?.length > 0) && |
| 290 |
categories.map((category, key) => { |
| 291 |
return <div |
| 292 |
className="betterdocs-col betterdocs-col-md-4" |
| 293 |
key={key} |
| 294 |
> |
| 295 |
<div className="betterdocs-dashboard-category-wrap"> |
| 296 |
<div className="betterdocs-dashboard-category-header"> |
| 297 |
<h3 className="betterdocs-dashboard-category-name"> |
| 298 |
{decodeEntities(category?.name)} |
| 299 |
</h3> |
| 300 |
</div> |
| 301 |
{docsCategoriesData?.data[category?.id] != undefined && <AllDocsGridItem category={category} docsData={docsCategoriesData?.data[category?.id]} deleteIds={variables == undefined ? {} : variables} isLoading={isLoading} mutate={mutate} setQuickEdit={setQuickEdit} />} |
| 302 |
<div className="betterdocs-dashboard-category-footer"> |
| 303 |
<a |
| 304 |
href={`post-new.php?post_type=docs&cat=${category.id}`} |
| 305 |
className="betterdocs-dashboard-btn betterdocs-dashboard-btn-white" |
| 306 |
> |
| 307 |
{__("Add New Doc", "betterdocs")} |
| 308 |
</a> |
| 309 |
</div> |
| 310 |
</div> |
| 311 |
</div> |
| 312 |
})} |
| 313 |
{!categoriesLoading && !docsCategoriesLoading && docsCategoriesData?.data['uncategorized'] != undefined && docsCategoriesData?.data['uncategorized']?.length > 0 && ( |
| 314 |
<div |
| 315 |
className="betterdocs-col betterdocs-col-md-4" |
| 316 |
key={'uncategorized'} |
| 317 |
> |
| 318 |
<div className="betterdocs-dashboard-category-wrap"> |
| 319 |
<div className="betterdocs-dashboard-category-header"> |
| 320 |
<h3 className="betterdocs-dashboard-category-name"> |
| 321 |
{__('Uncategorized', 'betterdocs')} |
| 322 |
</h3> |
| 323 |
</div> |
| 324 |
{<AllDocsGridItem category={{ |
| 325 |
'id':'uncategorized' |
| 326 |
}} docsData={docsCategoriesData?.data['uncategorized']} deleteIds={variables == undefined ? {} : variables} isLoading={isLoading} mutate={mutate} setQuickEdit={setQuickEdit} />} |
| 327 |
<div className="betterdocs-dashboard-category-footer"> |
| 328 |
<a |
| 329 |
href={`post-new.php?post_type=docs&cat=${''}`} |
| 330 |
className="betterdocs-dashboard-btn betterdocs-dashboard-btn-white" |
| 331 |
> |
| 332 |
{__("Add New Doc", "betterdocs")} |
| 333 |
</a> |
| 334 |
</div> |
| 335 |
</div> |
| 336 |
</div> |
| 337 |
)} |
| 338 |
</div> |
| 339 |
{(!categoriesLoading && !docsCategoriesLoading && parseInt(docsCategoriesData?.total_terms) > 100) && <div className="doc-list-pagination-wrap"> |
| 340 |
{<div className="pagination-group"><select value={docCategoriesQueryParams?.per_page} className="number-of-categories" onChange={(e) => { |
| 341 |
docCategoriesQueryParams['per_page'] = e?.target?.value; |
| 342 |
setStateManager({...stateManager, docCategoriesQueryParams:{...docCategoriesQueryParams}}); |
| 343 |
}}> |
| 344 |
{options?.map((value) => { |
| 345 |
return value; |
| 346 |
})} |
| 347 |
</select> |
| 348 |
<p>{__('category in this page', 'betterdocs')}</p></div>} |
| 349 |
<Pagination |
| 350 |
pageSize={docCategoriesQueryParams?.per_page} |
| 351 |
current={docCategoriesQueryParams?.page} |
| 352 |
total={docsCategoriesData?.total_terms} |
| 353 |
onChange={(value) => { |
| 354 |
docCategoriesQueryParams['page'] = value; |
| 355 |
setStateManager({...stateManager, docCategoriesQueryParams:{...docCategoriesQueryParams}}); |
| 356 |
}} |
| 357 |
itemRender={(current, type, originalElement) => itemRender(current, type, originalElement, docsCategoriesData?.total_terms)} |
| 358 |
/> |
| 359 |
</div>} |
| 360 |
</div> |
| 361 |
</section> |
| 362 |
)} |
| 363 |
</DragDropContext> |
| 364 |
{(!categoriesLoading && !docsCategoriesLoading && categories?.length == 0) && <NoDocsBody isDocs={docsCategoriesData?.data?.length == 0 && docsCategoriesData?.data['uncategorized'] == undefined ? false : true}/>} |
| 365 |
{(quickEdit?.isOpen) && <GridQuickEdit data={quickEdit?.data} setQuickEdit={setQuickEdit} setCategoriesPosts={queryClient} docCategoriesQueryParams={docCategoriesQueryParams}/>} |
| 366 |
</> |
| 367 |
); |
| 368 |
}; |
| 369 |
|
| 370 |
export default AllDocsGrid; |
| 371 |
|