payload-kanban-board
Version:
A kanban board plugin for Payload CMS
150 lines (148 loc) • 8.38 kB
JSX
'use client';
import { LexoRank } from 'lexorank';
import { useEffect, useState } from 'react';
import { DragDropContext } from 'react-beautiful-dnd';
import { toast } from '@payloadcms/ui';
import BoardColumn from '../BoardColumn/BoardColumn.js';
import { sortAndFilterDocumentsForStatus, sortAndFilterDocumentsWithoutStatus, } from '../utils/documents.util.js';
import './styles.scss';
const Board = (props) => {
const { statusDefinition, documents: initDocuments, hideNoStatusColumn, onDocumentkanbanStatusChange, collection, dragEnabled, collectionSlug, } = props;
const [documents, setDocuments] = useState(initDocuments ?? []);
useEffect(() => {
setDocuments(initDocuments);
}, [initDocuments]);
const updateDocument = async (documentId, destinationStatus, orderRank, sourceId) => {
try {
// Store the original document state before updating
const originalDocuments = [...documents];
const documentIndex = originalDocuments.findIndex((doc) => doc.id === documentId);
const originalDocumentState = documentIndex !== -1 ? { ...originalDocuments[documentIndex] } : null;
// Update the UI optimistically
setDocuments((prev) => {
const updatedDocumentIndex = prev.findIndex((_doc) => _doc.id === documentId);
if (updatedDocumentIndex === -1) {
return prev;
}
const newDocuments = [...prev];
newDocuments[updatedDocumentIndex] = {
...newDocuments[updatedDocumentIndex],
kanbanStatus: destinationStatus,
kanbanOrderRank: orderRank,
};
return newDocuments;
});
// Call the status change function and wait for the status code
const response = await onDocumentkanbanStatusChange(documentId, destinationStatus, orderRank);
const statusCode = response.status;
if (statusCode !== 200) {
const res = await response.json();
const errorMessage = res.errors[0].message;
// Revert the UI state if the API call fails
toast.error(errorMessage ? errorMessage : 'You are not authorised to update document status');
// Restore the original state for this document
if (originalDocumentState) {
setDocuments((prev) => {
const revertIndex = prev.findIndex((_doc) => _doc.id === documentId);
if (revertIndex === -1) {
return prev;
}
const revertedDocuments = [...prev];
revertedDocuments[revertIndex] = originalDocumentState;
return revertedDocuments;
});
}
return false;
}
return true;
}
catch (error) {
console.error('Error updating document:', error);
toast.error('Something went wrong');
// Revert to original documents on error
setDocuments(initDocuments);
return false;
}
};
const onDragEnd = async (result) => {
if (!dragEnabled) {
toast.error('You are not authorized to perform this action');
return;
}
if (!result.destination) {
return;
}
const source = result.source;
const destination = result.destination;
if (source.droppableId === destination.droppableId && source.index === destination.index) {
return;
}
const documentId = result.draggableId;
const sourceStatus = source.droppableId;
const destinationStatus = destination.droppableId;
const destinationIndex = destination.index;
try {
const destinationStatusGroup = sortAndFilterDocumentsForStatus(documents, destinationStatus);
const minOrderRank = documents[0]?.kanbanOrderRank ?? LexoRank.min().toString();
const maxOrderRank = documents[documents.length - 1]?.kanbanOrderRank ?? LexoRank.max().toString();
// First in entire collection when added to empty group
if (destinationStatusGroup.length === 0 &&
documents.findIndex((_doc) => _doc.id === documentId) === 0) {
return updateDocument(documentId, destinationStatus, LexoRank.min().toString(), source.droppableId);
}
// First in list on empty group
if (destinationStatusGroup.length === 0 && destinationIndex === 0) {
return updateDocument(documentId, destinationStatus, LexoRank.min().genNext().toString(), source.droppableId);
}
// First in list
if (destinationIndex === 0) {
const previousFirstDoc = [...destinationStatusGroup].shift();
// If the value has not been set, set a default value
if (!(typeof previousFirstDoc?.kanbanOrderRank === 'string')) {
const updatedOrderRank = LexoRank.parse(minOrderRank).between(LexoRank.max()).toString();
return updateDocument(documentId, destinationStatus, updatedOrderRank, source.droppableId);
}
const updatedOrderRank = LexoRank.parse(previousFirstDoc.kanbanOrderRank).genPrev();
return updateDocument(documentId, destinationStatus, updatedOrderRank.toString(), source.droppableId);
}
// Last in the list
if ((sourceStatus === destinationStatus &&
destinationIndex + 1 === destinationStatusGroup.length) ||
(sourceStatus !== destinationStatus && destinationIndex === destinationStatusGroup.length)) {
const previousLastDoc = [...destinationStatusGroup].pop();
// If the value has not been set, set a default value
if (!(typeof previousLastDoc?.kanbanOrderRank === 'string')) {
const updatedOrderRank = LexoRank.parse(maxOrderRank).between(LexoRank.min()).toString();
return updateDocument(documentId, destinationStatus, updatedOrderRank, source.droppableId);
}
const updatedOrderRank = LexoRank.parse(previousLastDoc.kanbanOrderRank).genNext();
return updateDocument(documentId, destinationStatus, updatedOrderRank.toString(), source.droppableId);
}
// Between 2 documents
let documentBefore = destinationStatusGroup[destinationIndex - 1];
let documentAfter = destinationStatusGroup[destinationIndex];
// Within the same list re-ordering to the bottom, switch the document before and after
if (sourceStatus === destinationStatus && source.index < destinationIndex) {
documentBefore = destinationStatusGroup[destinationIndex];
documentAfter = destinationStatusGroup[destinationIndex + 1];
}
const documentBeforeRank = LexoRank.parse(documentBefore.kanbanOrderRank);
const documentAfterRank = LexoRank.parse(documentAfter.kanbanOrderRank);
// Status change accepted
return updateDocument(documentId, destinationStatus, documentBeforeRank.between(documentAfterRank).toString(), source.droppableId);
}
catch (error) {
console.error('Error updating document:', error);
toast.error('Something went wrong');
}
};
return (<DragDropContext onDragEnd={(result) => onDragEnd(result)}>
<div className="scrumboard">
<div className="scrumboard-body">
{hideNoStatusColumn ? (<></>) : (<BoardColumn collection={collection} title={'No status'} identifier={'null'} contents={sortAndFilterDocumentsWithoutStatus(documents)} collapsible={true} dragEnabled={dragEnabled}/>)}
{statusDefinition?.options.map((status) => (<BoardColumn collection={collection} key={status.value} title={status.label} identifier={status.value} dragEnabled={dragEnabled} contents={sortAndFilterDocumentsForStatus(documents, status.value)}/>))}
</div>
</div>
</DragDropContext>);
};
export { Board };