mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(calendar): show agenda-view plugin events without re-navigation (#8811)
Plugin issue-provider calendars (e.g. the Google Calendar plugin) register asynchronously after the issue-provider store is hydrated. calendarEvents$ read getUseAgendaView() imperatively inside a store-stream map, so on first load the filter ran before the plugin registered (returning false) and the plugin's events stayed absent until a re-subscription (navigating away and back) re-projected the stream. Expose registration changes from the registry as a BehaviorSubject-backed registrationChanges$ and fold it into the plugin-provider filter so it re-runs when a plugin (un)registers. A BehaviorSubject (not toObservable(signal)) keeps calendarEvents$ emitting synchronously on subscribe.
This commit is contained in:
parent
9ae49a9e82
commit
b30c861b96
3 changed files with 139 additions and 7 deletions
|
|
@ -29,6 +29,7 @@ import { Subscription } from 'rxjs';
|
|||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginHttpService } from '../../plugins/issue-provider/plugin-http.service';
|
||||
import { IssueProviderPluginDefinition } from '../../plugins/issue-provider/plugin-issue-provider.model';
|
||||
import { IssueProviderPluginType } from '../issue/issue.model';
|
||||
import { NotIcalResponseError } from '../schedule/ical/is-likely-ical';
|
||||
// Static import forces ical.js into the main test bundle so the dynamic
|
||||
|
|
@ -2284,4 +2285,104 @@ END:VCALENDAR`;
|
|||
expect(allDay.dueWithTime).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: plugin issue-provider calendars (e.g. the Google Calendar plugin) register
|
||||
// asynchronously AFTER the issue-provider store is hydrated. calendarEvents$ must react to
|
||||
// that registration (via PluginIssueProviderRegistryService.registrationChanges$) and surface
|
||||
// the plugin's events WITHOUT needing a re-subscription — otherwise agenda events only appear
|
||||
// after navigating away and back to the Today view.
|
||||
describe('plugin registering after subscription', () => {
|
||||
it('surfaces plugin calendar events once the plugin registers (no re-subscribe)', fakeAsync(() => {
|
||||
const PLUGIN_KEY = 'plugin:gcal';
|
||||
const pluginProvider = {
|
||||
id: 'gcal-provider-id',
|
||||
issueProviderKey: PLUGIN_KEY,
|
||||
isEnabled: true,
|
||||
pluginConfig: {},
|
||||
} as unknown as IssueProviderPluginType;
|
||||
|
||||
const twoHoursMs = 2 * 60 * 60 * 1000;
|
||||
const futureStart = Date.now() + twoHoursMs;
|
||||
const getNewIssuesForBacklog = jasmine
|
||||
.createSpy('getNewIssuesForBacklog')
|
||||
.and.returnValue(
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'gcal-evt-1',
|
||||
title: 'Standup',
|
||||
start: futureStart,
|
||||
dueWithTime: futureStart,
|
||||
duration: 30 * 60 * 1000,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const mockPluginHttp = {
|
||||
createHttpHelper: jasmine.createSpy('createHttpHelper').and.returnValue({}),
|
||||
};
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
CalendarIntegrationService,
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectCalendarProviders, value: [] },
|
||||
// The provider config is already hydrated in the store before the plugin
|
||||
// (which supplies `useAgendaView`) has registered.
|
||||
{ selector: selectEnabledIssueProviders, value: [pluginProvider] },
|
||||
{ selector: selectAllCalendarTaskEventIds, value: [] },
|
||||
],
|
||||
}),
|
||||
{ provide: SnackService, useValue: mockSnackService },
|
||||
{ provide: PluginHttpService, useValue: mockPluginHttp },
|
||||
{ provide: TaskArchiveService, useValue: mockTaskArchiveService },
|
||||
],
|
||||
});
|
||||
|
||||
const freshService = TestBed.inject(CalendarIntegrationService);
|
||||
const registry = TestBed.inject(PluginIssueProviderRegistryService);
|
||||
|
||||
const emissions: string[][] = [];
|
||||
const sub = freshService.calendarEvents$.subscribe((entries) =>
|
||||
emissions.push(entries.flatMap((e) => e.items.map((i) => i.id))),
|
||||
);
|
||||
|
||||
// Before registration `getUseAgendaView` is false → the provider is not treated as a
|
||||
// calendar source and nothing is fetched.
|
||||
tick(0);
|
||||
flushMicrotasks();
|
||||
expect(emissions.flat()).not.toContain('gcal-evt-1');
|
||||
expect(getNewIssuesForBacklog).not.toHaveBeenCalled();
|
||||
|
||||
// Plugin finishes loading and registers as an agenda-view calendar provider.
|
||||
registry.register({
|
||||
pluginId: 'gcal',
|
||||
issueProviderKey: PLUGIN_KEY,
|
||||
definition: {
|
||||
getHeaders: () => ({}),
|
||||
getNewIssuesForBacklog,
|
||||
} as unknown as IssueProviderPluginDefinition,
|
||||
name: 'Google Calendar',
|
||||
humanReadableName: 'Google Calendar',
|
||||
icon: 'calendar',
|
||||
pollIntervalMs: 60000,
|
||||
issueStrings: { singular: 'Event', plural: 'Events' },
|
||||
useAgendaView: true,
|
||||
});
|
||||
|
||||
// Without any re-subscription, the plugin's event must now appear.
|
||||
tick(0);
|
||||
flushMicrotasks();
|
||||
tick(100);
|
||||
flushMicrotasks();
|
||||
|
||||
expect(getNewIssuesForBacklog).toHaveBeenCalled();
|
||||
expect(emissions[emissions.length - 1]).toContain('gcal-evt-1');
|
||||
|
||||
sub.unsubscribe();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,8 +120,18 @@ export class CalendarIntegrationService {
|
|||
this._store
|
||||
.select(selectCalendarProviders)
|
||||
.pipe(distinctUntilChanged(fastArrayCompare)),
|
||||
this._store.select(selectEnabledIssueProviders).pipe(
|
||||
map((providers) =>
|
||||
// `registrationChanges$` emits when a plugin (un)registers. Plugins load
|
||||
// asynchronously after bootstrap, while the issue-provider store is hydrated
|
||||
// early — so without this trigger `getUseAgendaView` is read once (before the
|
||||
// plugin registers, returning false), the store never re-emits, and the
|
||||
// plugin's calendar events stay absent until a re-subscription forces a
|
||||
// re-projection (e.g. navigating away and back). Re-run the filter on
|
||||
// registration so agenda-view plugin events surface without navigation.
|
||||
combineLatest([
|
||||
this._store.select(selectEnabledIssueProviders),
|
||||
this._pluginRegistry.registrationChanges$,
|
||||
]).pipe(
|
||||
map(([providers]) =>
|
||||
providers.filter(
|
||||
(p): p is IssueProviderPluginType =>
|
||||
isPluginIssueProvider(p.issueProviderKey) &&
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import {
|
||||
RegisteredPluginIssueProvider,
|
||||
IssueProviderPluginDefinition,
|
||||
|
|
@ -16,8 +18,21 @@ export class PluginIssueProviderRegistryService {
|
|||
/** Maps pluginId → registeredKey for cleanup */
|
||||
private _pluginIdToKey = new Map<string, string>();
|
||||
|
||||
/** Signal that increments on each registration/unregistration, so computed signals can react */
|
||||
readonly registrationVersion = signal(0);
|
||||
/**
|
||||
* Single source of truth for "a plugin (un)registered", exposed two ways so signal-
|
||||
* and stream-based consumers react to the same event without drifting:
|
||||
* - `registrationChanges$` (RxJS) for observable pipelines that must re-run without an
|
||||
* effect/CD flush AND emit synchronously on subscribe — e.g.
|
||||
* `CalendarIntegrationService.calendarEvents$`. (A `toObservable(signal)` only emits
|
||||
* once its effect runs, delaying even the initial value; a `BehaviorSubject` does not.)
|
||||
* - `registrationVersion` (signal) for `computed()` consumers, e.g. `tag-list`.
|
||||
*/
|
||||
private readonly _registrationVersion$ = new BehaviorSubject(0);
|
||||
readonly registrationChanges$: Observable<number> =
|
||||
this._registrationVersion$.asObservable();
|
||||
readonly registrationVersion = toSignal(this._registrationVersion$, {
|
||||
requireSync: true,
|
||||
});
|
||||
|
||||
register(opts: {
|
||||
pluginId: string;
|
||||
|
|
@ -53,7 +68,7 @@ export class PluginIssueProviderRegistryService {
|
|||
allowPrivateNetwork: opts.allowPrivateNetwork,
|
||||
});
|
||||
this._pluginIdToKey.set(opts.pluginId, key);
|
||||
this.registrationVersion.update((v) => v + 1);
|
||||
this._bumpRegistrationVersion();
|
||||
}
|
||||
|
||||
unregister(pluginId: string): void {
|
||||
|
|
@ -61,10 +76,16 @@ export class PluginIssueProviderRegistryService {
|
|||
if (key) {
|
||||
this._providers.delete(key);
|
||||
this._pluginIdToKey.delete(pluginId);
|
||||
this.registrationVersion.update((v) => v + 1);
|
||||
this._bumpRegistrationVersion();
|
||||
}
|
||||
}
|
||||
|
||||
/** Advance the single registration counter; both `registrationChanges$` and the
|
||||
* derived `registrationVersion` signal update from it. */
|
||||
private _bumpRegistrationVersion(): void {
|
||||
this._registrationVersion$.next(this._registrationVersion$.value + 1);
|
||||
}
|
||||
|
||||
/** Get the registered key for a pluginId (e.g. 'GITHUB' or 'plugin:my-plugin') */
|
||||
getRegisteredKey(pluginId: string): string | undefined {
|
||||
return this._pluginIdToKey.get(pluginId);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue