Last active
March 23, 2024 22:40
-
-
Save MarkAtOmniux/13ed64f976c6d747993c4efe0492a3ea to your computer and use it in GitHub Desktop.
A Project Board Planner board built using Payload CMS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import '~payload/scss'; | |
.add-project-phase { | |
@include blur-bg; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
height: 100%; | |
&__wrapper { | |
z-index: 1; | |
position: relative; | |
display: flex; | |
flex-direction: column; | |
gap: var(--base); | |
} | |
&__content { | |
display: flex; | |
flex-direction: column; | |
gap: var(--base); | |
> * { | |
margin: 0; | |
} | |
} | |
&__controls { | |
display: flex; | |
gap: var(--base); | |
margin-top: base(1); | |
.btn { | |
margin: 0; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import { Modal } from '@faceless-ui/modal'; | |
import { Button } from 'payload/components'; | |
import { | |
Form, | |
FormSubmit, | |
RenderFields, | |
fieldTypes, | |
} from 'payload/components/forms'; | |
import { Project } from 'payload/generated-types'; | |
import { useProject } from '../../../ProjectProvider'; | |
import './addEditPhaseModal.styles.scss; | |
const baseClass = 'add-project-phase'; | |
export const AddEditPhaseModal = () => { | |
const { addPhase, modalProps, editPhase, removePhase, toggleModal } = | |
useProject(); | |
async function submit(fields, data) { | |
try { | |
if (modalProps.type === 'edit') { | |
editPhase(modalProps.phaseIndex, data.title); | |
return; | |
} | |
addPhase(data.title); | |
} catch (error) { | |
console.log(error); | |
} | |
} | |
const message = () => { | |
switch (modalProps.type) { | |
case 'add': | |
return 'Enter the phase you wish to add'; | |
case 'edit': | |
return 'Enter the new title for the phase'; | |
case 'remove': | |
return 'Are you sure you want to remove this phase?'; | |
default: | |
return ''; | |
} | |
}; | |
return ( | |
<Modal className={baseClass} slug='add-edit-project-phase'> | |
<div className={`${baseClass}__wrapper`}> | |
<div className={`${baseClass}__content`}> | |
<h1> | |
{modalProps.type.charAt(0).toUpperCase() + modalProps.type.slice(1)}{' '} | |
phase | |
</h1> | |
<p>{message()}</p> | |
</div> | |
<Form | |
initialData={{ | |
title: modalProps.type === 'edit' ? modalProps.phaseTitle : '', | |
}} | |
onSubmit={submit}> | |
{modalProps.type !== 'remove' && ( | |
<RenderFields | |
fieldTypes={fieldTypes} | |
fieldSchema={[ | |
{ | |
name: 'title', | |
label: 'Title', | |
type: 'text', | |
required: true, | |
}, | |
]} | |
/> | |
)} | |
<div className={`${baseClass}__controls`}> | |
{modalProps.type === 'edit' ? ( | |
<> | |
<FormSubmit>Update title</FormSubmit> | |
<Button | |
buttonStyle='error' | |
onClick={() => toggleModal()} | |
aria-label='delete phase'> | |
Cancel | |
</Button> | |
</> | |
) : modalProps.type === 'remove' ? ( | |
<Button | |
buttonStyle='primary' | |
onClick={() => removePhase(modalProps.phaseIndex)}> | |
Remove phase | |
</Button> | |
) : ( | |
<FormSubmit>Add phase</FormSubmit> | |
)} | |
</div> | |
</Form> | |
</div> | |
</Modal> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import '~payload/scss'; | |
.phase-grid { | |
display: flex; | |
grid-gap: var(--base); | |
padding-right: base(4); | |
overflow-x: scroll; | |
-ms-overflow-style: none; | |
width: 100%; | |
} | |
.button-column { | |
max-width: 24rem; | |
min-width: 24rem; | |
flex: 1; | |
width: 100%; | |
padding: base(1); | |
border-radius: base(0.5); | |
border: 1px solid var(--theme-elevation-100); | |
background-color: transparent; | |
min-height: 90vh; | |
transition: all 0.05s ease-in-out; | |
cursor: pointer; | |
&:hover { | |
border-color: var(--theme-elevation-200); | |
background: var(--theme-elevation-50); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Task as TaskType, User } from 'payload/generated-types'; | |
import React, { FC, useEffect, useState } from 'react'; | |
import { Phase } from './Phase'; | |
import './editProject.styles.scss'; | |
import { AddEditPhaseModal } from './AddEditPhaseModal'; | |
import { DNDType, useProject } from './ProjectProvider'; | |
import { PlusIcon } from 'lucide-react'; | |
import { | |
DndContext, | |
closestCorners, | |
useSensor, | |
useSensors, | |
PointerSensor, | |
KeyboardSensor, | |
DragOverlay, | |
UniqueIdentifier, | |
} from '@dnd-kit/core'; | |
import { | |
sortableKeyboardCoordinates, | |
SortableContext, | |
arrayMove, | |
} from '@dnd-kit/sortable'; | |
import { Task } from './Task'; | |
const EditProject: FC<{ user: User; id: string }> = ({ user, id }) => { | |
const { project, openModal, backgroundUpdate } = useProject(); | |
const [phases, setPhases] = useState<DNDType[]>([]); | |
const [triggerUpdate, setTriggerUpdate] = useState(false); | |
const [activeId, setActiveId] = useState(undefined); | |
const findValueOfItems = (id: UniqueIdentifier, type: string) => { | |
if (type === 'phase') { | |
return phases.find((phase) => phase?.id === id); | |
} | |
if (type === 'task') { | |
return phases.find((phase) => | |
phase.tasks?.find((task) => task?.id === id) | |
); | |
} | |
}; | |
const findTaskField = (id: UniqueIdentifier | undefined, field: string) => { | |
const phase = findValueOfItems(id, 'task'); | |
if (!phase) return ''; | |
const item = phase.tasks.find((item) => item.id === id); | |
if (!item) return ''; | |
return item[field]; | |
}; | |
const findPhaseField = (id: UniqueIdentifier | undefined, field: string) => { | |
const container = findValueOfItems(id, 'phase'); | |
if (!container) return ''; | |
return container[field]; | |
}; | |
const findPhaseItems = (id: UniqueIdentifier | undefined) => { | |
const container = findValueOfItems(id, 'phase'); | |
if (!container) return []; | |
return container.tasks; | |
}; | |
const handleDragStart = (event: any) => { | |
const { active } = event; | |
const { id } = active; | |
setActiveId(id); | |
}; | |
const handleDragMove = (event: any) => { | |
const { active, over } = event; | |
// Handle items sorting | |
if ( | |
active.id.toString().includes('task') && | |
over?.id.toString().includes('task') && | |
active && | |
over && | |
active.id !== over.id | |
) { | |
//Find the active container and over Container | |
const activePhase = findValueOfItems(active.id, 'task'); | |
const overPhase = findValueOfItems(over.id, 'task'); | |
// If the active or over container is undefined, return | |
if (!activePhase || !overPhase) { | |
return; | |
} | |
// Find the active and over container index | |
const activePhaseIndex = phases.findIndex( | |
(phase) => phase.id === activePhase.id | |
); | |
const overPhaseIndex = phases.findIndex( | |
(phase) => phase.id === overPhase.id | |
); | |
// Find the index of the active and over item | |
const activeItemIndex = activePhase.tasks.findIndex( | |
(task) => task.id === active.id | |
); | |
const overItemIndex = overPhase.tasks.findIndex( | |
(task) => task.id === over.id | |
); | |
// In the same container | |
if (activePhaseIndex === overPhaseIndex) { | |
let newItems = [...phases]; | |
newItems[activePhaseIndex].tasks = arrayMove( | |
newItems[activePhaseIndex].tasks, | |
activeItemIndex, | |
overItemIndex | |
); | |
setPhases(newItems); | |
// update container | |
} else { | |
// Different container | |
let newItems = [...phases]; | |
const [removedItem] = newItems[activePhaseIndex].tasks.splice( | |
activeItemIndex, | |
1 | |
); | |
newItems[overPhaseIndex].tasks.splice(overItemIndex, 0, removedItem); | |
setPhases(newItems); | |
} | |
} | |
if ( | |
active.id.toString().includes('task') && | |
over?.id.toString().includes('phase') && | |
active && | |
over && | |
active.id !== over.id | |
) { | |
// Find the active and over container | |
const activeContainer = findValueOfItems(active.id, 'task'); | |
const overContainer = findValueOfItems(over.id, 'phase'); | |
// If the active or over container is undefined, return | |
if (!activeContainer || !overContainer) { | |
return; | |
} | |
// Find the active container index and over container | |
const activeContainerIndex = | |
project.phases.findIndex((phase) => phase.id === activeContainer.id) !== | |
-1 | |
? activeContainer.tasks.findIndex((task) => task.id === active.id) | |
: 0; | |
const overContainerIndex = | |
project.phases.findIndex((phase) => phase.id === overContainer.id) !== | |
-1 | |
? overContainer.tasks.findIndex((task) => task.id === over.id) | |
: 0; | |
// Find the index of the active item in teh active container | |
const activeItemIndex = activeContainer.tasks.findIndex( | |
(task) => task.id === active.id | |
); | |
// Remove the active item from the active container | |
let newItems = [...phases]; | |
const [removedItem] = newItems[activeContainerIndex]?.tasks.splice( | |
activeItemIndex, | |
1 | |
); | |
newItems[overContainerIndex].tasks.push(removedItem); | |
setPhases(newItems); | |
} | |
}; | |
const handleDragEnd = (event: any) => { | |
const { active, over } = event; | |
let newItems; | |
// Handle Phase Sorting | |
if ( | |
active.id.toString().includes('phase') && | |
over?.id.toString().includes('phase') && | |
active && | |
over && | |
active.id !== over.id | |
) { | |
const activePhaseIndex = phases.findIndex( | |
(phase) => phase.id === active.id | |
); | |
const overPhaseIndex = phases.findIndex((phase) => phase.id === over.id); | |
// Swap the active and over container | |
newItems = [...phases]; | |
newItems = arrayMove(newItems, activePhaseIndex, overPhaseIndex); | |
setPhases(newItems); | |
} | |
// Handle Item Sorting | |
if ( | |
active.id.toString().includes('task') && | |
over?.id.toString().includes('task') && | |
active && | |
over && | |
active.id !== over.id | |
) { | |
// Find the active container and over Container | |
const activePhase = findValueOfItems(active.id, 'task'); | |
const overPhase = findValueOfItems(over.id, 'task'); | |
if (!activePhase || !overPhase) { | |
return; | |
} | |
const activePhaseIndex = phases.findIndex( | |
(phase) => phase.id === activePhase.id | |
); | |
const overPhaseIndex = phases.findIndex( | |
(phase) => phase.id === overPhase.id | |
); | |
// Find the index of the active and over item | |
const activeItemIndex = activePhase.tasks.findIndex( | |
(task) => task.id === active.id | |
); | |
const overItemIndex = overPhase.tasks.findIndex( | |
(task) => task.id === over.id | |
); | |
// In the same container | |
if (activePhaseIndex === overPhaseIndex) { | |
newItems = [...phases]; | |
newItems[activePhaseIndex].tasks = arrayMove( | |
newItems[activePhaseIndex].tasks, | |
activeItemIndex, | |
overItemIndex | |
); | |
setPhases(newItems); | |
} else { | |
// Different container | |
newItems = [...phases]; | |
const [removedItem] = newItems[activePhaseIndex].tasks.splice( | |
activeItemIndex, | |
1 | |
); | |
newItems[overPhaseIndex].tasks.splice(overItemIndex, 0, removedItem); | |
setPhases(newItems); | |
} | |
} | |
if ( | |
active.id.toString().includes('task') && | |
over?.id.toString().includes('phase') && | |
active && | |
over && | |
active.id !== over.id | |
) { | |
const activePhase = findValueOfItems(active.id, 'task'); | |
const overPhase = findValueOfItems(over.id, 'phase'); | |
const activePhaseIndex = phases.findIndex( | |
(phase) => phase.id === activePhase.id | |
); | |
const overPhaseIndex = phases.findIndex( | |
(phase) => phase.id === overPhase.id | |
); | |
const activeItemIndex = activePhase.tasks.findIndex( | |
(task) => task.id === active.id | |
); | |
newItems = [...phases]; | |
const [removedItem] = newItems[activePhaseIndex].tasks.splice( | |
activeItemIndex, | |
1 | |
); | |
newItems[overPhaseIndex].tasks.push(removedItem); | |
// interate over all tasks in each phase and remove any items in the array with a value of undefined | |
newItems = newItems.map((phase) => ({ | |
...phase, | |
tasks: phase.tasks.filter((x) => x), | |
})); | |
setPhases(newItems); | |
} | |
setActiveId(null); | |
setTriggerUpdate(true); | |
}; | |
const sensors = useSensors( | |
useSensor(PointerSensor), | |
useSensor(KeyboardSensor, { | |
coordinateGetter: sortableKeyboardCoordinates, | |
}) | |
); | |
useEffect(() => { | |
if (triggerUpdate && activeId === null && phases.length > 0) { | |
backgroundUpdate(phases); | |
setTriggerUpdate(false); | |
} | |
}, [activeId, phases, triggerUpdate]); | |
useEffect(() => { | |
if (project) { | |
console.log(project); | |
const newPhases = | |
project?.phases.map((x) => ({ | |
title: x.title, | |
id: `${x.id}-phase`, | |
payloadId: x.id, | |
tasks: x.tasks.map((t: TaskType) => ({ | |
title: t.title, | |
date: t.dueDate ?? '', | |
assignedTo: t.assignedTo as User[], | |
id: `${t.id}-task`, | |
payloadId: t.id, | |
})), | |
})) ?? []; | |
setPhases(newPhases); | |
} | |
}, [project]); | |
return ( | |
project && ( | |
<> | |
<h1>{project?.title}</h1> | |
<div className='phase-grid'> | |
<DndContext | |
sensors={sensors} | |
collisionDetection={closestCorners} | |
onDragStart={handleDragStart} | |
onDragMove={handleDragMove} | |
onDragEnd={handleDragEnd}> | |
<SortableContext items={phases.map((x) => x.id)}> | |
{(phases ?? []).map((phase, i) => ( | |
<Phase | |
phaseIndex={i} | |
title={phase.title} | |
tasks={phase.tasks} | |
id={phase.id} | |
key={phase.id} | |
/> | |
))} | |
</SortableContext> | |
<DragOverlay adjustScale={false}> | |
{/* Drag Overlay For Task */} | |
{activeId && activeId.toString().includes('task') && ( | |
<Task | |
title={findTaskField(activeId, 'title')} | |
date={findTaskField(activeId, 'date')} | |
id={activeId} | |
/> | |
)} | |
{/* Drag Overlay For Phase */} | |
{activeId && activeId.toString().includes('phase') && ( | |
<Phase id={activeId} title={findPhaseField(activeId, 'title')}> | |
<div | |
style={{ | |
display: 'flex', | |
alignItems: 'start', | |
flexDirection: 'column', | |
rowGap: '1rem', | |
}}> | |
{findPhaseItems(activeId).map((task) => ( | |
<Task | |
key={task?.id} | |
title={task?.title} | |
date={task.date} | |
id={task?.id} | |
/> | |
))} | |
</div> | |
</Phase> | |
)} | |
</DragOverlay> | |
</DndContext> | |
<button onClick={() => openModal('add')} className='button-column'> | |
<h4>Add Phase</h4> | |
<div | |
style={{ | |
width: '100%', | |
display: 'flex', | |
alignItems: 'center', | |
justifyContent: 'center', | |
}}> | |
<PlusIcon size={24} /> | |
</div> | |
</button> | |
</div> | |
<AddEditPhaseModal /> | |
</> | |
) | |
); | |
}; | |
export default EditProject; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { DefaultTemplate, MinimalTemplate } from 'payload/components/templates'; | |
import { useConfig, useDocumentInfo } from 'payload/components/utilities'; | |
import { AdminViewProps } from 'payload/config'; | |
import React, { useState, useEffect } from 'react'; | |
import EditProject from './EditProject'; | |
import { User } from 'payload/generated-types'; | |
import { Gutter } from 'payload/components/elements'; | |
import { ProjectProvider } from './ProjectProvider'; | |
const Page: React.FC<AdminViewProps> = ({ user }) => { | |
const { id } = useDocumentInfo(); | |
return ( | |
<div | |
style={{ | |
alignItems: 'start', | |
marginTop: '1rem', | |
justifyContent: 'start', | |
}}> | |
<Gutter right={false} negativeRight={true}> | |
{id && ( | |
<ProjectProvider projectId={id as string}> | |
<EditProject user={user as unknown as User} id={id as string} /> | |
</ProjectProvider> | |
)} | |
</Gutter> | |
</div> | |
); | |
}; | |
export default Page; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import '~payload/scss'; | |
.phase-column { | |
max-width: 24rem; | |
min-width: 24rem; | |
flex: 1; | |
width: 100%; | |
padding: base(1); | |
border-radius: base(0.5); | |
overflow-y: scroll; | |
-ms-overflow-style: none; | |
scrollbar-width: none; | |
background-color: var(--theme-elevation-50); | |
min-height: 90vh; | |
&__grip { | |
cursor: move; /* fallback if grab cursor is unsupported */ | |
cursor: grab; | |
cursor: -moz-grab; | |
cursor: -webkit-grab; | |
border: 0; | |
padding: 0; | |
margin: 0; | |
background-color: transparent; | |
} | |
&__header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: base(1); | |
& > h5 { | |
margin-bottom: 0 !important; | |
} | |
} | |
&__draggable { | |
opacity: 60%; | |
} | |
&__add-task-button { | |
flex: 1; | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
width: 100%; | |
padding-top: base(0.5); | |
padding-bottom: base(0.5); | |
padding-left: base(0.5); | |
padding-right: base(0.5); | |
margin-top: base(0.5); | |
border-radius: base(0.25); | |
border: none; | |
background-color: var(--theme-elevation-50); | |
transition: all 0.02s ease-in-out; | |
cursor: pointer; | |
& > p { | |
margin: 0; | |
} | |
&:hover { | |
border-color: var(--theme-elevation-200); | |
background: var(--theme-elevation-150); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { FC } from 'react'; | |
import { GripVerticalIcon, PlusIcon } from 'lucide-react'; | |
import { Button } from 'payload/components'; | |
import { DNDType, useProject } from '../../../ProjectProvider'; | |
import { CSS } from '@dnd-kit/utilities'; | |
import { useDocumentDrawer } from 'payload/components/elements'; | |
import { SortableContext, useSortable } from '@dnd-kit/sortable'; | |
import { useDroppable } from '@dnd-kit/core'; | |
import { Task } from '../Task/Task'; | |
import './phase.styles.scss'; | |
const baseClass = 'phase-column'; | |
export const Phase: FC<{ | |
phaseIndex?: number; | |
id: DNDType['id']; | |
title: string; | |
tasks?: DNDType['tasks']; | |
children?: React.ReactNode; | |
}> = ({ id, phaseIndex = 0, children, title, tasks }) => { | |
const { openModal, addTask } = useProject(); | |
const [DocumentDrawer, _, { openDrawer, closeDrawer }] = useDocumentDrawer({ | |
collectionSlug: 'tasks', | |
}); | |
const { | |
attributes, | |
setNodeRef, | |
listeners, | |
transform, | |
transition, | |
isDragging, | |
} = useSortable({ | |
id, | |
data: { | |
type: 'phase', | |
}, | |
}); | |
const { setNodeRef: droppableNode } = useDroppable({ | |
id, | |
data: { | |
type: 'task', | |
}, | |
}); | |
return ( | |
<div | |
{...attributes} | |
ref={setNodeRef} | |
className={`${baseClass} ${isDragging && baseClass + '__draggable'}`} | |
style={{ transition, transform: CSS.Translate.toString(transform) }}> | |
<div className={`${baseClass}__header`}> | |
<div | |
style={{ | |
display: 'flex', | |
columnGap: '0.5rem', | |
alignItems: 'center', | |
}}> | |
<button {...listeners} className={`${baseClass}__grip`}> | |
<GripVerticalIcon size={20} /> | |
</button> | |
<h5>{title}</h5> | |
</div> | |
<div | |
style={{ display: 'flex', columnGap: '1rem', alignItems: 'center' }}> | |
<Button | |
aria-label='edit phase title' | |
round | |
buttonStyle='none' | |
iconStyle='without-border' | |
icon='edit' | |
onClick={() => openModal('edit', phaseIndex, title)} | |
/> | |
<Button | |
aria-label='remove phase' | |
round | |
buttonStyle='none' | |
iconStyle='without-border' | |
icon='x' | |
onClick={() => openModal('remove', phaseIndex, title)} | |
/> | |
</div> | |
</div> | |
{children ? ( | |
children | |
) : ( | |
<SortableContext | |
items={(tasks ?? []).filter((x) => x).map((x) => x.id)}> | |
<div | |
ref={droppableNode} | |
style={{ | |
display: 'flex', | |
alignItems: 'start', | |
flexDirection: 'column', | |
rowGap: '1rem', | |
minHeight: '1px', | |
}}> | |
{(tasks ?? []) | |
.filter((x) => x) | |
.map((task) => ( | |
<Task | |
assignedTo={task?.assignedTo ?? []} | |
key={task.id} | |
date={task.date} | |
title={task.title} | |
id={task.id} | |
/> | |
))} | |
</div> | |
</SortableContext> | |
)} | |
<button onClick={openDrawer} className={`${baseClass}__add-task-button`}> | |
<p>Add Task</p> | |
<PlusIcon size={16} /> | |
</button> | |
<DocumentDrawer | |
onSave={({ doc }) => { | |
addTask(phaseIndex, doc); | |
closeDrawer(); | |
}} | |
/> | |
</div> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useStepNav } from 'payload/dist/admin/components/elements/StepNav'; | |
import { Organization, Project, Task, User } from 'payload/generated-types'; | |
import React, { createContext, useContext, useEffect, useState } from 'react'; | |
import { useModal } from '@faceless-ui/modal'; | |
import { | |
UniqueIdentifier, | |
} from '@dnd-kit/core'; | |
import { toast } from 'react-toastify'; | |
type ProjectContextType = { | |
project: Project; | |
openModal: ( | |
type: 'edit' | 'add' | 'remove', | |
phaseIndex?: number, | |
phaseTitle?: string | |
) => void; | |
addPhase: (title: string) => void; | |
removePhase: (index: number) => void; | |
editPhase: (index: number, title: string) => void; | |
addTask: (phaseIndex: number, task: Task) => void; | |
backgroundUpdate: (data: any) => void; | |
refresh: () => void; | |
toggleModal: () => void; | |
modalProps: { | |
type: 'edit' | 'add' | 'remove'; | |
phaseIndex?: number; | |
phaseTitle?: string; | |
}; | |
}; | |
export type DNDType = { | |
id: UniqueIdentifier; | |
payloadId: string; | |
title: string; | |
tasks: { | |
id: UniqueIdentifier; | |
payloadId: string; | |
assignedTo: User[]; | |
date?: string; | |
title: string; | |
}[]; | |
}; | |
const ProjectContext = createContext<ProjectContextType>({ | |
project: {} as Project, | |
openModal: () => {}, | |
addPhase: () => {}, | |
removePhase: () => {}, | |
editPhase: () => {}, | |
backgroundUpdate: () => {}, | |
addTask: () => {}, | |
refresh: () => {}, | |
toggleModal: () => {}, | |
modalProps: { type: 'add', phaseTitle: '' }, | |
}); | |
export const ProjectProvider: React.FC<{ | |
children: React.ReactNode; | |
projectId: string; | |
}> = ({ children, projectId }) => { | |
const [project, setProject] = useState<Project>(null); | |
const [modalProps, setModalProps] = useState<{ | |
type: 'add' | 'edit' | 'remove'; | |
phaseIndex?: number; | |
phaseTitle?: string; | |
}>({ type: 'add', phaseTitle: '' }); | |
const { toggleModal } = useModal(); | |
const { setStepNav } = useStepNav(); | |
const openModal = ( | |
type: 'edit' | 'add' | 'remove', | |
phaseIndex?: number, | |
phaseTitle?: string | |
) => { | |
setModalProps({ type, phaseIndex, phaseTitle }); | |
toggleModal('add-edit-project-phase'); | |
}; | |
const updateProject = async (data: Partial<Project>) => { | |
const res = await fetch(`/api/projects/${projectId}`, { | |
method: 'PATCH', | |
credentials: 'include', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify(data), | |
}); | |
const updatedProject = await res.json(); | |
if (updatedProject.error) toast.error('Error updating project'); | |
setProject(updatedProject.doc); | |
}; | |
const fetchProject = async (id: string) => { | |
const res = await fetch(`/api/v1/projects/${id}`, { | |
method: 'GET', | |
credentials: 'include', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
}); | |
const data = await res.json(); | |
setProject(data); | |
setStepNav([ | |
{ | |
label: 'Projects', | |
url: `/admin/collections/projects?limit=10`, | |
}, | |
{ | |
label: data.title, | |
url: `/admin/projects/${data.id}`, | |
}, | |
]); | |
}; | |
const addPhase = async (title: string) => { | |
(project.phases ?? []).push({ | |
title, | |
tasks: [], | |
}); | |
project.phases.forEach((phase) => { | |
phase.tasks = phase.tasks.map((task: Task) => task.id); | |
}); | |
await updateProject({ phases: project.phases }); | |
toggleModal('add-edit-project-phase'); | |
}; | |
const editPhase = async (index: number, title: string) => { | |
project.phases[index].title = title; | |
project.phases.forEach((phase) => { | |
phase.tasks = phase.tasks.map((task: Task) => task.id); | |
}); | |
await updateProject({ phases: project.phases }); | |
toggleModal('add-edit-project-phase'); | |
}; | |
const removePhase = async (index: number) => { | |
project.phases.splice(index, 1); | |
project.phases.forEach((phase) => { | |
phase.tasks = phase.tasks.map((task: Task) => task.id); | |
}); | |
await updateProject({ phases: project.phases }); | |
toggleModal('add-edit-project-phase'); | |
}; | |
const addTask = async (phaseIndex: number, task: Task) => { | |
if (project.phases[phaseIndex].tasks === undefined) { | |
project.phases[phaseIndex].tasks = [task]; | |
} else { | |
project.phases[phaseIndex].tasks.push(task); | |
} | |
// loop through each project phase and set the tasks object to id only | |
project.phases.forEach((phase) => { | |
phase.tasks = phase.tasks.map((task: Task) => task.id); | |
}); | |
await updateProject({ phases: project.phases }); | |
}; | |
const backgroundUpdate = (data: DNDType[]) => { | |
// map data to a project type. remove task and phase from ids, push updates to the API | |
if (!project) return; | |
const updatedPhases: Partial<Project> = { | |
...project, | |
organization: (project.organization as Organization).id, | |
phases: data.map((phase) => ({ | |
id: phase.payloadId, | |
title: phase.title, | |
tasks: (phase.tasks ?? []) | |
.filter((x) => x) | |
.map((task) => task.payloadId), | |
})), | |
}; | |
updateProject(updatedPhases); | |
}; | |
useEffect(() => { | |
fetchProject(projectId); | |
}, [projectId]); | |
return ( | |
<ProjectContext.Provider | |
value={{ | |
project, | |
openModal, | |
addPhase, | |
removePhase, | |
editPhase, | |
backgroundUpdate, | |
modalProps, | |
refresh: () => fetchProject(projectId), | |
toggleModal: () => toggleModal('add-edit-project-phase'), | |
addTask, | |
}}> | |
{children} | |
</ProjectContext.Provider> | |
); | |
}; | |
export const useProject = () => useContext(ProjectContext); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { CollectionConfig, PayloadRequest } from 'payload/types'; | |
import Page from './views/Page'; | |
export const Projects: CollectionConfig = { | |
slug: 'projects', | |
labels: { | |
singular: 'Project', | |
plural: 'Projects', | |
}, | |
admin: { | |
useAsTitle: 'title', | |
components: { | |
views: { | |
Edit: Page, | |
}, | |
}, | |
}, | |
fields: [ | |
{ | |
type: 'text', | |
name: 'title', | |
}, | |
{ | |
type: 'array', | |
name: 'phases', | |
fields: [ | |
{ | |
type: 'text', | |
name: 'title', | |
}, | |
{ | |
type: 'relationship', | |
name: 'tasks', | |
hasMany: true, | |
relationTo: 'tasks', | |
}, | |
], | |
}, | |
], | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import '~payload/scss'; | |
.task { | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
width: 100%; | |
border-radius: base(0.25); | |
background-color: var(--theme-elevation-100); | |
border: 1px solid var(--theme-elevation-150); | |
transition: all 0.05s ease-in-out; | |
cursor: pointer; | |
&:hover { | |
background: var(--theme-elevation-100); | |
} | |
&__draggable { | |
opacity: 60%; | |
} | |
&__avatar { | |
width: 32px; | |
height: 32px; | |
border-radius: 50%; | |
} | |
&__pill { | |
width: fit-content; | |
} | |
&__drag_container { | |
cursor: move; /* fallback if grab cursor is unsupported */ | |
cursor: grab; | |
cursor: -moz-grab; | |
cursor: -webkit-grab; | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
width: 100%; | |
padding-top: base(0.75); | |
padding-bottom: base(0.75); | |
padding-left: base(0.5); | |
padding-right: base(1); | |
row-gap: base(0.5); | |
& > p { | |
margin: 0; | |
} | |
} | |
&__edit { | |
padding-top: base(0.25); | |
padding-right: base(0.5); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { User } from 'payload/generated-types'; | |
import React, { FC } from 'react'; | |
import { Button, useDocumentDrawer } from 'payload/components/elements'; | |
import { DNDType, useProject } from '../../../ProjectProvider'; | |
import { useSortable } from '@dnd-kit/sortable'; | |
import { CSS } from '@dnd-kit/utilities'; | |
import { Pill } from 'payload/components'; | |
import { format } from 'date-fns'; | |
import './task.styles.scss'; | |
const baseClass = 'task'; | |
export const Task: FC<{ | |
id: DNDType['tasks'][0]['id']; | |
title: string; | |
date?: string; | |
assignedTo?: User[]; | |
}> = ({ title, id, date, assignedTo }) => { | |
const [DocumentDrawer, _, { openDrawer, closeDrawer }] = useDocumentDrawer({ | |
collectionSlug: 'tasks, | |
id: id.toString().split('-task')[0], | |
}); | |
const { | |
attributes, | |
setNodeRef, | |
listeners, | |
transform, | |
transition, | |
isDragging, | |
} = useSortable({ | |
id, | |
data: { | |
type: 'task', | |
}, | |
}); | |
const { refresh } = useProject(); | |
return ( | |
<div | |
ref={setNodeRef} | |
{...attributes} | |
style={{ transition, transform: CSS.Translate.toString(transform) }} | |
className={`${baseClass} ${isDragging && baseClass + '__draggable'}`}> | |
<div className={`${baseClass}__drag_container`} {...listeners}> | |
<p>{title}</p> | |
{date && ( | |
<Pill className={`${baseClass}__pill`} rounded pillStyle='light'> | |
{format(Date.parse(date), 'do LLL, yyyy')} | |
</Pill> | |
)} | |
</div> | |
<div className={`${baseClass}__edit`}> | |
<Button | |
aria-label='edit phase title' | |
round | |
buttonStyle='none' | |
iconStyle='without-border' | |
icon='edit' | |
onClick={openDrawer} | |
/> | |
</div> | |
<DocumentDrawer | |
onSave={({ doc }) => { | |
refresh(); | |
closeDrawer(); | |
}} | |
/> | |
</div> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { CollectionConfig, PayloadRequest } from 'payload/types'; | |
export const Tasks: CollectionConfig = { | |
slug: 'tasks', | |
labels: { | |
singular: 'Task', | |
plural: 'Tasks', | |
}, | |
admin: { | |
useAsTitle: 'title', | |
hidden: () => true, | |
}, | |
fields: [ | |
{ | |
type: 'tabs', | |
tabs: [ | |
{ | |
label: 'Details', | |
fields: [ | |
{ | |
type: 'row', | |
fields: [ | |
{ | |
type: 'text', | |
name: 'title', | |
admin: { | |
width: '75%', | |
}, | |
}, | |
{ | |
type: 'date', | |
name: 'dueDate', | |
admin: { | |
width: '25%', | |
}, | |
}, | |
], | |
}, | |
{ | |
type: 'relationship', | |
name: 'assignedTo', | |
relationTo: 'users', | |
hasMany: true, | |
admin: { | |
allowCreate: false, | |
}, | |
}, | |
], | |
}, | |
{ | |
label: 'Notes', | |
fields: [ | |
{ | |
type: 'textarea', | |
name: 'notes', | |
admin: { | |
rows: 6, | |
}, | |
}, | |
], | |
} | |
], | |
}, | |
], | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment