mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-29 10:40:12 +00:00
fix(sync): sync parent.subTaskIds when task parentId changes via LWW
When LWW conflict resolution updates a task's parentId (making it a subtask or moving it to a different parent), we must also update the corresponding parent task's subTaskIds arrays to maintain bidirectional consistency: - Remove task from old parent's subTaskIds (if it was a subtask) - Add task to new parent's subTaskIds (if it becomes a subtask) This is analogous to the existing syncProjectTaskIds, syncTagTaskIds, and syncTodayTagTaskIds functions that handle other cross-entity relationships. Adds 10 unit tests covering: - Moving subtask between parents - Task becoming a subtask - Subtask becoming top-level - Edge cases (deleted parent, non-existent parent, order preservation)
This commit is contained in:
parent
187cbdafe3
commit
1982f69d22
2 changed files with 398 additions and 0 deletions
|
|
@ -1442,4 +1442,322 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(todayTag.taskIds).not.toContain(TASK_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent.subTaskIds sync on task parentId change', () => {
|
||||
const PARENT_A = 'parent-a';
|
||||
const PARENT_B = 'parent-b';
|
||||
const SUBTASK_ID = 'subtask-1';
|
||||
|
||||
const createStateWithParents = (
|
||||
subtaskParentId: string | undefined,
|
||||
parentASubTaskIds: string[],
|
||||
parentBSubTaskIds: string[],
|
||||
): Partial<RootState> =>
|
||||
({
|
||||
[TASK_FEATURE_NAME]: {
|
||||
ids: [SUBTASK_ID, PARENT_A, PARENT_B],
|
||||
entities: {
|
||||
[SUBTASK_ID]: createMockTask({
|
||||
id: SUBTASK_ID,
|
||||
parentId: subtaskParentId,
|
||||
subTaskIds: [],
|
||||
}),
|
||||
[PARENT_A]: createMockTask({
|
||||
id: PARENT_A,
|
||||
parentId: undefined,
|
||||
subTaskIds: parentASubTaskIds,
|
||||
}),
|
||||
[PARENT_B]: createMockTask({
|
||||
id: PARENT_B,
|
||||
parentId: undefined,
|
||||
subTaskIds: parentBSubTaskIds,
|
||||
}),
|
||||
},
|
||||
currentTaskId: null,
|
||||
selectedTaskId: null,
|
||||
taskDetailTargetPanel: null,
|
||||
isDataLoaded: true,
|
||||
lastCurrentTaskId: null,
|
||||
},
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
[TAG_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
}) as Partial<RootState>;
|
||||
|
||||
it('should move subtask from old parent to new parent when parentId changes', () => {
|
||||
// Subtask is under Parent A, LWW update moves it to Parent B
|
||||
const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_B,
|
||||
title: 'Updated Subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task;
|
||||
|
||||
expect(parentA.subTaskIds).not.toContain(SUBTASK_ID);
|
||||
expect(parentB.subTaskIds).toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should add subtask to parent.subTaskIds when task becomes subtask', () => {
|
||||
// Task is top-level, LWW update makes it a subtask of Parent A
|
||||
const state = createStateWithParents(undefined, [], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_A,
|
||||
title: 'Now a subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
|
||||
expect(parentA.subTaskIds).toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should remove subtask from parent.subTaskIds when parentId is removed', () => {
|
||||
// Subtask is under Parent A, LWW update makes it a top-level task
|
||||
const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: null,
|
||||
title: 'Now a top-level task',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
|
||||
expect(parentA.subTaskIds).not.toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should not duplicate subtask in parent.subTaskIds if already present', () => {
|
||||
// Subtask already in Parent B's subTaskIds (edge case)
|
||||
const state = createStateWithParents(PARENT_A, [SUBTASK_ID], [SUBTASK_ID]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_B,
|
||||
title: 'Updated Subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task;
|
||||
|
||||
// Should still only have one instance
|
||||
expect(parentB.subTaskIds.filter((id) => id === SUBTASK_ID).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not update parent.subTaskIds when parentId does not change', () => {
|
||||
const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_A, // Same as existing
|
||||
title: 'Updated Title Only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task;
|
||||
|
||||
// Parent A should still have the subtask
|
||||
expect(parentA.subTaskIds).toContain(SUBTASK_ID);
|
||||
// Parent B should still be empty
|
||||
expect(parentB.subTaskIds).not.toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should handle parentId change when old parent does not exist', () => {
|
||||
// Old parent was deleted, new parent exists
|
||||
const state = {
|
||||
[TASK_FEATURE_NAME]: {
|
||||
ids: [SUBTASK_ID, PARENT_B],
|
||||
entities: {
|
||||
[SUBTASK_ID]: createMockTask({
|
||||
id: SUBTASK_ID,
|
||||
parentId: 'deleted-parent',
|
||||
subTaskIds: [],
|
||||
}),
|
||||
[PARENT_B]: createMockTask({
|
||||
id: PARENT_B,
|
||||
parentId: undefined,
|
||||
subTaskIds: [],
|
||||
}),
|
||||
},
|
||||
currentTaskId: null,
|
||||
selectedTaskId: null,
|
||||
taskDetailTargetPanel: null,
|
||||
isDataLoaded: true,
|
||||
lastCurrentTaskId: null,
|
||||
},
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
[TAG_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
} as Partial<RootState>;
|
||||
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_B,
|
||||
title: 'Updated Subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task;
|
||||
|
||||
expect(parentB.subTaskIds).toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should handle parentId change when new parent does not exist', () => {
|
||||
// New parent doesn't exist (edge case)
|
||||
const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: 'non-existent-parent',
|
||||
title: 'Updated Subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
|
||||
// Old parent should have subtask removed
|
||||
expect(parentA.subTaskIds).not.toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should preserve parent.subTaskIds order when adding subtask', () => {
|
||||
const existingSubtasks = ['sub-a', 'sub-b', 'sub-c'];
|
||||
const state = {
|
||||
[TASK_FEATURE_NAME]: {
|
||||
ids: [SUBTASK_ID, PARENT_A],
|
||||
entities: {
|
||||
[SUBTASK_ID]: createMockTask({
|
||||
id: SUBTASK_ID,
|
||||
parentId: undefined,
|
||||
subTaskIds: [],
|
||||
}),
|
||||
[PARENT_A]: createMockTask({
|
||||
id: PARENT_A,
|
||||
parentId: undefined,
|
||||
subTaskIds: existingSubtasks,
|
||||
}),
|
||||
},
|
||||
currentTaskId: null,
|
||||
selectedTaskId: null,
|
||||
taskDetailTargetPanel: null,
|
||||
isDataLoaded: true,
|
||||
lastCurrentTaskId: null,
|
||||
},
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
[TAG_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
} as Partial<RootState>;
|
||||
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_A,
|
||||
title: 'Becoming a subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
|
||||
// Original order should be preserved, new subtask appended at end
|
||||
expect(parentA.subTaskIds).toEqual([...existingSubtasks, SUBTASK_ID]);
|
||||
});
|
||||
|
||||
it('should preserve parent.subTaskIds order when removing subtask', () => {
|
||||
const state = {
|
||||
[TASK_FEATURE_NAME]: {
|
||||
ids: [SUBTASK_ID, PARENT_A],
|
||||
entities: {
|
||||
[SUBTASK_ID]: createMockTask({
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_A,
|
||||
subTaskIds: [],
|
||||
}),
|
||||
[PARENT_A]: createMockTask({
|
||||
id: PARENT_A,
|
||||
parentId: undefined,
|
||||
subTaskIds: ['first', SUBTASK_ID, 'last'],
|
||||
}),
|
||||
},
|
||||
currentTaskId: null,
|
||||
selectedTaskId: null,
|
||||
taskDetailTargetPanel: null,
|
||||
isDataLoaded: true,
|
||||
lastCurrentTaskId: null,
|
||||
},
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
[TAG_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
} as Partial<RootState>;
|
||||
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: null, // Becoming top-level
|
||||
title: 'No longer a subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
|
||||
// Order should be preserved after removal
|
||||
expect(parentA.subTaskIds).toEqual(['first', 'last']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import { Project } from '../../../features/project/project.model';
|
|||
import { TAG_FEATURE_NAME, tagAdapter } from '../../../features/tag/store/tag.reducer';
|
||||
import { Tag } from '../../../features/tag/tag.model';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import {
|
||||
TASK_FEATURE_NAME,
|
||||
taskAdapter,
|
||||
} from '../../../features/tasks/store/task.reducer';
|
||||
import { Task } from '../../../features/tasks/task.model';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
|
|
@ -240,6 +245,71 @@ const syncTodayTagTaskIds = (
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates parent task's subTaskIds arrays when a task's parentId changes via LWW Update.
|
||||
*
|
||||
* When LWW conflict resolution updates a task's parentId (making it a subtask or
|
||||
* moving it to a different parent), we must also update the corresponding parent
|
||||
* task's subTaskIds arrays to maintain bidirectional consistency:
|
||||
* - Remove task from old parent's subTaskIds (if it was a subtask)
|
||||
* - Add task to new parent's subTaskIds (if it becomes a subtask)
|
||||
*
|
||||
* This is necessary because the original moveToOtherProject or convertToSubtask
|
||||
* actions update both the task and parent entities atomically, but LWW Update
|
||||
* only syncs the TASK entity state.
|
||||
*/
|
||||
const syncParentSubTaskIds = (
|
||||
state: RootState,
|
||||
taskId: string,
|
||||
oldParentId: string | undefined,
|
||||
newParentId: string | undefined,
|
||||
): RootState => {
|
||||
// If parentId didn't change, nothing to do
|
||||
if (oldParentId === newParentId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let taskState = state[TASK_FEATURE_NAME];
|
||||
|
||||
// Remove from old parent's subTaskIds
|
||||
if (oldParentId && taskState.entities[oldParentId]) {
|
||||
const oldParent = taskState.entities[oldParentId] as Task;
|
||||
if (oldParent.subTaskIds.includes(taskId)) {
|
||||
taskState = taskAdapter.updateOne(
|
||||
{
|
||||
id: oldParentId,
|
||||
changes: {
|
||||
subTaskIds: oldParent.subTaskIds.filter((id) => id !== taskId),
|
||||
},
|
||||
},
|
||||
taskState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to new parent's subTaskIds
|
||||
if (newParentId && taskState.entities[newParentId]) {
|
||||
const newParent = taskState.entities[newParentId] as Task;
|
||||
// Only add if not already present
|
||||
if (!newParent.subTaskIds.includes(taskId)) {
|
||||
taskState = taskAdapter.updateOne(
|
||||
{
|
||||
id: newParentId,
|
||||
changes: {
|
||||
subTaskIds: unique([...newParent.subTaskIds, taskId]),
|
||||
},
|
||||
},
|
||||
taskState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[TASK_FEATURE_NAME]: taskState,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Meta-reducer that handles LWW (Last-Write-Wins) Update actions.
|
||||
*
|
||||
|
|
@ -377,6 +447,16 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
const oldDueDay = existingEntity?.dueDay as string | undefined;
|
||||
const newDueDay = entityData['dueDay'] as string | undefined;
|
||||
updatedState = syncTodayTagTaskIds(updatedState, entityId, oldDueDay, newDueDay);
|
||||
|
||||
// Sync parent.subTaskIds when parentId changes
|
||||
const oldParentId = existingEntity?.parentId as string | undefined;
|
||||
const newParentId = entityData['parentId'] as string | undefined;
|
||||
updatedState = syncParentSubTaskIds(
|
||||
updatedState,
|
||||
entityId,
|
||||
oldParentId,
|
||||
newParentId,
|
||||
);
|
||||
}
|
||||
|
||||
return reducer(updatedState, action);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue