mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
fix(calendar): poll all calendar tasks and prevent auto-move of existing tasks
- Poll ALL calendar tasks across all projects, not just current context - Replace forkJoin with merge (forkJoin never emits with timer) - Add selectAllCalendarIssueTasks selector for cross-project calendar tasks - Prevent existing ICAL tasks from being auto-moved to current context - Add error handling to prevent polling stream termination on errors - Add comprehensive tests for polling effects and selector Fixes #4474
This commit is contained in:
parent
08971fce47
commit
c2b7627125
8 changed files with 1063 additions and 64 deletions
307
src/app/features/issue/issue.service.spec.ts
Normal file
307
src/app/features/issue/issue.service.spec.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { IssueService } from './issue.service';
|
||||
import { TaskService } from '../tasks/task.service';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { WorkContextService } from '../work-context/work-context.service';
|
||||
import { WorkContextType } from '../work-context/work-context.model';
|
||||
import { IssueProviderService } from './issue-provider.service';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
import { CalendarIntegrationService } from '../calendar-integration/calendar-integration.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/global-progress-bar.service';
|
||||
import { NavigateToTaskService } from '../../core-ui/navigate-to-task/navigate-to-task.service';
|
||||
import { Task, TaskWithSubTasks } from '../tasks/task.model';
|
||||
import { of } from 'rxjs';
|
||||
import { T } from '../../t.const';
|
||||
import { TODAY_TAG } from '../tag/tag.const';
|
||||
import { ICalIssueReduced } from './providers/calendar/calendar.model';
|
||||
import { SnackParams } from '../../core/snack/snack.model';
|
||||
import { JiraCommonInterfacesService } from './providers/jira/jira-common-interfaces.service';
|
||||
import { GithubCommonInterfacesService } from './providers/github/github-common-interfaces.service';
|
||||
import { TrelloCommonInterfacesService } from './providers/trello/trello-common-interfaces.service';
|
||||
import { GitlabCommonInterfacesService } from './providers/gitlab/gitlab-common-interfaces.service';
|
||||
import { CaldavCommonInterfacesService } from './providers/caldav/caldav-common-interfaces.service';
|
||||
import { OpenProjectCommonInterfacesService } from './providers/open-project/open-project-common-interfaces.service';
|
||||
import { GiteaCommonInterfacesService } from './providers/gitea/gitea-common-interfaces.service';
|
||||
import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service';
|
||||
import { LinearCommonInterfacesService } from './providers/linear/linear-common-interfaces.service';
|
||||
import { ClickUpCommonInterfacesService } from './providers/clickup/clickup-common-interfaces.service';
|
||||
import { CalendarCommonInterfacesService } from './providers/calendar/calendar-common-interfaces.service';
|
||||
|
||||
describe('IssueService', () => {
|
||||
let service: IssueService;
|
||||
let taskServiceSpy: jasmine.SpyObj<TaskService>;
|
||||
let snackServiceSpy: jasmine.SpyObj<SnackService>;
|
||||
let workContextServiceSpy: jasmine.SpyObj<WorkContextService>;
|
||||
let issueProviderServiceSpy: jasmine.SpyObj<IssueProviderService>;
|
||||
let projectServiceSpy: jasmine.SpyObj<ProjectService>;
|
||||
let calendarIntegrationServiceSpy: jasmine.SpyObj<CalendarIntegrationService>;
|
||||
let storeSpy: jasmine.SpyObj<Store>;
|
||||
let translateServiceSpy: jasmine.SpyObj<TranslateService>;
|
||||
let globalProgressBarServiceSpy: jasmine.SpyObj<GlobalProgressBarService>;
|
||||
let navigateToTaskServiceSpy: jasmine.SpyObj<NavigateToTaskService>;
|
||||
|
||||
const createMockTask = (overrides: Partial<Task> = {}): Task =>
|
||||
({
|
||||
id: 'existing-task-123',
|
||||
title: 'Existing Calendar Event Task',
|
||||
issueId: 'cal-event-456',
|
||||
issueProviderId: 'calendar-provider-1',
|
||||
issueType: 'ICAL',
|
||||
dueWithTime: new Date('2025-01-20T14:00:00Z').getTime(),
|
||||
projectId: 'project-1',
|
||||
tagIds: [],
|
||||
...overrides,
|
||||
}) as Task;
|
||||
|
||||
const createMockCalendarEvent = (
|
||||
overrides: Partial<ICalIssueReduced> = {},
|
||||
): ICalIssueReduced => ({
|
||||
id: 'cal-event-456',
|
||||
calProviderId: 'calendar-provider-1',
|
||||
title: 'Calendar Event',
|
||||
start: new Date('2025-01-20T14:00:00Z').getTime(),
|
||||
duration: 3600000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
taskServiceSpy = jasmine.createSpyObj('TaskService', [
|
||||
'checkForTaskWithIssueEverywhere',
|
||||
'getByIdWithSubTaskData$',
|
||||
'moveToCurrentWorkContext',
|
||||
'add',
|
||||
'addAndSchedule',
|
||||
'restoreTask',
|
||||
]);
|
||||
snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
|
||||
workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], {
|
||||
activeWorkContextId: TODAY_TAG.id,
|
||||
activeWorkContextType: WorkContextType.TAG,
|
||||
});
|
||||
issueProviderServiceSpy = jasmine.createSpyObj('IssueProviderService', [
|
||||
'getCfgOnce$',
|
||||
]);
|
||||
projectServiceSpy = jasmine.createSpyObj('ProjectService', [
|
||||
'getByIdOnce$',
|
||||
'moveTaskToTodayList',
|
||||
]);
|
||||
calendarIntegrationServiceSpy = jasmine.createSpyObj('CalendarIntegrationService', [
|
||||
'skipCalendarEvent',
|
||||
]);
|
||||
storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch']);
|
||||
translateServiceSpy = jasmine.createSpyObj('TranslateService', ['instant']);
|
||||
globalProgressBarServiceSpy = jasmine.createSpyObj('GlobalProgressBarService', [
|
||||
'countUp',
|
||||
'countDown',
|
||||
]);
|
||||
navigateToTaskServiceSpy = jasmine.createSpyObj('NavigateToTaskService', [
|
||||
'navigate',
|
||||
]);
|
||||
|
||||
// Default mock return values - use 'as any' to bypass strict type checking
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(
|
||||
of({ defaultProjectId: 'project-1' } as any),
|
||||
);
|
||||
|
||||
// Default mock for getByIdWithSubTaskData$ - needed when task already exists
|
||||
taskServiceSpy.getByIdWithSubTaskData$.and.returnValue(
|
||||
of({
|
||||
id: 'existing-task-123',
|
||||
title: 'Existing Task',
|
||||
subTasks: [],
|
||||
} as any),
|
||||
);
|
||||
|
||||
// Default mock for projectService
|
||||
projectServiceSpy.getByIdOnce$.and.returnValue(of({ title: 'Project 1' } as any));
|
||||
|
||||
// Create mock providers for all common interface services
|
||||
const mockCommonInterfaceService = jasmine.createSpyObj('CommonInterfaceService', [
|
||||
'isEnabled',
|
||||
'getAddTaskData',
|
||||
]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
IssueService,
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: SnackService, useValue: snackServiceSpy },
|
||||
{ provide: WorkContextService, useValue: workContextServiceSpy },
|
||||
{ provide: IssueProviderService, useValue: issueProviderServiceSpy },
|
||||
{ provide: ProjectService, useValue: projectServiceSpy },
|
||||
{ provide: CalendarIntegrationService, useValue: calendarIntegrationServiceSpy },
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: TranslateService, useValue: translateServiceSpy },
|
||||
{ provide: GlobalProgressBarService, useValue: globalProgressBarServiceSpy },
|
||||
{ provide: NavigateToTaskService, useValue: navigateToTaskServiceSpy },
|
||||
{ provide: JiraCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{ provide: GithubCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{ provide: TrelloCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{ provide: GitlabCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{ provide: CaldavCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{
|
||||
provide: OpenProjectCommonInterfacesService,
|
||||
useValue: mockCommonInterfaceService,
|
||||
},
|
||||
{ provide: GiteaCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{ provide: RedmineCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{ provide: LinearCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{ provide: ClickUpCommonInterfacesService, useValue: mockCommonInterfaceService },
|
||||
{
|
||||
provide: CalendarCommonInterfacesService,
|
||||
useValue: mockCommonInterfaceService,
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(IssueService);
|
||||
});
|
||||
|
||||
describe('addTaskFromIssue - ICAL task already exists', () => {
|
||||
it('should NOT move existing ICAL task to current context when task already exists', async () => {
|
||||
const existingTask = createMockTask();
|
||||
const calendarEvent = createMockCalendarEvent();
|
||||
|
||||
// Task already exists - checkForTaskWithIssueEverywhere returns the task
|
||||
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
|
||||
task: existingTask,
|
||||
subTasks: null,
|
||||
isFromArchive: false,
|
||||
});
|
||||
|
||||
await service.addTaskFromIssue({
|
||||
issueDataReduced: calendarEvent,
|
||||
issueProviderId: 'calendar-provider-1',
|
||||
issueProviderKey: 'ICAL',
|
||||
});
|
||||
|
||||
// Should NOT call moveToCurrentWorkContext - this is the key assertion
|
||||
expect(taskServiceSpy.moveToCurrentWorkContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show snackbar with Go to Task action when ICAL task already exists', async () => {
|
||||
const existingTask = createMockTask();
|
||||
const calendarEvent = createMockCalendarEvent();
|
||||
|
||||
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
|
||||
task: existingTask,
|
||||
subTasks: null,
|
||||
isFromArchive: false,
|
||||
});
|
||||
|
||||
await service.addTaskFromIssue({
|
||||
issueDataReduced: calendarEvent,
|
||||
issueProviderId: 'calendar-provider-1',
|
||||
issueProviderKey: 'ICAL',
|
||||
});
|
||||
|
||||
// Should show snackbar with task title and Go to Task action
|
||||
expect(snackServiceSpy.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
msg: T.F.TASK.S.TASK_ALREADY_EXISTS,
|
||||
actionStr: T.F.TASK.S.GO_TO_TASK,
|
||||
actionFn: jasmine.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should navigate to task when Go to Task action is clicked', async () => {
|
||||
const existingTask = createMockTask();
|
||||
const calendarEvent = createMockCalendarEvent();
|
||||
|
||||
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
|
||||
task: existingTask,
|
||||
subTasks: null,
|
||||
isFromArchive: false,
|
||||
});
|
||||
|
||||
await service.addTaskFromIssue({
|
||||
issueDataReduced: calendarEvent,
|
||||
issueProviderId: 'calendar-provider-1',
|
||||
issueProviderKey: 'ICAL',
|
||||
});
|
||||
|
||||
// Get the actionFn from the snackbar call and execute it
|
||||
const snackCall = snackServiceSpy.open.calls.mostRecent();
|
||||
const snackParams = snackCall.args[0] as SnackParams;
|
||||
const actionFn = snackParams.actionFn;
|
||||
actionFn!();
|
||||
|
||||
expect(navigateToTaskServiceSpy.navigate).toHaveBeenCalledWith(
|
||||
existingTask.id,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve original dueWithTime when ICAL task already exists', async () => {
|
||||
const originalDueWithTime = new Date('2025-01-25T10:00:00Z').getTime();
|
||||
const existingTask = createMockTask({ dueWithTime: originalDueWithTime });
|
||||
const calendarEvent = createMockCalendarEvent();
|
||||
|
||||
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
|
||||
task: existingTask,
|
||||
subTasks: null,
|
||||
isFromArchive: false,
|
||||
});
|
||||
|
||||
await service.addTaskFromIssue({
|
||||
issueDataReduced: calendarEvent,
|
||||
issueProviderId: 'calendar-provider-1',
|
||||
issueProviderKey: 'ICAL',
|
||||
});
|
||||
|
||||
// Should not modify the task at all - no moveToCurrentWorkContext
|
||||
expect(taskServiceSpy.moveToCurrentWorkContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return undefined when ICAL task already exists', async () => {
|
||||
const existingTask = createMockTask();
|
||||
const calendarEvent = createMockCalendarEvent();
|
||||
|
||||
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
|
||||
task: existingTask,
|
||||
subTasks: null,
|
||||
isFromArchive: false,
|
||||
});
|
||||
|
||||
const result = await service.addTaskFromIssue({
|
||||
issueDataReduced: calendarEvent,
|
||||
issueProviderId: 'calendar-provider-1',
|
||||
issueProviderKey: 'ICAL',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTaskFromIssue - non-ICAL issue types (unchanged behavior)', () => {
|
||||
it('should still move non-ICAL tasks to current context when found', async () => {
|
||||
const existingTask = createMockTask({ issueType: 'GITHUB' });
|
||||
const githubIssue = {
|
||||
id: 'github-issue-123',
|
||||
title: 'GitHub Issue',
|
||||
};
|
||||
|
||||
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({
|
||||
task: existingTask,
|
||||
subTasks: null,
|
||||
isFromArchive: false,
|
||||
});
|
||||
taskServiceSpy.getByIdWithSubTaskData$.and.returnValue(
|
||||
of(existingTask as TaskWithSubTasks),
|
||||
);
|
||||
|
||||
await service.addTaskFromIssue({
|
||||
issueDataReduced: githubIssue as any,
|
||||
issueProviderId: 'github-provider-1',
|
||||
issueProviderKey: 'GITHUB',
|
||||
});
|
||||
|
||||
// For non-ICAL types, should still call moveToCurrentWorkContext
|
||||
expect(taskServiceSpy.moveToCurrentWorkContext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -58,6 +58,7 @@ import { getDbDateStr } from '../../util/get-db-date-str';
|
|||
import { TODAY_TAG } from '../tag/tag.const';
|
||||
import typia from 'typia';
|
||||
import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/global-progress-bar.service';
|
||||
import { NavigateToTaskService } from '../../core-ui/navigate-to-task/navigate-to-task.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -83,6 +84,7 @@ export class IssueService {
|
|||
private _calendarIntegrationService = inject(CalendarIntegrationService);
|
||||
private _store = inject(Store);
|
||||
private _globalProgressBarService = inject(GlobalProgressBarService);
|
||||
private _navigateToTaskService = inject(NavigateToTaskService);
|
||||
|
||||
ISSUE_SERVICE_MAP: { [key: string]: IssueServiceInterface } = {
|
||||
[GITLAB_TYPE]: this._gitlabCommonInterfacesService,
|
||||
|
|
@ -662,6 +664,19 @@ export class IssueService {
|
|||
translateParams: { title: res.task.title },
|
||||
});
|
||||
return true;
|
||||
} else if (issueType === ICAL_TYPE) {
|
||||
// For calendar events, don't move to today - just show snackbar with navigation
|
||||
const taskId = res.task.id;
|
||||
this._snackService.open({
|
||||
ico: 'info',
|
||||
msg: T.F.TASK.S.TASK_ALREADY_EXISTS,
|
||||
translateParams: { title: res.task.title },
|
||||
actionStr: T.F.TASK.S.GO_TO_TASK,
|
||||
actionFn: () => {
|
||||
this._navigateToTaskService.navigate(taskId, false);
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
const taskWithTaskSubTasks = await this._taskService
|
||||
.getByIdWithSubTaskData$(res.task.id)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,566 @@
|
|||
// import { TestBed } from '@angular/core/testing';
|
||||
// import { provideMockActions } from '@ngrx/effects/testing';
|
||||
// import { Observable } from 'rxjs';
|
||||
//
|
||||
// import { PollIssueUpdatesEffects } from './poll-issue-updates.effects';
|
||||
//
|
||||
// describe('PollIssueUpdatesEffects', () => {
|
||||
// let actions$: Observable<any>;
|
||||
// let effects: PollIssueUpdatesEffects;
|
||||
//
|
||||
// beforeEach(() => {
|
||||
// TestBed.configureTestingModule({
|
||||
// providers: [
|
||||
// PollIssueUpdatesEffects,
|
||||
// provideMockActions(() => actions$)
|
||||
// ]
|
||||
// });
|
||||
//
|
||||
// effects = TestBed.inject(PollIssueUpdatesEffects);
|
||||
// });
|
||||
//
|
||||
// it('should be created', () => {
|
||||
// expect(effects).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { PollIssueUpdatesEffects } from './poll-issue-updates.effects';
|
||||
import { IssueService } from '../issue.service';
|
||||
import { WorkContextService } from '../../work-context/work-context.service';
|
||||
import { WorkContextType } from '../../work-context/work-context.model';
|
||||
import { setActiveWorkContext } from '../../work-context/store/work-context.actions';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import { selectEnabledIssueProviders } from './issue-provider.selectors';
|
||||
import { selectAllCalendarIssueTasks } from '../../tasks/store/task.selectors';
|
||||
import { ICAL_TYPE, GITHUB_TYPE, JIRA_TYPE } from '../issue.const';
|
||||
import { Task, TaskWithSubTasks } from '../../tasks/task.model';
|
||||
import { IssueProvider } from '../issue.model';
|
||||
|
||||
describe('PollIssueUpdatesEffects', () => {
|
||||
let effects: PollIssueUpdatesEffects;
|
||||
let actions$: Observable<any>;
|
||||
let store: MockStore;
|
||||
let issueServiceSpy: jasmine.SpyObj<IssueService>;
|
||||
let workContextServiceSpy: jasmine.SpyObj<WorkContextService>;
|
||||
|
||||
const createMockTask = (overrides: Partial<Task> = {}): Task =>
|
||||
({
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
projectId: 'project-1',
|
||||
tagIds: [],
|
||||
subTaskIds: [],
|
||||
timeSpentOnDay: {},
|
||||
timeSpent: 0,
|
||||
timeEstimate: 0,
|
||||
isDone: false,
|
||||
created: Date.now(),
|
||||
attachments: [],
|
||||
...overrides,
|
||||
}) as Task;
|
||||
|
||||
const createMockIssueProvider = (
|
||||
overrides: Partial<IssueProvider> = {},
|
||||
): IssueProvider =>
|
||||
({
|
||||
id: 'provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
isEnabled: true,
|
||||
isAutoPoll: true,
|
||||
isAutoAddToBacklog: false,
|
||||
isIntegratedAddTaskBar: false,
|
||||
defaultProjectId: null,
|
||||
pinnedSearch: null,
|
||||
...overrides,
|
||||
}) as IssueProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
issueServiceSpy = jasmine.createSpyObj('IssueService', [
|
||||
'getPollInterval',
|
||||
'refreshIssueTasks',
|
||||
]);
|
||||
workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], {
|
||||
allTasksForCurrentContext$: of([]),
|
||||
});
|
||||
|
||||
// Default: calendar poll interval is 10 minutes
|
||||
issueServiceSpy.getPollInterval.and.callFake((providerKey: string) => {
|
||||
if (providerKey === ICAL_TYPE) return 600000; // 10 minutes
|
||||
if (providerKey === GITHUB_TYPE) return 300000; // 5 minutes
|
||||
return 0;
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PollIssueUpdatesEffects,
|
||||
provideMockActions(() => actions$),
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectEnabledIssueProviders, value: [] },
|
||||
{ selector: selectAllCalendarIssueTasks, value: [] },
|
||||
],
|
||||
}),
|
||||
{ provide: IssueService, useValue: issueServiceSpy },
|
||||
{ provide: WorkContextService, useValue: workContextServiceSpy },
|
||||
],
|
||||
});
|
||||
|
||||
effects = TestBed.inject(PollIssueUpdatesEffects);
|
||||
store = TestBed.inject(MockStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.resetSelectors();
|
||||
});
|
||||
|
||||
describe('pollIssueChangesForCurrentContext$', () => {
|
||||
it('should be created', () => {
|
||||
expect(effects).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should trigger polling when setActiveWorkContext action is dispatched', fakeAsync(() => {
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
const calendarTask = createMockTask({
|
||||
id: 'cal-task-1',
|
||||
issueId: 'cal-event-123',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
// Subscribe to the effect
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
// Dispatch the action
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the delay before polling starts (default is 10 seconds)
|
||||
tick(10001);
|
||||
|
||||
// Should call refreshIssueTasks with calendar tasks
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
|
||||
[calendarTask],
|
||||
calendarProvider,
|
||||
);
|
||||
}));
|
||||
|
||||
it('should use selectAllCalendarIssueTasks for ICAL providers instead of current context', fakeAsync(() => {
|
||||
// Setup: Calendar provider and tasks in DIFFERENT projects
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
// Calendar tasks from multiple projects
|
||||
const calTaskProject1 = createMockTask({
|
||||
id: 'cal-task-1',
|
||||
projectId: 'project-1',
|
||||
issueId: 'cal-event-1',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
const calTaskProject2 = createMockTask({
|
||||
id: 'cal-task-2',
|
||||
projectId: 'project-2', // Different project
|
||||
issueId: 'cal-event-2',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
const calTaskProject3 = createMockTask({
|
||||
id: 'cal-task-3',
|
||||
projectId: 'project-3', // Third project
|
||||
issueId: 'cal-event-3',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
// Current context only has tasks from project-1
|
||||
const currentContextTasks: TaskWithSubTasks[] = [
|
||||
{ ...calTaskProject1, subTasks: [] },
|
||||
];
|
||||
|
||||
// But selectAllCalendarIssueTasks returns tasks from ALL projects
|
||||
const allCalendarTasks = [calTaskProject1, calTaskProject2, calTaskProject3];
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, allCalendarTasks);
|
||||
store.refreshState();
|
||||
|
||||
// Mock current context to only return project-1 tasks
|
||||
Object.defineProperty(workContextServiceSpy, 'allTasksForCurrentContext$', {
|
||||
get: () => of(currentContextTasks),
|
||||
});
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// Should call refreshIssueTasks with ALL calendar tasks, not just current context
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
|
||||
allCalendarTasks,
|
||||
calendarProvider,
|
||||
);
|
||||
|
||||
// Verify it's NOT using current context tasks (which would only have 1 task)
|
||||
const callArgs = issueServiceSpy.refreshIssueTasks.calls.mostRecent().args;
|
||||
expect(callArgs[0].length).toBe(3); // All 3 calendar tasks
|
||||
}));
|
||||
|
||||
it('should use current context tasks for non-ICAL providers like GITHUB', fakeAsync(() => {
|
||||
const githubProvider = createMockIssueProvider({
|
||||
id: 'github-provider-1',
|
||||
issueProviderKey: GITHUB_TYPE,
|
||||
});
|
||||
|
||||
const githubTaskCurrentContext = createMockTask({
|
||||
id: 'github-task-1',
|
||||
projectId: 'project-1',
|
||||
issueId: 'issue-123',
|
||||
issueType: GITHUB_TYPE,
|
||||
issueProviderId: 'github-provider-1',
|
||||
});
|
||||
|
||||
const currentContextTasks: TaskWithSubTasks[] = [
|
||||
{ ...githubTaskCurrentContext, subTasks: [] },
|
||||
];
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [githubProvider]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, []); // No calendar tasks
|
||||
store.refreshState();
|
||||
|
||||
Object.defineProperty(workContextServiceSpy, 'allTasksForCurrentContext$', {
|
||||
get: () => of(currentContextTasks),
|
||||
});
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// For GITHUB provider, should use current context tasks
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalled();
|
||||
const callArgs = issueServiceSpy.refreshIssueTasks.calls.mostRecent().args;
|
||||
// Verify the task has the expected properties (subTasks may be added by stream)
|
||||
expect(callArgs[0][0].id).toBe('github-task-1');
|
||||
expect(callArgs[0][0].issueType).toBe(GITHUB_TYPE);
|
||||
expect(callArgs[0][0].issueProviderId).toBe('github-provider-1');
|
||||
expect(callArgs[1]).toEqual(githubProvider);
|
||||
}));
|
||||
|
||||
it('should filter calendar tasks by provider ID', fakeAsync(() => {
|
||||
const calendarProvider1 = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
const calendarProvider2 = createMockIssueProvider({
|
||||
id: 'cal-provider-2',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
const calTaskProvider1 = createMockTask({
|
||||
id: 'cal-task-1',
|
||||
issueId: 'cal-event-1',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
const calTaskProvider2 = createMockTask({
|
||||
id: 'cal-task-2',
|
||||
issueId: 'cal-event-2',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-2',
|
||||
});
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [
|
||||
calendarProvider1,
|
||||
calendarProvider2,
|
||||
]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, [
|
||||
calTaskProvider1,
|
||||
calTaskProvider2,
|
||||
]);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// Should be called twice - once for each provider
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call should only have tasks for provider 1
|
||||
const firstCall = issueServiceSpy.refreshIssueTasks.calls.argsFor(0);
|
||||
expect(firstCall[0]).toEqual([calTaskProvider1]);
|
||||
expect(firstCall[1]).toEqual(calendarProvider1);
|
||||
|
||||
// Second call should only have tasks for provider 2
|
||||
const secondCall = issueServiceSpy.refreshIssueTasks.calls.argsFor(1);
|
||||
expect(secondCall[0]).toEqual([calTaskProvider2]);
|
||||
expect(secondCall[1]).toEqual(calendarProvider2);
|
||||
}));
|
||||
|
||||
it('should not poll providers with isAutoPoll set to false', fakeAsync(() => {
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
isAutoPoll: false, // Disabled
|
||||
});
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// Should NOT call refreshIssueTasks since auto-poll is disabled
|
||||
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should not poll providers with 0 poll interval', fakeAsync(() => {
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: JIRA_TYPE, // JIRA returns 0 poll interval
|
||||
});
|
||||
|
||||
// Override to return 0 for JIRA
|
||||
issueServiceSpy.getPollInterval.and.callFake((providerKey: string) => {
|
||||
if (providerKey === JIRA_TYPE) return 0;
|
||||
return 600000;
|
||||
});
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// Should NOT call refreshIssueTasks since poll interval is 0
|
||||
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should handle empty providers array gracefully', fakeAsync(() => {
|
||||
store.overrideSelector(selectEnabledIssueProviders, []);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// Should NOT call refreshIssueTasks since no providers
|
||||
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should not call refreshIssueTasks when no tasks match the provider', fakeAsync(() => {
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
// Return empty array - no tasks match this provider
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, []);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// Should NOT call refreshIssueTasks since no tasks match
|
||||
expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should filter out tasks without issueId', fakeAsync(() => {
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
const validTask = createMockTask({
|
||||
id: 'cal-task-1',
|
||||
issueId: 'cal-event-123',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
// Task without issueId (corrupted data)
|
||||
const invalidTask = createMockTask({
|
||||
id: 'cal-task-2',
|
||||
issueId: undefined as any, // Missing issueId
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, [validTask, invalidTask]);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
tick(10001);
|
||||
|
||||
// Should only call with valid task (the one with issueId)
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
|
||||
[validTask],
|
||||
calendarProvider,
|
||||
);
|
||||
}));
|
||||
|
||||
it('should continue polling after an error occurs', fakeAsync(() => {
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
const calendarTask = createMockTask({
|
||||
id: 'cal-task-1',
|
||||
issueId: 'cal-event-123',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]);
|
||||
store.refreshState();
|
||||
|
||||
// First call throws error, second succeeds
|
||||
let callCount = 0;
|
||||
issueServiceSpy.refreshIssueTasks.and.callFake(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
actionsSubject.next(
|
||||
setActiveWorkContext({
|
||||
activeType: WorkContextType.PROJECT,
|
||||
activeId: 'project-1',
|
||||
}),
|
||||
);
|
||||
|
||||
// First poll (should fail but not crash)
|
||||
tick(10001);
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second poll (should succeed)
|
||||
tick(600000); // 10 minutes
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(2);
|
||||
}));
|
||||
|
||||
it('should trigger polling on loadAllData action', fakeAsync(() => {
|
||||
const calendarProvider = createMockIssueProvider({
|
||||
id: 'cal-provider-1',
|
||||
issueProviderKey: ICAL_TYPE,
|
||||
});
|
||||
|
||||
const calendarTask = createMockTask({
|
||||
id: 'cal-task-1',
|
||||
issueId: 'cal-event-123',
|
||||
issueType: ICAL_TYPE,
|
||||
issueProviderId: 'cal-provider-1',
|
||||
});
|
||||
|
||||
store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]);
|
||||
store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]);
|
||||
store.refreshState();
|
||||
|
||||
const actionsSubject = new Subject<any>();
|
||||
actions$ = actionsSubject.asObservable();
|
||||
|
||||
effects.pollIssueChangesForCurrentContext$.subscribe();
|
||||
|
||||
// Use loadAllData instead of setActiveWorkContext
|
||||
actionsSubject.next(loadAllData({ appDataComplete: {} as any }));
|
||||
|
||||
tick(10001);
|
||||
|
||||
expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith(
|
||||
[calendarTask],
|
||||
calendarProvider,
|
||||
);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { forkJoin, Observable, timer } from 'rxjs';
|
||||
import { EMPTY, merge, Observable, timer } from 'rxjs';
|
||||
import { first, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { IssueService } from '../issue.service';
|
||||
import { TaskWithSubTasks } from '../../tasks/task.model';
|
||||
import { Task, TaskWithSubTasks } from '../../tasks/task.model';
|
||||
import { WorkContextService } from '../../work-context/work-context.service';
|
||||
import { setActiveWorkContext } from '../../work-context/store/work-context.actions';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { IssueProvider } from '../issue.model';
|
||||
import { selectEnabledIssueProviders } from './issue-provider.selectors';
|
||||
import { DELAY_BEFORE_ISSUE_POLLING } from '../issue.const';
|
||||
import { DELAY_BEFORE_ISSUE_POLLING, ICAL_TYPE } from '../issue.const';
|
||||
import { selectAllCalendarIssueTasks } from '../../tasks/store/task.selectors';
|
||||
import { IssueLog } from '../../../core/log';
|
||||
|
||||
@Injectable()
|
||||
export class PollIssueUpdatesEffects {
|
||||
|
|
@ -27,44 +29,89 @@ export class PollIssueUpdatesEffects {
|
|||
this.pollIssueTaskUpdatesActions$.pipe(
|
||||
switchMap(() => this._store.select(selectEnabledIssueProviders).pipe(first())),
|
||||
// Get the list of enabled issue providers
|
||||
switchMap((enabledProviders: IssueProvider[]) =>
|
||||
forkJoin(
|
||||
// For each enabled provider, start a polling timer
|
||||
enabledProviders
|
||||
// only for providers that have auto-polling enabled
|
||||
.filter((provider) => provider.isAutoPoll)
|
||||
// filter out providers with 0 poll interval (no polling)
|
||||
.filter(
|
||||
(provider) =>
|
||||
this._issueService.getPollInterval(provider.issueProviderKey) > 0,
|
||||
)
|
||||
.map((provider) =>
|
||||
timer(
|
||||
DELAY_BEFORE_ISSUE_POLLING,
|
||||
this._issueService.getPollInterval(provider.issueProviderKey),
|
||||
).pipe(
|
||||
// => whenever the provider specific poll timer ticks:
|
||||
// ---------------------------------------------------
|
||||
// Get all tasks for the current context
|
||||
switchMap(() =>
|
||||
this._workContextService.allTasksForCurrentContext$.pipe(
|
||||
// get once each cycle and no updates
|
||||
first(),
|
||||
map((tasks) =>
|
||||
// only use tasks that are assigned to the current issue provider
|
||||
tasks.filter((task) => task.issueProviderId === provider.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Refresh issue tasks for the current provider
|
||||
tap((issueTasks: TaskWithSubTasks[]) =>
|
||||
this._issueService.refreshIssueTasks(issueTasks, provider),
|
||||
),
|
||||
),
|
||||
switchMap((enabledProviders: IssueProvider[]) => {
|
||||
const providers = enabledProviders
|
||||
// only for providers that have auto-polling enabled
|
||||
.filter((provider) => provider.isAutoPoll)
|
||||
// filter out providers with 0 poll interval (no polling)
|
||||
.filter(
|
||||
(provider) =>
|
||||
this._issueService.getPollInterval(provider.issueProviderKey) > 0,
|
||||
);
|
||||
|
||||
// Handle empty providers case
|
||||
if (providers.length === 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
// Use merge instead of forkJoin so each timer can emit independently
|
||||
// (forkJoin waits for all observables to complete, but timer never completes)
|
||||
return merge(
|
||||
...providers.map((provider) =>
|
||||
timer(
|
||||
DELAY_BEFORE_ISSUE_POLLING,
|
||||
this._issueService.getPollInterval(provider.issueProviderKey),
|
||||
).pipe(
|
||||
// => whenever the provider specific poll timer ticks:
|
||||
// ---------------------------------------------------
|
||||
// Get tasks to refresh based on provider type
|
||||
switchMap(() => this._getTasksForProvider(provider)),
|
||||
// Refresh issue tasks for the current provider
|
||||
// Use try-catch to prevent errors from killing the polling stream
|
||||
tap((issueTasks: Task[]) => {
|
||||
if (issueTasks.length > 0) {
|
||||
try {
|
||||
this._issueService.refreshIssueTasks(issueTasks, provider);
|
||||
} catch (err) {
|
||||
IssueLog.error(
|
||||
'Error polling issue updates for ' + provider.id,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets tasks to refresh for a provider.
|
||||
* For calendar (ICAL) providers, returns ALL calendar tasks across all projects
|
||||
* since calendar events can be assigned to any project.
|
||||
* For other providers, returns only tasks in the current work context.
|
||||
*/
|
||||
private _getTasksForProvider(provider: IssueProvider): Observable<Task[]> {
|
||||
if (provider.issueProviderKey === ICAL_TYPE) {
|
||||
// For calendar providers, poll ALL calendar tasks across all projects
|
||||
// This ensures calendar event updates are synced regardless of which project is active
|
||||
return this._store.select(selectAllCalendarIssueTasks).pipe(
|
||||
first(),
|
||||
map((tasks) =>
|
||||
tasks.filter(
|
||||
(task) =>
|
||||
task.issueProviderId === provider.id &&
|
||||
// Safety: ensure task has valid issueId to prevent errors in refreshIssueTasks
|
||||
!!task.issueId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// For other providers, only poll tasks in the current context
|
||||
return this._workContextService.allTasksForCurrentContext$.pipe(
|
||||
first(),
|
||||
map((tasks: TaskWithSubTasks[]) =>
|
||||
tasks.filter(
|
||||
(task) =>
|
||||
task.issueProviderId === provider.id &&
|
||||
// Safety: ensure task has valid issueId
|
||||
!!task.issueId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -388,6 +388,88 @@ describe('Task Selectors', () => {
|
|||
expect(result[0]).toBe('ISSUE-123');
|
||||
});
|
||||
|
||||
it('should select all calendar issue tasks', () => {
|
||||
const result = fromSelectors.selectAllCalendarIssueTasks(mockState);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('task8');
|
||||
expect(result[0].issueType).toBe('ICAL');
|
||||
});
|
||||
|
||||
it('should select all calendar issue tasks from multiple projects', () => {
|
||||
// Create a state with multiple calendar tasks across different projects
|
||||
const multiCalendarTasks: { [id: string]: Task } = {
|
||||
...mockTasks,
|
||||
calTask1: {
|
||||
id: 'calTask1',
|
||||
title: 'Calendar Task 1',
|
||||
created: Date.now(),
|
||||
isDone: false,
|
||||
subTaskIds: [],
|
||||
tagIds: [],
|
||||
projectId: 'project1',
|
||||
timeSpentOnDay: {},
|
||||
issueId: 'CAL-001',
|
||||
issueType: 'ICAL',
|
||||
issueProviderId: 'cal-provider-1',
|
||||
timeEstimate: 3600000,
|
||||
timeSpent: 0,
|
||||
attachments: [],
|
||||
},
|
||||
calTask2: {
|
||||
id: 'calTask2',
|
||||
title: 'Calendar Task 2',
|
||||
created: Date.now(),
|
||||
isDone: false,
|
||||
subTaskIds: [],
|
||||
tagIds: [],
|
||||
projectId: 'project2', // Different project
|
||||
timeSpentOnDay: {},
|
||||
issueId: 'CAL-002',
|
||||
issueType: 'ICAL',
|
||||
issueProviderId: 'cal-provider-1',
|
||||
timeEstimate: 1800000,
|
||||
timeSpent: 0,
|
||||
attachments: [],
|
||||
},
|
||||
calTask3: {
|
||||
id: 'calTask3',
|
||||
title: 'Calendar Task 3',
|
||||
created: Date.now(),
|
||||
isDone: false,
|
||||
subTaskIds: [],
|
||||
tagIds: [],
|
||||
projectId: 'project3', // Third project (hidden)
|
||||
timeSpentOnDay: {},
|
||||
issueId: 'CAL-003',
|
||||
issueType: 'ICAL',
|
||||
issueProviderId: 'cal-provider-2', // Different provider
|
||||
timeEstimate: 7200000,
|
||||
timeSpent: 0,
|
||||
attachments: [],
|
||||
},
|
||||
};
|
||||
|
||||
const multiCalendarState = {
|
||||
...mockState,
|
||||
[TASK_FEATURE_NAME]: {
|
||||
...mockTaskState,
|
||||
ids: Object.keys(multiCalendarTasks),
|
||||
entities: multiCalendarTasks,
|
||||
},
|
||||
};
|
||||
|
||||
const result = fromSelectors.selectAllCalendarIssueTasks(multiCalendarState);
|
||||
// Should return all 4 ICAL tasks (task8 + 3 new ones)
|
||||
expect(result.length).toBe(4);
|
||||
// All should have issueType ICAL
|
||||
expect(result.every((t) => t.issueType === 'ICAL')).toBe(true);
|
||||
// Should include tasks from all projects
|
||||
const projectIds = result.map((t) => t.projectId);
|
||||
expect(projectIds).toContain('project1');
|
||||
expect(projectIds).toContain('project2');
|
||||
expect(projectIds).toContain('project3');
|
||||
});
|
||||
|
||||
it('should select tasks worked on or done for a day', () => {
|
||||
const result = fromSelectors.selectTasksWorkedOnOrDoneFlat(mockState, {
|
||||
day: today,
|
||||
|
|
|
|||
|
|
@ -415,6 +415,11 @@ export const selectAllCalendarTaskEventIds = createSelector(
|
|||
tasks.filter((task) => task.issueType === 'ICAL').map((t) => t.issueId as string),
|
||||
);
|
||||
|
||||
export const selectAllCalendarIssueTasks = createSelector(
|
||||
selectAllTasks,
|
||||
(tasks: Task[]): Task[] => tasks.filter((task) => task.issueType === 'ICAL'),
|
||||
);
|
||||
|
||||
export const selectTasksWorkedOnOrDoneFlat = createSelector(
|
||||
selectAllTasks,
|
||||
(tasks: Task[], props: { day: string }) => {
|
||||
|
|
|
|||
|
|
@ -1458,6 +1458,7 @@ const T = {
|
|||
REMINDER_ADDED: 'F.TASK.S.REMINDER_ADDED',
|
||||
REMINDER_DELETED: 'F.TASK.S.REMINDER_DELETED',
|
||||
REMINDER_UPDATED: 'F.TASK.S.REMINDER_UPDATED',
|
||||
TASK_ALREADY_EXISTS: 'F.TASK.S.TASK_ALREADY_EXISTS',
|
||||
TASK_CREATED: 'F.TASK.S.TASK_CREATED',
|
||||
},
|
||||
SELECT_OR_CREATE: 'F.TASK.SELECT_OR_CREATE',
|
||||
|
|
|
|||
|
|
@ -1487,6 +1487,7 @@
|
|||
"REMINDER_ADDED": "Scheduled <strong>{{title}}</strong> at <strong>{{date}}</strong>",
|
||||
"REMINDER_DELETED": "Deleted reminder for task",
|
||||
"REMINDER_UPDATED": "Updated reminder for task \"{{title}}\"",
|
||||
"TASK_ALREADY_EXISTS": "Task <strong>{{title}}</strong> already exists",
|
||||
"TASK_CREATED": "Created task \"{{taskTitle}}\""
|
||||
},
|
||||
"SELECT_OR_CREATE": "Select or create task",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue