fix(advanced checklist): animations and error handling (#1200)

* chore: lint

* fix: task completion persisted before animation ends

* fix: open/completed task transition timing

* fix: save draft

* fix: transitions

* fix: do not show generic error when first rendering a note

Co-authored-by: Johnny Almonte <johnny243@users.noreply.github.com>
This commit is contained in:
Johnny A
2022-07-04 13:10:41 -04:00
committed by GitHub
parent 68e0acecca
commit a0205a5c7d
10 changed files with 187 additions and 146 deletions

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import useResizeObserver from '@react-hook/resize-observer' import useResizeObserver from '@react-hook/resize-observer'
import React, { useEffect, useRef, useState } from 'react'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store' import type { AppDispatch, RootState } from './store'
@@ -42,3 +42,13 @@ export const useDebouncedCallback = (callback: () => void, waitMs: number = 500)
callback() callback()
}, waitMs) }, waitMs)
} }
export const usePrevious = (value: any) => {
const ref = useRef<typeof value>()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}

View File

@@ -1,18 +1,15 @@
import React, { ChangeEvent, forwardRef } from 'react' import { ChangeEvent, forwardRef, MouseEvent } from 'react'
type CheckBoxInputProps = { type CheckBoxInputProps = {
checked?: boolean checked?: boolean
disabled?: boolean disabled?: boolean
testId?: string testId?: string
onChange?: (event: ChangeEvent<HTMLInputElement>) => void onChange?: (event: ChangeEvent<HTMLInputElement>) => void
onClick?: (event: MouseEvent<SVGElement>) => void
} }
export const CheckBoxInput = forwardRef<HTMLInputElement, CheckBoxInputProps>( export const CheckBoxInput = forwardRef<HTMLInputElement, CheckBoxInputProps>(
({ checked, disabled, testId, onChange }, ref) => { ({ checked, disabled, testId, onChange, onClick }, ref) => {
function onCheckBoxButtonClick({ currentTarget }: React.MouseEvent<SVGElement>) {
!checked ? currentTarget.classList.add('explode') : currentTarget.classList.remove('explode')
}
return ( return (
<label className="checkbox-container"> <label className="checkbox-container">
<input <input
@@ -29,7 +26,7 @@ export const CheckBoxInput = forwardRef<HTMLInputElement, CheckBoxInputProps>(
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="3 2 22 20" viewBox="3 2 22 20"
className="checkbox-button" className="checkbox-button"
onClick={onCheckBoxButtonClick} onClick={onClick}
> >
<use xlinkHref="#checkbox-square" className="checkbox-square"></use> <use xlinkHref="#checkbox-square" className="checkbox-square"></use>
<use xlinkHref="#checkbox-mark" className="checkbox-mark"></use> <use xlinkHref="#checkbox-mark" className="checkbox-mark"></use>

View File

@@ -53,7 +53,8 @@ const CreateTask: React.FC<CreateTaskProps> = ({ group }) => {
} }
useDebouncedCallback(() => { useDebouncedCallback(() => {
if (group.draft !== undefined && taskDraft !== group.draft) { const currentDraft = group.draft ?? ''
if (currentDraft !== taskDraft) {
dispatch(tasksGroupDraft({ groupName, draft: taskDraft })) dispatch(tasksGroupDraft({ groupName, draft: taskDraft }))
} }
}) })

View File

@@ -13,7 +13,11 @@ const Container = styled.div`
const InvalidContentError: React.FC = () => { const InvalidContentError: React.FC = () => {
const lastError = useAppSelector((state) => state.tasks.lastError) const lastError = useAppSelector((state) => state.tasks.lastError)
return <Container>{lastError || 'An unknown error has occurred, and the content cannot be displayed.'}</Container> if (!lastError) {
return <></>
}
return <Container>{lastError}</Container>
} }
export default InvalidContentError export default InvalidContentError

View File

@@ -1,4 +1,3 @@
import { useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { useAppDispatch, useAppSelector } from '../../app/hooks' import { useAppDispatch, useAppSelector } from '../../app/hooks'
@@ -10,6 +9,7 @@ import TaskSectionList from './TaskSectionList'
import TaskGroupOptions from './TaskGroupOptions' import TaskGroupOptions from './TaskGroupOptions'
import { useEffect, useState } from 'react'
import { CircularProgressBar, GenericInlineText, MainTitle, RoundButton } from '../../common/components' import { CircularProgressBar, GenericInlineText, MainTitle, RoundButton } from '../../common/components'
import { ChevronDownIcon, ChevronUpIcon, ReorderIcon } from '../../common/components/icons' import { ChevronDownIcon, ChevronUpIcon, ReorderIcon } from '../../common/components/icons'
@@ -26,14 +26,6 @@ const TaskGroupContainer = styled.div<{ isLast?: boolean }>`
} }
` `
type CollapsableContainerProps = {
collapsed: boolean
}
const CollapsableContainer = styled.div<CollapsableContainerProps>`
display: ${({ collapsed }) => (collapsed ? 'none' : 'block')};
`
type TaskGroupProps = { type TaskGroupProps = {
group: GroupModel group: GroupModel
isDragging: boolean isDragging: boolean
@@ -69,7 +61,6 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
const allTasksCompleted = totalTasks > 0 && totalTasks === completedTasks const allTasksCompleted = totalTasks > 0 && totalTasks === completedTasks
function handleCollapse() { function handleCollapse() {
dispatch(tasksGroupCollapsed({ groupName, type: 'group', collapsed: !collapsed }))
setCollapsed(!collapsed) setCollapsed(!collapsed)
} }
@@ -80,6 +71,10 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
setCollapsed(false) setCollapsed(false)
} }
useEffect(() => {
dispatch(tasksGroupCollapsed({ groupName, type: 'group', collapsed }))
}, [collapsed, dispatch, groupName])
return ( return (
<TaskGroupContainer <TaskGroupContainer
ref={innerRef} ref={innerRef}
@@ -119,10 +114,12 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
)} )}
</div> </div>
<CollapsableContainer collapsed={collapsed}> {!collapsed && (
<CreateTask group={group} /> <>
<TaskSectionList group={group} /> <CreateTask group={group} />
</CollapsableContainer> <TaskSectionList group={group} />
</>
)}
</TaskGroupContainer> </TaskGroupContainer>
) )
} }

