mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(tasks): keep REST project moves atomic across replay (#9001)
* fix(tasks): make REST project moves atomic Capture affected subtasks in the persisted update action so project moves replay consistently, and repair stale project and section references during archive and restore. Fixes #8983 * fix(tasks): harden project move replay --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3ae2360345
commit
91a1f0eda9
19 changed files with 2717 additions and 174 deletions
|
|
@ -317,6 +317,8 @@ curl -X POST http://127.0.0.1:3876/tasks/task-id/archive
|
|||
|
||||
`parentId` and `subTaskIds` cannot be set via `PATCH` (returns 400 `UNSUPPORTED_FIELD`). Re-parenting an existing task is not supported by this API; delete and recreate the task instead.
|
||||
|
||||
Changing `projectId` on a top-level task moves the task and all of its subtasks atomically. The destination must be an existing active project; missing or archived destinations return 404 `PROJECT_NOT_FOUND`. A subtask's `projectId` is inherited from its parent and cannot be changed directly (400 `UNSUPPORTED_FIELD`); echoing its unchanged inherited value is accepted for GET-to-PATCH round trips. A project move can be combined with other writable fields in the same PATCH request.
|
||||
|
||||
---
|
||||
|
||||
### Task Control Endpoints
|
||||
|
|
@ -397,12 +399,14 @@ curl http://127.0.0.1:3876/tags
|
|||
|
||||
### Local REST API Error Codes
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
| ---------------- | ----------- | ------------------------------------------------------------------------------- |
|
||||
| `TASK_NOT_FOUND` | 404 | Task does not exist |
|
||||
| `INVALID_INPUT` | 400 | Invalid request body, or a field has the wrong value type (see `error.details`) |
|
||||
| `NOT_FOUND` | 404 | Route not found |
|
||||
| `INTERNAL_ERROR` | 500 | Internal server error |
|
||||
| Code | HTTP Status | Description |
|
||||
| ------------------- | ----------- | ------------------------------------------------------------------------------- |
|
||||
| `TASK_NOT_FOUND` | 404 | Task does not exist |
|
||||
| `PROJECT_NOT_FOUND` | 404 | Target project does not exist or is archived |
|
||||
| `INVALID_INPUT` | 400 | Invalid request body, or a field has the wrong value type (see `error.details`) |
|
||||
| `UNSUPPORTED_FIELD` | 400 | Field cannot be changed through this endpoint |
|
||||
| `NOT_FOUND` | 404 | Route not found |
|
||||
| `INTERNAL_ERROR` | 500 | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { LocalRestApiHandlerService } from './local-rest-api-handler.service';
|
|||
import { TaskService } from '../../features/tasks/task.service';
|
||||
import { TaskArchiveService } from '../../features/archive/task-archive.service';
|
||||
import { ProjectService } from '../../features/project/project.service';
|
||||
import { Project } from '../../features/project/project.model';
|
||||
import { TagService } from '../../features/tag/tag.service';
|
||||
import { TODAY_TAG } from '../../features/tag/tag.const';
|
||||
import { DateService } from '../date/date.service';
|
||||
|
|
@ -20,6 +21,7 @@ describe('LocalRestApiHandlerService', () => {
|
|||
let projectServiceMock: jasmine.SpyObj<ProjectService>;
|
||||
let tagServiceMock: jasmine.SpyObj<TagService>;
|
||||
let dateServiceMock: jasmine.SpyObj<DateService>;
|
||||
let activeProjects: Project[];
|
||||
let requestHandler: ((payload: LocalRestApiRequestPayload) => void) | null = null;
|
||||
let responsePromiseResolve: ((response: LocalRestApiResponsePayload) => void) | null =
|
||||
null;
|
||||
|
|
@ -100,6 +102,7 @@ describe('LocalRestApiHandlerService', () => {
|
|||
beforeEach(() => {
|
||||
requestHandler = null;
|
||||
responsePromiseResolve = null;
|
||||
activeProjects = [];
|
||||
|
||||
mockElectronApi();
|
||||
|
||||
|
|
@ -142,6 +145,9 @@ describe('LocalRestApiHandlerService', () => {
|
|||
list$: of([]),
|
||||
},
|
||||
);
|
||||
Object.defineProperty(projectServiceMock, 'list', {
|
||||
value: (() => activeProjects) as ProjectService['list'],
|
||||
});
|
||||
|
||||
tagServiceMock = jasmine.createSpyObj(
|
||||
'TagService',
|
||||
|
|
@ -831,6 +837,227 @@ describe('LocalRestApiHandlerService', () => {
|
|||
isDone: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update projectId together with other fields in one dispatch', async () => {
|
||||
let mockTask = createMockTask('task-1', { projectId: 'project-1' });
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
taskServiceMock.update.and.callFake((id, changes) => {
|
||||
if (id === mockTask.id) {
|
||||
mockTask = { ...mockTask, ...changes };
|
||||
}
|
||||
});
|
||||
activeProjects = [{ id: 'project-2' } as Project];
|
||||
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/task-1', {
|
||||
body: { projectId: 'project-2', title: 'Moved task' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.body.ok).toBe(true);
|
||||
expect(taskServiceMock.update).toHaveBeenCalledOnceWith('task-1', {
|
||||
projectId: 'project-2',
|
||||
title: 'Moved task',
|
||||
});
|
||||
if (!response.body.ok) {
|
||||
throw new Error('Expected a success response');
|
||||
}
|
||||
expect(response.body.data).toEqual(
|
||||
jasmine.objectContaining({
|
||||
projectId: 'project-2',
|
||||
title: 'Moved task',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject projectId changes for subtasks', async () => {
|
||||
const mockTask = createMockTask('subtask-1', {
|
||||
parentId: 'parent-1',
|
||||
projectId: 'project-1',
|
||||
});
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/subtask-1', {
|
||||
body: { projectId: 'project-2' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.ok).toBe(false);
|
||||
if (response.body.ok) {
|
||||
throw new Error('Expected an error response');
|
||||
}
|
||||
expect(response.body.error.code).toBe('UNSUPPORTED_FIELD');
|
||||
expect(taskServiceMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow an unchanged inherited projectId on subtasks', async () => {
|
||||
let mockTask = createMockTask('subtask-1', {
|
||||
parentId: 'parent-1',
|
||||
projectId: 'project-1',
|
||||
});
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
taskServiceMock.update.and.callFake((id, changes) => {
|
||||
if (id === mockTask.id) {
|
||||
mockTask = { ...mockTask, ...changes };
|
||||
}
|
||||
});
|
||||
activeProjects = [{ id: 'project-1' } as Project];
|
||||
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/subtask-1', {
|
||||
body: { projectId: 'project-1', title: 'Round-tripped subtask' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.body.ok).toBe(true);
|
||||
expect(taskServiceMock.update).toHaveBeenCalledOnceWith('subtask-1', {
|
||||
projectId: 'project-1',
|
||||
title: 'Round-tripped subtask',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow an unchanged archived projectId in a task round trip', async () => {
|
||||
const mockTask = createMockTask('task-1', {
|
||||
projectId: 'archived-project',
|
||||
});
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/task-1', {
|
||||
body: {
|
||||
projectId: 'archived-project',
|
||||
title: 'Round-tripped task',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.body.ok).toBe(true);
|
||||
expect(taskServiceMock.update).toHaveBeenCalledOnceWith('task-1', {
|
||||
projectId: 'archived-project',
|
||||
title: 'Round-tripped task',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject an unknown destination project', async () => {
|
||||
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/task-1', {
|
||||
body: { projectId: 'missing-project' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.ok).toBe(false);
|
||||
if (response.body.ok) {
|
||||
throw new Error('Expected an error response');
|
||||
}
|
||||
expect(response.body.error.code).toBe('PROJECT_NOT_FOUND');
|
||||
expect(taskServiceMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an archived destination project', async () => {
|
||||
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
activeProjects = [
|
||||
{
|
||||
id: 'archived-project',
|
||||
isArchived: true,
|
||||
} as Project,
|
||||
];
|
||||
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/task-1', {
|
||||
body: { projectId: 'archived-project' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.ok).toBe(false);
|
||||
if (response.body.ok) {
|
||||
throw new Error('Expected an error response');
|
||||
}
|
||||
expect(response.body.error.code).toBe('PROJECT_NOT_FOUND');
|
||||
expect(taskServiceMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject prototype-property names as missing project ids', async () => {
|
||||
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/task-1', {
|
||||
body: { projectId: 'constructor' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.ok).toBe(false);
|
||||
if (response.body.ok) {
|
||||
throw new Error('Expected an error response');
|
||||
}
|
||||
expect(response.body.error.code).toBe('PROJECT_NOT_FOUND');
|
||||
expect(taskServiceMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject prototype-property names as missing task ids', async () => {
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(Object.prototype as Task),
|
||||
});
|
||||
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/constructor', {
|
||||
body: { title: 'Ignored' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.ok).toBe(false);
|
||||
if (response.body.ok) {
|
||||
throw new Error('Expected an error response');
|
||||
}
|
||||
expect(response.body.error.code).toBe('TASK_NOT_FOUND');
|
||||
expect(taskServiceMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject empty or whitespace-only projectIds', async () => {
|
||||
const mockTask = createMockTask('task-1', { projectId: 'project-1' });
|
||||
Object.defineProperty(taskServiceMock, 'getByIdOnce$', {
|
||||
get: () => (_id: string) => of(mockTask),
|
||||
});
|
||||
|
||||
for (const projectId of ['', ' ']) {
|
||||
const response = await sendRequestAndWait(
|
||||
createRequest('PATCH', '/tasks/task-1', {
|
||||
body: { projectId },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.ok).toBe(false);
|
||||
if (response.body.ok) {
|
||||
throw new Error('Expected an error response');
|
||||
}
|
||||
expect(response.body.error.code).toBe('INVALID_INPUT');
|
||||
}
|
||||
expect(taskServiceMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /tasks/:id', () => {
|
||||
|
|
|
|||
|
|
@ -512,6 +512,46 @@ export class LocalRestApiHandlerService {
|
|||
return createErrorResponse(requestId, 404, 'TASK_NOT_FOUND', 'Task not found');
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'projectId')) {
|
||||
const targetProjectId = changes.projectId;
|
||||
if (typeof targetProjectId !== 'string' || !targetProjectId.trim()) {
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
400,
|
||||
'INVALID_INPUT',
|
||||
'projectId must be a non-empty string',
|
||||
);
|
||||
}
|
||||
const isProjectChange = targetProjectId !== task.projectId;
|
||||
// Echoing back the unchanged projectId is allowed on subtasks so
|
||||
// GET→PATCH round-trips don't fail; only actual changes are rejected.
|
||||
if (task.parentId && isProjectChange) {
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
400,
|
||||
'UNSUPPORTED_FIELD',
|
||||
'projectId cannot be changed directly on a subtask — move its parent task instead',
|
||||
);
|
||||
}
|
||||
|
||||
if (isProjectChange) {
|
||||
// list() only contains unarchived projects, and matching by iteration
|
||||
// (not entity-map lookup) keeps prototype-property names like
|
||||
// 'constructor' from resolving to a truthy non-project.
|
||||
const targetProject = this._projectService
|
||||
.list()
|
||||
.find((project) => project.id === targetProjectId && !project.isArchived);
|
||||
if (!targetProject) {
|
||||
return createErrorResponse(
|
||||
requestId,
|
||||
404,
|
||||
'PROJECT_NOT_FOUND',
|
||||
'Destination project not found or archived',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._taskService.update(taskId, changes);
|
||||
return createSuccessResponse(requestId, 200, await this._getTaskById(taskId));
|
||||
}
|
||||
|
|
@ -624,16 +664,17 @@ export class LocalRestApiHandlerService {
|
|||
return createSuccessResponse(requestId, 200, tags);
|
||||
}
|
||||
|
||||
// The id equality checks reject prototype-property names ('constructor',
|
||||
// 'toString', …) that entity-map lookups resolve to truthy non-tasks.
|
||||
private async _getTaskById(taskId: string): Promise<Task | undefined> {
|
||||
return (await firstValueFrom(this._taskService.getByIdOnce$(taskId))) || undefined;
|
||||
const task = await firstValueFrom(this._taskService.getByIdOnce$(taskId));
|
||||
return task?.id === taskId ? task : undefined;
|
||||
}
|
||||
|
||||
private async _getTaskWithSubTasksById(
|
||||
taskId: string,
|
||||
): Promise<TaskWithSubTasks | undefined> {
|
||||
return (
|
||||
(await firstValueFrom(this._taskService.getByIdWithSubTaskData$(taskId))) ||
|
||||
undefined
|
||||
);
|
||||
const task = await firstValueFrom(this._taskService.getByIdWithSubTaskData$(taskId));
|
||||
return task?.id === taskId ? task : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -399,6 +399,47 @@ describe('TaskService', () => {
|
|||
|
||||
expect(flushOneSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should capture linked and reverse-linked subtasks for project moves', () => {
|
||||
const parent = createMockTask('parent', { subTaskIds: ['listed-subtask'] });
|
||||
const listedSubTask = createMockTask('listed-subtask', {
|
||||
parentId: 'parent',
|
||||
});
|
||||
const reverseLinkedSubTask = createMockTask('reverse-linked-subtask', {
|
||||
parentId: 'parent',
|
||||
});
|
||||
store.setState({
|
||||
tasks: {
|
||||
ids: ['parent', 'listed-subtask', 'reverse-linked-subtask'],
|
||||
entities: {
|
||||
parent,
|
||||
['listed-subtask']: listedSubTask,
|
||||
['reverse-linked-subtask']: reverseLinkedSubTask,
|
||||
},
|
||||
currentTaskId: null,
|
||||
selectedTaskId: null,
|
||||
taskDetailTargetPanel: null,
|
||||
isDataLoaded: true,
|
||||
},
|
||||
});
|
||||
store.refreshState();
|
||||
|
||||
service.update('parent', { projectId: 'project-2', title: 'Moved' });
|
||||
|
||||
const expectedAction = TaskSharedActions.updateTask({
|
||||
task: {
|
||||
id: 'parent',
|
||||
changes: { projectId: 'project-2', title: 'Moved' },
|
||||
},
|
||||
projectMoveSubTaskIds: ['listed-subtask', 'reverse-linked-subtask'],
|
||||
});
|
||||
expect(store.dispatch).toHaveBeenCalledWith(expectedAction);
|
||||
expect(expectedAction.meta.entityIds).toEqual([
|
||||
'parent',
|
||||
'listed-subtask',
|
||||
'reverse-linked-subtask',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTags', () => {
|
||||
|
|
|
|||
|
|
@ -511,9 +511,28 @@ export class TaskService {
|
|||
if (Object.prototype.hasOwnProperty.call(changedFields, 'timeSpentOnDay')) {
|
||||
this._taskTimeSync.flushOne(id);
|
||||
}
|
||||
|
||||
const entities = this._taskEntities();
|
||||
const task = entities[id];
|
||||
const projectMoveSubTaskIds =
|
||||
Object.prototype.hasOwnProperty.call(changedFields, 'projectId') &&
|
||||
task &&
|
||||
!task.parentId
|
||||
? unique([
|
||||
...task.subTaskIds,
|
||||
...Object.values(entities)
|
||||
.filter(
|
||||
(candidate): candidate is Task =>
|
||||
!!candidate && candidate.parentId === id,
|
||||
)
|
||||
.map((subTask) => subTask.id),
|
||||
])
|
||||
: undefined;
|
||||
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.updateTask({
|
||||
task: { id, changes: changedFields },
|
||||
...(projectMoveSubTaskIds !== undefined && { projectMoveSubTaskIds }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4182,6 +4182,33 @@ describe('ConflictResolutionService', () => {
|
|||
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-upd']);
|
||||
});
|
||||
|
||||
it('should preserve the local winner operation footprint in its LWW op', async () => {
|
||||
const now = Date.now();
|
||||
const localOp = createOpWithTimestamp('local-upd', 'client-a', now);
|
||||
localOp.actionType = ActionType.TASK_SHARED_UPDATE;
|
||||
localOp.entityIds = ['task-1', 'subtask-1'];
|
||||
localOp.payload = {
|
||||
actionPayload: {
|
||||
task: { id: 'task-1', changes: { projectId: 'project-2' } },
|
||||
projectMoveSubTaskIds: ['subtask-1'],
|
||||
},
|
||||
entityChanges: [],
|
||||
};
|
||||
mockStore.select.and.returnValue(
|
||||
of({ id: 'task-1', title: 'Local winner', projectId: 'project-2' }),
|
||||
);
|
||||
|
||||
const lwwOp = await (service as any)._createLocalWinUpdateOp(
|
||||
createConflict(
|
||||
'task-1',
|
||||
[localOp],
|
||||
[createOpWithTimestamp('remote-upd', 'client-b', now - 1000)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(lwwOp.entityIds).toEqual(['task-1', 'subtask-1']);
|
||||
});
|
||||
|
||||
it('should not create local-win op when clientId is unavailable', async () => {
|
||||
const now = Date.now();
|
||||
mockClientIdProvider.loadClientId.and.returnValue(Promise.resolve(null));
|
||||
|
|
@ -6037,6 +6064,21 @@ describe('ConflictResolutionService', () => {
|
|||
expect(extractActionPayload(op.payload)['id']).toBe('task-canonical');
|
||||
});
|
||||
|
||||
it('should preserve and normalize an explicit operation footprint', () => {
|
||||
const op = service.createLWWUpdateOp(
|
||||
'TASK',
|
||||
'task-canonical',
|
||||
{ title: 'Local winner' },
|
||||
TEST_CLIENT_ID,
|
||||
{ [TEST_CLIENT_ID]: 1 },
|
||||
Date.now(),
|
||||
'replace',
|
||||
['subtask-1', 'task-canonical', 'subtask-1'],
|
||||
);
|
||||
|
||||
expect(op.entityIds).toEqual(['task-canonical', 'subtask-1']);
|
||||
});
|
||||
|
||||
it('should ensure _convertToLWWUpdatesIfNeeded merged payload has id even when base entity lacks id', () => {
|
||||
// Edge case: a malformed local DELETE payload whose embedded entity
|
||||
// somehow lacks an id (e.g. corruption). The merged LWW Update payload
|
||||
|
|
|
|||
|
|
@ -114,6 +114,70 @@ interface AutoResolveConflictsLwwOptions {
|
|||
remoteApplyLifecycleOwnedByCaller?: boolean;
|
||||
}
|
||||
|
||||
const getTaskProjectMoveEntityIds = (operation: Operation): string[] | undefined => {
|
||||
if (
|
||||
operation.actionType === toLwwUpdateActionType('TASK') &&
|
||||
operation.entityId &&
|
||||
Array.isArray(operation.entityIds)
|
||||
) {
|
||||
return Array.from(new Set([operation.entityId, ...operation.entityIds]));
|
||||
}
|
||||
|
||||
if (
|
||||
operation.actionType !== ActionType.TASK_SHARED_UPDATE ||
|
||||
!operation.entityId ||
|
||||
!operation.payload ||
|
||||
typeof operation.payload !== 'object'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = operation.payload as Record<string, unknown>;
|
||||
const actionPayload =
|
||||
payload['actionPayload'] && typeof payload['actionPayload'] === 'object'
|
||||
? (payload['actionPayload'] as Record<string, unknown>)
|
||||
: payload;
|
||||
const subTaskIds = actionPayload['projectMoveSubTaskIds'];
|
||||
if (!Array.isArray(subTaskIds)) return undefined;
|
||||
|
||||
return Array.from(
|
||||
new Set([
|
||||
operation.entityId,
|
||||
...subTaskIds.filter((id): id is string => typeof id === 'string'),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
export const getLatestTaskProjectMoveEntityIds = (
|
||||
operations: Operation[],
|
||||
): string[] | undefined => {
|
||||
let latest: { operation: Operation; entityIds: string[] } | undefined;
|
||||
for (const operation of operations) {
|
||||
const entityIds = getTaskProjectMoveEntityIds(operation);
|
||||
if (!entityIds) continue;
|
||||
if (
|
||||
!latest ||
|
||||
operation.timestamp > latest.operation.timestamp ||
|
||||
(operation.timestamp === latest.operation.timestamp &&
|
||||
operation.id > latest.operation.id)
|
||||
) {
|
||||
latest = { operation, entityIds };
|
||||
}
|
||||
}
|
||||
|
||||
return latest?.entityIds;
|
||||
};
|
||||
|
||||
const latestProjectMoveEntityIds = (
|
||||
entityId: string,
|
||||
operations: Operation[],
|
||||
): string[] | undefined => {
|
||||
const projectMoveEntityIds = getLatestTaskProjectMoveEntityIds(operations);
|
||||
if (!projectMoveEntityIds) return undefined;
|
||||
|
||||
return Array.from(new Set([entityId, ...projectMoveEntityIds]));
|
||||
};
|
||||
|
||||
const markLwwDeleteRecreation = (op: Operation): Operation =>
|
||||
isLwwUpdatePayload(op.payload)
|
||||
? {
|
||||
|
|
@ -262,6 +326,7 @@ export class ConflictResolutionService {
|
|||
* @param clientId - Client creating this operation
|
||||
* @param vectorClock - Merged vector clock (should dominate all conflicting ops)
|
||||
* @param timestamp - Preserved timestamp for correct LWW semantics
|
||||
* @param entityIds - Captured task-project-move footprint, when applicable
|
||||
* @returns New UPDATE operation ready for upload
|
||||
*/
|
||||
createLWWUpdateOp(
|
||||
|
|
@ -272,6 +337,7 @@ export class ConflictResolutionService {
|
|||
vectorClock: VectorClock,
|
||||
timestamp: number,
|
||||
lwwUpdateMode: LwwUpdateMode = 'replace',
|
||||
entityIds?: string[],
|
||||
): Operation {
|
||||
// NOTE: LWW Update action types (e.g., '[TASK] LWW Update') are intentionally
|
||||
// NOT in the ActionType enum. They are dynamically constructed here and matched
|
||||
|
|
@ -303,6 +369,9 @@ export class ConflictResolutionService {
|
|||
opType: OpType.Update,
|
||||
entityType,
|
||||
entityId,
|
||||
...(entityIds !== undefined && {
|
||||
entityIds: Array.from(new Set([entityId, ...entityIds])),
|
||||
}),
|
||||
payload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
|
|
@ -1914,6 +1983,10 @@ export class ConflictResolutionService {
|
|||
newClock,
|
||||
mergedTimestamp,
|
||||
'patch',
|
||||
latestProjectMoveEntityIds(conflict.entityId, [
|
||||
...conflict.localOps,
|
||||
...conflict.remoteOps,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2026,6 +2099,8 @@ export class ConflictResolutionService {
|
|||
clientId,
|
||||
newClock,
|
||||
preservedTimestamp,
|
||||
'replace',
|
||||
latestProjectMoveEntityIds(conflict.entityId, conflict.localOps),
|
||||
);
|
||||
return conflict.remoteOps.some((op) => op.opType === OpType.Delete)
|
||||
? markLwwDeleteRecreation(localWinOp)
|
||||
|
|
|
|||
|
|
@ -91,12 +91,15 @@ describe('SupersededOperationResolverService', () => {
|
|||
clientId: string,
|
||||
vectorClock: VectorClock,
|
||||
timestamp: number,
|
||||
_lwwUpdateMode?: 'replace' | 'patch',
|
||||
entityIds?: string[],
|
||||
) => ({
|
||||
id: 'generated-id-' + Math.random().toString(36).substring(7),
|
||||
actionType: `[${entityType}] LWW Update` as ActionType,
|
||||
opType: OpType.Update,
|
||||
entityType,
|
||||
entityId,
|
||||
entityIds,
|
||||
payload: entityState,
|
||||
clientId,
|
||||
vectorClock,
|
||||
|
|
@ -220,6 +223,15 @@ describe('SupersededOperationResolverService', () => {
|
|||
{ clientA: 5 },
|
||||
1000,
|
||||
);
|
||||
supersededOp.actionType = ActionType.TASK_SHARED_UPDATE;
|
||||
supersededOp.entityIds = ['task-1', 'subtask-1'];
|
||||
supersededOp.payload = {
|
||||
actionPayload: {
|
||||
task: { id: 'task-1', changes: { projectId: 'project-2' } },
|
||||
projectMoveSubTaskIds: ['subtask-1'],
|
||||
},
|
||||
entityChanges: [],
|
||||
};
|
||||
const entityState = { id: 'task-1', title: 'Test Task' };
|
||||
|
||||
mockVectorClockService.getCurrentVectorClock.and.returnValue(
|
||||
|
|
@ -243,11 +255,55 @@ describe('SupersededOperationResolverService', () => {
|
|||
expect(appendedOp.opType).toBe(OpType.Update);
|
||||
expect(appendedOp.entityType).toBe('TASK');
|
||||
expect(appendedOp.entityId).toBe('task-1');
|
||||
expect(appendedOp.entityIds).toEqual(['task-1', 'subtask-1']);
|
||||
expect(appendedOp.payload).toEqual(entityState);
|
||||
expect(appendedOp.clientId).toBe(TEST_CLIENT_ID);
|
||||
expect(appendedOp.timestamp).toBe(1000); // Preserved from original
|
||||
});
|
||||
|
||||
it('should not reuse a generic multi-task footprint for an LWW update', async () => {
|
||||
const supersededOp = createMockOperation(
|
||||
'op-1',
|
||||
'TASK',
|
||||
'task-1',
|
||||
{ clientA: 5 },
|
||||
1000,
|
||||
);
|
||||
supersededOp.entityIds = ['task-1', 'unrelated-task'];
|
||||
mockConflictResolutionService.getCurrentEntityState.and.resolveTo({
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
});
|
||||
|
||||
await service.resolveSupersededLocalOps([{ opId: 'op-1', op: supersededOp }]);
|
||||
|
||||
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
|
||||
.args[0] as Operation;
|
||||
expect(appendedOp.entityIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve a project-move footprint through another LWW replacement', async () => {
|
||||
const supersededOp = createMockOperation(
|
||||
'op-1',
|
||||
'TASK',
|
||||
'task-1',
|
||||
{ clientA: 5 },
|
||||
1000,
|
||||
);
|
||||
supersededOp.actionType = '[TASK] LWW Update' as ActionType;
|
||||
supersededOp.entityIds = ['task-1', 'subtask-1'];
|
||||
mockConflictResolutionService.getCurrentEntityState.and.resolveTo({
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
});
|
||||
|
||||
await service.resolveSupersededLocalOps([{ opId: 'op-1', op: supersededOp }]);
|
||||
|
||||
const appendedOp = mockOpLogStore.appendWithVectorClockUpdate.calls.first()
|
||||
.args[0] as Operation;
|
||||
expect(appendedOp.entityIds).toEqual(['task-1', 'subtask-1']);
|
||||
});
|
||||
|
||||
it('should create single merged op for multiple superseded ops on same entity', async () => {
|
||||
const supersededOp1 = createMockOperation(
|
||||
'op-1',
|
||||
|
|
@ -270,6 +326,20 @@ describe('SupersededOperationResolverService', () => {
|
|||
{ clientA: 5 },
|
||||
1500,
|
||||
);
|
||||
supersededOp1.actionType = ActionType.TASK_SHARED_UPDATE;
|
||||
supersededOp1.payload = {
|
||||
actionPayload: {
|
||||
projectMoveSubTaskIds: ['former-subtask'],
|
||||
},
|
||||
entityChanges: [],
|
||||
};
|
||||
supersededOp2.actionType = ActionType.TASK_SHARED_UPDATE;
|
||||
supersededOp2.payload = {
|
||||
actionPayload: {
|
||||
projectMoveSubTaskIds: ['current-subtask'],
|
||||
},
|
||||
entityChanges: [],
|
||||
};
|
||||
const entityState = { id: 'task-1', title: 'Latest State' };
|
||||
|
||||
mockVectorClockService.getCurrentVectorClock.and.returnValue(
|
||||
|
|
@ -293,6 +363,7 @@ describe('SupersededOperationResolverService', () => {
|
|||
.args[0] as Operation;
|
||||
// Timestamp should be max of all superseded ops (2000)
|
||||
expect(appendedOp.timestamp).toBe(2000);
|
||||
expect(appendedOp.entityIds).toEqual(['task-1', 'current-subtask']);
|
||||
});
|
||||
|
||||
it('should create separate ops for different entities', async () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { OperationLogStoreService } from '../persistence/operation-log-store.ser
|
|||
import { ActionType, Operation, OpType, VectorClock } from '../core/operation.types';
|
||||
import { mergeVectorClocks } from '../../core/util/vector-clock';
|
||||
import { OpLog } from '../../core/log';
|
||||
import { ConflictResolutionService } from './conflict-resolution.service';
|
||||
import {
|
||||
ConflictResolutionService,
|
||||
getLatestTaskProjectMoveEntityIds,
|
||||
} from './conflict-resolution.service';
|
||||
import { VectorClockService } from './vector-clock.service';
|
||||
import { LockService } from './lock.service';
|
||||
import { toEntityKey } from '../util/entity-key.util';
|
||||
|
|
@ -245,6 +248,12 @@ export class SupersededOperationResolverService {
|
|||
// would have a later timestamp than the original user action, causing it to
|
||||
// incorrectly win against concurrent ops that were actually made earlier.
|
||||
const preservedTimestamp = Math.max(...entityOps.map((e) => e.op.timestamp));
|
||||
const projectMoveEntityIds = getLatestTaskProjectMoveEntityIds(
|
||||
entityOps.map(({ op }) => op),
|
||||
);
|
||||
const declaredEntityIds = projectMoveEntityIds
|
||||
? Array.from(new Set([entityId, ...projectMoveEntityIds]))
|
||||
: undefined;
|
||||
|
||||
// Create new UPDATE op with current state and merged clock
|
||||
const newOp = this.conflictResolutionService.createLWWUpdateOp(
|
||||
|
|
@ -254,6 +263,8 @@ export class SupersededOperationResolverService {
|
|||
clientId,
|
||||
mergedClock,
|
||||
preservedTimestamp,
|
||||
'replace',
|
||||
declaredEntityIds,
|
||||
);
|
||||
|
||||
newOpsCreated.push(newOp);
|
||||
|
|
|
|||
|
|
@ -1602,6 +1602,326 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(projectB.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
for (const invalidTarget of [
|
||||
{ label: 'missing', id: 'missing-project', isArchived: false },
|
||||
{ label: 'prototype-like', id: '__proto__', isArchived: false },
|
||||
]) {
|
||||
it(`should retain the current project for a ${invalidTarget.label} target`, () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
if (invalidTarget.isArchived) {
|
||||
state[PROJECT_FEATURE_NAME]!.entities[PROJECT_B] = createMockProject({
|
||||
id: PROJECT_B,
|
||||
title: 'Archived Project',
|
||||
taskIds: [],
|
||||
isArchived: true,
|
||||
});
|
||||
}
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: invalidTarget.id,
|
||||
title: 'Keep this title',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
expect(() => reducer(state, action)).not.toThrow();
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]).toEqual(
|
||||
jasmine.objectContaining({
|
||||
projectId: PROJECT_A,
|
||||
title: 'Keep this title',
|
||||
}),
|
||||
);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual([
|
||||
TASK_ID,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
it('should replay a move to an archived-but-existing project', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
state[PROJECT_FEATURE_NAME]!.entities[PROJECT_B] = createMockProject({
|
||||
id: PROJECT_B,
|
||||
title: 'Archived Project',
|
||||
taskIds: [],
|
||||
isArchived: true,
|
||||
});
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: PROJECT_B,
|
||||
title: 'Archived destination',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]?.projectId).toBe(
|
||||
PROJECT_B,
|
||||
);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual([
|
||||
TASK_ID,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should move state-only subtasks with their parent when projectId changes', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
state[TASK_FEATURE_NAME] = {
|
||||
...state[TASK_FEATURE_NAME]!,
|
||||
ids: [TASK_ID, 'subtask1'],
|
||||
entities: {
|
||||
...state[TASK_FEATURE_NAME]!.entities,
|
||||
[TASK_ID]: createMockTask({
|
||||
projectId: PROJECT_A,
|
||||
subTaskIds: [],
|
||||
}),
|
||||
subtask1: createMockTask({
|
||||
id: 'subtask1',
|
||||
parentId: TASK_ID,
|
||||
projectId: PROJECT_A,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: PROJECT_B,
|
||||
title: 'Updated Task',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities.subtask1?.projectId).toBe(
|
||||
PROJECT_B,
|
||||
);
|
||||
});
|
||||
|
||||
it('should replay only the LWW operation footprint on divergent state', () => {
|
||||
const state = createStateWithProjects(
|
||||
PROJECT_A,
|
||||
[TASK_ID, 'captured-child', 'receiver-child'],
|
||||
[],
|
||||
);
|
||||
state[TASK_FEATURE_NAME] = {
|
||||
...state[TASK_FEATURE_NAME]!,
|
||||
ids: [TASK_ID, 'captured-child', 'receiver-child'],
|
||||
entities: {
|
||||
[TASK_ID]: createMockTask({
|
||||
projectId: PROJECT_A,
|
||||
subTaskIds: [],
|
||||
}),
|
||||
['captured-child']: createMockTask({
|
||||
id: 'captured-child',
|
||||
parentId: TASK_ID,
|
||||
projectId: PROJECT_A,
|
||||
}),
|
||||
['receiver-child']: createMockTask({
|
||||
id: 'receiver-child',
|
||||
parentId: TASK_ID,
|
||||
projectId: PROJECT_A,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: PROJECT_B,
|
||||
title: 'Moved task',
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: TASK_ID,
|
||||
entityIds: [TASK_ID, 'captured-child'],
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities['captured-child']?.projectId).toBe(
|
||||
PROJECT_B,
|
||||
);
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities['receiver-child']?.projectId).toBe(
|
||||
PROJECT_A,
|
||||
);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual([
|
||||
'receiver-child',
|
||||
]);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual([
|
||||
TASK_ID,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should repair stale project references without changing target backlog placement', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [], [TASK_ID]);
|
||||
state[PROJECT_FEATURE_NAME]!.entities[PROJECT_A] = createMockProject({
|
||||
id: PROJECT_A,
|
||||
taskIds: [],
|
||||
backlogTaskIds: [TASK_ID],
|
||||
});
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: PROJECT_A,
|
||||
title: 'Same project',
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: TASK_ID,
|
||||
entityIds: [TASK_ID],
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.backlogTaskIds,
|
||||
).toEqual([TASK_ID]);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
for (const unsafeTaskId of ['constructor', '__proto__']) {
|
||||
it(`should reject prototype-like task id ${unsafeTaskId}`, () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: unsafeTaskId,
|
||||
projectId: PROJECT_B,
|
||||
title: 'Unsafe task',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: unsafeTaskId },
|
||||
};
|
||||
spyOn(OpLog, 'warn');
|
||||
|
||||
expect(() => reducer(state, action)).not.toThrow();
|
||||
expect(mockReducer).toHaveBeenCalledWith(state, action);
|
||||
});
|
||||
}
|
||||
|
||||
it('should reject a prototype-like parent task id', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
parentId: 'constructor',
|
||||
projectId: PROJECT_A,
|
||||
title: 'Unsafe parent',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
expect(() => reducer(state, action)).not.toThrow();
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]?.parentId).toBeFalsy();
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual([
|
||||
TASK_ID,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve an empty project id as a valid no-project assignment', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: '',
|
||||
title: 'No project',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities[TASK_ID]?.projectId).toBe('');
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep a directly updated subtask in its parent project', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
state[TASK_FEATURE_NAME] = {
|
||||
...state[TASK_FEATURE_NAME]!,
|
||||
ids: [TASK_ID, 'subtask1'],
|
||||
entities: {
|
||||
...state[TASK_FEATURE_NAME]!.entities,
|
||||
[TASK_ID]: createMockTask({
|
||||
projectId: PROJECT_A,
|
||||
subTaskIds: ['subtask1'],
|
||||
}),
|
||||
subtask1: createMockTask({
|
||||
id: 'subtask1',
|
||||
parentId: TASK_ID,
|
||||
projectId: PROJECT_A,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'subtask1',
|
||||
parentId: TASK_ID,
|
||||
projectId: PROJECT_B,
|
||||
title: 'Updated Subtask',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: 'subtask1' },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities.subtask1?.projectId).toBe(
|
||||
PROJECT_A,
|
||||
);
|
||||
});
|
||||
|
||||
it('should move reverse-linked subtasks when recreating their parent', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, ['subtask1'], []);
|
||||
state[TASK_FEATURE_NAME] = {
|
||||
...state[TASK_FEATURE_NAME]!,
|
||||
ids: ['subtask1'],
|
||||
entities: {
|
||||
subtask1: createMockTask({
|
||||
id: 'subtask1',
|
||||
parentId: TASK_ID,
|
||||
projectId: PROJECT_A,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
projectId: PROJECT_B,
|
||||
title: 'Recreated Parent',
|
||||
tagIds: [],
|
||||
subTaskIds: [],
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
spyOn(OpLog, 'log');
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
expect(updatedState[TASK_FEATURE_NAME]?.entities.subtask1?.projectId).toBe(
|
||||
PROJECT_B,
|
||||
);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A]?.taskIds).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_B]?.taskIds).toEqual([
|
||||
TASK_ID,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove task from old project backlogTaskIds when projectId changes', () => {
|
||||
// Task is in Project A's backlog, LWW update moves it to Project B
|
||||
const state = {
|
||||
|
|
@ -1950,25 +2270,24 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(tagB.taskIds.filter((id) => id === TASK_ID).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle tag that does not exist gracefully', () => {
|
||||
// Task references a non-existent tag
|
||||
const state = createStateWithTags([TAG_A], [TASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
tagIds: [TAG_A, 'non-existent-tag'],
|
||||
title: 'Updated Task',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
for (const missingTagId of ['non-existent-tag', 'constructor', '__proto__']) {
|
||||
it(`should handle missing or unsafe tag id ${missingTagId} gracefully`, () => {
|
||||
const state = createStateWithTags([TAG_A], [TASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
tagIds: [TAG_A, missingTagId],
|
||||
title: 'Updated Task',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
reducer(state, action);
|
||||
expect(() => reducer(state, action)).not.toThrow();
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const tagA = updatedState[TAG_FEATURE_NAME]?.entities[TAG_A] as Tag;
|
||||
|
||||
expect(tagA.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const tagA = updatedState[TAG_FEATURE_NAME]?.entities[TAG_A] as Tag;
|
||||
expect(tagA.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
}
|
||||
|
||||
it('should not update tags when tagIds does not change', () => {
|
||||
const state = createStateWithTags([TAG_A, TAG_B], [TASK_ID], [TASK_ID]);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { filterTaskIdArraysFromTagOrProjectPayload } from '../../../op-log/apply
|
|||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
|
||||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
import { getProjectOrUndefined, repairTaskProjectForLww } from './task-shared-helpers';
|
||||
import { withLocalOnlySyncSettings } from '../../../features/config/local-only-sync-settings.util';
|
||||
import { SyncConfig } from '../../../features/config/global-config.model';
|
||||
import { LwwUpdateMode } from '../../../op-log/core/operation.types';
|
||||
|
|
@ -58,9 +59,16 @@ const syncProjectTaskIds = (
|
|||
const shouldAddToNewProject =
|
||||
!!newProjectId && !newIsSubTask && (oldProjectId !== newProjectId || oldIsSubTask);
|
||||
|
||||
// Remove from old project's taskIds
|
||||
if (shouldRemoveFromOldProject && oldProjectId && projectState.entities[oldProjectId]) {
|
||||
const oldProject = projectState.entities[oldProjectId] as Project;
|
||||
// Remove from old project's taskIds. The id equality check rejects
|
||||
// inherited Object.prototype members that a bare entities[id] lookup
|
||||
// returns truthy for when the id originates from a remote op.
|
||||
const oldProjectCandidate = oldProjectId
|
||||
? (projectState.entities[oldProjectId] as Project | undefined)
|
||||
: undefined;
|
||||
const oldProject =
|
||||
oldProjectCandidate?.id === oldProjectId ? oldProjectCandidate : undefined;
|
||||
|
||||
if (shouldRemoveFromOldProject && oldProjectId && oldProject) {
|
||||
const filteredTaskIds = oldProject.taskIds.filter((id) => id !== taskId);
|
||||
const filteredBacklogTaskIds = oldProject.backlogTaskIds.filter(
|
||||
(id) => id !== taskId,
|
||||
|
|
@ -89,9 +97,17 @@ const syncProjectTaskIds = (
|
|||
);
|
||||
}
|
||||
|
||||
// Add to new project's taskIds
|
||||
if (shouldAddToNewProject && newProjectId && projectState.entities[newProjectId]) {
|
||||
const newProject = projectState.entities[newProjectId] as Project;
|
||||
// Add to the new project's taskIds. Archived projects remain valid owners:
|
||||
// the archive operation can race with this task update during replay.
|
||||
const newProjectCandidate = newProjectId
|
||||
? (projectState.entities[newProjectId] as Project | undefined)
|
||||
: undefined;
|
||||
const newProject =
|
||||
newProjectCandidate && newProjectCandidate.id === newProjectId
|
||||
? newProjectCandidate
|
||||
: undefined;
|
||||
|
||||
if (shouldAddToNewProject && newProjectId && newProject) {
|
||||
// Only add if not already present
|
||||
if (!newProject.taskIds.includes(taskId)) {
|
||||
projectState = projectAdapter.updateOne(
|
||||
|
|
@ -153,8 +169,9 @@ const syncTagTaskIds = (
|
|||
|
||||
// Remove task from removed tags' taskIds
|
||||
for (const tagId of removedTags) {
|
||||
if (tagState.entities[tagId]) {
|
||||
const tag = tagState.entities[tagId] as Tag;
|
||||
const tagCandidate = tagState.entities[tagId] as Tag | undefined;
|
||||
const tag = tagCandidate?.id === tagId ? tagCandidate : undefined;
|
||||
if (tag) {
|
||||
if (tag.taskIds.includes(taskId)) {
|
||||
tagState = tagAdapter.updateOne(
|
||||
{
|
||||
|
|
@ -176,8 +193,9 @@ const syncTagTaskIds = (
|
|||
|
||||
// Add task to added tags' taskIds
|
||||
for (const tagId of addedTags) {
|
||||
if (tagState.entities[tagId]) {
|
||||
const tag = tagState.entities[tagId] as Tag;
|
||||
const tagCandidate = tagState.entities[tagId] as Tag | undefined;
|
||||
const tag = tagCandidate?.id === tagId ? tagCandidate : undefined;
|
||||
if (tag) {
|
||||
if (!tag.taskIds.includes(taskId)) {
|
||||
tagState = tagAdapter.updateOne(
|
||||
{
|
||||
|
|
@ -307,8 +325,12 @@ const syncParentSubTaskIds = (
|
|||
let taskState = state[TASK_FEATURE_NAME];
|
||||
|
||||
// Remove from old parent's subTaskIds
|
||||
if (oldParentId && taskState.entities[oldParentId]) {
|
||||
const oldParent = taskState.entities[oldParentId] as Task;
|
||||
const oldParentCandidate = oldParentId
|
||||
? (taskState.entities[oldParentId] as Task | undefined)
|
||||
: undefined;
|
||||
const oldParent =
|
||||
oldParentCandidate?.id === oldParentId ? oldParentCandidate : undefined;
|
||||
if (oldParentId && oldParent) {
|
||||
if (oldParent.subTaskIds.includes(taskId)) {
|
||||
taskState = taskAdapter.updateOne(
|
||||
{
|
||||
|
|
@ -328,8 +350,12 @@ const syncParentSubTaskIds = (
|
|||
}
|
||||
|
||||
// Add to new parent's subTaskIds
|
||||
if (newParentId && taskState.entities[newParentId]) {
|
||||
const newParent = taskState.entities[newParentId] as Task;
|
||||
const newParentCandidate = newParentId
|
||||
? (taskState.entities[newParentId] as Task | undefined)
|
||||
: undefined;
|
||||
const newParent =
|
||||
newParentCandidate?.id === newParentId ? newParentCandidate : undefined;
|
||||
if (newParentId && newParent) {
|
||||
// Only add if not already present
|
||||
if (!newParent.subTaskIds.includes(taskId)) {
|
||||
taskState = taskAdapter.updateOne(
|
||||
|
|
@ -532,12 +558,16 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
// set from op.entityId before reaching this reducer. Producers also
|
||||
// force the canonical id on-disk. The check below remains as a hard
|
||||
// guard for actions arriving with no usable id at all.
|
||||
if (!entityData['id']) {
|
||||
if (typeof entityData['id'] !== 'string' || !entityData['id']) {
|
||||
OpLog.warn('lwwUpdateMetaReducer: Entity data has no id');
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
const entityId = entityData['id'] as string;
|
||||
if (Object.prototype.hasOwnProperty.call(Object.prototype, entityId)) {
|
||||
OpLog.warn(`lwwUpdateMetaReducer: Unsafe entity id: ${entityId}`);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
// Sanitize date string fields to prevent corrupted data from sync (#6908)
|
||||
if (entityType === 'TASK') {
|
||||
|
|
@ -563,11 +593,51 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
}
|
||||
}
|
||||
|
||||
const existingEntity = (
|
||||
const existingEntityCandidate = (
|
||||
featureState as unknown as {
|
||||
entities?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
).entities?.[entityId];
|
||||
const existingEntity =
|
||||
existingEntityCandidate?.['id'] === entityId ? existingEntityCandidate : undefined;
|
||||
|
||||
if (
|
||||
entityType === 'TASK' &&
|
||||
Object.prototype.hasOwnProperty.call(entityData, 'parentId') &&
|
||||
typeof entityData['parentId'] === 'string' &&
|
||||
Object.prototype.hasOwnProperty.call(Object.prototype, entityData['parentId'])
|
||||
) {
|
||||
entityData['parentId'] = existingEntity?.parentId;
|
||||
}
|
||||
|
||||
// A remote projectId pointing at a project this client deleted would
|
||||
// orphan an existing task from every project list. Archived projects are
|
||||
// still valid owners: their archive op can race with the task update.
|
||||
// Recreated tasks deliberately keep out-of-order project references so a
|
||||
// later project op can complete the relationship.
|
||||
if (
|
||||
entityType === 'TASK' &&
|
||||
existingEntity &&
|
||||
Object.prototype.hasOwnProperty.call(entityData, 'projectId')
|
||||
) {
|
||||
const requestedProjectId = entityData['projectId'];
|
||||
const currentProjectId = existingEntity?.projectId;
|
||||
const fallbackProjectId =
|
||||
typeof currentProjectId === 'string' &&
|
||||
(currentProjectId === '' || getProjectOrUndefined(rootState, currentProjectId))
|
||||
? currentProjectId
|
||||
: undefined;
|
||||
|
||||
if (requestedProjectId == null) {
|
||||
entityData['projectId'] = undefined;
|
||||
} else if (
|
||||
typeof requestedProjectId !== 'string' ||
|
||||
(requestedProjectId !== '' &&
|
||||
!getProjectOrUndefined(rootState, requestedProjectId))
|
||||
) {
|
||||
entityData['projectId'] = fallbackProjectId;
|
||||
}
|
||||
}
|
||||
|
||||
let updatedFeatureState: unknown;
|
||||
|
||||
|
|
@ -675,19 +745,61 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
if (entityType === 'TASK' && updatedEntity) {
|
||||
// Sync project.taskIds when projectId changes
|
||||
const oldProjectId = existingEntity?.projectId as string | undefined;
|
||||
const newProjectId = updatedEntity.projectId as string | undefined;
|
||||
let newProjectId = updatedEntity.projectId as string | undefined;
|
||||
const oldIsSubTask = !!existingEntity?.parentId;
|
||||
const newParentId = updatedEntity.parentId as string | undefined;
|
||||
const newIsSubTask = !!newParentId;
|
||||
|
||||
updatedState = syncProjectTaskIds(
|
||||
updatedState,
|
||||
entityId,
|
||||
oldProjectId,
|
||||
newProjectId,
|
||||
oldIsSubTask,
|
||||
newIsSubTask,
|
||||
);
|
||||
// Subtasks inherit their project from the parent — a snapshot carrying
|
||||
// a diverging projectId (split state from an older client) is corrected
|
||||
// rather than applied.
|
||||
if (newParentId) {
|
||||
const parentCandidate = updatedState[TASK_FEATURE_NAME].entities[newParentId] as
|
||||
| Task
|
||||
| undefined;
|
||||
const parent = parentCandidate?.id === newParentId ? parentCandidate : undefined;
|
||||
if (parent && parent.projectId !== newProjectId) {
|
||||
newProjectId = parent.projectId;
|
||||
updatedState = {
|
||||
...updatedState,
|
||||
[TASK_FEATURE_NAME]: taskAdapter.updateOne(
|
||||
{ id: entityId, changes: { projectId: newProjectId } },
|
||||
updatedState[TASK_FEATURE_NAME],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const meta = actionAny['meta'];
|
||||
const explicitEntityIds =
|
||||
meta &&
|
||||
typeof meta === 'object' &&
|
||||
Array.isArray((meta as { entityIds?: unknown }).entityIds)
|
||||
? (meta as { entityIds: unknown[] }).entityIds.filter(
|
||||
(id): id is string => typeof id === 'string',
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (!newIsSubTask) {
|
||||
// Root snapshots repair every project list, even when projectId is
|
||||
// unchanged. New synthetic LWW ops replay their source footprint;
|
||||
// old ops without entityIds retain receiving-state repair behavior.
|
||||
updatedState = repairTaskProjectForLww(
|
||||
updatedState,
|
||||
updatedEntity as unknown as Task,
|
||||
newProjectId,
|
||||
explicitEntityIds,
|
||||
);
|
||||
} else {
|
||||
updatedState = syncProjectTaskIds(
|
||||
updatedState,
|
||||
entityId,
|
||||
oldProjectId,
|
||||
newProjectId,
|
||||
oldIsSubTask,
|
||||
newIsSubTask,
|
||||
);
|
||||
}
|
||||
|
||||
// Sync tag.taskIds when tagIds changes
|
||||
const oldTagIds = (existingEntity?.tagIds as string[]) || [];
|
||||
|
|
|
|||
|
|
@ -39,6 +39,19 @@ const stateWith = (
|
|||
};
|
||||
};
|
||||
|
||||
const addProject = (state: RootState, projectId: string): void => {
|
||||
const template = state[PROJECT_FEATURE_NAME].entities.project1;
|
||||
if (!template) throw new Error('Expected project1 test fixture');
|
||||
state[PROJECT_FEATURE_NAME].entities[projectId] = {
|
||||
...template,
|
||||
id: projectId,
|
||||
title: projectId,
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
};
|
||||
(state[PROJECT_FEATURE_NAME].ids as string[]).push(projectId);
|
||||
};
|
||||
|
||||
describe('sectionSharedMetaReducer', () => {
|
||||
let mockReducer: jasmine.Spy;
|
||||
let metaReducer: ActionReducer<any, Action>;
|
||||
|
|
@ -151,6 +164,92 @@ describe('sectionSharedMetaReducer', () => {
|
|||
expect(updated.entities['s2']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears stale project-section references when restoring a reverse-linked subtask', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
sub1: { parentId: 'parent', projectId: 'oldP' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['parent', 'sub1'],
|
||||
},
|
||||
{
|
||||
id: 'sTag',
|
||||
contextId: 'tag1',
|
||||
contextType: WorkContextType.TAG,
|
||||
title: 'tag section',
|
||||
taskIds: ['sub1'],
|
||||
},
|
||||
{
|
||||
id: 'sToday',
|
||||
contextId: TODAY_TAG.id,
|
||||
contextType: WorkContextType.TAG,
|
||||
title: 'today section',
|
||||
taskIds: ['sub1'],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
metaReducer(
|
||||
state,
|
||||
TaskSharedActions.restoreTask({
|
||||
task: createMockTask({ id: 'parent', projectId: 'project1', subTaskIds: [] }),
|
||||
subTasks: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const updated = (
|
||||
mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
}
|
||||
)[SECTION_FEATURE_NAME];
|
||||
expect(updated.entities['sOld']?.taskIds).toEqual([]);
|
||||
expect(updated.entities['sTag']?.taskIds).toEqual(['sub1']);
|
||||
expect(updated.entities['sToday']?.taskIds).toEqual(['sub1']);
|
||||
});
|
||||
|
||||
it('keeps section membership for a payload child owned by another parent', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
otherParent: { projectId: 'oldP', subTaskIds: ['collision'] },
|
||||
collision: { projectId: 'oldP', parentId: 'otherParent' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['collision'],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
metaReducer(
|
||||
state,
|
||||
TaskSharedActions.restoreTask({
|
||||
task: createMockTask({
|
||||
id: 'parent',
|
||||
projectId: 'project1',
|
||||
subTaskIds: ['collision', 'missing-child'],
|
||||
}),
|
||||
subTasks: [
|
||||
createMockTask({
|
||||
id: 'collision',
|
||||
parentId: 'parent',
|
||||
projectId: 'project1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockReducer.calls.mostRecent().args[0]).toBe(state);
|
||||
});
|
||||
|
||||
it('handles deleteTasks (bulk) across multiple sections', () => {
|
||||
const state = stateWith({ t1: {}, t2: {}, t3: {}, t4: {} }, [
|
||||
{
|
||||
|
|
@ -456,6 +555,383 @@ describe('sectionSharedMetaReducer', () => {
|
|||
expect(updated.entities['sTag']?.taskIds).toEqual(['parent']);
|
||||
});
|
||||
|
||||
it('strips project sections when updateTask changes projectId', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
parent: { projectId: 'oldP', subTaskIds: ['sub1'] },
|
||||
sub1: { projectId: 'oldP', parentId: 'parent' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['parent', 'sub1', 'unrelated'],
|
||||
},
|
||||
{
|
||||
id: 'sOther',
|
||||
contextId: 'newP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'target project section',
|
||||
taskIds: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
addProject(state, 'newP');
|
||||
const action = TaskSharedActions.updateTask({
|
||||
task: { id: 'parent', changes: { projectId: 'newP' } },
|
||||
});
|
||||
|
||||
metaReducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
};
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sOld']?.taskIds).toEqual([
|
||||
'unrelated',
|
||||
]);
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sOther']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips old-project sections when a task moves through LWW resolution', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
parent: { projectId: 'oldP', subTaskIds: [] },
|
||||
sub1: { projectId: 'oldP', parentId: 'parent' },
|
||||
receiverOnly: { projectId: 'oldP', parentId: 'parent' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['parent', 'sub1', 'receiverOnly'],
|
||||
},
|
||||
],
|
||||
);
|
||||
addProject(state, 'newP');
|
||||
|
||||
metaReducer(state, {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'parent',
|
||||
projectId: 'newP',
|
||||
meta: { entityIds: ['parent', 'sub1'] },
|
||||
} as Action);
|
||||
|
||||
const updated = (
|
||||
mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
}
|
||||
)[SECTION_FEATURE_NAME];
|
||||
expect(updated.entities['sOld']?.taskIds).toEqual(['receiverOnly']);
|
||||
});
|
||||
|
||||
it('repairs stale other-project sections for a same-project LWW snapshot', () => {
|
||||
const state = stateWith({ parent: { projectId: 'project1' } }, [
|
||||
{
|
||||
id: 'sTarget',
|
||||
contextId: 'project1',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'target section',
|
||||
taskIds: ['parent'],
|
||||
},
|
||||
{
|
||||
id: 'sStale',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'stale section',
|
||||
taskIds: ['parent'],
|
||||
},
|
||||
]);
|
||||
|
||||
metaReducer(state, {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'parent',
|
||||
projectId: 'project1',
|
||||
meta: { entityIds: ['parent'] },
|
||||
} as Action);
|
||||
|
||||
const updated = (
|
||||
mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
}
|
||||
)[SECTION_FEATURE_NAME];
|
||||
expect(updated.entities['sTarget']?.taskIds).toEqual(['parent']);
|
||||
expect(updated.entities['sStale']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips old-project sections when LWW clears projectId', () => {
|
||||
const state = stateWith({ parent: { projectId: 'oldP' } }, [
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['parent'],
|
||||
},
|
||||
]);
|
||||
|
||||
metaReducer(state, {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'parent',
|
||||
projectId: '',
|
||||
} as Action);
|
||||
|
||||
const updated = (
|
||||
mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
}
|
||||
)[SECTION_FEATURE_NAME];
|
||||
expect(updated.entities['sOld']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips project sections for reverse-linked children when LWW recreates a parent', () => {
|
||||
const state = stateWith({ sub1: { projectId: 'oldP', parentId: 'parent' } }, [
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['sub1'],
|
||||
},
|
||||
]);
|
||||
|
||||
metaReducer(state, {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'parent',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
} as Action);
|
||||
|
||||
const updated = (
|
||||
mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
}
|
||||
)[SECTION_FEATURE_NAME];
|
||||
expect(updated.entities['sOld']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips old-project sections when LWW reparents a subtask across projects', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
oldParent: { projectId: 'oldP', subTaskIds: ['sub1'] },
|
||||
newParent: { projectId: 'project1', subTaskIds: [] },
|
||||
sub1: { projectId: 'oldP', parentId: 'oldParent' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['sub1'],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
metaReducer(state, {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'sub1',
|
||||
parentId: 'newParent',
|
||||
projectId: 'project1',
|
||||
} as Action);
|
||||
|
||||
const updated = (
|
||||
mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
}
|
||||
)[SECTION_FEATURE_NAME];
|
||||
expect(updated.entities['sOld']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips old-project sections when LWW promotes a subtask in another project', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
oldParent: { projectId: 'oldP', subTaskIds: ['sub1'] },
|
||||
sub1: { projectId: 'oldP', parentId: 'oldParent' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['sub1'],
|
||||
},
|
||||
],
|
||||
);
|
||||
addProject(state, 'newP');
|
||||
|
||||
metaReducer(state, {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'sub1',
|
||||
parentId: undefined,
|
||||
projectId: 'newP',
|
||||
} as Action);
|
||||
|
||||
const updated = (
|
||||
mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
}
|
||||
)[SECTION_FEATURE_NAME];
|
||||
expect(updated.entities['sOld']?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps section references for a rejected direct subtask projectId update', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
parent: { projectId: 'oldP', subTaskIds: ['sub1'] },
|
||||
sub1: { projectId: 'oldP', parentId: 'parent' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['sub1'],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
metaReducer(
|
||||
state,
|
||||
TaskSharedActions.updateTask({
|
||||
task: { id: 'sub1', changes: { projectId: 'project1' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockReducer.calls.mostRecent().args[0]).toBe(state);
|
||||
});
|
||||
|
||||
it('cleans only the captured project-move footprint on divergent state', () => {
|
||||
const state = stateWith(
|
||||
{
|
||||
parent: { projectId: 'oldP', subTaskIds: [] },
|
||||
orphan: { projectId: 'oldP', parentId: 'parent' },
|
||||
receiverOnly: { projectId: 'oldP', parentId: 'parent' },
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['parent', 'orphan', 'receiverOnly'],
|
||||
},
|
||||
],
|
||||
);
|
||||
addProject(state, 'newP');
|
||||
const action = TaskSharedActions.updateTask({
|
||||
task: { id: 'parent', changes: { projectId: 'newP' } },
|
||||
projectMoveSubTaskIds: ['orphan'],
|
||||
});
|
||||
|
||||
metaReducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
};
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sOld']?.taskIds).toEqual([
|
||||
'receiverOnly',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps sections unchanged for a missing project destination', () => {
|
||||
const state = stateWith({ task1: { projectId: 'project1' } }, [
|
||||
{
|
||||
id: 'sCurrent',
|
||||
contextId: 'project1',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'current project section',
|
||||
taskIds: ['task1'],
|
||||
},
|
||||
]);
|
||||
|
||||
metaReducer(
|
||||
state,
|
||||
TaskSharedActions.updateTask({
|
||||
task: { id: 'task1', changes: { projectId: 'missing-project' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
};
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sCurrent']?.taskIds).toEqual([
|
||||
'task1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes stale section references when restoring a task family', () => {
|
||||
const state = stateWith({}, [
|
||||
{
|
||||
id: 'sOld',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'old project section',
|
||||
taskIds: ['parent', 'subtask', 'unrelated'],
|
||||
},
|
||||
]);
|
||||
const parent = createMockTask({
|
||||
id: 'parent',
|
||||
projectId: 'newP',
|
||||
subTaskIds: ['subtask'],
|
||||
});
|
||||
const subTask = createMockTask({
|
||||
id: 'subtask',
|
||||
parentId: 'parent',
|
||||
projectId: 'oldP',
|
||||
});
|
||||
|
||||
metaReducer(
|
||||
state,
|
||||
TaskSharedActions.restoreTask({ task: parent, subTasks: [subTask] }),
|
||||
);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
};
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sOld']?.taskIds).toEqual([
|
||||
'unrelated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('repairs stale project sections when projectId is patched unchanged', () => {
|
||||
const state = stateWith({ task1: { projectId: 'newP' } }, [
|
||||
{
|
||||
id: 'sStale',
|
||||
contextId: 'oldP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'stale project section',
|
||||
taskIds: ['task1'],
|
||||
},
|
||||
{
|
||||
id: 'sTarget',
|
||||
contextId: 'newP',
|
||||
contextType: WorkContextType.PROJECT,
|
||||
title: 'target project section',
|
||||
taskIds: ['task1'],
|
||||
},
|
||||
]);
|
||||
addProject(state, 'newP');
|
||||
const action = TaskSharedActions.updateTask({
|
||||
task: { id: 'task1', changes: { projectId: 'newP' } },
|
||||
});
|
||||
|
||||
metaReducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState & {
|
||||
[SECTION_FEATURE_NAME]: SectionState;
|
||||
};
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sStale']?.taskIds).toEqual([]);
|
||||
expect(updatedState[SECTION_FEATURE_NAME].entities['sTarget']?.taskIds).toEqual([
|
||||
'task1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('updateTask without tagIds change is a no-op for sections', () => {
|
||||
const state = stateWith({ t1: { tagIds: ['a'] } }, [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ import { WorkContextType } from '../../../features/work-context/work-context.mod
|
|||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import { moveItemAfterAnchor } from '../../../features/work-context/store/work-context-meta.helper';
|
||||
import { canApplyConvertToSubTask } from '../../../features/tasks/util/can-convert-task-to-sub-task';
|
||||
import {
|
||||
collectTaskAndSubTaskIds,
|
||||
getProjectOrUndefined,
|
||||
isValidTaskProjectIdUpdate,
|
||||
} from './task-shared-helpers';
|
||||
import { toLwwUpdateActionType } from '../../../op-log/core/lww-update-action-types';
|
||||
|
||||
// Must run before taskSharedCrudMetaReducer — handlers read pre-update
|
||||
// task state to compute cleanups. Position pinned by
|
||||
|
|
@ -29,21 +35,6 @@ interface ExtendedState extends RootState {
|
|||
|
||||
type Handler = (state: ExtendedState, action: Action) => ExtendedState;
|
||||
|
||||
const collectAffectedTaskIds = (
|
||||
state: ExtendedState,
|
||||
primaryTaskIds: string[],
|
||||
): string[] => {
|
||||
const taskState = state[TASK_FEATURE_NAME];
|
||||
const all = new Set<string>(primaryTaskIds);
|
||||
for (const id of primaryTaskIds) {
|
||||
const t = taskState.entities[id];
|
||||
if (t?.subTaskIds?.length) {
|
||||
for (const sub of t.subTaskIds) all.add(sub);
|
||||
}
|
||||
}
|
||||
return Array.from(all);
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk `taskIds` once removing entries in `removedSet`. Returns `null`
|
||||
* when nothing was removed so callers can keep the original array
|
||||
|
|
@ -112,12 +103,13 @@ const removeProjectSections = (
|
|||
/**
|
||||
* Strip `taskIds` from sections owned by `projectId` (PROJECT context).
|
||||
* Used when a task leaves a project (moveToOtherProject) — its section
|
||||
* membership in the old project becomes stale.
|
||||
* membership in the old project becomes stale. Omitting `projectId`
|
||||
* strips from every project's sections (repair paths).
|
||||
*/
|
||||
const removeTaskIdsFromProjectSections = (
|
||||
sectionState: SectionState,
|
||||
taskIds: string[],
|
||||
projectId: string,
|
||||
projectId?: string,
|
||||
): SectionState => {
|
||||
if (taskIds.length === 0) return sectionState;
|
||||
|
||||
|
|
@ -128,7 +120,7 @@ const removeTaskIdsFromProjectSections = (
|
|||
const s = sectionState.entities[id];
|
||||
if (!s) continue;
|
||||
if (s.contextType !== WorkContextType.PROJECT) continue;
|
||||
if (s.contextId !== projectId) continue;
|
||||
if (projectId !== undefined && s.contextId !== projectId) continue;
|
||||
const filtered = filterRemovingTaskIds(s.taskIds, taskIdSet);
|
||||
if (filtered !== null) {
|
||||
updates.push({ id: s.id, changes: { taskIds: filtered } });
|
||||
|
|
@ -139,6 +131,36 @@ const removeTaskIdsFromProjectSections = (
|
|||
return sectionAdapter.updateMany(updates, sectionState);
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip task IDs from every project section except the destination project's.
|
||||
* This is used by generic task updates, which may be repairing state where the
|
||||
* task's current projectId no longer identifies every stale section reference.
|
||||
*/
|
||||
const removeTaskIdsFromOtherProjectSections = (
|
||||
sectionState: SectionState,
|
||||
taskIds: string[],
|
||||
targetProjectId: string,
|
||||
): SectionState => {
|
||||
if (taskIds.length === 0) return sectionState;
|
||||
|
||||
const taskIdSet = new Set(taskIds);
|
||||
const updates: Update<Section>[] = [];
|
||||
|
||||
for (const id of sectionState.ids) {
|
||||
const section = sectionState.entities[id];
|
||||
if (!section) continue;
|
||||
if (section.contextType !== WorkContextType.PROJECT) continue;
|
||||
if (section.contextId === targetProjectId) continue;
|
||||
const filtered = filterRemovingTaskIds(section.taskIds, taskIdSet);
|
||||
if (filtered !== null) {
|
||||
updates.push({ id: section.id, changes: { taskIds: filtered } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!updates.length) return sectionState;
|
||||
return sectionAdapter.updateMany(updates, sectionState);
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip `taskIds` from the singleton TODAY-tag section bucket.
|
||||
*/
|
||||
|
|
@ -178,7 +200,7 @@ const handleTaskRemoval = (
|
|||
state: ExtendedState,
|
||||
primaryTaskIds: string[],
|
||||
): ExtendedState => {
|
||||
const affectedIds = collectAffectedTaskIds(state, primaryTaskIds);
|
||||
const affectedIds = collectTaskAndSubTaskIds(state, primaryTaskIds);
|
||||
return withSectionStateUpdate(
|
||||
state,
|
||||
cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], affectedIds),
|
||||
|
|
@ -198,7 +220,7 @@ const handleMoveToOtherProject = (
|
|||
const oldProjectId = t?.projectId;
|
||||
if (!oldProjectId || oldProjectId === targetProjectId) return state;
|
||||
|
||||
const affectedTaskIds = collectAffectedTaskIds(state, [taskId]);
|
||||
const affectedTaskIds = collectTaskAndSubTaskIds(state, [taskId]);
|
||||
return withSectionStateUpdate(
|
||||
state,
|
||||
removeTaskIdsFromProjectSections(
|
||||
|
|
@ -297,11 +319,6 @@ const handleRemoveTaskFromSection = (
|
|||
* tasks within its single-action transform. Sections it would
|
||||
* otherwise affect aren't pruned. Handling it here requires walking
|
||||
* the operations array, which is non-trivial.
|
||||
*
|
||||
* - LWW conflict-resolution can reassign a task's projectId; project-
|
||||
* context section.taskIds can leak phantom references until the next
|
||||
* `dataRepair` pass clears them. Visible impact bounded by render-time
|
||||
* intersection in `undoneTasksBySection`.
|
||||
*/
|
||||
const ACTION_HANDLERS: Record<string, Handler> = {
|
||||
[TaskSharedActions.deleteTask.type]: (state, action) => {
|
||||
|
|
@ -313,27 +330,18 @@ const ACTION_HANDLERS: Record<string, Handler> = {
|
|||
return handleTaskRemoval(state, taskIds);
|
||||
},
|
||||
[TaskSharedActions.moveToArchive.type]: (state, action) => {
|
||||
// Strip archived task ids from sections. Restore is intentionally
|
||||
// NOT a counterpart — the task comes back without a section, mirror
|
||||
// of how restore drops missing tagIds.
|
||||
//
|
||||
// Union payload-subTasks with state-derived subtasks: payload covers
|
||||
// the replay-with-missing-state case; state covers callers who pass
|
||||
// an empty `subTasks` array.
|
||||
const { tasks } = action as ReturnType<typeof TaskSharedActions.moveToArchive>;
|
||||
const idSet = new Set<string>();
|
||||
for (const t of tasks) {
|
||||
idSet.add(t.id);
|
||||
if (t.subTasks?.length) for (const st of t.subTasks) idSet.add(st.id);
|
||||
}
|
||||
const stateExpanded = collectAffectedTaskIds(
|
||||
const affectedTaskIds = collectTaskAndSubTaskIds(
|
||||
state,
|
||||
tasks.map((t) => t.id),
|
||||
tasks.flatMap((t) => [...(t.subTaskIds ?? []), ...t.subTasks.map((st) => st.id)]),
|
||||
);
|
||||
for (const id of stateExpanded) idSet.add(id);
|
||||
return withSectionStateUpdate(
|
||||
state,
|
||||
cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], Array.from(idSet)),
|
||||
cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], affectedTaskIds),
|
||||
);
|
||||
},
|
||||
[TaskSharedActions.deleteProject.type]: (state, action) => {
|
||||
|
|
@ -362,6 +370,143 @@ const ACTION_HANDLERS: Record<string, Handler> = {
|
|||
>;
|
||||
return handleMoveToOtherProject(state, task.id, targetProjectId);
|
||||
},
|
||||
[TaskSharedActions.restoreTask.type]: (state, action) => {
|
||||
// Restored tasks come back without a section (mirror of how restore
|
||||
// drops missing tagIds) — strip any stale refs left by a pre-fix
|
||||
// archive. The guard mirrors the lifecycle reducer's idempotent
|
||||
// replay: when the root is already active, task state is untouched,
|
||||
// so section membership must survive too.
|
||||
const { task, subTasks } = action as ReturnType<typeof TaskSharedActions.restoreTask>;
|
||||
if (Object.prototype.hasOwnProperty.call(Object.prototype, task.id)) return state;
|
||||
if (state[TASK_FEATURE_NAME].entities[task.id]?.id === task.id) return state;
|
||||
const taskIds = new Set(collectTaskAndSubTaskIds(state, [task.id]));
|
||||
for (const subTaskId of task.subTaskIds ?? []) {
|
||||
const existingSubTask = state[TASK_FEATURE_NAME].entities[subTaskId];
|
||||
if (!existingSubTask || existingSubTask.parentId === task.id) {
|
||||
taskIds.add(subTaskId);
|
||||
}
|
||||
}
|
||||
for (const subTask of subTasks) {
|
||||
const existingSubTask = state[TASK_FEATURE_NAME].entities[subTask.id];
|
||||
if (
|
||||
existingSubTask?.parentId === task.id ||
|
||||
(!existingSubTask && subTask.parentId === task.id)
|
||||
) {
|
||||
taskIds.add(subTask.id);
|
||||
}
|
||||
}
|
||||
return withSectionStateUpdate(
|
||||
state,
|
||||
removeTaskIdsFromProjectSections(state[SECTION_FEATURE_NAME], Array.from(taskIds)),
|
||||
);
|
||||
},
|
||||
[TaskSharedActions.updateTask.type]: (state, action) => {
|
||||
const { task, projectMoveSubTaskIds } = action as ReturnType<
|
||||
typeof TaskSharedActions.updateTask
|
||||
>;
|
||||
const targetProjectId = task.changes.projectId;
|
||||
if (typeof targetProjectId !== 'string') return state;
|
||||
|
||||
const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task | undefined;
|
||||
if (
|
||||
!currentTask ||
|
||||
!isValidTaskProjectIdUpdate(state, currentTask, targetProjectId)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const affectedTaskIds =
|
||||
projectMoveSubTaskIds !== undefined
|
||||
? [task.id as string, ...projectMoveSubTaskIds]
|
||||
: collectTaskAndSubTaskIds(state, [task.id as string]);
|
||||
return withSectionStateUpdate(
|
||||
state,
|
||||
removeTaskIdsFromOtherProjectSections(
|
||||
state[SECTION_FEATURE_NAME],
|
||||
affectedTaskIds,
|
||||
targetProjectId,
|
||||
),
|
||||
);
|
||||
},
|
||||
[toLwwUpdateActionType('TASK')]: (state, action) => {
|
||||
const update = action as Action & {
|
||||
id?: unknown;
|
||||
parentId?: unknown;
|
||||
projectId?: unknown;
|
||||
meta?: { entityIds?: unknown };
|
||||
};
|
||||
if (typeof update.id !== 'string') return state;
|
||||
|
||||
const currentTaskCandidate = state[TASK_FEATURE_NAME].entities[update.id] as
|
||||
| Task
|
||||
| undefined;
|
||||
const currentTask =
|
||||
currentTaskCandidate?.id === update.id ? currentTaskCandidate : undefined;
|
||||
const explicitEntityIds = Array.isArray(update.meta?.entityIds)
|
||||
? update.meta.entityIds.filter((id): id is string => typeof id === 'string')
|
||||
: undefined;
|
||||
const affectedTaskIds =
|
||||
explicitEntityIds !== undefined
|
||||
? Array.from(new Set([update.id, ...explicitEntityIds]))
|
||||
: collectTaskAndSubTaskIds(state, [update.id]);
|
||||
|
||||
const hasParentId = Object.prototype.hasOwnProperty.call(update, 'parentId');
|
||||
const hasProjectId = Object.prototype.hasOwnProperty.call(update, 'projectId');
|
||||
if (!hasParentId && !hasProjectId) return state;
|
||||
|
||||
if (!currentTask) {
|
||||
return withSectionStateUpdate(
|
||||
state,
|
||||
removeTaskIdsFromProjectSections(state[SECTION_FEATURE_NAME], affectedTaskIds),
|
||||
);
|
||||
}
|
||||
|
||||
let targetProjectId: string | undefined = currentTask.projectId;
|
||||
let requestedProjectId: string | undefined = currentTask.projectId;
|
||||
if (hasProjectId) {
|
||||
if (update.projectId == null) {
|
||||
requestedProjectId = undefined;
|
||||
} else if (
|
||||
typeof update.projectId === 'string' &&
|
||||
(update.projectId === '' || getProjectOrUndefined(state, update.projectId))
|
||||
) {
|
||||
requestedProjectId = update.projectId;
|
||||
}
|
||||
}
|
||||
const targetParentId =
|
||||
hasParentId && typeof update.parentId === 'string' && update.parentId
|
||||
? update.parentId
|
||||
: undefined;
|
||||
|
||||
if (targetParentId) {
|
||||
const targetParentCandidate = state[TASK_FEATURE_NAME].entities[targetParentId] as
|
||||
| Task
|
||||
| undefined;
|
||||
const targetParent =
|
||||
targetParentCandidate?.id === targetParentId ? targetParentCandidate : undefined;
|
||||
targetProjectId = targetParent?.projectId ?? requestedProjectId;
|
||||
} else if (hasParentId || !currentTask.parentId) {
|
||||
targetProjectId = requestedProjectId;
|
||||
}
|
||||
|
||||
const becomesSubTask = hasParentId ? !!targetParentId : !!currentTask.parentId;
|
||||
const leavesCurrentProjectSection =
|
||||
(!currentTask.parentId && becomesSubTask) ||
|
||||
targetProjectId !== currentTask.projectId;
|
||||
const repairsRootProjectRefs = !becomesSubTask && hasProjectId;
|
||||
if (!leavesCurrentProjectSection && !repairsRootProjectRefs) return state;
|
||||
|
||||
return withSectionStateUpdate(
|
||||
state,
|
||||
becomesSubTask
|
||||
? removeTaskIdsFromProjectSections(state[SECTION_FEATURE_NAME], affectedTaskIds)
|
||||
: removeTaskIdsFromOtherProjectSections(
|
||||
state[SECTION_FEATURE_NAME],
|
||||
affectedTaskIds,
|
||||
targetProjectId ?? '',
|
||||
),
|
||||
);
|
||||
},
|
||||
[TaskSharedActions.convertToSubTask.type]: (state, action) => {
|
||||
const { taskId, targetParentId } = action as ReturnType<
|
||||
typeof TaskSharedActions.convertToSubTask
|
||||
|
|
@ -411,7 +556,7 @@ export const sectionSharedMetaReducer: MetaReducer<RootState> = (
|
|||
// removes ids from TODAY without going through a known action.
|
||||
const removedFromToday = diffRemovedTodayTaskIds(state, next);
|
||||
if (!removedFromToday) return next;
|
||||
const affected = collectAffectedTaskIds(next as ExtendedState, removedFromToday);
|
||||
const affected = collectTaskAndSubTaskIds(next as ExtendedState, removedFromToday);
|
||||
return applyTodayTagSectionCleanup(next, affected);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1330,9 +1330,14 @@ describe('taskSharedCrudMetaReducer', () => {
|
|||
});
|
||||
|
||||
describe('updateTask action', () => {
|
||||
const createUpdateTaskAction = (taskId: string, changes: Partial<Task>) =>
|
||||
const createUpdateTaskAction = (
|
||||
taskId: string,
|
||||
changes: Partial<Task>,
|
||||
projectMoveSubTaskIds?: string[],
|
||||
) =>
|
||||
TaskSharedActions.updateTask({
|
||||
task: { id: taskId, changes },
|
||||
...(projectMoveSubTaskIds !== undefined && { projectMoveSubTaskIds }),
|
||||
});
|
||||
|
||||
it('should update task properties without affecting tags', () => {
|
||||
|
|
@ -1360,6 +1365,340 @@ describe('taskSharedCrudMetaReducer', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should atomically move a task and its subtasks to another project', () => {
|
||||
const testState = createStateWithExistingTasks(['task1'], ['subtask1']);
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
projectId: 'project1',
|
||||
subTaskIds: ['subtask1'],
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].entities.subtask1 = createMockTask({
|
||||
id: 'subtask1',
|
||||
projectId: 'project1',
|
||||
parentId: 'task1',
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].entities['target-task'] = createMockTask({
|
||||
id: 'target-task',
|
||||
projectId: 'project2',
|
||||
});
|
||||
(testState[TASK_FEATURE_NAME].ids as string[]).push('target-task');
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = {
|
||||
...testState[PROJECT_FEATURE_NAME].entities.project1,
|
||||
id: 'project2',
|
||||
title: 'Project 2',
|
||||
taskIds: ['target-task'],
|
||||
backlogTaskIds: [],
|
||||
} as Project;
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
|
||||
const action = createUpdateTaskAction('task1', {
|
||||
projectId: 'project2',
|
||||
title: 'Moved task',
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
|
||||
entities: jasmine.objectContaining({
|
||||
project1: jasmine.objectContaining({
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
}),
|
||||
project2: jasmine.objectContaining({
|
||||
taskIds: ['target-task', 'task1'],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
[TASK_FEATURE_NAME]: jasmine.objectContaining({
|
||||
entities: jasmine.objectContaining({
|
||||
task1: jasmine.objectContaining({
|
||||
projectId: 'project2',
|
||||
title: 'Moved task',
|
||||
}),
|
||||
subtask1: jasmine.objectContaining({ projectId: 'project2' }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should replay only the captured project-move footprint on divergent state', () => {
|
||||
const testState = createStateWithExistingTasks(['task1'], ['orphan-subtask']);
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].entities['orphan-subtask'] = createMockTask({
|
||||
id: 'orphan-subtask',
|
||||
projectId: 'project1',
|
||||
parentId: 'task1',
|
||||
});
|
||||
(testState[TASK_FEATURE_NAME].ids as string[]).push('receiver-only-subtask');
|
||||
testState[TASK_FEATURE_NAME].entities['receiver-only-subtask'] = createMockTask({
|
||||
id: 'receiver-only-subtask',
|
||||
projectId: 'project1',
|
||||
parentId: 'task1',
|
||||
});
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = {
|
||||
...testState[PROJECT_FEATURE_NAME].entities.project1,
|
||||
id: 'project2',
|
||||
title: 'Project 2',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
} as Project;
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
const action = createUpdateTaskAction('task1', { projectId: 'project2' }, [
|
||||
'orphan-subtask',
|
||||
]);
|
||||
|
||||
expect(action.meta.entityIds).toEqual(['task1', 'orphan-subtask']);
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
|
||||
entities: jasmine.objectContaining({
|
||||
project1: jasmine.objectContaining({
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
}),
|
||||
project2: jasmine.objectContaining({ taskIds: ['task1'] }),
|
||||
}),
|
||||
}),
|
||||
[TASK_FEATURE_NAME]: jasmine.objectContaining({
|
||||
entities: jasmine.objectContaining({
|
||||
task1: jasmine.objectContaining({ projectId: 'project2' }),
|
||||
'orphan-subtask': jasmine.objectContaining({ projectId: 'project2' }),
|
||||
'receiver-only-subtask': jasmine.objectContaining({
|
||||
projectId: 'project1',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore a missing project destination while applying other fields', () => {
|
||||
const testState = createStateWithExistingTasks(['task1']);
|
||||
const action = createUpdateTaskAction('task1', {
|
||||
projectId: 'missing-project',
|
||||
title: 'Updated title',
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
...expectProjectUpdate('project1', { taskIds: ['task1'] }),
|
||||
...expectTaskUpdate('task1', {
|
||||
projectId: 'project1',
|
||||
title: 'Updated title',
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
for (const invalidTarget of [
|
||||
{ label: 'prototype-like', id: 'constructor' },
|
||||
{ label: 'prototype-like', id: '__proto__' },
|
||||
]) {
|
||||
it(`should ignore a ${invalidTarget.label} project destination while applying other fields`, () => {
|
||||
const testState = createStateWithExistingTasks(['task1']);
|
||||
const action = createUpdateTaskAction('task1', {
|
||||
projectId: invalidTarget.id,
|
||||
title: 'Updated title',
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
...expectProjectUpdate('project1', { taskIds: ['task1'] }),
|
||||
...expectTaskUpdate('task1', {
|
||||
projectId: 'project1',
|
||||
title: 'Updated title',
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('should replay a move to an archived-but-existing project', () => {
|
||||
const testState = createStateWithExistingTasks(['task1']);
|
||||
testState[PROJECT_FEATURE_NAME].entities['archived-project'] = {
|
||||
...(testState[PROJECT_FEATURE_NAME].entities.project1 as Project),
|
||||
id: 'archived-project',
|
||||
isArchived: true,
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
};
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('archived-project');
|
||||
const action = createUpdateTaskAction('task1', {
|
||||
projectId: 'archived-project',
|
||||
title: 'Updated title',
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
...expectProjectUpdate('project1', { taskIds: [] }),
|
||||
...expectProjectUpdate('archived-project', { taskIds: ['task1'] }),
|
||||
...expectTaskUpdate('task1', {
|
||||
projectId: 'archived-project',
|
||||
title: 'Updated title',
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow clearing the project with an empty project id', () => {
|
||||
const testState = createStateWithExistingTasks(['task1']);
|
||||
const action = createUpdateTaskAction('task1', {
|
||||
projectId: '',
|
||||
title: 'Updated title',
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
...expectProjectUpdate('project1', { taskIds: [] }),
|
||||
...expectTaskUpdate('task1', {
|
||||
projectId: '',
|
||||
title: 'Updated title',
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore direct project changes on subtasks', () => {
|
||||
const testState = createStateWithExistingTasks(['task1'], ['subtask1']);
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
projectId: 'project1',
|
||||
subTaskIds: ['subtask1'],
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].entities.subtask1 = createMockTask({
|
||||
id: 'subtask1',
|
||||
projectId: 'project1',
|
||||
parentId: 'task1',
|
||||
});
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = {
|
||||
...testState[PROJECT_FEATURE_NAME].entities.project1,
|
||||
id: 'project2',
|
||||
title: 'Project 2',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
} as Project;
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
const action = createUpdateTaskAction('subtask1', {
|
||||
projectId: 'project2',
|
||||
title: 'Updated subtask',
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
expectTaskUpdate('subtask1', {
|
||||
projectId: 'project1',
|
||||
title: 'Updated subtask',
|
||||
}),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should repair stale project memberships when projectId is patched unchanged', () => {
|
||||
const testState = createStateWithExistingTasks(['task1']);
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = {
|
||||
...testState[PROJECT_FEATURE_NAME].entities.project1,
|
||||
id: 'project2',
|
||||
title: 'Project 2',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
} as Project;
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
projectId: 'project2',
|
||||
});
|
||||
|
||||
const action = createUpdateTaskAction('task1', { projectId: 'project2' });
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
|
||||
entities: jasmine.objectContaining({
|
||||
project1: jasmine.objectContaining({ taskIds: [] }),
|
||||
project2: jasmine.objectContaining({ taskIds: ['task1'] }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve task order when projectId is patched unchanged', () => {
|
||||
const testState = createStateWithExistingTasks(['before', 'task1', 'after']);
|
||||
const action = createUpdateTaskAction('task1', { projectId: 'project1' });
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
expectProjectUpdate('project1', {
|
||||
taskIds: ['before', 'task1', 'after'],
|
||||
}),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve backlog position when projectId is patched unchanged', () => {
|
||||
const testState = createStateWithExistingTasks([], ['before', 'task1', 'after']);
|
||||
const action = createUpdateTaskAction('task1', { projectId: 'project1' });
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
expectProjectUpdate('project1', {
|
||||
taskIds: [],
|
||||
backlogTaskIds: ['before', 'task1', 'after'],
|
||||
}),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add task to new tags and remove from old tags when tagIds are updated', () => {
|
||||
const testState = createStateWithExistingTasks(
|
||||
['task1'],
|
||||
|
|
|
|||
|
|
@ -33,12 +33,15 @@ import {
|
|||
ActionHandlerMap,
|
||||
addTaskToList,
|
||||
addTaskToPlannerDay,
|
||||
collectTaskAndSubTaskIds,
|
||||
getProject,
|
||||
getTag,
|
||||
hasInvalidTodayTag,
|
||||
isValidTaskProjectIdUpdate,
|
||||
ProjectTaskList,
|
||||
filterOutTodayTag,
|
||||
removeTaskFromPlannerDays,
|
||||
removeTasksFromAllProjects,
|
||||
removeTasksFromAllTags,
|
||||
removeTasksFromList,
|
||||
TaskWithTags,
|
||||
|
|
@ -633,7 +636,11 @@ const removeInProgressTagOnCompletion = (
|
|||
};
|
||||
};
|
||||
|
||||
const handleUpdateTask = (state: RootState, taskUpdate: Update<Task>): RootState => {
|
||||
const handleUpdateTask = (
|
||||
state: RootState,
|
||||
taskUpdate: Update<Task>,
|
||||
projectMoveSubTaskIds?: string[],
|
||||
): RootState => {
|
||||
const taskId = taskUpdate.id as string;
|
||||
const currentTask = state[TASK_FEATURE_NAME].entities[taskId] as Task;
|
||||
|
||||
|
|
@ -648,11 +655,29 @@ const handleUpdateTask = (state: RootState, taskUpdate: Update<Task>): RootState
|
|||
currentTask,
|
||||
todayStr,
|
||||
);
|
||||
const cleanedTaskUpdate = removeInProgressTagOnCompletion(
|
||||
let cleanedTaskUpdate = removeInProgressTagOnCompletion(
|
||||
sanitizedTaskUpdate,
|
||||
currentTask,
|
||||
);
|
||||
|
||||
// Subtasks inherit their project from the parent, and only an existing
|
||||
// project (or '' for no project) is a usable destination for a top-level
|
||||
// task. Archived projects remain valid during replay because their archive
|
||||
// op can race with this update. Strip null/undefined/unknown destinations
|
||||
// as well as at API boundaries so malformed or legacy ops can neither split
|
||||
// parent from child nor orphan a task from every project list.
|
||||
if (Object.prototype.hasOwnProperty.call(cleanedTaskUpdate.changes, 'projectId')) {
|
||||
const requestedProjectId = cleanedTaskUpdate.changes.projectId;
|
||||
if (
|
||||
typeof requestedProjectId !== 'string' ||
|
||||
!isValidTaskProjectIdUpdate(state, currentTask, requestedProjectId)
|
||||
) {
|
||||
const changes = { ...cleanedTaskUpdate.changes };
|
||||
delete changes.projectId;
|
||||
cleanedTaskUpdate = { ...cleanedTaskUpdate, changes };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tag changes if tagIds are being updated
|
||||
if (cleanedTaskUpdate.changes.tagIds) {
|
||||
const oldTagIds = currentTask.tagIds;
|
||||
|
|
@ -661,6 +686,74 @@ const handleUpdateTask = (state: RootState, taskUpdate: Update<Task>): RootState
|
|||
updatedState = handleTagUpdates(updatedState, taskId, oldTagIds, newTagIds);
|
||||
}
|
||||
|
||||
const hasProjectIdUpdate = Object.prototype.hasOwnProperty.call(
|
||||
cleanedTaskUpdate.changes,
|
||||
'projectId',
|
||||
);
|
||||
const targetProjectId = cleanedTaskUpdate.changes.projectId;
|
||||
if (
|
||||
hasProjectIdUpdate &&
|
||||
typeof targetProjectId === 'string' &&
|
||||
!currentTask.parentId
|
||||
) {
|
||||
const subTaskIds =
|
||||
projectMoveSubTaskIds !== undefined
|
||||
? unique(
|
||||
projectMoveSubTaskIds.filter(
|
||||
(id) =>
|
||||
id !== taskId &&
|
||||
!Object.prototype.hasOwnProperty.call(Object.prototype, id),
|
||||
),
|
||||
)
|
||||
: collectTaskAndSubTaskIds(state, [taskId]).filter((id) => id !== taskId);
|
||||
const allTaskIds = [taskId, ...subTaskIds];
|
||||
const targetProjectBefore =
|
||||
updatedState[PROJECT_FEATURE_NAME].entities[targetProjectId];
|
||||
const isSameProject = currentTask.projectId === targetProjectId;
|
||||
updatedState = removeTasksFromAllProjects(updatedState, allTaskIds);
|
||||
|
||||
const targetProject = updatedState[PROJECT_FEATURE_NAME].entities[targetProjectId];
|
||||
if (targetProject && targetProjectBefore) {
|
||||
if (isSameProject) {
|
||||
const subTaskIdSet = new Set(subTaskIds);
|
||||
let taskIds = unique(
|
||||
targetProjectBefore.taskIds.filter((id) => !subTaskIdSet.has(id)),
|
||||
);
|
||||
let backlogTaskIds = unique(
|
||||
targetProjectBefore.backlogTaskIds.filter((id) => !subTaskIdSet.has(id)),
|
||||
);
|
||||
|
||||
if (taskIds.includes(taskId)) {
|
||||
backlogTaskIds = backlogTaskIds.filter((id) => id !== taskId);
|
||||
} else if (!backlogTaskIds.includes(taskId)) {
|
||||
taskIds = [...taskIds, taskId];
|
||||
}
|
||||
|
||||
updatedState = updateProject(updatedState, targetProjectId, {
|
||||
taskIds,
|
||||
backlogTaskIds,
|
||||
});
|
||||
} else {
|
||||
updatedState = updateProject(updatedState, targetProjectId, {
|
||||
taskIds: unique([...targetProject.taskIds, taskId]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (subTaskIds.length > 0) {
|
||||
updatedState = {
|
||||
...updatedState,
|
||||
[TASK_FEATURE_NAME]: taskAdapter.updateMany(
|
||||
subTaskIds.map((id) => ({
|
||||
id,
|
||||
changes: { projectId: targetProjectId },
|
||||
})),
|
||||
updatedState[TASK_FEATURE_NAME],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle task state updates using existing task reducer logic
|
||||
let taskState = updatedState[TASK_FEATURE_NAME];
|
||||
const { timeSpentOnDay, timeEstimate } = cleanedTaskUpdate.changes;
|
||||
|
|
@ -860,8 +953,10 @@ const createActionHandlers = (state: RootState, action: Action): ActionHandlerMa
|
|||
);
|
||||
},
|
||||
[TaskSharedActions.updateTask.type]: () => {
|
||||
const { task } = action as ReturnType<typeof TaskSharedActions.updateTask>;
|
||||
return handleUpdateTask(state, task);
|
||||
const { task, projectMoveSubTaskIds } = action as ReturnType<
|
||||
typeof TaskSharedActions.updateTask
|
||||
>;
|
||||
return handleUpdateTask(state, task, projectMoveSubTaskIds);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { RootState } from '../../root-state';
|
|||
import { Tag } from '../../../features/tag/tag.model';
|
||||
import { Project } from '../../../features/project/project.model';
|
||||
import { Task } from '../../../features/tasks/task.model';
|
||||
import {
|
||||
TASK_FEATURE_NAME,
|
||||
taskAdapter,
|
||||
} from '../../../features/tasks/store/task.reducer';
|
||||
import {
|
||||
PROJECT_FEATURE_NAME,
|
||||
projectAdapter,
|
||||
|
|
@ -20,7 +24,12 @@ import { TODAY_TAG } from '../../../features/tag/tag.const';
|
|||
// =============================================================================
|
||||
|
||||
export type ProjectTaskList = 'backlogTaskIds' | 'taskIds';
|
||||
export type TaskEntity = { id: string; projectId?: string | null; tagIds?: string[] };
|
||||
export type TaskEntity = {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
tagIds?: string[];
|
||||
subTaskIds?: string[];
|
||||
};
|
||||
export type TaskWithTags = Task & { tagIds: string[] };
|
||||
export type ActionHandler = (state: RootState) => RootState;
|
||||
export type ActionHandlerMap = Record<string, ActionHandler>;
|
||||
|
|
@ -46,6 +55,46 @@ export const updateTags = (state: RootState, updates: Update<Tag>[]): RootState
|
|||
[TAG_FEATURE_NAME]: tagAdapter.updateMany(updates, state[TAG_FEATURE_NAME]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Collects parent task IDs plus children found through either side of the
|
||||
* parent/subtask relationship. The reverse lookup mirrors deleteTaskHelper's
|
||||
* protection against sync races where parent.subTaskIds is incomplete.
|
||||
* `payloadSubTaskIds` covers replay on clients where neither side of the
|
||||
* relationship has arrived yet (children only known from the action payload).
|
||||
*/
|
||||
export const collectTaskAndSubTaskIds = (
|
||||
state: RootState,
|
||||
parentTaskIds: string[],
|
||||
payloadSubTaskIds: string[] | undefined = [],
|
||||
): string[] => {
|
||||
const parentIdSet = new Set(parentTaskIds);
|
||||
const taskIds = new Set([...parentTaskIds, ...(payloadSubTaskIds ?? [])]);
|
||||
const taskState = state[TASK_FEATURE_NAME];
|
||||
|
||||
for (const parentTaskId of parentTaskIds) {
|
||||
const parentTask = taskState.entities[parentTaskId];
|
||||
for (const subTaskId of parentTask?.subTaskIds ?? []) {
|
||||
taskIds.add(subTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const taskId of taskState.ids as string[]) {
|
||||
const task = taskState.entities[taskId];
|
||||
if (task?.parentId && parentIdSet.has(task.parentId)) {
|
||||
taskIds.add(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(taskIds);
|
||||
};
|
||||
|
||||
export const isValidTaskProjectIdUpdate = (
|
||||
state: RootState,
|
||||
task: Task,
|
||||
projectId: string,
|
||||
): boolean =>
|
||||
!task.parentId && (projectId === '' || !!getProjectOrUndefined(state, projectId));
|
||||
|
||||
/**
|
||||
* Removes the given task IDs from every tag's `taskIds`. Scans ALL tags from
|
||||
* the CURRENT state (not a payload-provided list or the task's own `tagIds`)
|
||||
|
|
@ -59,6 +108,8 @@ export const removeTasksFromAllTags = (
|
|||
state: RootState,
|
||||
taskIds: string[],
|
||||
): RootState => {
|
||||
if (taskIds.length === 0) return state;
|
||||
|
||||
const taskIdSet = new Set(taskIds);
|
||||
const tagUpdates = (state[TAG_FEATURE_NAME].ids as string[])
|
||||
.map((tagId) => state[TAG_FEATURE_NAME].entities[tagId])
|
||||
|
|
@ -67,13 +118,122 @@ export const removeTasksFromAllTags = (
|
|||
(tag): Update<Tag> => ({
|
||||
id: tag.id,
|
||||
changes: {
|
||||
taskIds: removeTasksFromList(tag.taskIds, taskIds),
|
||||
taskIds: tag.taskIds.filter((id) => !taskIdSet.has(id)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return updateTags(state, tagUpdates);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the given task IDs from every project's regular and backlog lists.
|
||||
* Scanning all projects also repairs one-sided references left by older clients
|
||||
* or partial updates where task.projectId no longer matches the containing list.
|
||||
*/
|
||||
export const removeTasksFromAllProjects = (
|
||||
state: RootState,
|
||||
taskIds: string[],
|
||||
): RootState => {
|
||||
if (taskIds.length === 0) return state;
|
||||
|
||||
const taskIdSet = new Set(taskIds);
|
||||
const projectUpdates = (state[PROJECT_FEATURE_NAME].ids as string[])
|
||||
.map((projectId) => state[PROJECT_FEATURE_NAME].entities[projectId])
|
||||
.filter(
|
||||
(project): project is Project =>
|
||||
!!project &&
|
||||
((project.taskIds ?? []).some((id) => taskIdSet.has(id)) ||
|
||||
(project.backlogTaskIds ?? []).some((id) => taskIdSet.has(id))),
|
||||
)
|
||||
.map(
|
||||
(project): Update<Project> => ({
|
||||
id: project.id,
|
||||
changes: {
|
||||
taskIds: (project.taskIds ?? []).filter((id) => !taskIdSet.has(id)),
|
||||
backlogTaskIds: (project.backlogTaskIds ?? []).filter(
|
||||
(id) => !taskIdSet.has(id),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (projectUpdates.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[PROJECT_FEATURE_NAME]: projectAdapter.updateMany(
|
||||
projectUpdates,
|
||||
state[PROJECT_FEATURE_NAME],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Repairs a root task's project relationship after an LWW update. New LWW
|
||||
* operations derived from project moves carry the source operation's entityIds,
|
||||
* which are the deterministic move footprint. Legacy LWW operations have no
|
||||
* such footprint and fall back to deriving children from receiving state.
|
||||
*
|
||||
* Every project is scanned so stale one-sided references are removed. The
|
||||
* destination project's existing root placement (regular vs backlog) and
|
||||
* ordering are preserved when possible; subtasks never belong in either list.
|
||||
*/
|
||||
export const repairTaskProjectForLww = (
|
||||
state: RootState,
|
||||
task: Pick<Task, 'id' | 'projectId' | 'subTaskIds'>,
|
||||
targetProjectId: string | undefined,
|
||||
operationTaskIds?: string[],
|
||||
): RootState => {
|
||||
const targetProject = targetProjectId
|
||||
? getProjectOrUndefined(state, targetProjectId)
|
||||
: undefined;
|
||||
|
||||
// Project creation can arrive after a task recreation LWW. Preserve that
|
||||
// out-of-order task snapshot until the referenced project is replayed.
|
||||
if (targetProjectId && !targetProject) return state;
|
||||
|
||||
const allTaskIds = unique(
|
||||
operationTaskIds !== undefined
|
||||
? [task.id, ...operationTaskIds]
|
||||
: collectTaskAndSubTaskIds(state, [task.id], task.subTaskIds),
|
||||
).filter((id) => !Object.prototype.hasOwnProperty.call(Object.prototype, id));
|
||||
let updatedState = removeTasksFromAllProjects(state, allTaskIds);
|
||||
|
||||
if (targetProjectId && targetProject) {
|
||||
const childIds = new Set(allTaskIds.filter((id) => id !== task.id));
|
||||
let taskIds = unique((targetProject.taskIds ?? []).filter((id) => !childIds.has(id)));
|
||||
let backlogTaskIds = unique(
|
||||
(targetProject.backlogTaskIds ?? []).filter((id) => !childIds.has(id)),
|
||||
);
|
||||
|
||||
if (taskIds.includes(task.id)) {
|
||||
backlogTaskIds = backlogTaskIds.filter((id) => id !== task.id);
|
||||
} else if (!backlogTaskIds.includes(task.id)) {
|
||||
taskIds = [...taskIds, task.id];
|
||||
}
|
||||
|
||||
updatedState = updateProject(updatedState, targetProjectId, {
|
||||
taskIds,
|
||||
backlogTaskIds,
|
||||
});
|
||||
}
|
||||
|
||||
const taskUpdates: Update<Task>[] = allTaskIds.map((id) => ({
|
||||
id,
|
||||
changes: { projectId: targetProjectId },
|
||||
}));
|
||||
|
||||
return {
|
||||
...updatedState,
|
||||
[TASK_FEATURE_NAME]: taskAdapter.updateMany(
|
||||
taskUpdates,
|
||||
updatedState[TASK_FEATURE_NAME],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ENTITY GETTERS
|
||||
// =============================================================================
|
||||
|
|
@ -83,7 +243,7 @@ export const removeTasksFromAllTags = (
|
|||
* Callers should check existence before calling if tag may not exist.
|
||||
*/
|
||||
export const getTag = (state: RootState, tagId: string): Tag => {
|
||||
const tag = state[TAG_FEATURE_NAME].entities[tagId];
|
||||
const tag = getTagOrUndefined(state, tagId);
|
||||
if (!tag) {
|
||||
throw new Error(
|
||||
`Tag ${tagId} not found in state. This may indicate an out-of-order remote operation.`,
|
||||
|
|
@ -92,13 +252,21 @@ export const getTag = (state: RootState, tagId: string): Tag => {
|
|||
return tag as Tag;
|
||||
};
|
||||
|
||||
export const getTagOrUndefined = (state: RootState, tagId: string): Tag | undefined => {
|
||||
const tag = state[TAG_FEATURE_NAME].entities[tagId] as Tag | undefined;
|
||||
return tag?.id === tagId ? tag : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a project entity from state. Throws if project doesn't exist.
|
||||
* Callers should check existence before calling if project may not exist.
|
||||
*/
|
||||
export const getProject = (state: RootState, projectId: string): Project => {
|
||||
const project = state[PROJECT_FEATURE_NAME].entities[projectId];
|
||||
if (!project) {
|
||||
// The id equality check rejects inherited Object.prototype members
|
||||
// ('constructor', 'toString', …) that a bare entities[id] lookup returns
|
||||
// truthy for when the id comes from external input (REST API, remote ops).
|
||||
if (!project || project.id !== projectId) {
|
||||
throw new Error(
|
||||
`Project ${projectId} not found in state. This may indicate an out-of-order remote operation.`,
|
||||
);
|
||||
|
|
@ -113,8 +281,10 @@ export const getProject = (state: RootState, projectId: string): Project => {
|
|||
export const getProjectOrUndefined = (
|
||||
state: RootState,
|
||||
projectId: string,
|
||||
): Project | undefined =>
|
||||
state[PROJECT_FEATURE_NAME].entities[projectId] as Project | undefined;
|
||||
): Project | undefined => {
|
||||
const project = state[PROJECT_FEATURE_NAME].entities[projectId] as Project | undefined;
|
||||
return project?.id === projectId ? project : undefined;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// LIST MANIPULATION HELPERS
|
||||
|
|
|
|||
|
|
@ -99,6 +99,76 @@ describe('taskSharedLifecycleMetaReducer', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should remove stale task references from every project when archiving', () => {
|
||||
const testState = createStateWithExistingTasks(['task1']);
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
|
||||
id: 'project2',
|
||||
title: 'Project 2',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
projectId: 'project2',
|
||||
});
|
||||
const action = createArchiveAction([
|
||||
createTaskWithSubTasks({ id: 'task1', projectId: 'project2' }),
|
||||
]);
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
|
||||
entities: jasmine.objectContaining({
|
||||
project1: jasmine.objectContaining({ taskIds: [] }),
|
||||
project2: jasmine.objectContaining({ taskIds: [] }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean reverse-linked subtasks omitted from the archive payload', () => {
|
||||
const testState = createStateWithExistingTasks(
|
||||
['task1', 'orphan-subtask'],
|
||||
['orphan-subtask'],
|
||||
);
|
||||
testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({
|
||||
id: 'task1',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].entities['orphan-subtask'] = createMockTask({
|
||||
id: 'orphan-subtask',
|
||||
projectId: 'project1',
|
||||
parentId: 'task1',
|
||||
});
|
||||
const action = createArchiveAction([
|
||||
createTaskWithSubTasks({
|
||||
id: 'task1',
|
||||
projectId: 'project1',
|
||||
subTaskIds: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
expectProjectUpdate('project1', {
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
}),
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty tasks array', () => {
|
||||
const action = createArchiveAction([]);
|
||||
|
||||
|
|
@ -656,6 +726,16 @@ describe('taskSharedLifecycleMetaReducer', () => {
|
|||
subTasks,
|
||||
});
|
||||
|
||||
for (const unsafeTaskId of ['constructor', '__proto__']) {
|
||||
it(`should reject prototype-like restored task id ${unsafeTaskId}`, () => {
|
||||
const action = createRestoreAction({ id: unsafeTaskId });
|
||||
|
||||
expect(() => metaReducer(baseState, action)).not.toThrow();
|
||||
expect(mockReducer).toHaveBeenCalledWith(baseState, action);
|
||||
expect(baseState[PROJECT_FEATURE_NAME].entities.project1?.taskIds).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it('should add task to project taskIds', () => {
|
||||
const action = createRestoreAction();
|
||||
|
||||
|
|
@ -680,6 +760,7 @@ describe('taskSharedLifecycleMetaReducer', () => {
|
|||
{
|
||||
...expectProjectUpdate('project1', { taskIds: ['task1'] }),
|
||||
...expectTagUpdate('tag1', { taskIds: ['task1', 'subtask1'] }),
|
||||
...expectTaskUpdate('subtask1', { parentId: 'task1' }),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
|
|
@ -687,6 +768,152 @@ describe('taskSharedLifecycleMetaReducer', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should restore subtasks into the parent project', () => {
|
||||
const subTasks = [
|
||||
createMockTask({
|
||||
id: 'subtask1',
|
||||
parentId: 'task1',
|
||||
projectId: 'project1',
|
||||
}),
|
||||
];
|
||||
const testState = createBaseState();
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
|
||||
id: 'project2',
|
||||
title: 'Project 2',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
const action = createRestoreAction(
|
||||
{
|
||||
id: 'task1',
|
||||
projectId: 'project2',
|
||||
subTaskIds: ['subtask1'],
|
||||
},
|
||||
subTasks,
|
||||
);
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
...expectTaskUpdate('task1', { projectId: 'project2' }),
|
||||
...expectTaskUpdate('subtask1', { projectId: 'project2' }),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should repair an existing reverse-linked subtask omitted from restore payload', () => {
|
||||
const testState = createStateWithExistingTasks(['subtask1']);
|
||||
testState[TASK_FEATURE_NAME].entities.subtask1 = createMockTask({
|
||||
id: 'subtask1',
|
||||
parentId: 'task1',
|
||||
projectId: 'project1',
|
||||
});
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
|
||||
id: 'project2',
|
||||
title: 'Destination Project',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
const action = createRestoreAction({
|
||||
id: 'task1',
|
||||
projectId: 'project2',
|
||||
subTaskIds: [],
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState;
|
||||
expect(updatedState[TASK_FEATURE_NAME].entities.task1?.subTaskIds).toEqual([
|
||||
'subtask1',
|
||||
]);
|
||||
expect(updatedState[TASK_FEATURE_NAME].entities.subtask1?.projectId).toBe(
|
||||
'project2',
|
||||
);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME].entities.project1?.taskIds).toEqual([]);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME].entities.project2?.taskIds).toEqual([
|
||||
'task1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not adopt payload child ids owned by another active parent', () => {
|
||||
const testState = createStateWithExistingTasks(['other-parent', 'colliding-child']);
|
||||
testState[TASK_FEATURE_NAME].entities['other-parent'] = createMockTask({
|
||||
id: 'other-parent',
|
||||
projectId: 'project1',
|
||||
subTaskIds: ['colliding-child'],
|
||||
});
|
||||
testState[TASK_FEATURE_NAME].entities['colliding-child'] = createMockTask({
|
||||
id: 'colliding-child',
|
||||
parentId: 'other-parent',
|
||||
projectId: 'project1',
|
||||
});
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
|
||||
id: 'project2',
|
||||
title: 'Restore Project',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
const action = createRestoreAction(
|
||||
{
|
||||
id: 'task1',
|
||||
projectId: 'project2',
|
||||
subTaskIds: ['colliding-child', 'missing-child'],
|
||||
},
|
||||
[
|
||||
createMockTask({
|
||||
id: 'colliding-child',
|
||||
parentId: 'task1',
|
||||
projectId: 'project2',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState;
|
||||
expect(updatedState[TASK_FEATURE_NAME].entities.task1?.subTaskIds).toEqual([]);
|
||||
expect(updatedState[TASK_FEATURE_NAME].entities['colliding-child']).toEqual(
|
||||
jasmine.objectContaining({
|
||||
parentId: 'other-parent',
|
||||
projectId: 'project1',
|
||||
}),
|
||||
);
|
||||
expect(updatedState[TASK_FEATURE_NAME].entities['missing-child']).toBeUndefined();
|
||||
expect(updatedState[TAG_FEATURE_NAME].entities.tag1?.taskIds).toEqual(['task1']);
|
||||
});
|
||||
|
||||
it('should keep canonical relationships when replaying restore for an active task', () => {
|
||||
const testState = createStateWithExistingTasks(['task1']);
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
|
||||
id: 'project2',
|
||||
title: 'Payload Project',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
const action = createRestoreAction({
|
||||
id: 'task1',
|
||||
projectId: 'project2',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as RootState;
|
||||
expect(updatedState[TASK_FEATURE_NAME].entities.task1?.projectId).toBe('project1');
|
||||
expect(updatedState[PROJECT_FEATURE_NAME].entities.project1?.taskIds).toEqual([
|
||||
'task1',
|
||||
]);
|
||||
expect(updatedState[PROJECT_FEATURE_NAME].entities.project2?.taskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add tasks to existing taskIds', () => {
|
||||
const testState = createStateWithExistingTasks(
|
||||
['existing-task'],
|
||||
|
|
@ -707,7 +934,82 @@ describe('taskSharedLifecycleMetaReducer', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should remove stale project references before restoring a task', () => {
|
||||
const testState = createStateWithExistingTasks(
|
||||
['archived-task'],
|
||||
['archived-task'],
|
||||
);
|
||||
testState[TASK_FEATURE_NAME] = {
|
||||
...testState[TASK_FEATURE_NAME],
|
||||
ids: [],
|
||||
entities: {},
|
||||
};
|
||||
testState[PROJECT_FEATURE_NAME].entities.project2 = createMockProject({
|
||||
id: 'project2',
|
||||
title: 'Project 2',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('project2');
|
||||
const action = createRestoreAction({
|
||||
id: 'archived-task',
|
||||
projectId: 'project2',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
[PROJECT_FEATURE_NAME]: jasmine.objectContaining({
|
||||
entities: jasmine.objectContaining({
|
||||
project1: jasmine.objectContaining({
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
}),
|
||||
project2: jasmine.objectContaining({ taskIds: ['archived-task'] }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
describe('stale reference normalization (issue #6270)', () => {
|
||||
it('should preserve an archived-but-existing project on restore', () => {
|
||||
const testState = createBaseState();
|
||||
testState[PROJECT_FEATURE_NAME].entities['archived-project'] = createMockProject({
|
||||
id: 'archived-project',
|
||||
isArchived: true,
|
||||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
});
|
||||
(testState[PROJECT_FEATURE_NAME].ids as string[]).push('archived-project');
|
||||
const action = createRestoreAction({
|
||||
id: 'archived-task',
|
||||
projectId: 'archived-project',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
metaReducer(testState, action);
|
||||
|
||||
expectStateUpdate(
|
||||
{
|
||||
...expectTaskUpdate('archived-task', {
|
||||
projectId: 'archived-project',
|
||||
}),
|
||||
...expectProjectUpdate('archived-project', {
|
||||
taskIds: ['archived-task'],
|
||||
}),
|
||||
},
|
||||
action,
|
||||
mockReducer,
|
||||
testState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reassign stale projectId to INBOX on restore', () => {
|
||||
const testState = createBaseState();
|
||||
// Add INBOX_PROJECT to state
|
||||
|
|
@ -740,7 +1042,7 @@ describe('taskSharedLifecycleMetaReducer', () => {
|
|||
const action = createRestoreAction({
|
||||
id: 'archived-task',
|
||||
projectId: 'project1',
|
||||
tagIds: ['tag1', 'DELETED_TAG'],
|
||||
tagIds: ['tag1', 'DELETED_TAG', 'constructor', '__proto__'],
|
||||
});
|
||||
|
||||
metaReducer(baseState, action);
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@ import {
|
|||
PROJECT_FEATURE_NAME,
|
||||
projectAdapter,
|
||||
} from '../../../features/project/store/project.reducer';
|
||||
import { TAG_FEATURE_NAME } from '../../../features/tag/store/tag.reducer';
|
||||
import {
|
||||
TASK_FEATURE_NAME,
|
||||
taskAdapter,
|
||||
} from '../../../features/tasks/store/task.reducer';
|
||||
import { Tag } from '../../../features/tag/tag.model';
|
||||
import { Project } from '../../../features/project/project.model';
|
||||
import { Task, TaskWithSubTasks } from '../../../features/tasks/task.model';
|
||||
import { TODAY_TAG } from '../../../features/tag/tag.const';
|
||||
import { INBOX_PROJECT } from '../../../features/project/project.const';
|
||||
|
|
@ -20,10 +18,12 @@ import { TASK_REPEAT_CFG_FEATURE_NAME } from '../../../features/task-repeat-cfg/
|
|||
import { unique } from '../../../util/unique';
|
||||
import {
|
||||
ActionHandlerMap,
|
||||
getProject,
|
||||
collectTaskAndSubTaskIds,
|
||||
getProjectOrUndefined,
|
||||
getTag,
|
||||
getTagOrUndefined,
|
||||
removeTasksFromAllProjects,
|
||||
removeTasksFromAllTags,
|
||||
removeTasksFromList,
|
||||
TaskEntity,
|
||||
updateTags,
|
||||
} from './task-shared-helpers';
|
||||
|
|
@ -33,44 +33,18 @@ import {
|
|||
// =============================================================================
|
||||
|
||||
const handleMoveToArchive = (state: RootState, tasks: TaskWithSubTasks[]): RootState => {
|
||||
const taskIdsToArchive = tasks.flatMap((t) => [t.id, ...t.subTasks.map((st) => st.id)]);
|
||||
|
||||
// Get tag/project associations from CURRENT STATE, not payload.
|
||||
// This is critical for remote sync: the payload reflects the originating client's
|
||||
// state, but this client may have different tag/project associations for the same tasks.
|
||||
// Using current state ensures we clean up all references on THIS client.
|
||||
const projectIds = unique(
|
||||
taskIdsToArchive
|
||||
.map((taskId) => state[TASK_FEATURE_NAME].entities[taskId]?.projectId)
|
||||
.filter((pid): pid is string => !!pid),
|
||||
const taskIdsToArchive = collectTaskAndSubTaskIds(
|
||||
state,
|
||||
tasks.map((task) => task.id),
|
||||
tasks.flatMap((task) => [
|
||||
...(task.subTaskIds ?? []),
|
||||
...task.subTasks.map((subTask) => subTask.id),
|
||||
]),
|
||||
);
|
||||
|
||||
let updatedState = state;
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
const projectUpdates = projectIds
|
||||
.filter((pid) => !!state[PROJECT_FEATURE_NAME].entities[pid])
|
||||
.map((pid): Update<Project> => {
|
||||
const project = getProject(state, pid);
|
||||
return {
|
||||
id: pid,
|
||||
changes: {
|
||||
taskIds: removeTasksFromList(project.taskIds, taskIdsToArchive),
|
||||
backlogTaskIds: removeTasksFromList(project.backlogTaskIds, taskIdsToArchive),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (projectUpdates.length > 0) {
|
||||
updatedState = {
|
||||
...updatedState,
|
||||
[PROJECT_FEATURE_NAME]: projectAdapter.updateMany(
|
||||
projectUpdates,
|
||||
updatedState[PROJECT_FEATURE_NAME],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Scan every project instead of trusting task.projectId. Older partial updates
|
||||
// could leave a task referenced by a different project than the task claims.
|
||||
const updatedState = removeTasksFromAllProjects(state, taskIdsToArchive);
|
||||
|
||||
// Scan every tag, not just each task's own `tagIds` — see
|
||||
// removeTasksFromAllTags for why (one-sided tag refs after sync).
|
||||
|
|
@ -86,17 +60,18 @@ const normalizeRestoredTask = <T extends TaskEntity | Task>(
|
|||
t: T,
|
||||
state: RootState,
|
||||
): T => {
|
||||
// Reassign stale projectId to INBOX
|
||||
// Reassign a deleted projectId to INBOX. Archived projects remain valid
|
||||
// task owners and must survive reducer replay.
|
||||
let projectId = t.projectId;
|
||||
if (projectId && !state[PROJECT_FEATURE_NAME].entities[projectId]) {
|
||||
projectId = state[PROJECT_FEATURE_NAME].entities[INBOX_PROJECT.id]
|
||||
if (projectId && !getProjectOrUndefined(state, projectId)) {
|
||||
projectId = getProjectOrUndefined(state, INBOX_PROJECT.id)
|
||||
? INBOX_PROJECT.id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Strip stale tagIds and TODAY_TAG (must never be in task.tagIds)
|
||||
const tagIds = (t.tagIds || []).filter(
|
||||
(tagId) => tagId !== TODAY_TAG.id && !!state[TAG_FEATURE_NAME].entities[tagId],
|
||||
(tagId) => tagId !== TODAY_TAG.id && !!getTagOrUndefined(state, tagId),
|
||||
);
|
||||
|
||||
// Clear stale repeatCfgId (only present on full Task, not minimal TaskEntity)
|
||||
|
|
@ -115,26 +90,92 @@ const handleRestoreTask = (
|
|||
task: TaskEntity,
|
||||
subTasks: Task[],
|
||||
): RootState => {
|
||||
if (Object.prototype.hasOwnProperty.call(Object.prototype, task.id)) return state;
|
||||
|
||||
// A replayed restore is idempotent when the root is already active. NgRx
|
||||
// addMany would otherwise ignore the root but add payload children as
|
||||
// orphans the root's subTaskIds never references.
|
||||
if (state[TASK_FEATURE_NAME].entities[task.id]?.id === task.id) return state;
|
||||
|
||||
// Normalize stale refs before adding to active state
|
||||
const restoredTask = normalizeRestoredTask(
|
||||
const normalizedRestoredTask = normalizeRestoredTask(
|
||||
{ ...task, isDone: false, doneOn: undefined },
|
||||
state,
|
||||
);
|
||||
const restoredSubTasks = subTasks.map((st) => normalizeRestoredTask(st, state));
|
||||
const restoredTask = {
|
||||
...normalizedRestoredTask,
|
||||
projectId: normalizedRestoredTask.projectId ?? '',
|
||||
};
|
||||
const declaredSubTaskIds = new Set(restoredTask.subTaskIds ?? []);
|
||||
const restoredSubTasks = subTasks
|
||||
.filter((subTask) => {
|
||||
const existingSubTask = state[TASK_FEATURE_NAME].entities[subTask.id];
|
||||
const belongsToRestoredTask =
|
||||
subTask.parentId === restoredTask.id ||
|
||||
(!subTask.parentId && declaredSubTaskIds.has(subTask.id));
|
||||
return (
|
||||
belongsToRestoredTask &&
|
||||
(!existingSubTask || existingSubTask.parentId === restoredTask.id)
|
||||
);
|
||||
})
|
||||
.map((subTask) => ({
|
||||
...normalizeRestoredTask(subTask, state),
|
||||
parentId: restoredTask.id,
|
||||
projectId: restoredTask.projectId,
|
||||
}));
|
||||
|
||||
const updatedTaskState = taskAdapter.addMany(
|
||||
[restoredTask as Task, ...restoredSubTasks],
|
||||
state[TASK_FEATURE_NAME],
|
||||
);
|
||||
|
||||
let updatedState = {
|
||||
let updatedState: RootState = {
|
||||
...state,
|
||||
[TASK_FEATURE_NAME]: updatedTaskState,
|
||||
};
|
||||
|
||||
// Adopt reverse-linked children (already active with parentId → root but
|
||||
// missing from the payload/root's subTaskIds) so the restored parent and
|
||||
// its children end up in one project with a two-sided relationship.
|
||||
const restoreCandidateIds = collectTaskAndSubTaskIds(
|
||||
updatedState,
|
||||
[restoredTask.id],
|
||||
[...(restoredTask.subTaskIds ?? []), ...restoredSubTasks.map((st) => st.id)],
|
||||
);
|
||||
const verifiedSubTaskIds = restoreCandidateIds.filter(
|
||||
(id) =>
|
||||
id !== restoredTask.id &&
|
||||
updatedState[TASK_FEATURE_NAME].entities[id]?.parentId === restoredTask.id,
|
||||
);
|
||||
const normalizedSubTaskIds = unique(verifiedSubTaskIds);
|
||||
const restoredTaskIds = [restoredTask.id, ...normalizedSubTaskIds];
|
||||
const restoredTaskUpdates: Update<Task>[] = normalizedSubTaskIds.map((id) => ({
|
||||
id,
|
||||
changes: { projectId: restoredTask.projectId },
|
||||
}));
|
||||
if (
|
||||
normalizedSubTaskIds.length !== (restoredTask.subTaskIds ?? []).length ||
|
||||
normalizedSubTaskIds.some(
|
||||
(id, index) => id !== (restoredTask.subTaskIds ?? [])[index],
|
||||
)
|
||||
) {
|
||||
restoredTaskUpdates.push({
|
||||
id: restoredTask.id,
|
||||
changes: { subTaskIds: normalizedSubTaskIds },
|
||||
});
|
||||
}
|
||||
updatedState = {
|
||||
...updatedState,
|
||||
[TASK_FEATURE_NAME]: taskAdapter.updateMany(
|
||||
restoredTaskUpdates,
|
||||
updatedState[TASK_FEATURE_NAME],
|
||||
),
|
||||
};
|
||||
updatedState = removeTasksFromAllProjects(updatedState, restoredTaskIds);
|
||||
|
||||
// Update project
|
||||
if (restoredTask.projectId) {
|
||||
const project = state[PROJECT_FEATURE_NAME].entities[restoredTask.projectId];
|
||||
const project = getProjectOrUndefined(updatedState, restoredTask.projectId);
|
||||
if (project) {
|
||||
updatedState = {
|
||||
...updatedState,
|
||||
|
|
@ -152,20 +193,20 @@ const handleRestoreTask = (
|
|||
}
|
||||
|
||||
// Update tags
|
||||
const allTasks = [restoredTask, ...restoredSubTasks];
|
||||
const tagTaskMap = allTasks.reduce(
|
||||
(map, t) => {
|
||||
(t.tagIds || []).forEach((tagId) => {
|
||||
if (!map[tagId]) map[tagId] = [];
|
||||
map[tagId].push(t.id);
|
||||
});
|
||||
return map;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
const allTasks = restoredTaskIds
|
||||
.map((id) => updatedState[TASK_FEATURE_NAME].entities[id])
|
||||
.filter((restoredEntity): restoredEntity is Task => !!restoredEntity);
|
||||
const tagTaskMap = new Map<string, string[]>();
|
||||
for (const restoredEntity of allTasks) {
|
||||
for (const tagId of restoredEntity.tagIds ?? []) {
|
||||
const taskIds = tagTaskMap.get(tagId) ?? [];
|
||||
taskIds.push(restoredEntity.id);
|
||||
tagTaskMap.set(tagId, taskIds);
|
||||
}
|
||||
}
|
||||
|
||||
const tagUpdates = Object.entries(tagTaskMap)
|
||||
.filter(([tagId]) => state[TAG_FEATURE_NAME].entities[tagId])
|
||||
const tagUpdates = Array.from(tagTaskMap.entries())
|
||||
.filter(([tagId]) => getTagOrUndefined(state, tagId))
|
||||
.map(
|
||||
([tagId, taskIds]): Update<Tag> => ({
|
||||
id: tagId,
|
||||
|
|
|
|||
|
|
@ -254,15 +254,28 @@ export const TaskSharedActions = createActionGroup({
|
|||
}),
|
||||
|
||||
// Task Updates
|
||||
updateTask: (taskProps: { task: Update<Task>; isIgnoreShortSyntax?: boolean }) => ({
|
||||
...taskProps,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: taskProps.task.id as string,
|
||||
opType: OpType.Update,
|
||||
} satisfies PersistentActionMeta,
|
||||
}),
|
||||
updateTask: (taskProps: {
|
||||
task: Update<Task>;
|
||||
isIgnoreShortSyntax?: boolean;
|
||||
projectMoveSubTaskIds?: string[];
|
||||
}) => {
|
||||
const entityId = taskProps.task.id as string;
|
||||
|
||||
return {
|
||||
...taskProps,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId,
|
||||
...(taskProps.projectMoveSubTaskIds !== undefined && {
|
||||
entityIds: Array.from(
|
||||
new Set([entityId, ...taskProps.projectMoveSubTaskIds]),
|
||||
),
|
||||
}),
|
||||
opType: OpType.Update,
|
||||
} satisfies PersistentActionMeta,
|
||||
};
|
||||
},
|
||||
|
||||
// Bulk task update - creates single operation instead of N operations
|
||||
// Critical for repeating task config updates that affect many archived instances
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue