feat(advanced checklist): collapsible group sections (#1167)

* feat(advanced checklist): collapsible group sections

* fix: type checking

* fix: type checking for migrations

* fix: remove stale files

* fix: type checking

* fix: copy default sections when collapsing sections

* chore: format

Co-authored-by: Johnny Almonte <johnny243@users.noreply.github.com>
This commit is contained in:
Johnny A
2022-06-27 23:01:50 -04:00
committed by GitHub
parent 36645d7fbf
commit 59e5324a29
32 changed files with 782 additions and 266 deletions

View File

@@ -16,7 +16,7 @@
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "react-app-rewired start",
"test:coverage": "npm run test -- --coverage --watchAll",
"test:coverage": "npm run test -- --coverage --watchAll --no-silent",
"eject": "react-scripts eject",
"components:compile": "react-app-rewired build",
"test": "react-app-rewired test --watchAll=false --silent",

View File

@@ -1,11 +1,12 @@
type RoundButtonProps = {
testId?: string
onClick: () => void
size?: 'normal' | 'small'
}
export const RoundButton: React.FC<RoundButtonProps> = ({ testId, onClick, children }) => {
export const RoundButton: React.FC<RoundButtonProps> = ({ testId, onClick, children, size = 'normal' }) => {
return (
<button data-testid={testId} className="sn-icon-button" onClick={onClick}>
<button data-testid={testId} className={`sn-icon-button ${size}`} onClick={onClick}>
{children}
</button>
)

View File

@@ -1,7 +1,7 @@
export const AddIcon = () => {
return (
<svg
className="sn-icon sm block"
className="sn-icon small block"
fill="none"
height="14"
viewBox="0 0 14 14"

View File

@@ -1,5 +1,6 @@
import { GroupPayload, TaskPayload } from '../features/tasks/tasks-slice'
import { DEFAULT_SECTIONS, GroupModel, TaskModel } from '../features/tasks/tasks-slice'
import {
arrayDefault,
arrayMoveImmutable,
arrayMoveMutable,
getPercentage,
@@ -86,7 +87,7 @@ describe('getPercentage', () => {
describe('groupTasksByCompletedStatus', () => {
it('should return open tasks and completed tasks', () => {
const tasks: TaskPayload[] = [
const tasks: TaskModel[] = [
{
id: 'test-1',
description: 'Testing #1',
@@ -147,13 +148,15 @@ describe('getTaskArrayFromGroupedTasks', () => {
},
]
const groupedTasks: GroupPayload[] = [
const groupedTasks: GroupModel[] = [
{
name: 'Work',
sections: DEFAULT_SECTIONS,
tasks: workTasks,
},
{
name: 'Personal',
sections: DEFAULT_SECTIONS,
tasks: personalTasks,
},
]
@@ -216,20 +219,22 @@ describe('getPlainPreview', () => {
},
]
const groupedTasks: GroupPayload[] = [
const groupedTasks: GroupModel[] = [
{
name: 'Work',
sections: DEFAULT_SECTIONS,
tasks: workTasks,
},
{
name: 'Personal',
sections: DEFAULT_SECTIONS,
tasks: personalTasks,
},
]
expect(getPlainPreview(groupedTasks)).toBe('2/5 tasks completed')
expect(getPlainPreview([])).toBe('0/0 tasks completed')
expect(getPlainPreview([{ name: 'Test', tasks: [] }])).toBe('0/0 tasks completed')
expect(getPlainPreview([{ name: 'Test', tasks: [], sections: [] }])).toBe('0/0 tasks completed')
})
})
@@ -251,7 +256,7 @@ describe('parseMarkdownTasks', () => {
- [x] Bar
- [ ] Foobar`
expect(parseMarkdownTasks(payload)).toMatchObject<GroupPayload>({
expect(parseMarkdownTasks(payload)).toMatchObject<GroupModel>({
name: 'Checklist',
tasks: [
{
@@ -273,6 +278,19 @@ describe('parseMarkdownTasks', () => {
createdAt: expect.any(Date),
},
],
sections: DEFAULT_SECTIONS,
})
})
})
describe('arrayDefault', () => {
it('should fallback to default value', () => {
expect(arrayDefault({ defaultValue: [] })).toEqual([])
expect(arrayDefault({ value: undefined, defaultValue: [] })).toEqual([])
expect(arrayDefault({ value: [], defaultValue: ['test'] })).toEqual(['test'])
})
it('should return value', () => {
expect(arrayDefault({ value: ['test'], defaultValue: [] })).toEqual(['test'])
})
})

View File

@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid'
import { GroupPayload, TaskPayload } from '../features/tasks/tasks-slice'
import { DEFAULT_SECTIONS, GroupModel, TaskModel } from '../features/tasks/tasks-slice'
export function arrayMoveMutable(array: any[], fromIndex: number, toIndex: number) {
const startIndex = fromIndex < 0 ? array.length + fromIndex : fromIndex
@@ -26,7 +26,7 @@ export function getPercentage(numberA: number, numberB: number): number {
return Number(percentage.toFixed(2))
}
export function groupTasksByCompletedStatus(tasks: TaskPayload[]) {
export function groupTasksByCompletedStatus(tasks: TaskModel[]) {
const openTasks = tasks.filter((task) => !task.completed)
const completedTasks = tasks.filter((task) => task.completed)
return {
@@ -35,8 +35,8 @@ export function groupTasksByCompletedStatus(tasks: TaskPayload[]) {
}
}
export function getTaskArrayFromGroupedTasks(groupedTasks: GroupPayload[]): TaskPayload[] {
let taskArray: TaskPayload[] = []
export function getTaskArrayFromGroupedTasks(groupedTasks: GroupModel[]): TaskModel[] {
let taskArray: TaskModel[] = []
groupedTasks.forEach((group) => {
taskArray = taskArray.concat(group.tasks)
@@ -52,14 +52,14 @@ export function truncateText(text: string, limit: number = 50) {
return text.substring(0, limit) + '...'
}
export function getPlainPreview(groupedTasks: GroupPayload[]) {
export function getPlainPreview(groupedTasks: GroupModel[]) {
const allTasks = getTaskArrayFromGroupedTasks(groupedTasks)
const { completedTasks } = groupTasksByCompletedStatus(allTasks)
return `${completedTasks.length}/${allTasks.length} tasks completed`
}
function createTaskFromLine(rawTask: string): TaskPayload | undefined {
function createTaskFromLine(rawTask: string): TaskModel | undefined {
const IS_COMPLETED = /^- \[x\] /i
const OPEN_PREFIX = '- [ ] '
@@ -77,7 +77,7 @@ function createTaskFromLine(rawTask: string): TaskPayload | undefined {
}
}
export function parseMarkdownTasks(payload?: string): GroupPayload | undefined {
export function parseMarkdownTasks(payload?: string): GroupModel | undefined {
if (!payload) {
return
}
@@ -88,7 +88,7 @@ export function parseMarkdownTasks(payload?: string): GroupPayload | undefined {
}
const lines = payload.split('\n')
const tasks: TaskPayload[] = []
const tasks: TaskModel[] = []
lines
.filter((line) => line.replace(/ /g, '').length > 0)
@@ -102,6 +102,7 @@ export function parseMarkdownTasks(payload?: string): GroupPayload | undefined {
return {
name: 'Checklist',
tasks,
sections: DEFAULT_SECTIONS,
}
}
@@ -114,7 +115,7 @@ export function isJsonString(rawString: string) {
return true
}
export function isLastActiveGroup(allGroups: GroupPayload[], groupName: string): boolean {
export function isLastActiveGroup(allGroups: GroupModel[], groupName: string): boolean {
if (allGroups.length === 0) {
return true
}
@@ -131,3 +132,13 @@ export function isLastActiveGroup(allGroups: GroupPayload[], groupName: string):
return lastActiveGroup.name === groupName
}
export function arrayDefault({ value, defaultValue }: { value?: any[]; defaultValue: any[] }) {
if (!value) {
return defaultValue
}
if (value.length === 0) {
return defaultValue
}
return value
}

View File

@@ -8,6 +8,7 @@ import { tasksGroupAdded } from './tasks-slice'
const defaultTasksState = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'test',

View File

@@ -3,7 +3,7 @@ import { RootState } from '../../app/store'
import { testRender } from '../../testUtils'
import CreateTask from './CreateTask'
import { taskAdded } from './tasks-slice'
import { DEFAULT_SECTIONS, taskAdded } from './tasks-slice'
jest.mock('uuid', () => {
return {
@@ -14,6 +14,7 @@ jest.mock('uuid', () => {
const defaultGroup = {
name: 'My default group',
tasks: [],
sections: DEFAULT_SECTIONS,
}
it('renders a button by default', () => {

View File

@@ -3,7 +3,7 @@ import styled from 'styled-components'
import { v4 as uuidv4 } from 'uuid'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { GroupPayload, taskAdded, tasksGroupDraft } from './tasks-slice'
import { GroupModel, taskAdded, tasksGroupDraft } from './tasks-slice'
import { TextInput } from '../../common/components'
import { DottedCircleIcon } from '../../common/components/icons'
@@ -21,7 +21,7 @@ const Container = styled.div`
`
type CreateTaskProps = {
group: GroupPayload
group: GroupModel
}
const CreateTask: React.FC<CreateTaskProps> = ({ group }) => {

View File

@@ -12,6 +12,7 @@ it('renders the alert dialog when no groups are available to merge', () => {
const defaultState: Partial<RootState> = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -44,6 +45,7 @@ it('renders the alert dialog when there are groups available to merge', () => {
const defaultState: Partial<RootState> = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -106,6 +108,7 @@ it('should close the dialog if no group is selected and the Merge button is clic
const defaultState: Partial<RootState> = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -168,6 +171,7 @@ it('should dispatch the action to merge groups', () => {
const defaultState: Partial<RootState> = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import NotePreview from './NotePreview'
import { GroupPayload } from './tasks-slice'
import { DEFAULT_SECTIONS, GroupModel } from './tasks-slice'
const workTasks = [
{
@@ -44,7 +44,7 @@ const miscTasks = [
]
it('should render without tasks', () => {
const groupedTasks: GroupPayload[] = []
const groupedTasks: GroupModel[] = []
render(<NotePreview groupedTasks={groupedTasks} />)
@@ -71,10 +71,12 @@ it('should render with tasks', () => {
{
name: 'Work',
tasks: workTasks,
sections: DEFAULT_SECTIONS,
},
{
name: 'Personal',
tasks: personalTasks,
sections: DEFAULT_SECTIONS,
},
]
@@ -103,14 +105,17 @@ it('should render a summary of the remaining group(s)', () => {
{
name: 'Work',
tasks: workTasks,
sections: DEFAULT_SECTIONS,
},
{
name: 'Personal',
tasks: personalTasks,
sections: DEFAULT_SECTIONS,
},
{
name: 'Misc',
tasks: miscTasks,
sections: DEFAULT_SECTIONS,
},
{
name: 'Groceries',
@@ -121,6 +126,7 @@ it('should render a summary of the remaining group(s)', () => {
createdAt: new Date(),
},
],
sections: DEFAULT_SECTIONS,
},
]

View File

@@ -4,7 +4,7 @@ import {
groupTasksByCompletedStatus,
truncateText,
} from '../../common/utils'
import { GroupPayload, TaskPayload } from './tasks-slice'
import { GroupModel, TaskModel } from './tasks-slice'
const GROUPS_PREVIEW_LIMIT = 3
const MAX_GROUP_DESCRIPTION_LENGTH = 30
@@ -14,7 +14,7 @@ const Title: React.FC = ({ children }) => {
}
type GroupSummaryProps = {
groups: GroupPayload[]
groups: GroupModel[]
}
const GroupSummary: React.FC<GroupSummaryProps> = ({ groups }) => {
@@ -37,7 +37,7 @@ const GroupSummary: React.FC<GroupSummaryProps> = ({ groups }) => {
return (
<p data-testid="group-summary" key={`group-${group.name}`} className="mb-1">
{truncateText(group.name, MAX_GROUP_DESCRIPTION_LENGTH)}
<span className="px-2 text-neutral">
<span className="px-2 neutral">
{totalCompletedTasks}/{totalTasks}
</span>
</p>
@@ -54,11 +54,11 @@ const GroupSummary: React.FC<GroupSummaryProps> = ({ groups }) => {
}
type NotePreviewProps = {
groupedTasks: GroupPayload[]
groupedTasks: GroupModel[]
}
const NotePreview: React.FC<NotePreviewProps> = ({ groupedTasks }) => {
const allTasks: TaskPayload[] = getTaskArrayFromGroupedTasks(groupedTasks)
const allTasks: TaskModel[] = getTaskArrayFromGroupedTasks(groupedTasks)
const { completedTasks } = groupTasksByCompletedStatus(allTasks)
const percentage = getPercentage(allTasks.length, completedTasks.length)
const roundedPercentage = Math.floor(percentage / 10) * 10

View File

@@ -12,6 +12,7 @@ it('renders the alert dialog with an input box', () => {
const defaultState: Partial<RootState> = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: defaultGroup,
@@ -44,6 +45,7 @@ it('should dispatch the action to merge groups', () => {
const defaultState: Partial<RootState> = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: defaultGroup,
@@ -107,6 +109,7 @@ it('should dispatch the action to merge groups on Enter press', () => {
const defaultState: Partial<RootState> = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: defaultGroup,

View File

@@ -3,6 +3,7 @@ import { fireEvent, screen } from '@testing-library/react'
import { RootState } from '../../app/store'
import { testRender } from '../../testUtils'
import TaskGroup from './TaskGroup'
import { DEFAULT_SECTIONS } from './tasks-slice'
const defaultGroup = {
name: 'default group',
@@ -20,6 +21,7 @@ const defaultGroup = {
createdAt: new Date(),
},
],
sections: DEFAULT_SECTIONS,
}
it('renders the group name', () => {
@@ -58,23 +60,23 @@ it('renders the element that is used to create a new task', () => {
it('renders the element that is used to display the list of tasks', () => {
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
expect(screen.getByTestId('task-list')).toBeInTheDocument()
expect(screen.getByTestId('task-section-list')).toBeInTheDocument()
})
it('collapses the group', () => {
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
const createTask = screen.getByTestId('create-task-input')
const taskItemList = screen.getByTestId('task-list')
const taskSectionList = screen.getByTestId('task-section-list')
expect(createTask).toBeVisible()
expect(taskItemList).toBeVisible()
expect(taskSectionList).toBeVisible()
const collapseButton = screen.getByTestId('collapse-task-group')
fireEvent.click(collapseButton)
expect(createTask).not.toBeVisible()
expect(taskItemList).not.toBeVisible()
expect(taskSectionList).not.toBeVisible()
})
it('shows group options', () => {

View File

@@ -3,10 +3,10 @@ import styled from 'styled-components'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { getPercentage } from '../../common/utils'
import { GroupPayload, tasksGroupCollapsed } from './tasks-slice'
import { GroupModel, tasksGroupCollapsed } from './tasks-slice'
import CreateTask from './CreateTask'
import TaskItemList from './TaskItemList'
import TaskSectionList from './TaskSectionList'
import TaskGroupOptions from './TaskGroupOptions'
@@ -31,7 +31,7 @@ const CollapsableContainer = styled.div<CollapsableContainerProps>`
`
type TaskGroupProps = {
group: GroupPayload
group: GroupModel
isDragging: boolean
isLast?: boolean
style?: React.CSSProperties
@@ -65,7 +65,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
const allTasksCompleted = totalTasks > 0 && totalTasks === completedTasks
function handleCollapse() {
dispatch(tasksGroupCollapsed({ groupName, collapsed: !collapsed }))
dispatch(tasksGroupCollapsed({ groupName, type: 'group', collapsed: !collapsed }))
setCollapsed(!collapsed)
}
@@ -117,7 +117,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
<CollapsableContainer collapsed={collapsed}>
<CreateTask group={group} />
<TaskItemList group={group} />
<TaskSectionList group={group} />
</CollapsableContainer>
</TaskGroupContainer>
)

View File

@@ -2,10 +2,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { testRender } from '../../testUtils'
import TaskItem from './TaskItem'
import { taskDeleted, taskModified, TaskPayload, taskToggled } from './tasks-slice'
import { taskDeleted, TaskModel, taskModified, taskToggled } from './tasks-slice'
const groupName = 'default group'
const task: TaskPayload = {
const task: TaskModel = {
id: 'test-1',
description: 'Testing #1',
completed: false,

View File

@@ -4,7 +4,7 @@ import { ChangeEvent, createRef, KeyboardEvent, useEffect, useState } from 'reac
import styled from 'styled-components'
import { useAppDispatch, useAppSelector, useDidMount } from '../../app/hooks'
import { taskDeleted, taskModified, TaskPayload, taskToggled } from './tasks-slice'
import { taskDeleted, TaskModel, taskModified, taskToggled } from './tasks-slice'
import { CheckBoxInput, TextAreaInput } from '../../common/components'
@@ -37,7 +37,7 @@ const Container = styled.div<{ completed?: boolean }>`
`
export type TaskItemProps = {
task: TaskPayload
task: TaskModel
groupName: string
innerRef?: (element?: HTMLElement | null | undefined) => any
}

View File

@@ -1,65 +0,0 @@
import { screen, within } from '@testing-library/react'
import { testRender } from '../../testUtils'
import TaskItemList from './TaskItemList'
const defaultGroup = {
name: 'default group',
tasks: [
{
id: 'test-1',
description: 'Testing #1',
completed: false,
createdAt: new Date(),
},
{
id: 'test-2',
description: 'Testing #2',
completed: false,
createdAt: new Date(),
},
],
}
it('renders the open tasks container', async () => {
testRender(<TaskItemList group={defaultGroup} />)
const openTasksContainer = screen.getByTestId('open-tasks-container')
expect(openTasksContainer).toBeInTheDocument()
expect(openTasksContainer).toHaveTextContent('open tasks')
const taskItems = within(openTasksContainer).getAllByTestId('task-item')
expect(taskItems).toHaveLength(2)
const completedTasksActions = screen.queryByTestId('completed-tasks-actions')
expect(completedTasksActions).not.toBeInTheDocument()
})
it('renders the completed tasks container', () => {
const groupWithCompletedTask = {
name: 'a new group',
tasks: [
...defaultGroup.tasks,
{
id: 'test-3',
description: 'Testing #3',
completed: true,
createdAt: new Date(),
},
],
}
testRender(<TaskItemList group={groupWithCompletedTask} />)
const completedTasksContainer = screen.getByTestId('completed-tasks-container')
expect(completedTasksContainer).toBeInTheDocument()
expect(completedTasksContainer).toHaveTextContent('completed tasks')
const taskItems = within(completedTasksContainer).getAllByTestId('task-item')
expect(taskItems).toHaveLength(1)
const completedTasksActions = screen.getByTestId('completed-tasks-actions')
expect(completedTasksActions).toBeInTheDocument()
})

View File

@@ -1,64 +0,0 @@
import React from 'react'
import { DragDropContext, DropResult } from 'react-beautiful-dnd'
import styled from 'styled-components'
import { useAppDispatch } from '../../app/hooks'
import { groupTasksByCompletedStatus } from '../../common/utils'
import { GroupPayload, tasksReordered } from './tasks-slice'
import CompletedTasksActions from './CompletedTasksActions'
import TasksContainer from './TasksContainer'
const Container = styled.div`
position: relative;
`
type TaskItemListProps = {
group: GroupPayload
}
const TaskItemList: React.FC<TaskItemListProps> = ({ group }) => {
const dispatch = useAppDispatch()
const { openTasks, completedTasks } = groupTasksByCompletedStatus(group.tasks)
function onDragEnd(result: DropResult) {
const droppedOutsideList = !result.destination
if (droppedOutsideList) {
return
}
const { source, destination } = result
if (!destination) {
return
}
dispatch(
tasksReordered({
groupName: group.name,
swapTaskIndex: source.index,
withTaskIndex: destination.index,
isSameSection: source.droppableId === destination.droppableId,
}),
)
}
return (
<Container data-testid="task-list">
<DragDropContext onDragEnd={onDragEnd}>
<TasksContainer testId="open-tasks-container" type="open" tasks={openTasks} groupName={group.name} />
<TasksContainer
testId="completed-tasks-container"
type="completed"
tasks={completedTasks}
groupName={group.name}
>
{completedTasks.length > 0 && <CompletedTasksActions groupName={group.name} />}
</TasksContainer>
</DragDropContext>
</Container>
)
}
export default TaskItemList

View File

@@ -0,0 +1,110 @@
import { screen, within } from '@testing-library/react'
import { RootState } from '../../app/store'
import { testRender } from '../../testUtils'
import { DEFAULT_SECTIONS, GroupModel } from './tasks-slice'
import TaskSectionList from './TaskSectionList'
const defaultGroup: GroupModel = {
name: 'default group',
tasks: [
{
id: 'test-1',
description: 'Testing #1',
completed: false,
createdAt: new Date(),
},
{
id: 'test-2',
description: 'Testing #2',
completed: false,
createdAt: new Date(),
},
],
sections: [
{
id: 'open-tasks',
name: 'Open tasks',
},
{
id: 'completed-tasks',
name: 'Completed tasks',
},
],
}
it('renders the open tasks container', async () => {
testRender(<TaskSectionList group={defaultGroup} />)
const openTasksContainer = screen.getByTestId('open-tasks-section')
expect(openTasksContainer).toBeInTheDocument()
expect(openTasksContainer).toHaveTextContent('Open tasks')
const taskItems = within(openTasksContainer).getAllByTestId('task-item')
expect(taskItems).toHaveLength(2)
const completedTasksActions = screen.queryByTestId('completed-tasks-actions')
expect(completedTasksActions).not.toBeInTheDocument()
})
it('renders the completed tasks section', () => {
const groupWithCompletedTask = defaultGroup
groupWithCompletedTask.tasks.push({
id: 'test-3',
description: 'Testing #3',
completed: true,
createdAt: new Date(),
})
testRender(<TaskSectionList group={groupWithCompletedTask} />)
const completedTasksSection = screen.getByTestId('completed-tasks-section')
expect(completedTasksSection).toBeInTheDocument()
expect(completedTasksSection).toHaveTextContent('Completed tasks')
const taskItems = within(completedTasksSection).getAllByTestId('task-item')
expect(taskItems).toHaveLength(1)
const completedTasksActions = screen.getByTestId('completed-tasks-actions')
expect(completedTasksActions).toBeInTheDocument()
})
it('renders default sections', () => {
const defaultState: Partial<RootState> = {
settings: {
canEdit: true,
isRunningOnMobile: false,
spellCheckerEnabled: true,
},
tasks: {
schemaVersion: '1.0.0',
defaultSections: DEFAULT_SECTIONS,
groups: [],
},
}
const group: GroupModel = {
name: 'Test group',
tasks: [
...defaultGroup.tasks,
{
id: 'test-3',
description: 'Testing #3',
completed: true,
createdAt: new Date(),
},
],
}
testRender(<TaskSectionList group={group} />, {}, defaultState)
const completedTasksSection = screen.getByTestId('completed-tasks-section')
expect(completedTasksSection).toBeInTheDocument()
expect(completedTasksSection).toHaveTextContent('Completed tasks')
const taskItems = within(completedTasksSection).getAllByTestId('task-item')
expect(taskItems).toHaveLength(1)
const completedTasksActions = screen.getByTestId('completed-tasks-actions')
expect(completedTasksActions).toBeInTheDocument()
})

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { DragDropContext, DropResult } from 'react-beautiful-dnd'
import styled from 'styled-components'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { GroupModel, tasksReordered } from './tasks-slice'
import CompletedTasksActions from './CompletedTasksActions'
import TasksSection from './TasksSection'
const Container = styled.div`
position: relative;
`
type TaskSectionListProps = {
group: GroupModel
}
const TaskSectionList: React.FC<TaskSectionListProps> = ({ group }) => {
const dispatch = useAppDispatch()
const defaultSections = useAppSelector((state) => state.tasks.defaultSections)
function onDragEnd(result: DropResult) {
const droppedOutsideList = !result.destination
if (droppedOutsideList) {
return
}
const { source, destination } = result
if (!destination) {
return
}
dispatch(
tasksReordered({
groupName: group.name,
swapTaskIndex: source.index,
withTaskIndex: destination.index,
isSameSection: source.droppableId === destination.droppableId,
}),
)
}
const sections = group.sections ?? defaultSections
return (
<Container data-testid="task-section-list">
{sections.map((section) => {
const tasks = group.tasks.filter((task) =>
section.id === 'completed-tasks' ? task.completed === true : !task.completed,
)
return (
<DragDropContext key={`${section.id}-section-dnd`} onDragEnd={onDragEnd}>
<TasksSection testId={`${section.id}-section`} groupName={group.name} section={section} tasks={tasks}>
{section.id === 'completed-tasks' && tasks.length > 0 && <CompletedTasksActions groupName={group.name} />}
</TasksSection>
</DragDropContext>
)
})}
</Container>
)
}
export default TaskSectionList

View File

@@ -1,35 +1,18 @@
import './TasksContainer.scss'
import './TasksSection.scss'
import React from 'react'
import React, { useState } from 'react'
import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import styled from 'styled-components'
import { useAppSelector } from '../../app/hooks'
import { SubTitle } from '../../common/components'
import { TaskPayload } from './tasks-slice'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { RoundButton, SubTitle } from '../../common/components'
import { SectionModel, TaskModel, tasksGroupCollapsed } from './tasks-slice'
import { ChevronDownIcon, ChevronUpIcon } from '../../common/components/icons'
import TaskItem from './TaskItem'
const InnerTasksContainer = styled.div<{
type: ContainerType
items: number
}>`
display: flex;
flex-direction: column;
& > *:not(:last-child) {
margin-bottom: 5px;
}
${({ type, items }) => (type === 'completed' && items > 0 ? 'margin-bottom: 28px' : '')};
`
const OuterContainer = styled.div<{ type: ContainerType; items: number }>`
${({ type, items }) => (type === 'open' && items > 0 ? 'margin-bottom: 18px' : '')};
`
const SubTitleContainer = styled.div`
const SectionHeader = styled.div`
align-items: center;
display: flex;
@@ -38,6 +21,23 @@ const SubTitleContainer = styled.div`
}
`
const InnerTasksContainer = styled.div<{ collapsed: boolean }>`
display: ${({ collapsed }) => (collapsed ? 'none' : 'flex')};
flex-direction: column;
& > *:not(:last-child) {
margin-bottom: 5px;
}
`
const OuterContainer = styled.div<{ addMargin: boolean; items: number; collapsed: boolean }>`
margin-bottom: ${({ addMargin, items, collapsed }) => (addMargin && items > 0 && !collapsed ? '10px' : '0')};
`
const ChildrenContainer = styled.div<{ addMargin: boolean; items: number }>`
margin-top: ${({ addMargin, items }) => (addMargin && items > 0 ? '15px' : '0')};
`
const Wrapper = styled.div`
color: var(--sn-stylekit-foreground-color);
`
@@ -50,33 +50,48 @@ const getItemStyle = (isDragging: boolean, draggableStyle?: DraggingStyle | NotD
}),
})
type ContainerType = 'open' | 'completed'
type TasksContainerProps = {
type TasksSectionProps = {
groupName: string
tasks: TaskPayload[]
type: ContainerType
tasks: TaskModel[]
section: SectionModel
testId?: string
}
const TasksContainer: React.FC<TasksContainerProps> = ({ groupName, tasks, type, testId, children }) => {
const TasksSection: React.FC<TasksSectionProps> = ({ groupName, tasks, section, testId, children }) => {
const dispatch = useAppDispatch()
const canEdit = useAppSelector((state) => state.settings.canEdit)
const droppableId = `${type}-tasks-droppable`
const droppableId = `${section.id}-droppable`
const [collapsed, setCollapsed] = useState<boolean>(!!section.collapsed)
const handleCollapse = () => {
dispatch(tasksGroupCollapsed({ groupName, type: section.id, collapsed: !collapsed }))
setCollapsed(!collapsed)
}
return (
<OuterContainer data-testid={testId} type={type} items={tasks.length}>
<OuterContainer
data-testid={testId}
addMargin={section.id === 'open-tasks'}
items={tasks.length}
collapsed={collapsed}
>
<Droppable droppableId={droppableId} isDropDisabled={!canEdit}>
{(provided) => (
<Wrapper>
<SubTitleContainer>
<SubTitle>{type} tasks</SubTitle>
</SubTitleContainer>
<SectionHeader>
<SubTitle>{section.name}</SubTitle>
{tasks.length > 0 && (
<RoundButton onClick={handleCollapse} size="small">
{!collapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
</RoundButton>
)}
</SectionHeader>
<InnerTasksContainer
{...provided.droppableProps}
className={`${type}-tasks-container`}
items={tasks.length}
className={`${section.id}-container`}
collapsed={collapsed}
ref={provided.innerRef}
type={type}
>
<TransitionGroup component={null} childFactory={(child) => React.cloneElement(child)}>
{tasks.map((task, index) => {
@@ -148,7 +163,9 @@ const TasksContainer: React.FC<TasksContainerProps> = ({ groupName, tasks, type,
</TransitionGroup>
{provided.placeholder}
</InnerTasksContainer>
{children}
<ChildrenContainer addMargin={section.id === 'completed-tasks'} items={tasks.length}>
{children}
</ChildrenContainer>
</Wrapper>
)}
</Droppable>
@@ -156,4 +173,4 @@ const TasksContainer: React.FC<TasksContainerProps> = ({ groupName, tasks, type,
)
}
export default TasksContainer
export default TasksSection

View File

@@ -0,0 +1,37 @@
import BaseMigration, { PartialData } from './BaseMigration'
class MockMigration extends BaseMigration {
override get version() {
return '0.0.0'
}
override upgrade(data: PartialData) {
return data
}
override downgrade(data: PartialData) {
return data
}
}
describe('BaseMigration', () => {
const mockMigration = new MockMigration()
it('should throw error if version is not in the semantic version scheme', () => {
expect(() => {
mockMigration.run({ schemaVersion: '0.0.0.0', groups: [], defaultSections: [] })
}).toThrowError("'0.0.0.0' is not in the semantic version scheme: MAJOR.MINOR.PATCH")
})
it('should throw error if version is not a number', () => {
expect(() => {
mockMigration.run({ schemaVersion: 'a.0.0', groups: [], defaultSections: [] })
}).toThrowError('MAJOR version should be a number')
expect(() => {
mockMigration.run({ schemaVersion: '0.a.0', groups: [], defaultSections: [] })
}).toThrowError('MINOR version should be a number')
expect(() => {
mockMigration.run({ schemaVersion: '0.0.a', groups: [], defaultSections: [] })
}).toThrowError('PATCH version should be a number')
})
})

View File

@@ -0,0 +1,63 @@
const SemanticVersionParts = ['MAJOR', 'MINOR', 'PATCH']
enum MigrationAction {
Upgrade = 'up',
Downgrade = 'down',
Nothing = 'nothing',
}
export type PartialData = {
schemaVersion: string
defaultSections?: any[]
groups: any[]
}
abstract class BaseMigration {
protected abstract get version(): string
protected abstract upgrade(data: PartialData): PartialData
protected abstract downgrade(data: PartialData): PartialData
private parseVersion(version: string): number[] {
const versionScheme = version.split('.')
if (versionScheme.length !== SemanticVersionParts.length) {
throw Error(`'${version}' is not in the semantic version scheme: ${SemanticVersionParts.join('.')}`)
}
return versionScheme.map((value, index) => {
const number = Number(value)
if (isNaN(number)) {
throw Error(`${SemanticVersionParts[index]} version should be a number`)
}
return number
})
}
protected getAction(schemaVersion: string): MigrationAction {
const fromVersion = this.parseVersion(schemaVersion)
const toVersion = this.parseVersion(this.version)
for (let index = 0; index < fromVersion.length; index++) {
if (fromVersion[index] < toVersion[index]) {
return MigrationAction.Upgrade
}
if (fromVersion[index] > toVersion[index]) {
return MigrationAction.Downgrade
}
}
return MigrationAction.Nothing
}
public run(data: PartialData): PartialData {
const { schemaVersion } = data
const migrationAction = this.getAction(schemaVersion)
switch (migrationAction) {
case MigrationAction.Upgrade:
return this.upgrade(data)
case MigrationAction.Downgrade:
return this.downgrade(data)
default:
return data
}
}
}
export default BaseMigration

View File

@@ -0,0 +1,150 @@
import { TasksState } from '../tasks-slice'
import BaseMigration, { PartialData } from './BaseMigration'
import MigrationService from './MigrationService'
class MockMigration extends BaseMigration {
override get version() {
return '1.0.123'
}
override upgrade(data: PartialData) {
return {
...data,
schemaVersion: this.version,
}
}
override downgrade(data: PartialData) {
return {
...data,
schemaVersion: this.version,
}
}
}
describe('MigrationService', () => {
it('should upgrade 1.0.0 to 1.0.123', () => {
const testData: Partial<TasksState> = {
schemaVersion: '1.0.0',
groups: [
{
name: 'Test group #1',
tasks: [
{
id: 'some-id',
description: 'A simple task',
completed: false,
createdAt: new Date(),
},
{
id: 'another-id',
description: 'Another simple task',
completed: true,
createdAt: new Date(),
},
],
collapsed: true,
},
{
name: 'Test group #2',
tasks: [
{
id: 'yet-another-id',
description: 'Yet another simple task',
completed: true,
createdAt: new Date(),
},
],
},
],
}
const migrationClasses = [MockMigration]
const migrationService = new MigrationService(migrationClasses)
const result = migrationService.performMigrations(testData as any)
expect(result).toEqual<Partial<TasksState>>({
...testData,
schemaVersion: '1.0.123',
})
})
it('should do nothing if latest version', () => {
const testData: Partial<TasksState> = {
schemaVersion: '1.0.123',
groups: [
{
name: 'Test group #1',
tasks: [
{
id: 'some-id',
description: 'A simple task',
completed: false,
createdAt: new Date(),
},
],
collapsed: true,
},
{
name: 'Test group #2',
tasks: [
{
id: 'yet-another-id',
description: 'Yet another simple task',
completed: true,
createdAt: new Date(),
},
],
},
],
}
const migrationClasses = [MockMigration]
const migrationService = new MigrationService(migrationClasses)
const result = migrationService.performMigrations(testData as any)
expect(result).toBe(testData)
})
it('should downgrade if version > 1.0.123', () => {
const testData: Partial<TasksState> = {
schemaVersion: '1.0.130',
groups: [
{
name: 'Test group #1',
tasks: [
{
id: 'some-id',
description: 'A simple task',
completed: false,
createdAt: new Date(),
},
],
collapsed: true,
},
{
name: 'Test group #2',
tasks: [
{
id: 'yet-another-id',
description: 'Yet another simple task',
completed: true,
createdAt: new Date(),
},
],
},
],
}
const migrationClasses = [MockMigration]
const migrationService = new MigrationService(migrationClasses)
const result = migrationService.performMigrations(testData as any)
expect(result).toMatchObject(
expect.objectContaining({
schemaVersion: '1.0.123',
groups: testData.groups,
}),
)
})
})

View File

@@ -0,0 +1,25 @@
import { PartialData } from './BaseMigration'
import { MigrationClasses } from './versions'
class MigrationService {
private migrationClasses: any[]
constructor(migrationClasses?: any[]) {
this.migrationClasses = migrationClasses ?? MigrationClasses
}
private getMigrationInstances() {
return this.migrationClasses.map((migrationClass) => {
return new migrationClass()
})
}
public performMigrations(data: PartialData) {
this.getMigrationInstances().forEach((migration) => {
data = migration.run(data)
})
return data
}
}
export default MigrationService

View File

@@ -1,6 +1,7 @@
import type { TasksState } from './tasks-slice'
import reducer, {
DEFAULT_SECTIONS,
deleteAllCompleted,
LATEST_SCHEMA_VERSION,
openAllCompleted,
taskAdded,
taskDeleted,
@@ -15,6 +16,7 @@ import reducer, {
tasksGroupReordered,
tasksLoaded,
tasksReordered,
TasksState,
taskToggled,
} from './tasks-slice'
@@ -23,11 +25,11 @@ it('should return the initial state', () => {
reducer(undefined, {
type: undefined,
}),
).toEqual({ schemaVersion: '1.0.0', groups: [] })
).toEqual<TasksState>({ schemaVersion: LATEST_SCHEMA_VERSION, groups: [], defaultSections: [] })
})
it('should handle a task being added to a non-existing group', () => {
const previousState: TasksState = { schemaVersion: '1.0.0', groups: [] }
const previousState: TasksState = { schemaVersion: '1.0.0', groups: [], defaultSections: [] }
expect(
reducer(
@@ -37,8 +39,9 @@ it('should handle a task being added to a non-existing group', () => {
groupName: 'Test',
}),
),
).toEqual({
).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [],
})
})
@@ -46,6 +49,7 @@ it('should handle a task being added to a non-existing group', () => {
it('should handle a task being added to the existing tasks store', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -69,8 +73,9 @@ it('should handle a task being added to the existing tasks store', () => {
groupName: 'Test',
}),
),
).toEqual({
).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -96,6 +101,7 @@ it('should handle a task being added to the existing tasks store', () => {
it('should handle an existing task being modified', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -107,6 +113,7 @@ it('should handle an existing task being modified', () => {
createdAt: new Date(),
},
],
sections: DEFAULT_SECTIONS,
},
],
}
@@ -119,8 +126,9 @@ it('should handle an existing task being modified', () => {
groupName: 'Test',
}),
),
).toEqual({
).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -133,6 +141,7 @@ it('should handle an existing task being modified', () => {
updatedAt: expect.any(Date),
},
],
sections: DEFAULT_SECTIONS,
},
],
})
@@ -141,6 +150,7 @@ it('should handle an existing task being modified', () => {
it('should not modify tasks if an invalid id is provided', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -164,8 +174,9 @@ it('should not modify tasks if an invalid id is provided', () => {
groupName: 'Test',
}),
),
).toEqual({
).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -185,6 +196,7 @@ it('should not modify tasks if an invalid id is provided', () => {
it('should keep completed field as-is, if task is modified', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -211,8 +223,9 @@ it('should keep completed field as-is, if task is modified', () => {
groupName: 'Test',
}),
),
).toEqual({
).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -233,6 +246,7 @@ it('should keep completed field as-is, if task is modified', () => {
it('should handle an existing task being toggled', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -248,8 +262,9 @@ it('should handle an existing task being toggled', () => {
],
}
expect(reducer(previousState, taskToggled({ id: 'some-id', groupName: 'Test' }))).toEqual({
expect(reducer(previousState, taskToggled({ id: 'some-id', groupName: 'Test' }))).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -271,6 +286,7 @@ it('should handle an existing task being toggled', () => {
test('toggled tasks should be on top of the list', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -298,8 +314,9 @@ test('toggled tasks should be on top of the list', () => {
],
}
expect(reducer(previousState, taskToggled({ id: 'another-id', groupName: 'Test' }))).toEqual({
expect(reducer(previousState, taskToggled({ id: 'another-id', groupName: 'Test' }))).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -333,6 +350,7 @@ test('toggled tasks should be on top of the list', () => {
it('should handle an existing completed task being toggled', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -348,8 +366,9 @@ it('should handle an existing completed task being toggled', () => {
],
}
expect(reducer(previousState, taskToggled({ id: 'some-id', groupName: 'Test' }))).toEqual({
expect(reducer(previousState, taskToggled({ id: 'some-id', groupName: 'Test' }))).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -370,6 +389,7 @@ it('should handle an existing completed task being toggled', () => {
it('should handle an existing task being deleted', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -391,8 +411,9 @@ it('should handle an existing task being deleted', () => {
],
}
expect(reducer(previousState, taskDeleted({ id: 'some-id', groupName: 'Test' }))).toEqual({
expect(reducer(previousState, taskDeleted({ id: 'some-id', groupName: 'Test' }))).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -412,6 +433,7 @@ it('should handle an existing task being deleted', () => {
it('should handle opening all tasks that are marked as completed', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -439,8 +461,9 @@ it('should handle opening all tasks that are marked as completed', () => {
],
}
expect(reducer(previousState, openAllCompleted({ groupName: 'Test' }))).toEqual({
expect(reducer(previousState, openAllCompleted({ groupName: 'Test' }))).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -472,6 +495,7 @@ it('should handle opening all tasks that are marked as completed', () => {
it('should handle clear all completed tasks', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -499,8 +523,9 @@ it('should handle clear all completed tasks', () => {
],
}
expect(reducer(previousState, deleteAllCompleted({ groupName: 'Test' }))).toEqual({
expect(reducer(previousState, deleteAllCompleted({ groupName: 'Test' }))).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -520,6 +545,7 @@ it('should handle clear all completed tasks', () => {
it('should handle loading tasks into the tasks store, if an invalid payload is provided', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -535,21 +561,23 @@ it('should handle loading tasks into the tasks store, if an invalid payload is p
],
}
expect(reducer(previousState, tasksLoaded('null'))).toEqual({
schemaVersion: '1.0.0',
expect(reducer(previousState, tasksLoaded('null'))).toEqual<TasksState>({
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: DEFAULT_SECTIONS,
groups: [],
initialized: true
initialized: true,
})
expect(reducer(previousState, tasksLoaded('undefined'))).toMatchObject({
expect(reducer(previousState, tasksLoaded('undefined'))).toMatchObject<TasksState>({
...previousState,
initialized: false,
lastError: expect.stringContaining('An error has occurred while parsing the note\'s content')
lastError: expect.stringContaining("An error has occurred while parsing the note's content"),
})
})
it('should initialize the storage with an empty object', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -565,8 +593,9 @@ it('should initialize the storage with an empty object', () => {
],
}
expect(reducer(previousState, tasksLoaded(''))).toEqual({
schemaVersion: '1.0.0',
expect(reducer(previousState, tasksLoaded(''))).toEqual<TasksState>({
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: DEFAULT_SECTIONS,
groups: [],
initialized: true,
})
@@ -574,12 +603,13 @@ it('should initialize the storage with an empty object', () => {
it('should handle loading tasks into the tasks store, with a valid payload', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: DEFAULT_SECTIONS,
groups: [],
}
const tasksPayload: TasksState = {
schemaVersion: '2.0.0',
const tasksPayload: Partial<TasksState> = {
schemaVersion: LATEST_SCHEMA_VERSION,
groups: [
{
name: 'Test',
@@ -608,8 +638,9 @@ it('should handle loading tasks into the tasks store, with a valid payload', ()
}
const serializedPayload = JSON.stringify(tasksPayload)
expect(reducer(previousState, tasksLoaded(serializedPayload))).toEqual({
schemaVersion: '2.0.0',
expect(reducer(previousState, tasksLoaded(serializedPayload))).toEqual<TasksState>({
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: DEFAULT_SECTIONS,
groups: [
{
name: 'Test',
@@ -639,11 +670,43 @@ it('should handle loading tasks into the tasks store, with a valid payload', ()
})
})
it('should handle adding a new task group', () => {
const previousState: TasksState = { schemaVersion: '1.0.0', groups: [] }
it('should set defaultSections property if not provided', () => {
const previousState: TasksState = {
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: [],
groups: [],
}
expect(reducer(previousState, tasksGroupAdded({ groupName: 'New group' }))).toEqual({
const tasksPayload: Partial<TasksState> = {
schemaVersion: LATEST_SCHEMA_VERSION,
groups: [
{
name: 'Test',
tasks: [],
},
],
}
const serializedPayload = JSON.stringify(tasksPayload)
expect(reducer(previousState, tasksLoaded(serializedPayload))).toEqual<TasksState>({
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: DEFAULT_SECTIONS,
groups: [
{
name: 'Test',
tasks: [],
},
],
initialized: true,
})
})
it('should handle adding a new task group', () => {
const previousState: TasksState = { schemaVersion: '1.0.0', groups: [], defaultSections: [] }
expect(reducer(previousState, tasksGroupAdded({ groupName: 'New group' }))).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'New group',
@@ -656,6 +719,7 @@ it('should handle adding a new task group', () => {
it('should handle adding an existing task group', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Existing group',
@@ -677,6 +741,7 @@ it('should handle adding an existing task group', () => {
it('should handle reordering tasks from the same section', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -714,8 +779,9 @@ it('should handle reordering tasks from the same section', () => {
isSameSection: true,
}),
),
).toEqual({
).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -747,6 +813,7 @@ it('should handle reordering tasks from the same section', () => {
it('should handle reordering tasks from different sections', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -784,8 +851,9 @@ it('should handle reordering tasks from different sections', () => {
isSameSection: false,
}),
),
).toEqual({
).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -819,6 +887,7 @@ it('should handle reordering task groups', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -864,8 +933,9 @@ it('should handle reordering task groups', () => {
}),
)
const expectedState = {
const expectedState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Testing',
@@ -909,6 +979,7 @@ it('should handle reordering task groups', () => {
it('should handle deleting groups', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -948,8 +1019,9 @@ it('should handle deleting groups', () => {
const currentState = reducer(previousState, tasksGroupDeleted({ groupName: 'Testing' }))
const expectedState = {
const expectedState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -982,6 +1054,7 @@ it('should handle deleting groups', () => {
it('should not merge the same group', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1027,6 +1100,7 @@ it('should not merge the same group', () => {
it('should handle merging groups', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test group #1',
@@ -1069,8 +1143,9 @@ it('should handle merging groups', () => {
tasksGroupMerged({ groupName: 'Test group #3', mergeWith: 'Test group #2' }),
)
expect(currentState).toMatchObject({
expect(currentState).toMatchObject<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test group #1',
@@ -1107,6 +1182,7 @@ it('should handle merging groups', () => {
it('should handle renaming a group', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1135,8 +1211,9 @@ it('should handle renaming a group', () => {
const currentState = reducer(previousState, tasksGroupRenamed({ groupName: 'Testing', newName: 'Tested' }))
expect(currentState).toEqual({
expect(currentState).toEqual<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1167,6 +1244,7 @@ it('should handle renaming a group', () => {
it("should rename a group and preserve it's current order", () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: '1st group',
@@ -1206,8 +1284,9 @@ it("should rename a group and preserve it's current order", () => {
const currentState = reducer(previousState, tasksGroupRenamed({ groupName: '2nd group', newName: 'Middle group' }))
expect(currentState).toMatchObject({
expect(currentState).toMatchObject<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: '1st group',
@@ -1249,6 +1328,7 @@ it("should rename a group and preserve it's current order", () => {
it('should handle collapsing groups', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1286,10 +1366,14 @@ it('should handle collapsing groups', () => {
],
}
const currentState = reducer(previousState, tasksGroupCollapsed({ groupName: 'Testing', collapsed: true }))
const currentState = reducer(
previousState,
tasksGroupCollapsed({ groupName: 'Testing', type: 'group', collapsed: true }),
)
const expectedState = {
const expectedState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1334,6 +1418,7 @@ it('should handle collapsing groups', () => {
it('should handle saving task draft for groups', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1373,8 +1458,9 @@ it('should handle saving task draft for groups', () => {
const currentState = reducer(previousState, tasksGroupDraft({ groupName: 'Tests', draft: 'Remember to ...' }))
const expectedState = {
const expectedState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1419,6 +1505,7 @@ it('should handle saving task draft for groups', () => {
it('should handle setting a group as last active', () => {
const previousState: TasksState = {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1447,8 +1534,9 @@ it('should handle setting a group as last active', () => {
const currentState = reducer(previousState, tasksGroupLastActive({ groupName: 'Testing' }))
expect(currentState).toMatchObject({
expect(currentState).toMatchObject<TasksState>({
schemaVersion: '1.0.0',
defaultSections: [],
groups: [
{
name: 'Test',
@@ -1480,7 +1568,8 @@ it('should handle setting a group as last active', () => {
it('should detect and load legacy content', () => {
const payload = '- [ ] Foo bar'
expect(reducer(undefined, tasksLoaded(payload))).toMatchObject<TasksState>({
schemaVersion: '1.0.0',
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: [],
initialized: false,
groups: [],
legacyContent: {

View File

@@ -1,20 +1,34 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { arrayMoveImmutable, isJsonString, parseMarkdownTasks } from '../../common/utils'
import { arrayDefault, arrayMoveImmutable, isJsonString, parseMarkdownTasks } from '../../common/utils'
export const LATEST_SCHEMA_VERSION = '1.0.0'
export const DEFAULT_SECTIONS: SectionModel[] = [
{
id: 'open-tasks',
name: 'Open tasks',
},
{
id: 'completed-tasks',
name: 'Completed tasks',
},
]
export type TasksState = {
schemaVersion: string
groups: GroupPayload[]
groups: GroupModel[]
defaultSections: SectionModel[]
initialized?: boolean
legacyContent?: GroupPayload
legacyContent?: GroupModel
lastError?: string
}
const initialState: TasksState = {
schemaVersion: '1.0.0',
schemaVersion: LATEST_SCHEMA_VERSION,
defaultSections: [],
groups: [],
}
export type TaskPayload = {
export type TaskModel = {
id: string
description: string
completed?: boolean
@@ -23,12 +37,19 @@ export type TaskPayload = {
completedAt?: Date
}
export type GroupPayload = {
export type SectionModel = {
id: string
name: string
collapsed?: boolean
}
export type GroupModel = {
name: string
collapsed?: boolean
draft?: string
lastActive?: Date
tasks: TaskPayload[]
tasks: TaskModel[]
sections?: SectionModel[]
}
const tasksSlice = createSlice({
@@ -221,15 +242,27 @@ const tasksSlice = createSlice({
state,
action: PayloadAction<{
groupName: string
type: 'group' | 'open-tasks' | 'completed-tasks' | string
collapsed: boolean
}>,
) {
const { groupName, collapsed } = action.payload
const { groupName, type, collapsed } = action.payload
const group = state.groups.find((item) => item.name === groupName)
if (!group) {
return
}
group.collapsed = collapsed
if (type === 'group') {
group.collapsed = collapsed
return
}
if (!group.sections) {
group.sections = state.defaultSections.map((section) => ({ id: section.id, name: section.name }))
}
const section = group.sections.find((item) => item.id === type)
if (!section) {
return
}
section.collapsed = collapsed
},
tasksGroupDraft(
state,
@@ -294,14 +327,16 @@ const tasksSlice = createSlice({
}
const parsedState = JSON.parse(payload) as TasksState
const newState: TasksState = {
schemaVersion: parsedState?.schemaVersion ?? '1.0.0',
let newState: TasksState = {
schemaVersion: parsedState?.schemaVersion ?? LATEST_SCHEMA_VERSION,
defaultSections: arrayDefault({ value: parsedState?.defaultSections, defaultValue: DEFAULT_SECTIONS }),
groups: parsedState?.groups ?? [],
}
if (newState !== initialState) {
state.schemaVersion = newState.schemaVersion
state.groups = newState.groups
state.defaultSections = newState.defaultSections
state.initialized = true
delete state.lastError
}

View File

@@ -100,8 +100,8 @@ const TaskEditor: React.FC = () => {
}
editorKit.current!.saveItemWithPresave(currentNote, () => {
const { schemaVersion, groups } = store.getState().tasks
currentNote.content.text = JSON.stringify({ schemaVersion, groups }, null, 2)
const { schemaVersion, groups, defaultSections } = store.getState().tasks
currentNote.content.text = JSON.stringify({ schemaVersion, groups, defaultSections }, null, 2)
currentNote.content.preview_plain = getPlainPreview(groups)
currentNote.content.preview_html = renderToString(<NotePreview groupedTasks={groups} />)

View File

@@ -28,7 +28,7 @@ html {
margin-right: 0.75rem;
}
.sn-icon.sm {
.sn-icon.small {
height: 0.875rem;
width: 0.875rem;
}
@@ -56,6 +56,12 @@ html {
background-color: inherit;
border-width: 1px;
}
&.small {
height: 1.25rem;
min-width: 1.25rem;
width: 1.25rem;
}
}
.pt-1px {

View File

@@ -8,6 +8,7 @@ import { RootState } from './app/store'
const defaultMockState: RootState = {
tasks: {
schemaVersion: '1.0.0',
defaultSections: [],
groups: [],
},
settings: {