-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: add repeat subtasks for repeating tasks #427 #2659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ import { | |
delay, | ||
filter, | ||
first, | ||
map, | ||
mergeMap, | ||
take, | ||
tap, | ||
|
@@ -22,11 +23,16 @@ import { | |
import { selectTaskRepeatCfgFeatureState } from './task-repeat-cfg.reducer'; | ||
import { PersistenceService } from '../../../core/persistence/persistence.service'; | ||
import { Task, TaskArchive, TaskCopy } from '../../tasks/task.model'; | ||
import { updateTask } from '../../tasks/store/task.actions'; | ||
import { addSubTask, updateTask } from '../../tasks/store/task.actions'; | ||
import { TaskService } from '../../tasks/task.service'; | ||
import { TaskRepeatCfgService } from '../task-repeat-cfg.service'; | ||
import { TaskRepeatCfg, TaskRepeatCfgState } from '../task-repeat-cfg.model'; | ||
import { forkJoin, from, merge, of } from 'rxjs'; | ||
import { | ||
DEFAULT_TASK_REPEAT_CFG, | ||
TaskRepeatCfg, | ||
TaskRepeatCfgCopy, | ||
TaskRepeatCfgState, | ||
} from '../task-repeat-cfg.model'; | ||
import { Observable, forkJoin, from, merge, of } from 'rxjs'; | ||
import { setActiveWorkContext } from '../../work-context/store/work-context.actions'; | ||
import { SyncTriggerService } from '../../../imex/sync/sync-trigger.service'; | ||
import { SyncProviderService } from '../../../imex/sync/sync-provider.service'; | ||
|
@@ -38,6 +44,8 @@ import { Update } from '@ngrx/entity'; | |
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string'; | ||
import { isToday } from '../../../util/is-today.util'; | ||
import { DateService } from 'src/app/core/date/date.service'; | ||
import { getWorklogStr } from 'src/app/util/get-work-log-str'; | ||
import { getTaskById } from '../../tasks/store/task.reducer.util'; | ||
|
||
@Injectable() | ||
export class TaskRepeatCfgEffects { | ||
|
@@ -58,6 +66,131 @@ export class TaskRepeatCfgEffects { | |
{ dispatch: false }, | ||
); | ||
|
||
/** | ||
* Updates the repeatCfg of a task, if the task was updated. | ||
*/ | ||
updateRepeatCfgWhenTaskUpdates: any = createEffect( | ||
() => | ||
this._actions$.pipe( | ||
ofType(updateTask), | ||
tap(async (aAction) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 'await' inside the 'tap' operator may lead to unexpected behavior. It's better to use 'mergeMap' or 'concatMap' to handle asynchronous operations in an Observable chain. [important] |
||
const allTasks = await this._taskService.allTasks$.pipe(first()).toPromise(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better to directly use |
||
const task = allTasks.find((aTask) => aTask.id === aAction.task.id)!; | ||
|
||
if (task.repeatCfgId !== null) { | ||
const repeatCfgForTask = await this._taskRepeatCfgService | ||
.getTaskRepeatCfgById$(task.repeatCfgId) | ||
.pipe(first()) | ||
.toPromise(); | ||
|
||
const taskChanges = aAction.task.changes; | ||
|
||
// TODO: is there a better way to do this? Is there anything missing? | ||
const repeatCfgChanges: Partial<TaskRepeatCfgCopy> = { | ||
projectId: taskChanges.projectId ?? repeatCfgForTask.projectId, | ||
title: taskChanges.title ?? repeatCfgForTask.title, | ||
tagIds: taskChanges.tagIds ?? repeatCfgForTask.tagIds, | ||
notes: taskChanges.notes ?? repeatCfgForTask.notes, | ||
}; | ||
|
||
// TODO: Do we need to do this for all instances?? | ||
this._taskRepeatCfgService.updateTaskRepeatCfg( | ||
task.repeatCfgId, | ||
repeatCfgChanges, | ||
); | ||
} | ||
}), | ||
), | ||
{ dispatch: false }, // Question: What exactly does this do? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 'dispatch: false' in the createEffect function means that the effect will not dispatch any new actions. If you want to dispatch new actions from within the effect, you should remove this option or set it to true. [medium] |
||
); | ||
|
||
/** | ||
* When a main task is made repeatable, this function checks if there are subtasks. | ||
* If that is the case, a repeat-cfg gets added for each subtask, too. | ||
*/ | ||
addTaskRepeatCfgForSubTasksOf: any = createEffect( | ||
() => | ||
this._actions$.pipe( | ||
ofType(addTaskRepeatCfgToTask), | ||
tap(async (aAction) => { | ||
// TODO: is there an easier way to get to the parent task? | ||
const allTasks = await this._taskService.allTasks$.pipe(first()).toPromise(); | ||
const parentTask = allTasks.find((aTask) => aTask.id === aAction.taskId); | ||
const parentTaskRepeatCfg = aAction.taskRepeatCfg; | ||
|
||
if (parentTask !== undefined && parentTask.subTaskIds.length > 0) { | ||
for (const aSubTaskId of parentTask.subTaskIds) { | ||
const task = allTasks.find((aTask) => aTask.id === aSubTaskId)!; | ||
|
||
const repeatCfg = { | ||
...parentTaskRepeatCfg, | ||
// TODO: anything missing in this list that should not be overwritten by the parent? | ||
title: task.title, | ||
notes: task.notes, | ||
defaultEstimate: task.timeEstimate, // is this correct? | ||
parentId: parentTask.repeatCfgId, | ||
}; | ||
|
||
this._taskRepeatCfgService.addTaskRepeatCfgToTask( | ||
task.id, | ||
task.projectId, | ||
repeatCfg, | ||
); | ||
} | ||
} | ||
}), | ||
), | ||
{ dispatch: false }, // Question: What exactly does this do? | ||
); | ||
|
||
/** | ||
* When adding a sub task, this function checks if the parent is a repeatable task and therefore the sub-task also has to be. | ||
* If that is the case, a repeat-cfg gets added for each subtask, too. | ||
*/ | ||
addTaskRepeatCfgForSubTask: any = createEffect( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Imho subtasks should not be repeatable tasks, but just subtasks of a task with a repeatable config. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since finished tasks get deleted (at least that's what it seemed like when I tried to observe how the app works?) and I am not confident enough at this point to try and change things on such a fundamental level, I based this approach on the fact that repeatCfgs are persisted and can be used to store the information of the sub tasks. I basically mirrored that structure with the repeatCfs: All Tasks (sub + parent) are stored as tasks (all together) in the store (only difference is if they have subtask-ids or a parent-Id or not) Since all information of a repeatable task gets generated from a repeatable config, I only see 2 ways of storing the information for the sub tasks:
Definitely let me know if I missed something. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Afaics subtasks don't need to "know" that they are a child task of a repeatable task and I also think different parent repeatable tasks should be allowed to have different sub tasks. To solve this I would not reference "real" sub tasks but rather provide a simple input that allows to create a list of sub tasks with no more information than maybe the title. Using real "subtasks" as reference point creates all sorts of messy problems, when moving them around or deleting them or their parent. |
||
() => | ||
this._actions$.pipe( | ||
ofType(addSubTask), | ||
tap(async (aAction) => { | ||
const task = aAction.task; | ||
|
||
// we only want to continue if the task doesn't already have a repeatCfgId | ||
if (task.repeatCfgId === null) { | ||
console.log('A TASKKK', task); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider removing the console.log statements as they are generally not recommended for production code. They can expose sensitive information and clutter the console output. [important] |
||
|
||
// TODO: is there an easier way to get to the parent task? | ||
const allTasks = await this._taskService.allTasks$.pipe(first()).toPromise(); | ||
const parentTask = allTasks.find((aTask) => aTask.id === aAction.parentId); | ||
|
||
console.log('PARENT TASK', parentTask); | ||
|
||
if (parentTask !== undefined && parentTask.repeatCfgId !== null) { | ||
const parentRepeatCfg = await this._taskRepeatCfgService | ||
.getTaskRepeatCfgById$(parentTask.repeatCfgId) | ||
.pipe(first()) | ||
.toPromise(); | ||
|
||
const repeatCfg = { | ||
...parentRepeatCfg, | ||
// TODO: anything missing in this list that should not be overwritten by the parent? | ||
title: task.title, | ||
notes: task.notes || undefined, | ||
defaultEstimate: task.timeEstimate, // is this correct? | ||
parentId: parentRepeatCfg.id, | ||
}; | ||
|
||
this._taskRepeatCfgService.addTaskRepeatCfgToTask( | ||
task.id, | ||
task.projectId, | ||
repeatCfg, | ||
); | ||
} | ||
} | ||
}), | ||
), | ||
{ dispatch: false }, // Question: What exactly does this do? | ||
); | ||
|
||
private triggerRepeatableTaskCreation$ = merge( | ||
this._syncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$, | ||
this._actions$.pipe( | ||
|
@@ -85,8 +218,14 @@ export class TaskRepeatCfgEffects { | |
|
||
// existing tasks with sub tasks are loaded, because need to move them to the archive | ||
mergeMap(([taskRepeatCfgs, currentTaskId]) => { | ||
// we only want to work with parent tasks, so filter out sub tasks | ||
const parentTasksRepeatCfgs = taskRepeatCfgs.filter( | ||
(aTask) => aTask.parentId === null, | ||
); | ||
|
||
// NOTE sorting here is important | ||
const sorted = taskRepeatCfgs.sort(sortRepeatableTaskCfgs); | ||
const sorted = parentTasksRepeatCfgs.sort(sortRepeatableTaskCfgs); | ||
|
||
return from(sorted).pipe( | ||
mergeMap((taskRepeatCfg: TaskRepeatCfg) => | ||
this._taskRepeatCfgService.getActionsForTaskRepeatCfg( | ||
|
@@ -106,24 +245,47 @@ export class TaskRepeatCfgEffects { | |
ofType(deleteTaskRepeatCfg), | ||
concatMap(({ id }) => this._taskService.getTasksByRepeatCfgId$(id).pipe(take(1))), | ||
filter((tasks) => tasks && !!tasks.length), | ||
mergeMap((tasks: Task[]) => | ||
tasks.map((task) => | ||
concatMap((value: TaskCopy[], index) => { | ||
const tasks: Readonly<TaskCopy>[] = value; | ||
const allSubIds = tasks.flatMap((aTask) => aTask.subTaskIds); | ||
|
||
return this._taskService | ||
.getByIdsLive$(allSubIds) | ||
.pipe(map((aAllSubTasks: TaskCopy[]) => ({ aAllSubTasks, tasks }))); | ||
}), | ||
mergeMap(({ aAllSubTasks, tasks }) => { | ||
return [...aAllSubTasks, ...tasks].map((task) => | ||
updateTask({ | ||
task: { | ||
id: task.id, | ||
changes: { repeatCfgId: null }, | ||
}, | ||
}), | ||
), | ||
), | ||
); | ||
}), | ||
), | ||
); | ||
|
||
removeConfigIdFromTaskArchiveTasks$: any = createEffect( | ||
() => | ||
this._actions$.pipe( | ||
ofType(deleteTaskRepeatCfg), | ||
tap(({ id }) => { | ||
tap(async ({ id }) => { | ||
const subTasks = await this._taskRepeatCfgService | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think that this is necessary, since only parent tasks should have a repeat config linked to it, but not their sub tasks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please see comment below |
||
.getTaskRepeatCfgsByParentId$(id) | ||
.pipe(first()) | ||
.toPromise(); | ||
|
||
if (subTasks.length > 0) { | ||
const subTaskIds = subTasks.map((aTask) => aTask.id); | ||
|
||
// remove repeat cfgs from sub tasks | ||
for (const aId of subTaskIds) { | ||
this._removeRepeatCfgFromArchiveTasks(aId); | ||
} | ||
} | ||
|
||
// remove repeat cfg from main task | ||
this._removeRepeatCfgFromArchiveTasks(id); | ||
}), | ||
), | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really need this (honestly I am unsure :D)? Are there potential edge cases and problems that this might cause?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure about edge cases, but without this you have no way of changing contents of a repeatable task as far as I was able to see.
What I did: I tried to change the title of a repeatable task, but since each day the new tasks are generated based on the repeatCfg (where the title was not updated), the old title would appear. So if I wanted to actually change the title, I would have to remove the repeat-cfg first, then change the title and then add the repeat-config back.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently you can change existing repeatable task instances by editing the repeatable config. If you edit the title there, the app asks you if you want to update all existing tasks. The idea behind it is, that you might want to have different notes and even tags for different instances. But I can see how it might be more intuitive to ditch this behavior and do it like this. Let's try it out!