View File

@@ -7,7 +7,7 @@ $transition-duration: 750ms;
} }
0% { 0% {
opacity: 1; opacity: 1;
max-height: 25px; max-height: 100px;
} }
} }
@@ -18,18 +18,30 @@ $transition-duration: 750ms;
} }
100% { 100% {
opacity: 1; opacity: 1;
max-height: 25px; max-height: 100px;
} }
} }
.task-item.fade-out { .fade-out {
animation: fadeOut ease $transition-duration; animation: fadeOut ease $transition-duration;
animation-delay: $transition-duration; animation-delay: 0s;
animation-fill-mode: forwards; animation-fill-mode: forwards;
} }
.task-item.fade-in { .fade-in {
animation: fadeIn ease $transition-duration; animation: fadeIn ease $transition-duration;
animation-delay: 0s; animation-delay: 0s;
animation-fill-mode: forwards; animation-fill-mode: forwards;
} }
.completed {
animation-delay: 1.8s;
}
.opened {
animation-delay: 1.2s;
}
.hide {
display: none;
}

View File

@@ -1,6 +1,6 @@
import './TaskItem.scss' import './TaskItem.scss'
import { ChangeEvent, createRef, KeyboardEvent, useState } from 'react' import { ChangeEvent, KeyboardEvent, MouseEvent, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { useAppDispatch, useAppSelector, useDebouncedCallback, useResize } from '../../app/hooks' import { useAppDispatch, useAppSelector, useDebouncedCallback, useResize } from '../../app/hooks'
@@ -8,30 +8,11 @@ import { taskDeleted, TaskModel, taskModified, taskToggled } from './tasks-slice
import { CheckBoxInput, TextAreaInput } from '../../common/components' import { CheckBoxInput, TextAreaInput } from '../../common/components'
/** const Container = styled.div`
* A delay in the dispatch function, when a task is opened.
* Necessary to allow for transitions to occur.
*/
const DISPATCH_OPENED_DELAY_MS = 1_250
/**
* A delay in the dispatch function, when a task is completed.
* Necessary to allow for transitions to occur.
*/
const DISPATCH_COMPLETED_DELAY_MS = 1_850
const Container = styled.div<{ completed?: boolean }>`
align-content: center; align-content: center;
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
${({ completed }) =>
completed &&
`
color: var(--sn-stylekit-info-color);
`}
min-width: 10%; min-width: 10%;
max-width: 90%; max-width: 90%;
` `
@@ -39,11 +20,11 @@ const Container = styled.div<{ completed?: boolean }>`
export type TaskItemProps = { export type TaskItemProps = {
task: TaskModel task: TaskModel
groupName: string groupName: string
innerRef?: (element?: HTMLElement | null | undefined) => any
} }
const TaskItem: React.FC<TaskItemProps> = ({ task, groupName, innerRef, ...props }) => { const TaskItem: React.FC<TaskItemProps> = ({ task, groupName }) => {
const textAreaRef = createRef<HTMLTextAreaElement>() const containerRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -68,12 +49,14 @@ const TaskItem: React.FC<TaskItemProps> = ({ task, groupName, innerRef, ...props
const singleLineHeight = 20 const singleLineHeight = 20
const currentHeight = parseFloat(textarea.style.height) const currentHeight = parseFloat(textarea.style.height)
const containerElement = containerRef.current
if (currentHeight > singleLineHeight) { if (currentHeight > singleLineHeight) {
textarea.parentElement?.classList.add('align-baseline') containerElement?.classList.add('align-baseline')
textarea.parentElement?.classList.remove('align-center') containerElement?.classList.remove('align-center')
} else { } else {
textarea.parentElement?.classList.add('align-center') containerElement?.classList.add('align-center')
textarea.parentElement?.classList.remove('align-baseline') containerElement?.classList.remove('align-baseline')
} }
} }
@@ -81,15 +64,27 @@ const TaskItem: React.FC<TaskItemProps> = ({ task, groupName, innerRef, ...props
const newCompletedState = !completed const newCompletedState = !completed
setCompleted(newCompletedState) setCompleted(newCompletedState)
newCompletedState const textarea = textAreaRef.current
? textAreaRef.current!.classList.add('cross-out')
: textAreaRef.current!.classList.add('no-text-decoration')
const dispatchDelay = newCompletedState ? DISPATCH_COMPLETED_DELAY_MS : DISPATCH_OPENED_DELAY_MS if (newCompletedState) {
textarea?.classList.add(...['cross-out', 'info-color'])
} else {
textarea?.classList.add('no-text-decoration')
}
setTimeout(() => { dispatch(taskToggled({ id: task.id, groupName }))
dispatch(taskToggled({ id: task.id, groupName })) }
}, dispatchDelay)
function onCheckBoxClick({ currentTarget }: MouseEvent<SVGElement>) {
const parentElement = containerRef.current?.parentElement
if (task.completed) {
currentTarget.classList.remove('explode')
parentElement?.classList.add('completed')
} else {
currentTarget.classList.add('explode')
parentElement?.classList.add('opened')
}
} }
function onTextChange(event: ChangeEvent<HTMLTextAreaElement>) { function onTextChange(event: ChangeEvent<HTMLTextAreaElement>) {
@@ -125,8 +120,14 @@ const TaskItem: React.FC<TaskItemProps> = ({ task, groupName, innerRef, ...props
useResize(textAreaRef, resizeTextArea) useResize(textAreaRef, resizeTextArea)
return ( return (
<Container data-testid="task-item" completed={completed} ref={innerRef} {...props}> <Container className="task-item" data-testid="task-item" ref={containerRef}>
<CheckBoxInput testId="check-box-input" checked={completed} disabled={!canEdit} onChange={onCheckBoxToggle} /> <CheckBoxInput
testId="check-box-input"
checked={completed}
disabled={!canEdit}
onChange={onCheckBoxToggle}
onClick={onCheckBoxClick}
/>
<TextAreaInput <TextAreaInput
testId="text-area-input" testId="text-area-input"
className="text-area-input" className="text-area-input"

View File

@@ -51,7 +51,13 @@ const TaskSectionList: React.FC<TaskSectionListProps> = ({ group }) => {
) )
return ( return (
<DragDropContext key={`${section.id}-section-dnd`} onDragEnd={onDragEnd}> <DragDropContext key={`${section.id}-section-dnd`} onDragEnd={onDragEnd}>
<TasksSection testId={`${section.id}-section`} groupName={group.name} section={section} tasks={tasks}> <TasksSection
testId={`${section.id}-section`}
groupName={group.name}
section={section}
tasks={tasks}
allTasks={group.tasks}
>
{section.id === 'completed-tasks' && tasks.length > 0 && <CompletedTasksActions groupName={group.name} />} {section.id === 'completed-tasks' && tasks.length > 0 && <CompletedTasksActions groupName={group.name} />}
</TasksSection> </TasksSection>
</DragDropContext> </DragDropContext>

View File

@@ -1,11 +1,11 @@
import './TasksSection.scss' import './TasksSection.scss'
import React, { useState } from 'react' import React, { useEffect, useState } from 'react'
import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd' import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd'
import { CSSTransition, TransitionGroup } from 'react-transition-group' import { CSSTransition, TransitionGroup } from 'react-transition-group'
import styled from 'styled-components' import styled from 'styled-components'
import { useAppDispatch, useAppSelector } from '../../app/hooks' import { useAppDispatch, useAppSelector, usePrevious } from '../../app/hooks'
import { RoundButton, SubTitle } from '../../common/components' import { RoundButton, SubTitle } from '../../common/components'
import { SectionModel, TaskModel, tasksGroupCollapsed } from './tasks-slice' import { SectionModel, TaskModel, tasksGroupCollapsed } from './tasks-slice'
@@ -21,8 +21,8 @@ const SectionHeader = styled.div`
} }
` `
const InnerTasksContainer = styled.div<{ collapsed: boolean }>` const InnerTasksContainer = styled.div`
display: ${({ collapsed }) => (collapsed ? 'none' : 'flex')}; display: flex;
flex-direction: column; flex-direction: column;
& > *:not(:last-child) { & > *:not(:last-child) {
@@ -42,6 +42,21 @@ const Wrapper = styled.div`
color: var(--sn-stylekit-foreground-color); color: var(--sn-stylekit-foreground-color);
` `
const COMPLETED_TASK_TIMEOUT_MS = 1_800
const OPEN_TASK_TIMEOUT_MS = 1_200
type TransitionTimeout = {
enter: number
exit: number
}
function getTimeout(completed?: boolean): TransitionTimeout {
return {
enter: completed ? COMPLETED_TASK_TIMEOUT_MS : OPEN_TASK_TIMEOUT_MS,
exit: !completed ? COMPLETED_TASK_TIMEOUT_MS : OPEN_TASK_TIMEOUT_MS,
}
}
const getItemStyle = (isDragging: boolean, draggableStyle?: DraggingStyle | NotDraggingStyle) => ({ const getItemStyle = (isDragging: boolean, draggableStyle?: DraggingStyle | NotDraggingStyle) => ({
...draggableStyle, ...draggableStyle,
...(isDragging && { ...(isDragging && {
@@ -54,21 +69,27 @@ type TasksSectionProps = {
groupName: string groupName: string
tasks: TaskModel[] tasks: TaskModel[]
section: SectionModel section: SectionModel
allTasks?: TaskModel[]
testId?: string testId?: string
} }
const TasksSection: React.FC<TasksSectionProps> = ({ groupName, tasks, section, testId, children }) => { const TasksSection: React.FC<TasksSectionProps> = ({ groupName, tasks, section, allTasks, testId, children }) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const canEdit = useAppSelector((state) => state.settings.canEdit) const canEdit = useAppSelector((state) => state.settings.canEdit)
const droppableId = `${section.id}-droppable` const droppableId = `${section.id}-droppable`
const [collapsed, setCollapsed] = useState<boolean>(!!section.collapsed) const [collapsed, setCollapsed] = useState<boolean>(!!section.collapsed)
const handleCollapse = () => { function handleCollapse() {
dispatch(tasksGroupCollapsed({ groupName, type: section.id, collapsed: !collapsed }))
setCollapsed(!collapsed) setCollapsed(!collapsed)
} }
const prevTasks: TaskModel[] = usePrevious(allTasks)
useEffect(() => {
dispatch(tasksGroupCollapsed({ groupName, type: section.id, collapsed }))
}, [collapsed, dispatch, groupName, section.id])
return ( return (
<OuterContainer <OuterContainer
data-testid={testId} data-testid={testId}
@@ -87,80 +108,68 @@ const TasksSection: React.FC<TasksSectionProps> = ({ groupName, tasks, section,
</RoundButton> </RoundButton>
)} )}
</SectionHeader> </SectionHeader>
<InnerTasksContainer {!collapsed && (
{...provided.droppableProps} <InnerTasksContainer
className={`${section.id}-container`} {...provided.droppableProps}
collapsed={collapsed} className={`${section.id}-container`}
ref={provided.innerRef} ref={provided.innerRef}
> >
<TransitionGroup component={null} childFactory={(child) => React.cloneElement(child)}> <TransitionGroup component={null}>
{tasks.map((task, index) => ( {tasks.map((task, index) => {
<CSSTransition const timeout = getTimeout(task.completed)
key={`${task.id}-${!!task.completed}`} return (
classNames={{ <CSSTransition
enter: 'fade-in', key={task.id}
enterActive: 'fade-in', timeout={timeout}
enterDone: 'fade-in', classNames={{
exit: 'fade-out', enter: 'fade-in',
exitActive: 'fade-out', enterActive: 'fade-in',
exitDone: 'fade-out', enterDone: 'fade-in',
}} exit: 'fade-out',
timeout={{ exitActive: 'fade-out',
enter: 1_500, exitDone: 'fade-out',
exit: 1_250, }}
}} onEnter={(node: HTMLElement) => {
onEnter={(node: HTMLElement) => { const exists = prevTasks.find((t) => t.id === task.id)
node.classList.remove('explode') exists && node.classList.add('hide')
}}
onEntered={(node: HTMLElement) => {
node.classList.remove('fade-in')
const completed = !!task.completed const completed = !!task.completed
completed && node.classList.add('explode') completed && node.classList.add('explode')
!completed && node.classList.remove('explode')
}}
onEntered={(node: HTMLElement) => {
node.classList.remove('hide')
setTimeout(() => node.classList.remove(...['fade-in', 'explode']), timeout.enter)
}}
>
<Draggable
key={`draggable-${task.id}`}
draggableId={`draggable-${task.id}`}
index={index}
isDragDisabled={!canEdit}
>
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
const { style, ...restDraggableProps } = draggableProps
return (
<div
style={getItemStyle(isDragging, style)}
ref={innerRef}
{...restDraggableProps}
{...dragHandleProps}
>
<TaskItem key={`task-item-${task.id}`} task={task} groupName={groupName} />
</div>
)
}}
</Draggable>
</CSSTransition>
)
})}
</TransitionGroup>
node.addEventListener( {provided.placeholder}
'animationend', </InnerTasksContainer>
() => { )}
node.classList.remove('explode')
},
false,
)
}}
onExited={(node: HTMLElement) => {
node.classList.remove('fade-out')
}}
addEndListener={(node, done) => {
done()
}}
mountOnEnter
unmountOnExit
>
<Draggable
key={`draggable-${task.id}`}
draggableId={`draggable-${task.id}`}
index={index}
isDragDisabled={!canEdit}
>
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
const { style, ...restDraggableProps } = draggableProps
return (
<div className="task-item" style={getItemStyle(isDragging, style)} {...restDraggableProps}>
<TaskItem
key={`task-item-${task.id}`}
task={task}
groupName={groupName}
innerRef={innerRef}
{...dragHandleProps}
/>
</div>
)
}}
</Draggable>
</CSSTransition>
))}
</TransitionGroup>
{provided.placeholder}
</InnerTasksContainer>
<ChildrenContainer addMargin={section.id === 'completed-tasks'} items={tasks.length}> <ChildrenContainer addMargin={section.id === 'completed-tasks'} items={tasks.length}>
{children} {children}
</ChildrenContainer> </ChildrenContainer>

View File

@@ -49,6 +49,10 @@ html {
fill: var(--sn-stylekit-info-color); fill: var(--sn-stylekit-info-color);
} }
.info-color {
color: var(--sn-stylekit-info-color);
}
.sn-icon-button { .sn-icon-button {
border-width: 0; border-width: 0;