diff --git a/package.json b/package.json index 5e8e118..9ee4985 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "postinstall": "node -r @babel/register .erb/scripts/CheckNativeDep.js && electron-builder install-app-deps && yarn cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.babel.js && opencollective-postinstall && yarn-deduplicate yarn.lock", "start": "node -r @babel/register ./.erb/scripts/CheckPortInUse.js && cross-env yarn start:renderer", "start:main": "cross-env NODE_ENV=development electron -r ./.erb/scripts/BabelRegister ./src/main.dev.ts", - "start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.babel.js" + "start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.babel.js", + "test": "jest" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -112,6 +113,27 @@ "hot", "reload" ], + "jest": { + "testURL": "http://localhost/", + "moduleNameMapper": { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js", + "\\.(css|less|sass|scss)$": "identity-obj-proxy" + }, + "moduleFileExtensions": [ + "js", + "jsx", + "ts", + "tsx", + "json" + ], + "moduleDirectories": [ + "node_modules", + "src/node_modules" + ], + "setupFiles": [ + "./.erb/scripts/CheckBuildsExist.js" + ] + }, "devDependencies": { "@babel/core": "^7.12.9", "@babel/plugin-proposal-class-properties": "^7.12.1", @@ -139,6 +161,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", "@types/history": "4.7.6", + "@types/jest": "^26.0.24", "@types/node": "14.14.10", "@types/react": "^16.9.44", "@types/react-dom": "^16.9.9", @@ -147,6 +170,7 @@ "@typescript-eslint/eslint-plugin": "^4.8.1", "@typescript-eslint/parser": "^4.8.1", "babel-eslint": "^10.1.0", + "babel-jest": "^26.1.0", "babel-loader": "^8.2.2", "babel-plugin-dev-expression": "^0.2.2", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", @@ -172,6 +196,7 @@ "eslint-import-resolver-webpack": "^0.13.0", "eslint-plugin-compat": "^3.8.0", "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jest": "^24.1.3", "eslint-plugin-jsx-a11y": "6.4.1", "eslint-plugin-prettier": "^3.1.4", "eslint-plugin-promise": "^4.2.1", @@ -179,6 +204,8 @@ "eslint-plugin-react-hooks": "^4.0.8", "file-loader": "^6.0.0", "husky": "^4.2.5", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.0.6", "lint-staged": "^10.2.11", "mini-css-extract-plugin": "^1.3.1", "node-sass": "^5.0.0", @@ -189,7 +216,7 @@ "sass-loader": "^10.1.0", "style-loader": "^2.0.0", "terser-webpack-plugin": "^5.0.3", - "typescript": "^4.0.5", + "typescript": "^4.3.5", "url-loader": "^4.1.0", "webpack": "^5.5.1", "webpack-bundle-analyzer": "^4.1.0", diff --git a/src/base/AbstractModel.ts b/src/base/AbstractModel.ts index 21883a6..3612ef1 100644 --- a/src/base/AbstractModel.ts +++ b/src/base/AbstractModel.ts @@ -3,7 +3,7 @@ export default abstract class AbstractModel { return Object.keys(this); } - protected load(data: T) { + protected load(data: T) { if (data) { this.getAttributes().forEach((attribute) => { if (data.hasOwnProperty(attribute)) { diff --git a/src/base/TreeModelHelper.ts b/src/base/TreeModelHelper.ts deleted file mode 100644 index 46994e7..0000000 --- a/src/base/TreeModelHelper.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ITreeItem } from '../types/ITreeItem'; - -const TreeModelHelper = { - modifyItemsWithIdsRecursive>( - treeItems: T[], - ids: string[], - fn: (treeItem: T, ids: string[]) => void - ) { - treeItems.forEach((item) => { - fn(item, ids); - if (Array.isArray(item.children) && item.children.length) { - TreeModelHelper.modifyItemsWithIdsRecursive(item.children, ids, fn); - } - }); - }, - - getItemRecursive>( - tasks: T[], - condition: (task: T) => boolean - ): T | undefined { - for (const task of tasks) { - if (condition(task)) { - return task; - } - if (Array.isArray(task.children)) { - const found = this.getItemRecursive(task.children, condition); - if (found) { - return found; - } - } - } - return undefined; - }, - - getFlatItemsRecursive>( - tree: T[], - condition: (task: T) => boolean - ): T[] { - const result: T[] = []; - - this.getFlatItemsRecursiveBase(tree, condition, result); - - return result; - }, - - getFlatItemsRecursiveBase>( - treeItems: T[], - condition: (item: T) => boolean, - result: T[] - ): T[] { - for (const item of treeItems) { - if (condition(item)) { - result.push(item); - } - if (Array.isArray(item.children)) { - this.getFlatItemsRecursiveBase(item.children, condition, result); - } - } - return result; - }, - - deleteItems>( - treeItems: T[], - condition: (task: T) => boolean - ): T[] { - const result = treeItems.filter((t) => !condition(t)); - for (let i = 0; i < result.length; i++) { - const task = treeItems[i]; - if (Array.isArray(task.children)) { - treeItems[i].children = this.deleteItems(task.children, condition); - } - } - return result; - }, -}; - -export default TreeModelHelper; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f7de88e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,3 @@ +export const Features = { + myDay: false, +}; diff --git a/src/helpers/TreeModelHelper.test.ts b/src/helpers/TreeModelHelper.test.ts new file mode 100644 index 0000000..ff3271c --- /dev/null +++ b/src/helpers/TreeModelHelper.test.ts @@ -0,0 +1,88 @@ +import TreeModelHelper from './TreeModelHelper'; +import TaskFactory from '../modules/tasks/TaskFactory'; +import { IJsonTaskModel } from '../modules/tasks/models/TaskModel'; +import { TasksByProject } from '../modules/tasks/models/TasksByProject'; +import { ITreeItemWithParent } from '../types/ITreeItem'; + +describe('TreeModelHelper', () => { + let testTasks: TasksByProject | undefined; + beforeEach(() => { + const factory = new TaskFactory(); + /* Structure: + -task1 + --task1-1 + ---task-1-1-1 + -task2 + --task21 + --task22 + */ + const task111: Partial = { + key: '111', + title: 'task1-1-1', + parent: null, + children: [], + }; + const task11: Partial = { + key: '11', + title: 'task1-1', + parent: null, + children: [task111 as IJsonTaskModel], + }; + const task1: Partial = { + key: '1', + title: 'task1', + parent: null, + children: [task11 as IJsonTaskModel], + }; + + task111.parent = task11 as IJsonTaskModel; + task11.parent = task1 as IJsonTaskModel; + + const task21: Partial = { + key: '21', + title: 'task21', + parent: null, + children: [], + }; + const task22: Partial = { + key: '22', + title: 'task22', + parent: null, + children: [], + }; + const task2: Partial = { + key: '2', + title: 'task2', + parent: null, + children: [task21, task22] as IJsonTaskModel[], + }; + task21.parent = task2 as IJsonTaskModel; + task22.parent = task2 as IJsonTaskModel; + + testTasks = factory.createTasks(({ + proj: [task1, task2], + } as unknown) as TasksByProject); + }); + + test('getPathToNode #1', () => { + if (!testTasks) { + throw new Error(); + } + const task111 = testTasks.proj[0].children[0].children[0]; + expect(TreeModelHelper.getPathToNode(task111)).toStrictEqual([ + '1', + '11', + '111', + ]); + }); + + test('getPathToNode #2', () => { + if (!testTasks) { + throw new Error(); + } + const task22 = testTasks.proj[1].children[0]; + expect(TreeModelHelper.getPathToNode(task22)).toStrictEqual(['2', '21']); + }); + + test(''); +}); diff --git a/src/helpers/TreeModelHelper.ts b/src/helpers/TreeModelHelper.ts new file mode 100644 index 0000000..f0ba275 --- /dev/null +++ b/src/helpers/TreeModelHelper.ts @@ -0,0 +1,238 @@ +import { toJS } from 'mobx'; + +import { ITreeItem, ITreeItemWithParent } from '../types/ITreeItem'; +import { TaskInMyDay } from '../modules/tasks/models/TaskInMyDay'; +import TaskModel from '../modules/tasks/models/TaskModel'; +import TaskFactory from '../modules/tasks/TaskFactory'; +import ProjectModel from '../modules/projects/models/ProjectModel'; + +// @ts-ignore TODO remove +window.toJS = toJS; + +const TreeModelHelper = { + getPathToNode(node: T) { + const result: string[] = []; + + let ptrNode: T | undefined = node; + while (ptrNode) { + result.unshift(ptrNode.key); + // @ts-ignore + ptrNode = ptrNode.parent; + } + + return result; + }, + + copyItemsToTreeUnderProject( + project: ProjectModel | undefined, + sourceTree: TaskModel[], + destTree: TaskInMyDay[], + keysToNode: string[] + ): boolean { + if (!project) { + return false; + } + + let destProject = destTree.find((node) => node.key === project.key); + if (!destProject) { + destProject = TaskFactory.createTaskModelProxy( + new TaskModel({ + key: project.key, + projectId: project.key, + title: project.title, + active: false, + checked: false, + children: [], + datesInProgress: [], + details: [], + expanded: true, + inMyDay: new Date().toString(), + parent: null, + time: [], + withoutActions: true, + }) + ); + destTree.push(destProject); + } + + return TreeModelHelper.copyItemsToTree( + sourceTree, + destProject.children, + keysToNode + ); + }, + + /** + * Make a copy of tasks to 'My Day' + */ + copyItemsToTree( + sourceTree: TaskModel[], + destTree: TaskInMyDay[], + keysToNode: string[] + ) { + let keyIdx = 0; + let sourceChildren = sourceTree; + let destChildren = destTree; + + if (keysToNode.length === 1) { + const source = sourceChildren.find((node) => node.key === keysToNode[0]); + if (source) { + destChildren.push(TaskFactory.createTaskModelProxy(source)); + } + return !!source; + } + + do { + const nextSourceNode = sourceChildren.find( + (task) => task.key === keysToNode[keyIdx] + ); + if (!nextSourceNode) { + return false; + } + + const nextDestNode = destChildren.find( + (task) => task.key === keysToNode[keyIdx] + ); + + if (nextDestNode) { + // We already have a copy of node, go on + keyIdx++; + sourceChildren = nextSourceNode.children; + destChildren = nextDestNode.children; + } else { + // Make a copy from this node + const restKeysToNode = keysToNode.slice(keyIdx); + return TreeModelHelper.copySubItemsToTree( + sourceChildren, + destChildren, + restKeysToNode + ); + } + } while (keyIdx < keysToNode.length); + + return true; + }, + + copySubItemsToTree( + sourceTree: TaskModel[], + destTree: TaskInMyDay[], + keysToNode: string[] + ) { + if (!sourceTree) { + return false; + } + + let keyIdx = 0; + let destChildren = destTree; + let sourceNode = sourceTree.find((node) => node.key === keysToNode[keyIdx]); + + if (!sourceNode) { + return false; + } + + while (true) { + const copyNode = TaskFactory.createTaskModelProxy(sourceNode); + destChildren.push(copyNode); + + keyIdx++; + if (keyIdx === keysToNode.length) { + return true; + } + + destChildren = copyNode.children; + sourceNode = sourceNode.children.find( + (node) => node.key === keysToNode[keyIdx] + ); + if (!sourceNode) { + return false; + } + } + }, + + walkRecursive>( + fn: (t: T, p?: T) => void, + treeItems: T[], + parent?: T + ) { + treeItems.forEach((item) => { + fn(item, parent); + if (item.children?.length) { + TreeModelHelper.walkRecursive(fn, item.children, item); + } + }); + }, + + modifyItemsWithIdsRecursive>( + treeItems: T[], + ids: string[], + fn: (treeItem: T, ids: string[]) => void + ) { + treeItems.forEach((item) => { + fn(item, ids); + if (Array.isArray(item.children) && item.children.length) { + TreeModelHelper.modifyItemsWithIdsRecursive(item.children, ids, fn); + } + }); + }, + + getItemRecursive>( + tasks: T[], + condition: (task: T) => boolean + ): T | undefined { + for (const task of tasks) { + if (condition(task)) { + return task; + } + if (Array.isArray(task.children)) { + const found = this.getItemRecursive(task.children, condition); + if (found) { + return found; + } + } + } + return undefined; + }, + + getFlatItemsRecursive>( + tree: T[], + condition: (task: T) => boolean + ): T[] { + const result: T[] = []; + + this.getFlatItemsRecursiveBase(tree, condition, result); + + return result; + }, + + getFlatItemsRecursiveBase>( + treeItems: T[], + condition: (item: T) => boolean, + result: T[] + ): T[] { + for (const item of treeItems) { + if (condition(item)) { + result.push(item); + } + if (Array.isArray(item.children)) { + this.getFlatItemsRecursiveBase(item.children, condition, result); + } + } + return result; + }, + + deleteItems>( + treeItems: T[], + condition: (task: T) => boolean + ): T[] { + const result = treeItems.filter((t) => !condition(t)); + for (let i = 0; i < result.length; i++) { + const task = treeItems[i]; + if (Array.isArray(task.children)) { + treeItems[i].children = this.deleteItems(task.children, condition); + } + } + return result; + }, +}; + +export default TreeModelHelper; diff --git a/src/main.dev.ts b/src/main.dev.ts index b8060eb..1f0cfb1 100644 --- a/src/main.dev.ts +++ b/src/main.dev.ts @@ -18,6 +18,7 @@ initSentry(); import path from 'path'; import { app, BrowserWindow, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; +// @ts-ignore import Badge from 'electron-windows-badge'; import './main/IpcMain'; diff --git a/src/modules/projects/ProjectFactory.ts b/src/modules/projects/ProjectFactory.ts index 8322b75..672c40e 100644 --- a/src/modules/projects/ProjectFactory.ts +++ b/src/modules/projects/ProjectFactory.ts @@ -1,3 +1,27 @@ import AbstractFactory from '../../base/AbstractFactory'; +import ProjectModel, { + DEFAULT_PROJECT_ID, + DEFAULT_PROJECTS, + IJsonProjectItem, +} from './models/ProjectModel'; +import { Features } from '../../config'; -export default class ProjectFactory extends AbstractFactory {} +export default class ProjectFactory extends AbstractFactory { + createProjects(projectItems: IJsonProjectItem[]): ProjectModel[] { + if (Features.myDay) { + const hasMyDay = projectItems.find( + (p) => p.key === DEFAULT_PROJECT_ID.MyDay + ); + if (!hasMyDay) { + const myDayProj = DEFAULT_PROJECTS.find( + (p) => p.key === DEFAULT_PROJECT_ID.MyDay + ); + if (myDayProj) { + projectItems.unshift(myDayProj); + } + } + } + + return this.createList(ProjectModel, projectItems); + } +} diff --git a/src/modules/projects/ProjectRepository.ts b/src/modules/projects/ProjectRepository.ts index 9ced8ec..9642637 100644 --- a/src/modules/projects/ProjectRepository.ts +++ b/src/modules/projects/ProjectRepository.ts @@ -1,5 +1,8 @@ import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; +import { IJsonProjectItem } from './models/ProjectModel'; -export default class ProjectRepository extends AbstractFileRepository { +export default class ProjectRepository extends AbstractFileRepository< + IJsonProjectItem[] +> { fileName = 'projects.json'; } diff --git a/src/modules/projects/ProjectService.ts b/src/modules/projects/ProjectService.ts index 0a980c3..328722d 100644 --- a/src/modules/projects/ProjectService.ts +++ b/src/modules/projects/ProjectService.ts @@ -11,7 +11,8 @@ export default class ProjectService extends AbstractServiceWithProfile< getAll(): ProjectModel[] { const data = this.repository.restore(DEFAULT_PROJECTS); - return this.factory.createList(ProjectModel, data); + + return this.factory.createProjects(data); } save(data: ProjectModel[]): void { diff --git a/src/modules/projects/ProjectStore.ts b/src/modules/projects/ProjectStore.ts index 0042a06..97d47ee 100644 --- a/src/modules/projects/ProjectStore.ts +++ b/src/modules/projects/ProjectStore.ts @@ -2,7 +2,7 @@ import { autorun, makeAutoObservable } from 'mobx'; import ProjectModel from './models/ProjectModel'; import ProjectService from './ProjectService'; -import TreeModelHelper from '../../base/TreeModelHelper'; +import TreeModelHelper from '../../helpers/TreeModelHelper'; import { Undefined } from '../../types/CommonTypes'; import { RootStore } from '../RootStore'; import GaService from '../../services/gaService/GaService'; diff --git a/src/modules/projects/models/ProjectModel.ts b/src/modules/projects/models/ProjectModel.ts index fd47c7d..f26c3db 100644 --- a/src/modules/projects/models/ProjectModel.ts +++ b/src/modules/projects/models/ProjectModel.ts @@ -3,20 +3,38 @@ import * as colors from '@ant-design/colors'; import AbstractModel from '../../../base/AbstractModel'; import { ITreeItem } from '../../../types/ITreeItem'; -export const DEFAULT_PROJECTS: any[] = [ +export enum DEFAULT_PROJECT_ID { + MyDay = '0', + Inbox = '1', +} + +export const DEFAULT_PROJECTS: IJsonProjectItem[] = [ + // { + // key: DEFAULT_PROJECT_ID.MyDay, + // title: 'My Day', + // color: colors.yellow.primary || '', + // deletable: false, + // expanded: false, + // }, { - key: '1', + key: DEFAULT_PROJECT_ID.Inbox, title: 'Inbox', - color: colors.blue, + color: colors.blue.primary || '', + deletable: false, + expanded: false, }, ]; -interface IJsonProjectItem extends ITreeItem { +export interface IJsonProjectItem extends ITreeItem { color: string; + expanded: boolean; + deletable: boolean; } interface IProjectModel extends ITreeItem { color: string; + expanded: boolean; + deletable: boolean; } export default class ProjectModel extends AbstractModel @@ -25,11 +43,17 @@ export default class ProjectModel extends AbstractModel title: string = ''; color: string = ''; expanded: boolean = false; + deletable: boolean = true; children?: ProjectModel[] = []; constructor(props: IJsonProjectItem) { super(); - this.load(props); // TODO вынести итератор - this.children = props.children?.map((json) => new ProjectModel(json)) || []; + + const newProps = { + ...props, + children: props.children?.map((json) => new ProjectModel(json)), + }; + + this.load(newProps); } } diff --git a/src/modules/tasks/TaskFactory.ts b/src/modules/tasks/TaskFactory.ts index 7c1402c..4b62249 100644 --- a/src/modules/tasks/TaskFactory.ts +++ b/src/modules/tasks/TaskFactory.ts @@ -1,6 +1,8 @@ import AbstractFactory from '../../base/AbstractFactory'; -import TasksByProject from './models/TasksByProject'; +import { TasksByProject } from './models/TasksByProject'; import TaskModel from './models/TaskModel'; +import { TaskInMyDay, taskModelProxyHandler } from './models/TaskInMyDay'; +import { DEFAULT_PROJECT_ID } from '../projects/models/ProjectModel'; export default class TaskFactory extends AbstractFactory { createTasks(data: TasksByProject): TasksByProject { @@ -8,6 +10,15 @@ export default class TaskFactory extends AbstractFactory { Object.keys(data).forEach((projectId) => { newData[projectId] = this.createList(TaskModel, data[projectId]); }); + + newData[DEFAULT_PROJECT_ID.MyDay] = []; + return newData; } + + static createTaskModelProxy(taskModel: TaskModel): TaskInMyDay { + const target = new TaskInMyDay(taskModel, []); + + return new Proxy(target, taskModelProxyHandler); + } } diff --git a/src/modules/tasks/TaskRepository.ts b/src/modules/tasks/TaskRepository.ts index acecda8..85653c6 100644 --- a/src/modules/tasks/TaskRepository.ts +++ b/src/modules/tasks/TaskRepository.ts @@ -1,5 +1,5 @@ import AbstractFileRepository from '../../base/repositories/AbstractFileRepository'; -import TasksByProject from './models/TasksByProject'; +import { TasksByProject } from './models/TasksByProject'; export default class TaskRepository extends AbstractFileRepository< TasksByProject diff --git a/src/modules/tasks/TaskService.ts b/src/modules/tasks/TaskService.ts index 70dd95c..295b53d 100644 --- a/src/modules/tasks/TaskService.ts +++ b/src/modules/tasks/TaskService.ts @@ -1,7 +1,19 @@ +import { toJS } from 'mobx'; + import TaskRepository from './TaskRepository'; import TaskFactory from './TaskFactory'; -import TasksByProject from './models/TasksByProject'; +import { TasksByProject } from './models/TasksByProject'; import AbstractServiceWithProfile from '../../base/AbstractServiceWithProfile'; +import TreeModelHelper from '../../helpers/TreeModelHelper'; +import { ITreeItemWithParent } from '../../types/ITreeItem'; + +const setParent = >(item: T, parent?: T) => { + item.parent = parent || null; +}; + +const clearParent = (item: ITreeItemWithParent) => { + item.parent = null; +}; export default class TaskService extends AbstractServiceWithProfile< TasksByProject @@ -11,10 +23,31 @@ export default class TaskService extends AbstractServiceWithProfile< getAll(): TasksByProject { const data: TasksByProject = this.repository.restore({}); + TaskService.fillParent(data); return this.factory.createTasks(data); } save(data: TasksByProject) { - this.repository.save(data); + const copyData = toJS(data); + TaskService.clearParent(copyData); + this.repository.save(copyData); + } + + private static fillParent(data: TasksByProject) { + Object.values(data).forEach((projectTasks) => { + TreeModelHelper.walkRecursive>( + setParent, + projectTasks + ); + }); + } + + private static clearParent(data: TasksByProject) { + Object.values(data).forEach((projectTasks) => { + TreeModelHelper.walkRecursive>( + clearParent, + projectTasks + ); + }); } } diff --git a/src/modules/tasks/TaskStore.ts b/src/modules/tasks/TaskStore.ts index 73ce01e..6ad1dfb 100644 --- a/src/modules/tasks/TaskStore.ts +++ b/src/modules/tasks/TaskStore.ts @@ -2,16 +2,21 @@ import { autorun, makeAutoObservable } from 'mobx'; import TaskService from './TaskService'; import TaskModel, { ITimeRangeModel } from './models/TaskModel'; -import TasksByProject from '../../modules/tasks/models/TasksByProject'; -import TreeModelHelper from '../../base/TreeModelHelper'; +import { + Task, + TasksByProject, +} from '../../modules/tasks/models/TasksByProject'; +import TreeModelHelper from '../../helpers/TreeModelHelper'; import BadgeService from '../BadgeService'; -import { RootStore } from '../RootStore'; +import rootStore, { RootStore } from '../RootStore'; import GaService from '../../services/gaService/GaService'; import { EEventCategory, ETasksEvents, ETimeRangeEvents, } from '../../services/gaService/EEvents'; +import { DEFAULT_PROJECT_ID } from '../projects/models/ProjectModel'; +import { ITreeItemWithParent } from '../../types/ITreeItem'; export default class TaskStore { tasks: TasksByProject = {}; @@ -50,7 +55,7 @@ export default class TaskStore { GaService.event(EEventCategory.TimeRange, ETimeRangeEvents.Delete); } - getTasks(projectId: string): TaskModel[] { + getTasks(projectId: string): Task[] { return this.tasks[projectId] || []; } @@ -92,6 +97,20 @@ export default class TaskStore { GaService.event(EEventCategory.Tasks, ETasksEvents.Create); } + addToMyDay(task: TaskModel) { + task.inMyDay = new Date(); + + const pathToNode = TreeModelHelper.getPathToNode(task); + + TreeModelHelper.copyItemsToTreeUnderProject( + rootStore.projectStore.get(task.projectId), + this.tasks[task.projectId], + // @ts-ignore + this.tasks[DEFAULT_PROJECT_ID.MyDay], + pathToNode + ); + } + delete(task: TaskModel) { function condition(_task: TaskModel) { return _task.key === task.key; @@ -172,12 +191,14 @@ export default class TaskStore { } markExpanded(projectId: string, taskIds: string[]) { - const markExpanded = (task: TaskModel, taskIds: string[]) => { - task.expanded = taskIds.includes(task.key); + const markExpanded = (task: Task, taskIds: string[]) => { + if (task instanceof TaskModel) { + task.expanded = taskIds.includes(task.key); + } }; if (Array.isArray(this.tasks[projectId])) { - TreeModelHelper.modifyItemsWithIdsRecursive( + TreeModelHelper.modifyItemsWithIdsRecursive( this.tasks[projectId], taskIds, markExpanded @@ -192,7 +213,7 @@ export default class TaskStore { condition: (task: TaskModel) => boolean ) { if (Array.isArray(this.tasks[projectId])) { - return TreeModelHelper.getFlatItemsRecursive( + return TreeModelHelper.getFlatItemsRecursive( this.tasks[projectId], condition ).map((task) => task.key); diff --git a/src/modules/tasks/models/TaskInMyDay.ts b/src/modules/tasks/models/TaskInMyDay.ts new file mode 100644 index 0000000..b0ebe89 --- /dev/null +++ b/src/modules/tasks/models/TaskInMyDay.ts @@ -0,0 +1,36 @@ +import TaskModel from './TaskModel'; + +export class TaskInMyDay extends TaskModel { + origin: TaskModel | null = null; + children: TaskInMyDay[] = []; + + constructor(originTaskModel: TaskModel, children: TaskInMyDay[]) { + super(originTaskModel); + this.origin = originTaskModel; + this.children = children; + } +} + +export const taskModelProxyHandler: ProxyHandler = { + get(target: TaskInMyDay, prop: string | symbol): any { + return target?.[prop as keyof TaskInMyDay]; + }, + set(target: TaskInMyDay, prop: string | symbol, value: any): boolean { + if (prop === 'duration') { + console.error( + `TaskModel: Can't set prop '${prop.toString()}' in`, + target + ); + return false; + } + // @ts-ignore + target[prop] = value; + + if (!['expanded', 'children'].includes(prop as string)) { + // @ts-ignore + target.origin[prop] = value; + } + + return true; + }, +}; diff --git a/src/modules/tasks/models/TaskModel.ts b/src/modules/tasks/models/TaskModel.ts index 4334e66..8cbda86 100644 --- a/src/modules/tasks/models/TaskModel.ts +++ b/src/modules/tasks/models/TaskModel.ts @@ -1,9 +1,8 @@ import { action, computed, makeObservable, observable } from 'mobx'; -import isSameDay from 'date-fns/isSameDay'; +import { isSameDay, startOfDay } from 'date-fns'; import AbstractModel from '../../../base/AbstractModel'; -import { ITreeItem } from '../../../types/ITreeItem'; -import { startOfDay } from 'date-fns'; +import { ITreeItemWithParent } from '../../../types/ITreeItem'; export interface IJsonTimeRangeModel { start: string; @@ -17,54 +16,67 @@ export interface ITimeRangeModel { description?: string; } -interface IJsonTaskModel extends ITreeItem { - projectId: string; - checked: boolean; - active: boolean; - expanded: boolean; - time: string[][] | IJsonTimeRangeModel[]; - datesInProgress: string[]; - children: IJsonTaskModel[]; - details: string[]; +export interface IJsonTaskModel extends ITreeItemWithParent { + projectId?: string; + checked?: boolean; + active?: boolean; + expanded?: boolean; + inMyDay?: string; + time?: string[][] | IJsonTimeRangeModel[]; + datesInProgress?: string[]; + details?: string[]; } -export default class TaskModel extends AbstractModel { +const parseTimeRageItems = ( + timeItems: (string[] | IJsonTimeRangeModel | ITimeRangeModel)[] +) => { + return timeItems.map( + (range: string[] | IJsonTimeRangeModel | ITimeRangeModel) => { + if (Array.isArray(range)) { + return { + start: new Date(range[0]), + end: range[1] ? new Date(range[1]) : undefined, + description: undefined, + }; + } else { + return { + start: new Date(range.start), + end: range.end ? new Date(range.end) : undefined, + description: range.description, + }; + } + } + ); +}; + +export default class TaskModel extends AbstractModel + implements ITreeItemWithParent { key: string = ''; title: string = ''; children: TaskModel[] = []; + parent: TaskModel | null = null; // update parent on drug&drop projectId: string = ''; checked: boolean = false; - expanded: boolean = true; active: boolean = false; + expanded: boolean = true; + inMyDay: Date | null = null; time: ITimeRangeModel[] = []; datesInProgress: Date[] = []; details: string = ''; + withoutActions: boolean = false; // TODO make a new class - constructor(props: IJsonTaskModel) { + constructor(props: IJsonTaskModel | TaskModel) { super(); - this.load(props); - this.children = props.children?.map((json) => new TaskModel(json)) || []; - this.time = - // @ts-ignore - props.time?.map( - (range: string[] | IJsonTimeRangeModel) => { - if (Array.isArray(range)) { - return { - start: new Date(range[0]), - end: range[1] ? new Date(range[1]) : undefined, - description: undefined, - }; - } else { - return { - start: new Date(range.start), - end: range.end ? new Date(range.end) : undefined, - description: range.description, - }; - } - } - ) || []; - this.datesInProgress = - props.datesInProgress?.map((date) => new Date(date)) || []; + const newProps = { + ...props, + children: props.children?.map((json) => new TaskModel(json)) || [], + time: props.time ? parseTimeRageItems(props.time) : [], + datesInProgress: + props.datesInProgress?.map((date) => new Date(date)) || [], + inMyDay: props?.inMyDay ? new Date(props?.inMyDay) : null, + }; + + this.load(newProps); makeObservable(this, { key: observable, @@ -117,9 +129,10 @@ export default class TaskModel extends AbstractModel { start() { this.active = true; - this.addDateWhenWasInProgress(new Date()); + const dateNow = new Date(); + this.addDateWhenWasInProgress(dateNow); this.time.push({ - start: new Date(), + start: dateNow, end: undefined, description: undefined, }); diff --git a/src/modules/tasks/models/TaskWithProjectNameModel.ts b/src/modules/tasks/models/TaskWithProjectNameModel.ts new file mode 100644 index 0000000..11a349b --- /dev/null +++ b/src/modules/tasks/models/TaskWithProjectNameModel.ts @@ -0,0 +1,23 @@ +import AbstractModel from '../../../base/AbstractModel'; +import { ITreeItemWithParent } from '../../../types/ITreeItem'; +import TaskModel from './TaskModel'; + +interface ITaskWithProjectName { + key: string; + title: string; + parent: null; + children: TaskModel[]; +} + +export class TaskWithProjectNameModel extends AbstractModel + implements ITreeItemWithParent { + key: string = ''; + title: string = ''; + parent: TaskModel | null = null; + children: TaskModel[] = []; + + constructor(props: ITaskWithProjectName) { + super(); + this.load(props); + } +} diff --git a/src/modules/tasks/models/TasksByProject.ts b/src/modules/tasks/models/TasksByProject.ts index f7cae9d..fb5d3ad 100644 --- a/src/modules/tasks/models/TasksByProject.ts +++ b/src/modules/tasks/models/TasksByProject.ts @@ -1,5 +1,6 @@ import TaskModel from './TaskModel'; +import { TaskInMyDay } from './TaskInMyDay'; +import { TaskWithProjectNameModel } from './TaskWithProjectNameModel'; -type TasksByProject = Record; - -export default TasksByProject; +export type Task = TaskModel | TaskInMyDay | TaskWithProjectNameModel; +export type TasksByProject = Record; diff --git a/src/screens/projects/ProjectsScreen.tsx b/src/screens/projects/ProjectsScreen.tsx index d7a1fd1..1241da8 100644 --- a/src/screens/projects/ProjectsScreen.tsx +++ b/src/screens/projects/ProjectsScreen.tsx @@ -9,7 +9,9 @@ import TaskInput from './components/TaskInput'; import rootStore from '../../modules/RootStore'; import TreeList from './components/TreeList'; import TaskModel from '../../modules/tasks/models/TaskModel'; -import ProjectModel from '../../modules/projects/models/ProjectModel'; +import ProjectModel, { + DEFAULT_PROJECT_ID, +} from '../../modules/projects/models/ProjectModel'; import ProjectModal from './components/ProjectModals/ProjectModal'; import TaskNode from './components/TaskNode/TaskNode'; import DrawerTask from './components/DrawerTask/DrawerTask'; @@ -27,6 +29,9 @@ const TaskList = TreeList( }, { checkable: true, + isDraggable() { + return projectStore.activeProject !== DEFAULT_PROJECT_ID.MyDay; + }, onExpand(keys: Key[]) { tasksStore.markExpanded(projectStore.activeProject, keys as string[]); }, @@ -52,6 +57,7 @@ const ProjectList = TreeList( }, { selectable: false, + draggable: true, titleRender(project: ProjectModel) { return ( void) { + const preventDefault = useCallback((fn: () => void) => { return (e: SyntheticEvent) => { e.stopPropagation(); fn(); }; - } + }, []); return (
{task.title} {duration} + {Features.myDay && ( + tasksStore.addToMyDay(task))} + /> + )} {!task.active ? ( tasksStore.startTimer(task))} diff --git a/src/screens/projects/components/TreeList.tsx b/src/screens/projects/components/TreeList.tsx index b72dbb9..5cde8b4 100644 --- a/src/screens/projects/components/TreeList.tsx +++ b/src/screens/projects/components/TreeList.tsx @@ -16,6 +16,7 @@ interface TreePropsExtended getCheckedKeys?: () => Key[]; getExpandedKeys?: () => Key[]; titleRender?: (item: T) => React.ReactNode; + isDraggable?: () => boolean; } export default function TreeList>( @@ -23,7 +24,7 @@ export default function TreeList>( updateData: (items: T[]) => void, options: TreePropsExtended ) { - const { getCheckedKeys, getExpandedKeys, ...rest } = options; + const { getCheckedKeys, getExpandedKeys, isDraggable, ...rest } = options; return observer(({ onSelect }: TreeListProps) => { const data = getData(); @@ -112,13 +113,15 @@ export default function TreeList>( ); } + const draggable = isDraggable ? isDraggable() : rest.draggable; + return ( = ITreeItem> { key: string; children?: T[]; } + +export interface ITreeItemWithParent< + T extends ITreeItemWithParent = ITreeItemWithParent +> extends ITreeItem { + parent: T | null; +} diff --git a/yarn.lock b/yarn.lock index 3a051f9..26b2a34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10391,10 +10391,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389" - integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ== +typescript@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" + integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== typical@^5.0.0, typical@^5.2.0: version "5.2.0"