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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { tasksGroupAdded } from './tasks-slice'
|
||||
const defaultTasksState = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: 'test',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export const MigrationClasses: any[] = []
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RootState } from './app/store'
|
||||
const defaultMockState: RootState = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [],
|
||||
},
|
||||
settings: {
|
||||
|
||||
Reference in New Issue
Block a user