Task Board
Board components to drag and drop for lists
Task Board
Task Board component with the dnd.
CheckIcon
DeleteTaskSectionModal
hooks
Icon
InputText
shared
store
TasksBoard
App.tsx
createProvider.tsx
data.ts
Provider.tsx
import { Flex } from '@chakra-ui/react'
import { DeleteTaskSectionModal } from './DeleteTaskSectionModal'
import { Provider } from './Provider'
import { TasksBoardContent, TasksBoardList, TasksBoard } from './TasksBoard'
export function App() {
return (
<Provider>
<Flex
flex={1}
w="full"
h="full"
flexDirection="column"
bg="transparent"
alignItems="center"
justifyContent="center"
minH={{ base: '600px', lg: '800px' }}
>
<TasksBoard>
<TasksBoardContent>
<TasksBoardList />
</TasksBoardContent>
</TasksBoard>
</Flex>
<DeleteTaskSectionModal />
</Provider>
)
}
import type { PropsWithChildren } from 'react'
import { DeleteTaskSectionModalProvider } from './DeleteTaskSectionModal'
import { data } from './data'
import { TaskSectionsProvider } from './store/entities/taskSections'
export function Provider({ children }: PropsWithChildren) {
return (
<TaskSectionsProvider initialData={data}>
<DeleteTaskSectionModalProvider>
{children}
</DeleteTaskSectionModalProvider>
</TaskSectionsProvider>
)
}
import { forwardRef } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React, { createContext, memo } from 'react'
/**
* Creates a provider component for a given context.
*
* @param {function} useValue - A function that takes props and returns the context value.
*
* @return An object containing the provider component, the context object, and a hook to consume the context.
*/
export function createProvider<
ContextProps extends object,
Props extends object,
>(useValue: (props: Props) => ContextProps) {
const Context = createContext<ContextProps>({} as ContextProps)
const useContext = () => {
const context = React.useContext(Context)
if (!Object.keys(context).length) {
throw new Error(
`【${
(useValue as any).__PROVIDER__
}】Context needs to be consumed in Provider`,
)
}
return context
}
const Provider: React.FC<PropsWithChildren<Props>> = memo<
PropsWithChildren<Props>
>(
forwardRef((props, ref) => (
<Component {...props} ref={ref} {...useValue(props)} />
)) as React.FC<PropsWithChildren<Props>>,
)
Provider.displayName = 'Provider'
const Component: React.FC<PropsWithChildren<Props> & ContextProps> = memo<
PropsWithChildren<Props> & ContextProps
>(
forwardRef(({ children, ...rest }, ref) => {
return (
<Context.Provider value={{ ...(rest as unknown as ContextProps), ref }}>
{children}
</Context.Provider>
)
}) as React.FC<PropsWithChildren<Props> & ContextProps>,
)
return {
Provider,
Context,
useContext,
}
}
export const data = [
{
id: '0AY01HT2S6N8EMQPRTEBNKMPD0DWD',
name: 'Backlog',
tasks: [
{
id: '0BC01HT2S6NY4M1849Y9HTHNXY6RV',
taskId: '0BA01HT2S6MNXW5F3DAVGYMEWJA24',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKMPD0DWD',
task: {
id: '0BA01HT2S6MNXW5F3DAVGYMEWJA24',
name: 'Implement new card design',
isNew: false,
completed: false,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HTK0QYREJ',
taskId: '0BA01HT2S6MKHGMGWGFW76E9FSDHZ',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKMPD0DWD',
task: {
id: '0BA01HT2S6MKHGMGWGFW76E9FSDHZ',
name: 'User bug report',
isNew: false,
completed: false,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HTKJRG5FZ',
taskId: '0BA01HT2S6MNXW5F3DAVGYYD882VG',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKMPD0DWD',
task: {
id: '0BA01HT2S6MNXW5F3DAVGYYD882VG',
name: 'Design iOS prototype',
isNew: false,
completed: false,
},
},
],
},
{
id: '0AY01HT2S6N8EMQPRTEBNKQGP4VXB',
name: 'Ready',
tasks: [
{
id: '0BC01HT2S6NY4M1849Y9HTMSBPDC3',
taskId: '0BA01HT2S6MNXW5F3DAVGZ0XEA6A5',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKQGP4VXB',
task: {
id: '0BA01HT2S6MNXW5F3DAVGZ0XEA6A5',
name: 'Scope performance improvements',
isNew: false,
completed: false,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HTNH66E40',
taskId: '0BA01HT2S6MNXW5F3DAVGZ30WBJQE',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKQGP4VXB',
task: {
id: '0BA01HT2S6MNXW5F3DAVGZ30WBJQE',
name: 'Implement mobile menu',
isNew: false,
completed: false,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HTQP59Z9R',
taskId: '0BA01HT2S6MNXW5F3DAVGZ4V7GAF2',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKQGP4VXB',
task: {
id: '0BA01HT2S6MNXW5F3DAVGZ4V7GAF2',
name: 'Support for offline mode',
isNew: false,
completed: false,
},
},
],
},
{
id: '0AY01HT2S6N8EMQPRTEBNKS33P5PF',
name: 'In Progress',
tasks: [
{
id: '0BC01HT2S6NY4M1849Y9HTSCC5XAX',
taskId: '0BA01HT2S6MNXW5F3DAVGZ69HPWCM',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKS33P5PF',
task: {
id: '0BA01HT2S6MNXW5F3DAVGZ69HPWCM',
name: 'Introduce CI',
isNew: false,
completed: false,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HTW6BDDVZ',
taskId: '0BA01HT2S6MNXW5F3DAVGZ6NXV3NN',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKS33P5PF',
task: {
id: '0BA01HT2S6MNXW5F3DAVGZ6NXV3NN',
name: 'Login with Google',
isNew: false,
completed: true,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HTXJ7ZZ0T',
taskId: '0BA01HT2S6MNXW5F3DAVGZAHJVERC',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKS33P5PF',
task: {
id: '0BA01HT2S6MNXW5F3DAVGZAHJVERC',
name: 'Implement undo function',
isNew: false,
completed: true,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HTYRGCXAY',
taskId: '0BA01HT2S6MNXW5F3DAVGZBAHJSHQ',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKS33P5PF',
task: {
id: '0BA01HT2S6MNXW5F3DAVGZBAHJSHQ',
name: 'Export to PDF file',
isNew: false,
completed: true,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HV166QWXE',
taskId: '0BA01HT2S6MNXW5F3DAVGYQS4G92E',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKS33P5PF',
task: {
id: '0BA01HT2S6MNXW5F3DAVGYQS4G92E',
name: 'User getting sent duplicate notifications',
isNew: false,
completed: false,
},
},
],
},
{
id: '0AY01HT2S6N8EMQPRTEBNKSN2KMSG',
name: 'Done',
tasks: [
{
id: '0BC01HT2S6NY4M1849Y9HV30M0NSA',
taskId: '0BA01HT2S6MNXW5F3DAVGYREZ3NEG',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKSN2KMSG',
task: {
id: '0BA01HT2S6MNXW5F3DAVGYREZ3NEG',
name: "User can't invite teammate via modal page",
isNew: false,
completed: false,
},
},
{
id: '0BC01HT2S6NY4M1849Y9HV52BS0ED',
taskId: '0BA01HT2S6MNXW5F3DAVGYTJWYJ8K',
taskSectionId: '0AY01HT2S6N8EMQPRTEBNKSN2KMSG',
task: {
id: '0BA01HT2S6MNXW5F3DAVGYTJWYJ8K',
name: 'Broken links on my page',
isNew: false,
completed: false,
},
},
],
},
]
import React, { useMemo } from 'react'
import type { IconProps } from '../Icon'
import { Icon } from '../Icon'
type Props = {
completed: boolean
} & Omit<IconProps, 'icon'>
export function CheckIcon(props: Props) {
const { completed, color, ...rest } = props
const iconStyle = useMemo<IconProps>(() => {
if (completed)
return { icon: 'checkCircleFilled', color: 'teal.400', opacity: 0.6 }
return { icon: 'checkCircle', color: color ?? 'gray.500' }
}, [completed, color])
return (
<Icon
_hover={{ color: 'teal.300' }}
cursor="pointer"
transition="all 0.15s ease-out"
{...rest}
{...iconStyle}
/>
)
}
import {
Button,
Flex,
Stack,
Text,
Radio,
RadioGroup,
Divider,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalCloseButton,
} from '@chakra-ui/react'
import React, { useCallback } from 'react'
import { useDeleteTaskSectionModalContext } from './Provider'
export function DeleteTaskSectionModal() {
const { isOpen } = useDeleteTaskSectionModalContext()
if (!isOpen) return null
return <Component />
}
export function Component() {
const {
isOpen,
onClose,
taskSection,
onDeleteAndDeleteTask,
onDeleteAndKeepTask,
taskSize,
} = useDeleteTaskSectionModalContext()
const [value, setValue] = React.useState('1')
const handleDelete = useCallback(async () => {
if (value === '1') {
onDeleteAndKeepTask()
} else {
onDeleteAndDeleteTask()
}
onClose()
}, [onClose, onDeleteAndDeleteTask, onDeleteAndKeepTask, value])
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Are you sure you want to delete this section?</ModalHeader>
<ModalCloseButton />
<Divider />
<ModalBody>
<Stack spacing={6}>
<Text>
This section{' '}
<Text as="span" fontWeight="bold">
{taskSection?.name}
</Text>{' '}
</Text>
<Flex flexDirection="column">
<RadioGroup onChange={setValue} value={value}>
<Stack>
<Radio value="1">
Delete this section and keep this {taskSize} task
</Radio>
<Radio value="2">
Delete this section and delete this {taskSize} task
</Radio>
</Stack>
</RadioGroup>
</Flex>
</Stack>
</ModalBody>
<Divider />
<ModalFooter>
<Button onClick={onClose}>Cancel</Button>
<Button ml={2} colorScheme="red" onClick={handleDelete}>
Delete section
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
import { useCallback, useState } from 'react'
import { createProvider } from '../createProvider'
import type { TaskSection } from '../store/entities/taskSections'
import { useTaskSectionsContext } from '../store/entities/taskSections'
type ContextProps = {
isOpen: boolean
onClose: () => void
onOpen: () => void
setTaskSectionId: (taskSectionId: string) => void
taskSection: TaskSection | null
onDeleteAndDeleteTask: () => void
onDeleteAndKeepTask: () => void
taskSize: number
}
const useValue = (): ContextProps => {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [taskSectionId, setTaskSectionId] = useState<string>('')
const { getTaskSectionById, deleteTaskSection, setTasks, taskSectionIds } =
useTaskSectionsContext()
const taskSection = taskSectionId ? getTaskSectionById(taskSectionId) : null
const onClose = useCallback(() => {
setIsOpen(false)
setTaskSectionId('')
}, [])
const onOpen = useCallback(() => {
setIsOpen(true)
}, [])
const onDeleteAndDeleteTask = useCallback(() => {
deleteTaskSection(taskSectionId)
}, [deleteTaskSection, taskSectionId])
const onDeleteAndKeepTask = useCallback(() => {
const taskSectionIdsOfOthers = taskSectionIds.filter(
(t) => t !== taskSectionId,
)
deleteTaskSection(taskSectionId)
if (taskSectionIdsOfOthers.length && taskSection?.tasks.length) {
const newTaskSectionId = taskSectionIdsOfOthers[0]!
const newTaskSection = getTaskSectionById(newTaskSectionId)
const newTasks = Array.from(newTaskSection.tasks)
setTasks(taskSectionIdsOfOthers[0]!, [...newTasks, ...taskSection.tasks])
}
}, [
deleteTaskSection,
getTaskSectionById,
setTasks,
taskSection?.tasks,
taskSectionId,
taskSectionIds,
])
const taskSize = taskSection?.tasks.length || 0
return {
isOpen,
onOpen,
onClose,
setTaskSectionId,
taskSection,
onDeleteAndDeleteTask,
onDeleteAndKeepTask,
taskSize,
}
}
useValue.__PROVIDER__ = 'DeleteTaskSectionModal'
export const {
Provider: DeleteTaskSectionModalProvider,
useContext: useDeleteTaskSectionModalContext,
} = createProvider(useValue)
import type { IconProps as ChakraIconProps } from '@chakra-ui/react'
import { Icon as ChakraIcon } from '@chakra-ui/react'
import { forwardRef } from '@chakra-ui/react'
import React from 'react'
import type { IconType } from './icons'
import { icons } from './icons'
type Props = ChakraIconProps & {
icon: IconType
size?: Sizes
ref?: React.ForwardedRef<any>
}
export type IconProps = Props
const sizes = {
'3xl': {
w: 10,
h: 10,
},
'2xl': {
w: 8,
h: 8,
},
xl: {
w: 6,
h: 6,
},
lg: {
w: '1.5em',
h: '1.5em',
},
md: {
w: '1.25em',
h: '1.25em',
},
sm: {
w: '1.15em',
h: '1.15em',
},
xs: {
w: '1em',
h: '1em',
},
} as const
type Sizes = keyof typeof sizes
export const Icon = forwardRef<Props, 'svg'>((props, ref) => {
const { size, icon, ...iconProps } = props
const iconComponent = icons[icon]
const sizeStyle = sizes[size ?? 'md']
return (
<ChakraIcon
ref={ref}
as={iconComponent}
color="whiteAlpha"
{...sizeStyle}
{...(iconProps as any)}
/>
)
})
Icon.id = 'Icon'
import { AiFillCheckCircle } from 'react-icons/ai'
import { BiPlus, BiDotsHorizontalRounded, BiCheckCircle } from 'react-icons/bi'
export const icons = {
plus: BiPlus,
dotsHorizontalRounded: BiDotsHorizontalRounded,
checkCircleFilled: AiFillCheckCircle,
checkCircle: BiCheckCircle,
} as const
export type IconType = keyof typeof icons
import type { FlexProps, TextareaProps, ChakraProps } from '@chakra-ui/react'
import { Box, Flex, Textarea } from '@chakra-ui/react'
import React, { memo, useMemo } from 'react'
type Props = {
value: string
onChange: TextareaProps['onChange']
onClick?: FlexProps['onClick']
onKeyDown?: TextareaProps['onKeyDown']
onFocus?: TextareaProps['onFocus']
onBlur?: TextareaProps['onBlur']
autoFocus?: TextareaProps['autoFocus']
containerStyle?: FlexProps
placeholder?: string
textareaRef?: React.ForwardedRef<any>
noBorder?: boolean
} & ChakraProps
export const InputText = memo<Props>(function InputText(props) {
const {
value,
onChange,
onKeyDown,
containerStyle,
placeholder,
onClick,
onFocus,
onBlur,
autoFocus,
textareaRef,
noBorder,
...rest
} = props
const style = useMemo<ChakraProps>(
() => ({
w: 'full',
h: 'full',
minH: props.minH || 'auto',
m: 0,
border: '1px',
borderColor: 'transparent',
borderRadius: 'md',
paddingLeft: noBorder ? 0 : 2,
paddingRight: noBorder ? 0 : 2,
_hover: {
borderColor: noBorder ? 'transparent' : 'gray.400',
},
_focus: {
borderColor: noBorder ? 'transparent' : 'gray.500',
},
wordBreak: 'break-all',
transition: 'none',
}),
[props.minH, noBorder],
)
return (
<Flex
flex={1}
position="relative"
onClick={onClick}
{...rest}
{...containerStyle}
w="full"
>
<Box {...style} visibility="hidden">
{value}
</Box>
<Textarea
ref={textareaRef}
p={0}
{...style}
{...rest}
resize="none"
onChange={onChange}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
autoFocus={autoFocus}
position="absolute"
top={0}
left={0}
focusBorderColor="transparent"
value={value}
placeholder={placeholder}
/>
</Flex>
)
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React from 'react'
type Props = FlexProps
export function TasksBoard(props: Props) {
return <Flex flex={1} w="full" h="full" flexDirection="column" {...props} />
}
import { useEffect } from 'react'
export const useDebounce = <T>(
value: T,
callback: (value: T) => void,
delay: number,
) => {
useEffect(() => {
const timer = window.setTimeout(() => {
callback(value)
}, delay)
return () => {
window.clearTimeout(timer)
}
}, [callback, delay, value])
}
import type { DropResult } from '@hello-pangea/dnd'
import { useCallback } from 'react'
import type { TaskSectionTask } from '../store/entities/taskSections'
import { useTaskSectionsContext } from '../store/entities/taskSections'
export const useDnd = () => {
const { getTaskSectionById, setTasks } = useTaskSectionsContext()
const handleDnd = useCallback(
({ destination, source }: DropResult) => {
if (!destination) return
if (
source.droppableId === destination.droppableId &&
source.index === destination.index
)
return
const start = getTaskSectionById(source.droppableId)
const finish = getTaskSectionById(destination.droppableId)
if (start.id === finish.id) {
const newTasks = Array.from(start.tasks)
const [deleted] = newTasks.splice(source.index, 1)
newTasks.splice(destination.index, 0, deleted as TaskSectionTask)
setTasks(source.droppableId, newTasks)
return
}
const newStartTasks = Array.from(start.tasks)
const [deleted] = newStartTasks.splice(source.index, 1)
setTasks(source.droppableId, newStartTasks)
const newFinishTasks = Array.from(finish.tasks)
newFinishTasks.splice(destination.index, 0, deleted as TaskSectionTask)
setTasks(destination.droppableId, newFinishTasks)
},
[getTaskSectionById, setTasks],
)
return {
handleDnd,
}
}
import type { DraggableChildrenFn, DraggingStyle } from '@hello-pangea/dnd'
import type React from 'react'
import { useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
export const useDraggableInPortal = () => {
const self = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const div = document.createElement('div')
div.style.position = 'absolute'
div.style.pointerEvents = 'none'
div.style.top = '0'
div.style.width = '100%'
div.style.height = '100%'
self.current = div
document.body.appendChild(div)
return () => {
document.body.removeChild(div)
}
}, [self])
return (render: DraggableChildrenFn): DraggableChildrenFn =>
(provided, ...args) => {
const element = render(provided, ...args)
if (
(provided?.draggableProps?.style as DraggingStyle).position === 'fixed'
) {
return createPortal(
element,
self.current as HTMLDivElement,
) as React.ReactNode
}
return element
}
}
import type { ChakraProps } from '@chakra-ui/react'
import { useStyleConfig } from '@chakra-ui/react'
import { useMemo } from 'react'
type DrawerStyle = {
body: ChakraProps
modal: ChakraProps
closeButton: ChakraProps
dialogContainer: ChakraProps
footer: ChakraProps
header: ChakraProps
overlay: ChakraProps
}
export const useDrawerStyle = () => {
const style = useStyleConfig('Drawer') as DrawerStyle
return {
drawerStyle: useMemo((): DrawerStyle => {
return style
}, [style]),
}
}
import useHoverReactHook from '@react-hook/hover'
import { useRef } from 'react'
export const useHover = <T extends HTMLElement>() => {
const ref = useRef<T | null>(null)
const isHovering = useHoverReactHook(ref)
return {
ref,
isHovering,
}
}
import { useEffect, useRef } from 'react'
export const useMountedRef = () => {
const mountedRef = useRef<boolean | null>(null)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
})
return {
mountedRef,
}
}
import { useEffect, useRef } from 'react'
export const usePrevious = <T>(value: T) => {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
import { useDisclosure } from '@chakra-ui/react'
import { useEffect } from 'react'
import { useHover } from './useHover'
import { useMountedRef } from './useMountedRef'
type Props = {
openDelay?: number
}
export const useTooltip = (props?: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { ref, isHovering } = useHover()
const { mountedRef } = useMountedRef()
useEffect(() => {
if (isHovering) {
if (props?.openDelay) {
setTimeout(() => {
if (mountedRef.current) {
onOpen()
}
}, props.openDelay)
return
}
onOpen()
} else {
onClose()
}
}, [isHovering, mountedRef, onClose, onOpen, props?.openDelay])
return {
ref,
isOpen,
onClose,
onOpen,
}
}
export const isHTMLElement = (obj: any): obj is HTMLElement =>
obj instanceof Element
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { memo } from 'react'
type Props = FlexProps
export const TasksBoardContent = memo<Props>(function TasksBoardContent(props) {
return (
<Flex
flex={1}
overflowX="scroll"
overflowY="hidden"
position="relative"
h="full"
bg="gray.50"
_dark={{
bg: '#0c0a09',
}}
{...props}
>
<Flex flex={1} flexDirection="column">
{props.children}
</Flex>
</Flex>
)
})
import { Flex } from '@chakra-ui/react'
import { DragDropContext } from '@hello-pangea/dnd'
import React, { memo } from 'react'
import { useDnd } from '../../hooks/useDnd'
import { useTaskSectionsContext } from '../../store/entities/taskSections'
import { TasksBoardListSection } from '../TasksBoardListSection'
import { AddTaskSection } from '../TasksBoardListSection/AddTaskSection'
export const TasksBoardList = memo(function TasksBoardList() {
const { taskSectionIds } = useTaskSectionsContext()
const { handleDnd } = useDnd()
return (
<DragDropContext onDragEnd={handleDnd}>
<Flex direction="row" flex={1} position="relative">
{taskSectionIds.map((id) => (
<TasksBoardListSection taskSectionId={id} key={id} />
))}
<AddTaskSection />
</Flex>
</DragDropContext>
)
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex, forwardRef } from '@chakra-ui/react'
import React, { memo, useMemo } from 'react'
import { useTaskSectionsContext } from '../../store/entities/taskSections'
type Props = FlexProps & {
taskId: string
isDragging?: boolean
}
export const Card = memo<Props>(function Card({ taskId, isDragging, ...rest }) {
const { getTaskById } = useTaskSectionsContext()
const task = getTaskById(taskId)
return (
<Component completed={task.completed} isDragging={isDragging} {...rest} />
)
})
type ComponentProps = FlexProps & {
completed: boolean
isDragging?: boolean
}
const Component = memo<ComponentProps>(
forwardRef(function Component(props, ref) {
const { completed, isDragging, ...rest } = props
const style = useMemo(
(): FlexProps => ({
...(completed ? { opacity: 0.6 } : {}),
...(isDragging
? {
bg: 'teal.50',
borderColor: 'teal.400',
_dark: {
borderColor: 'gray.200',
bg: 'teal.900',
},
}
: {}),
}),
[isDragging, completed],
)
return (
<Flex
ref={ref}
flexDirection="column"
w="full"
bg="white"
border={1}
borderStyle="solid"
borderColor="gray.200"
borderRadius="md"
_hover={{
borderColor: 'gray.300',
boxShadow: 'sm',
}}
p={4}
position="relative"
minH="120px"
_dark={{
bg: 'gray.900',
borderColor: 'gray.400',
_hover: {
borderColor: 'gray.200',
},
}}
{...style}
{...(rest as FlexProps)}
/>
)
}),
)
import type { FlexProps } from '@chakra-ui/react'
import { Box } from '@chakra-ui/react'
import { Draggable } from '@hello-pangea/dnd'
import React, { memo } from 'react'
import { Card } from './Card'
import { TasksName } from './TasksName'
type Props = FlexProps & {
taskId: string
index: number
}
export const TasksBoardListItem = memo<Props>(
function TasksBoardListItem(props) {
return <Component {...props} />
},
)
const Component = memo<Props>(function Component(props) {
return (
<Draggable
key={props.taskId}
draggableId={props.taskId}
index={props.index}
>
{(provided, snapshot) => (
<Box
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
pb={2}
>
<Card taskId={props.taskId} isDragging={snapshot.isDragging}>
<TasksName taskId={props.taskId} />
</Card>
</Box>
)}
</Draggable>
)
})
import type { ButtonProps } from '@chakra-ui/react'
import { Button } from '@chakra-ui/react'
import React, { memo, useCallback } from 'react'
import { Icon } from '../../Icon'
import { useTaskSectionsContext } from '../../store/entities/taskSections'
type Props = {
taskSectionId: string
} & ButtonProps
export const AddTask = memo<Props>(function AddTask({
taskSectionId,
...rest
}) {
const { addTask } = useTaskSectionsContext()
const handleClick = useCallback(() => {
addTask(taskSectionId)
}, [addTask, taskSectionId])
return <Component {...rest} onClick={handleClick} />
})
type ComponentProps = {
onClick: () => void
} & ButtonProps
const Component = memo<ComponentProps>(function Component({
onClick,
...rest
}) {
return (
<Button
mt={2}
onClick={onClick}
leftIcon={<Icon icon="plus" />}
variant="ghost"
size="md"
fontSize="sm"
{...rest}
>
Add task
</Button>
)
})
import { Button, Flex } from '@chakra-ui/react'
import React, { memo, useCallback } from 'react'
import { Icon } from '../../Icon'
import { useTaskSectionsContext } from '../../store/entities/taskSections'
export const AddTaskSection = memo(function AddTaskSection() {
const { addTaskSection } = useTaskSectionsContext()
const handleClick = useCallback(async () => {
addTaskSection()
}, [addTaskSection])
return (
<Flex w={40} mt={3} ml={2}>
<Button
leftIcon={<Icon icon="plus" />}
variant="ghost"
onClick={handleClick}
size="sm"
>
Add section
</Button>
</Flex>
)
})
import { useCallback, useState } from 'react'
import { createProvider } from '../../createProvider'
type ContextProps = {
focused: boolean
onFocusInput: () => void
onUnfocusInput: () => void
taskSectionId: string
}
type Props = {
taskSectionId: string
}
const useValue = (props: Props): ContextProps => {
const [focused, setFocused] = useState(false)
const onFocusInput = useCallback(() => {
setFocused(true)
}, [])
const onUnfocusInput = useCallback(() => {
setFocused(false)
}, [])
return {
focused,
onFocusInput,
onUnfocusInput,
taskSectionId: props.taskSectionId,
} as const
}
useValue.__PROVIDER__ = 'TasksBoardListSection'
export const { Provider, useContext: useTasksBoardListSectionContext } =
createProvider(useValue)
import { Flex, Box } from '@chakra-ui/react'
import { Droppable } from '@hello-pangea/dnd'
import React, { memo } from 'react'
import { useTaskSectionsContext } from '../../store/entities/taskSections'
import { TasksBoardListItem } from '../TasksBoardListItem'
import { AddTask } from './AddTask'
import { Header } from './Header'
import { Provider } from './Provider'
type Props = {
taskSectionId: string
}
export const TasksBoardListSection = memo<Props>(
function TasksBoardListSection(props) {
const { getTaskIdsByTaskSectionId } = useTaskSectionsContext()
const taskIds = getTaskIdsByTaskSectionId(props.taskSectionId)
return (
<Provider taskSectionId={props.taskSectionId}>
<Component taskIds={taskIds} taskSectionId={props.taskSectionId} />
</Provider>
)
},
)
type ComponentProps = Props & {
taskIds: string[]
}
const Component = memo<ComponentProps>(function Component({
taskIds,
taskSectionId,
}) {
return (
<>
<Flex
flexDirection="column"
w="304px"
maxW="304px"
h="full"
px={3}
py={2}
transition="all .15s ease-out"
>
<Header taskSectionId={taskSectionId} />
<Droppable droppableId={taskSectionId}>
{(provided) => (
<Flex
flexDirection="column"
pb={20}
position="relative"
ref={provided.innerRef}
{...provided.droppableProps}
>
{taskIds.length > 0 && (
<Box mt={2}>
{taskIds.map((id, i) => (
<TasksBoardListItem taskId={id} key={id} index={i} />
))}
{provided.placeholder}
<AddTask taskSectionId={taskSectionId} />
</Box>
)}
{taskIds.length === 0 && (
<>
<Flex
bgGradient="linear(to-b, gray.100, gray.50)"
borderRadius="md"
w="full"
h="calc(100% - 8px)"
position="absolute"
top={2}
left={0}
pt={2}
px={2}
_dark={{
bgGradient: 'none',
}}
>
<AddTask
taskSectionId={taskSectionId}
w="full"
_hover={{
bg: 'gray.200',
}}
_dark={{
_hover: {
bg: 'whiteAlpha.200',
},
}}
/>
</Flex>
</>
)}
</Flex>
)}
</Droppable>
</Flex>
</>
)
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { memo, useCallback } from 'react'
import { CheckIcon } from '../../../CheckIcon'
import { useTaskSectionsContext } from '../../../store/entities/taskSections'
import type { Task } from '../../../store/entities/tasks'
import { TasksNameField } from './TasksNameField'
type Props = FlexProps & {
taskId: string
}
export const TasksName = memo<Props>(function TasksName({ taskId }) {
const { getTaskById, setTaskName, deleteTask, toggleTaskDone } =
useTaskSectionsContext()
const task = getTaskById(taskId)
const handleDeleteTask = useCallback(async () => {
deleteTask(taskId)
}, [deleteTask, taskId])
const handleChangeName = useCallback(
(val: string) => {
setTaskName(val, taskId)
},
[setTaskName, taskId],
)
const handleToggle = useCallback(() => {
toggleTaskDone(taskId)
}, [taskId, toggleTaskDone])
return (
<Component
task={task}
onChange={handleChangeName}
onDelete={handleDeleteTask}
onToggleDone={handleToggle}
/>
)
})
type ComponentProps = {
task: Task
onChange: (value: string) => void
onDelete: () => void
onToggleDone: () => void
}
const Component = memo<ComponentProps>(function Component({
task,
onDelete,
onChange,
onToggleDone,
}) {
const handleToggleDone = useCallback(
async (e: React.MouseEvent<SVGElement>) => {
e.stopPropagation()
onToggleDone()
},
[onToggleDone],
)
return (
<Flex>
<CheckIcon completed={task.completed} onClick={handleToggleDone} />
<TasksNameField
taskId={task.id}
value={task.name}
isNew={task.isNew}
onChange={onChange}
onDeleteTask={onDelete}
flex={1}
/>
</Flex>
)
})
import type { InputProps } from '@chakra-ui/react'
import { Flex, useOutsideClick } from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { InputText } from '../../../InputText'
import { useDebounce } from '../../../hooks/useDebounce'
type Props = {
taskId: string
value: string
onChange: (val: string) => void
isNew?: boolean
onDeleteTask?: () => void
focusedBorder?: boolean
} & Omit<InputProps, 'onChange'>
export const TasksNameField = memo<Props>(function TasksNameField(props) {
const [value, setValue] = useState<string>(props.value)
const autoFocus = props.isNew
const ref = useRef<HTMLDivElement | null>(null)
useOutsideClick({
ref,
handler: () => {
if (!value) props.onDeleteTask?.()
},
})
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value)
},
[],
)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.code === 'Enter') e.preventDefault()
}, [])
useEffect(() => {
setValue(props.value)
}, [props.value])
useDebounce(value, props.onChange, 500)
return (
<Flex position="relative" flex={1} ref={ref}>
<InputText
value={value}
onChange={handleChange}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
fontSize="sm"
placeholder="Write a task name"
autoFocus={autoFocus}
borderRadius="sm"
minH="23px"
containerStyle={{
ml: 1,
maxH: 20,
}}
/>
</Flex>
)
})
import { Flex, Stack } from '@chakra-ui/react'
import React, { memo } from 'react'
import { AddTaskButton } from './AddTaskButton'
import { MoreAction } from './MoreAction'
import { TaskSectionName } from './TaskSectionName'
type Props = {
taskSectionId: string
}
export const Header = memo<Props>(function Header(props) {
return (
<Flex h="36px" alignItems="center">
<TaskSectionName taskSectionId={props.taskSectionId} />
<Stack direction="row" spacing={1} ml="auto">
<AddTaskButton taskSectionId={props.taskSectionId} />
<MoreAction />
</Stack>
</Flex>
)
})
import { useCallback, useMemo, useState } from 'react'
import { v4 as uuid } from 'uuid'
import { createProvider } from '../../../createProvider'
import type { Task } from '../tasks'
import type { TaskSection, TaskSectionTask } from './types'
type ContextProps = {
taskSections: TaskSection[]
taskSectionIds: string[]
getTaskIdsByTaskSectionId: (taskSectionId: string) => string[]
getTaskSectionById: (taskSectionId: string) => TaskSection
setTaskSectionName: (value: string, taskSectionId: string) => void
addTask: (taskSectionId: string) => void
setTasks: (taskSectionId: string, taskSectionTasks: TaskSectionTask[]) => void
getTaskById: (taskId: string) => Task
setTaskName: (value: string, taskId: string) => void
deleteTask: (taskId: string) => void
deleteTaskSection: (taskSectionId: string) => void
hasTasksInTaskSection: (taskSectionId: string) => boolean
addTaskSection: () => void
toggleTaskDone: (taskId: string) => void
}
type Props = {
initialData: TaskSection[]
}
const useValue = (props: Props): ContextProps => {
const [taskSections, setTaskSections] = useState<TaskSection[]>(
props.initialData,
)
const taskSectionsGroupById = useMemo(() => {
return taskSections.reduce(
(acc, t) => {
acc[t.id] = t
return acc
},
{} as Record<string, TaskSection>,
)
}, [taskSections])
const tasksGroupById = useMemo<Record<string, Task>>(() => {
return taskSections
.reduce((acc, t) => [...acc, ...t.tasks], [] as TaskSectionTask[])
.reduce(
(acc, t) => {
acc[t.taskId] = t.task
return acc
},
{} as Record<string, Task>,
)
}, [taskSections])
const taskSectionTasks = useMemo(() => {
return taskSections.reduce(
(acc, t) => [...acc, ...t.tasks],
[] as TaskSectionTask[],
)
}, [taskSections])
const taskSectionIds = useMemo(() => {
return taskSections.map((t) => t.id)
}, [taskSections])
const getTaskIdsByTaskSectionId = useCallback(
(taskSectionId: string) => {
return (
taskSectionsGroupById[taskSectionId]?.tasks.map((t) => t.taskId) || []
)
},
[taskSectionsGroupById],
)
const getTaskSectionById = useCallback(
(taskSectionId: string) => {
return taskSectionsGroupById[taskSectionId] as TaskSection
},
[taskSectionsGroupById],
)
const setTaskSectionName = useCallback(
(value: string, taskSectionId: string) => {
setTaskSections((s) => {
return s.map((ts) => {
if (ts.id === taskSectionId) {
return {
...ts,
name: value || 'Untitled Section',
isNew: false,
}
}
return ts
})
})
},
[],
)
const addTask = useCallback((taskSectionId: string) => {
setTaskSections((s) => {
return s.map((ts) => {
if (ts.id === taskSectionId) {
const taskId = uuid()
return {
...ts,
tasks: [
...ts.tasks,
{
id: uuid(),
taskId,
taskSectionId,
task: {
id: taskId,
completed: false,
name: '',
isNew: true,
},
},
],
}
}
return ts
})
})
}, [])
const getTaskById = useCallback(
(id: string) => {
return tasksGroupById[id] as Task
},
[tasksGroupById],
)
const getTaskSectionTaskById = useCallback(
(taskId: string) => {
return taskSectionTasks.find((t) => t.taskId === taskId)
},
[taskSectionTasks],
)
const updateTask = useCallback(
(taskId: string, value: Partial<Task>) => {
const found = getTaskSectionTaskById(taskId)
setTaskSections((s) => {
return s.map((ts) => {
if (found && ts.id === found.taskSectionId) {
return {
...ts,
tasks: ts.tasks.map((t) => {
if (t.taskId === taskId) {
return {
...t,
...{
...found,
task: {
...found.task,
...value,
},
},
}
}
return t
}),
}
}
return ts
})
})
},
[getTaskSectionTaskById],
)
const setTaskName = useCallback(
(value: string, taskId: string) => {
const found = getTaskSectionTaskById(taskId)
if (found?.task.isNew && !found.task.name && !value) return
if (found?.task.name === value) return
updateTask(taskId, {
isNew: false,
name: value,
})
},
[getTaskSectionTaskById, updateTask],
)
const toggleTaskDone = useCallback(
(taskId: string) => {
const found = taskSectionTasks.find((t) => t.taskId === taskId)
updateTask(taskId, {
completed: !found?.task?.completed,
})
},
[taskSectionTasks, updateTask],
)
const setTasks = useCallback(
(taskSectionId: string, taskSectionTasks: TaskSectionTask[]) => {
setTaskSections((s) => {
return s.map((ts) => {
if (ts.id === taskSectionId) {
return {
...ts,
tasks: taskSectionTasks.map((t) => ({
...t,
taskSectionId,
})),
}
}
return ts
})
})
},
[],
)
const deleteTask = useCallback(
(taskId: string) => {
setTaskSections((s) => {
return s.map((ts) => {
const found = taskSectionTasks.find((t) => t.taskId === taskId)
if (found && ts.id === found.taskSectionId) {
return {
...ts,
tasks: ts.tasks.filter((t) => t.taskId !== taskId),
}
}
return ts
})
})
},
[taskSectionTasks],
)
const deleteTaskSection = useCallback((taskSectionId: string) => {
setTaskSections((s) => {
return s.filter((ts) => ts.id !== taskSectionId)
})
}, [])
const hasTasksInTaskSection = useCallback(
(taskSectionId: string) => {
return !!taskSectionTasks.find((t) => t.taskSectionId === taskSectionId)
},
[taskSectionTasks],
)
const addTaskSection = useCallback(() => {
const taskSectionId = uuid()
setTaskSections((s) => {
return [
...s,
{
id: taskSectionId,
name: '',
tasks: [],
isNew: true,
},
]
})
}, [])
return {
taskSections,
taskSectionIds,
getTaskIdsByTaskSectionId,
getTaskSectionById,
setTaskSectionName,
addTask,
getTaskById,
setTaskName,
deleteTask,
deleteTaskSection,
hasTasksInTaskSection,
addTaskSection,
toggleTaskDone,
setTasks,
}
}
useValue.__PROVIDER__ = 'TaskSectionsProvider'
export const {
Provider: TaskSectionsProvider,
useContext: useTaskSectionsContext,
} = createProvider(useValue)
import type { Task } from '../tasks/types'
export type TaskSection = {
id: string
name: string
tasks: TaskSectionTask[]
isNew?: boolean
}
export type TaskSectionTask = {
id: string
taskId: string
taskSectionId: string
task: Task
}
export type Task = {
id: string
name: string
completed: boolean
isNew: boolean
}
import { IconButton } from '@chakra-ui/react'
import { Tooltip } from '@chakra-ui/react'
import type { MutableRefObject } from 'react'
import React, { memo, useCallback } from 'react'
import { Icon } from '../../../../Icon'
import { useTooltip } from '../../../../hooks/useTooltip'
import { useTaskSectionsContext } from '../../../../store/entities/taskSections'
type Props = {
taskSectionId: string
}
export const AddTaskButton = memo<Props>(function AddTaskButton(props) {
const { addTask } = useTaskSectionsContext()
const handleClick = useCallback(async () => {
addTask(props.taskSectionId)
}, [addTask, props.taskSectionId])
return <Component onClick={handleClick} />
})
type ComponentProps = {
onClick: () => void
}
const Component = memo<ComponentProps>(function Component({ onClick }) {
const { ref, isOpen, onClose } = useTooltip()
const handleClick = useCallback(async () => {
onClose()
onClick()
}, [onClose, onClick])
return (
<Tooltip
hasArrow
label="Add task"
aria-label="Add task button"
isOpen={isOpen}
>
<IconButton
ref={ref as MutableRefObject<HTMLButtonElement>}
aria-label="Add task button"
icon={<Icon icon="plus" color="text.muted" />}
variant="ghost"
size="sm"
onClick={handleClick}
/>
</Tooltip>
)
})
import {
Box,
IconButton,
PortalManager,
Menu,
MenuButton,
} from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../../Icon'
import { MenuList } from './MenuList'
export const MoreAction = memo(function MoreAction() {
return (
<PortalManager zIndex={1500}>
<Box>
<Menu placement="bottom-start" isLazy>
<MenuButton
aria-label="More actions"
as={IconButton}
icon={<Icon icon="dotsHorizontalRounded" color="text.muted" />}
variant="ghost"
size="sm"
/>
<MenuList />
</Menu>
</Box>
</PortalManager>
)
})
import type { InputProps } from '@chakra-ui/react'
import { Input as AtomsInput, useOutsideClick } from '@chakra-ui/react'
import React, { memo, useCallback, useRef, useState } from 'react'
type Props = {
onClickOutside: () => void
onChange: (val: string) => void
value: string
} & Omit<InputProps, 'onChange'>
export const Input = memo<Props>(function Input(props) {
const { onClickOutside, onChange, ...rest } = props
const [value, setValue] = useState<string>(props.value)
const handleClickOutside = useCallback(() => {
onChange(value)
onClickOutside()
}, [onChange, onClickOutside, value])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}, [])
const ref = useRef<HTMLInputElement | null>(null)
useOutsideClick({
ref,
handler: handleClickOutside,
})
return (
<AtomsInput
ref={ref}
autoFocus
fontSize="md"
placeholder="New section"
variant="unstyled"
fontWeight="semibold"
border="1px"
borderColor="gray.300"
px={2}
maxW={80}
bg="white"
_dark={{
bg: 'gray.900',
}}
{...rest}
onChange={handleChange}
value={value}
/>
)
})
import { Box } from '@chakra-ui/react'
import React, { memo, useCallback, useMemo } from 'react'
import { useTaskSectionsContext } from '../../../../store/entities/taskSections'
import { useTasksBoardListSectionContext } from '../../Provider'
import { Input } from './Input'
type Props = {
taskSectionId: string
}
export const TaskSectionName = memo<Props>(function TaskSectionName({
taskSectionId,
}) {
const { getTaskSectionById, setTaskSectionName } = useTaskSectionsContext()
const taskSection = getTaskSectionById(taskSectionId)
const handleSetTaskSectionName = useCallback(
(value: string) => {
setTaskSectionName(value, taskSectionId)
},
[setTaskSectionName, taskSectionId],
)
return (
<Component
name={taskSection.name}
setSectionName={handleSetTaskSectionName}
isNew={taskSection.isNew}
/>
)
})
type ComponentProps = {
name: string
setSectionName: (value: string) => void
isNew?: boolean
}
const Component = memo<ComponentProps>(function Component({
isNew,
name,
setSectionName,
}) {
const { focused, onFocusInput, onUnfocusInput } =
useTasksBoardListSectionContext()
const showInput = useMemo(() => {
if (isNew) return true
return focused
}, [focused, isNew])
const handleClick = useCallback(() => {
onFocusInput()
}, [onFocusInput])
const handleClickOutside = useCallback(() => {
onUnfocusInput()
}, [onUnfocusInput])
const handleChange = useCallback(
async (val: string) => {
setSectionName(val)
},
[setSectionName],
)
if (showInput) {
return (
<Input
onClickOutside={handleClickOutside}
onChange={handleChange}
value={name}
/>
)
}
return (
<Box
px={2}
maxW={80}
noOfLines={1}
fontWeight="semibold"
border="1px"
borderColor="transparent"
onClick={handleClick}
cursor="pointer"
>
{name}
</Box>
)
})
import { MenuList as AtomsMenuList, MenuItem } from '@chakra-ui/react'
import React, { memo, useCallback } from 'react'
import { useDeleteTaskSectionModalContext } from '../../../../../DeleteTaskSectionModal'
import { useTaskSectionsContext } from '../../../../../store/entities/taskSections'
import { useTasksBoardListSectionContext } from '../../../Provider'
export const MenuList = memo(function MenuList() {
const { setTaskSectionId, onOpen } = useDeleteTaskSectionModalContext()
const { deleteTaskSection, hasTasksInTaskSection } = useTaskSectionsContext()
const { onFocusInput, taskSectionId } = useTasksBoardListSectionContext()
const handleRenameSection = useCallback(() => {
onFocusInput()
}, [onFocusInput])
const handleDeleteSection = useCallback(async () => {
if (!hasTasksInTaskSection(taskSectionId)) {
deleteTaskSection(taskSectionId)
return
}
setTaskSectionId(taskSectionId)
onOpen()
}, [
deleteTaskSection,
hasTasksInTaskSection,
onOpen,
setTaskSectionId,
taskSectionId,
])
return (
<AtomsMenuList>
<MenuItem onClick={handleRenameSection}>Rename section</MenuItem>
<MenuItem onClick={handleDeleteSection} color="alert">
Delete section
</MenuItem>
</AtomsMenuList>
)
})
Backlog
Implement new card design
User bug report
Design iOS prototype
Ready
Scope performance improvements
Implement mobile menu
Support for offline mode
In Progress
Introduce CI
Login with Google
Implement undo function
Export to PDF file
User getting sent duplicate notifications
Done
User can't invite teammate via modal page
Broken links on my page