fix(plugins): expose focused task API to iframe plugins (#8291)

Co-authored-by: Shem Freeze <whatamehs@Shems-MacBook-Air.local>
This commit is contained in:
Meh-S-Eze 2026-06-15 07:57:34 -05:00 committed by GitHub
parent a4c0dc7d46
commit 3db96fd8a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 267 additions and 0 deletions

View file

@ -297,6 +297,8 @@ Iframe plugins automatically receive:
- `getTasks()` - Get all active tasks
- `getArchivedTasks()` - Get archived tasks
- `getCurrentContextTasks()` - Get tasks in current context
- `getSelectedTask()` - Get the task selected in the task detail panel, or `null`
- `getFocusedTask()` - Get the currently focused task row, or `null`. Task-row focus is cleared when focus moves elsewhere, including into iframe side panels; use `getSelectedTask()` for persistent side-panel task context.
- `addTask(task)` - Create a new task
- `updateTask(taskId, updates)` - Update existing task

View file

@ -131,6 +131,8 @@ The Plugin API is exposed to plugins via a global `PluginAPI` object. Plugins ru
**Data — Tasks:**
- `getTasks()`, `getArchivedTasks()`, `getCurrentContextTasks()` — Read tasks.
- `getSelectedTask()` — Read the task selected in the task detail panel, or `null`.
- `getFocusedTask()` — Read the currently focused task row, or `null`. Row focus is transient and is cleared when focus moves elsewhere, including into iframe side panels; use `getSelectedTask()` for persistent side-panel context.
- `addTask(taskData)`, `updateTask(taskId, updates)`, `deleteTask(taskId)` — Create/update/delete.
- `batchUpdateForProject(request)` — Batch create/update/delete/reorder for a project.
- `reorderTasks(taskIds, contextId, contextType)` — Reorder tasks.

View file

@ -130,6 +130,8 @@ Add these to your manifest.json based on what your plugin needs:
- `getTasks` - Read tasks
- `getArchivedTasks` - Read archived tasks
- `getCurrentContextTasks` - Read current context tasks
- `getSelectedTask` - Read the task selected in the task detail panel
- `getFocusedTask` - Read the currently focused task row, if any
- `addTask` - Create tasks
- `getAllProjects` - Read projects
- `addProject` - Create projects

View file

@ -571,6 +571,21 @@ export interface PluginAPI {
getCurrentContextTasks(): Promise<Task[]>;
/**
* Returns the task currently selected in the task detail panel, or null if
* no task is selected. This is the stable task reader for side-panel plugins
* that need to keep working after their iframe receives focus.
*/
getSelectedTask(): Promise<Task | null>;
/**
* Returns the task row currently focused by the user, or null if no task row
* has focus. Task-row focus is transient and is cleared when focus moves
* elsewhere, including into an iframe side panel. Use `getSelectedTask()` when
* the plugin needs persistent task context.
*/
getFocusedTask(): Promise<Task | null>;
/**
* Returns a complete read-only snapshot of the application state including
* tasks, projects, tags, notes, task repeat configurations, simple counters

View file

@ -14,6 +14,8 @@ describe('PluginAPI', () => {
let showIndexHtmlAsViewSpy: jasmine.Spy;
let reInitDataSpy: jasmine.Spy;
let dispatchActionSpy: jasmine.Spy;
let getSelectedTaskSpy: jasmine.Spy;
let getFocusedTaskSpy: jasmine.Spy;
let mockBridge: jasmine.SpyObj<{
createBoundMethods: () => Record<string, unknown>;
getAppState: () => Promise<unknown>;
@ -34,6 +36,12 @@ describe('PluginAPI', () => {
showIndexHtmlAsViewSpy = jasmine.createSpy('showIndexHtmlAsView');
reInitDataSpy = jasmine.createSpy('reInitData').and.resolveTo();
dispatchActionSpy = jasmine.createSpy('dispatchAction');
getSelectedTaskSpy = jasmine
.createSpy('getSelectedTask')
.and.resolveTo({ id: 'selected-task', title: 'Selected Task' });
getFocusedTaskSpy = jasmine
.createSpy('getFocusedTask')
.and.resolveTo({ id: 'focused-task', title: 'Focused Task' });
mockBridge = jasmine.createSpyObj('PluginBridgeService', [
'createBoundMethods',
@ -47,6 +55,8 @@ describe('PluginAPI', () => {
mockBridge.createBoundMethods.and.returnValue({
showIndexHtmlAsView: showIndexHtmlAsViewSpy,
dispatchAction: dispatchActionSpy,
getSelectedTask: getSelectedTaskSpy,
getFocusedTask: getFocusedTaskSpy,
persistDataSynced: jasmine.createSpy('persistDataSynced'),
log: {
critical: jasmine.createSpy(),
@ -217,6 +227,24 @@ describe('PluginAPI', () => {
});
});
describe('task selection readers', () => {
it('should delegate selected task lookup to the bound bridge method', async () => {
const selectedTask = await pluginAPI.getSelectedTask();
expect(selectedTask?.id).toBe('selected-task');
expect(selectedTask?.title).toBe('Selected Task');
expect(getSelectedTaskSpy).toHaveBeenCalledTimes(1);
});
it('should delegate focused task lookup to the bound bridge method', async () => {
const focusedTask = await pluginAPI.getFocusedTask();
expect(focusedTask?.id).toBe('focused-task');
expect(focusedTask?.title).toBe('Focused Task');
expect(getFocusedTaskSpy).toHaveBeenCalledTimes(1);
});
});
describe('lifecycle registration', () => {
type LifecycleRegisters = NonNullable<ConstructorParameters<typeof PluginAPI>[5]>;

View file

@ -207,6 +207,18 @@ export class PluginAPI implements PluginAPIInterface {
return tasks.map(taskCopyToTaskData);
}
async getSelectedTask(): Promise<Task | null> {
PluginLog.log(`Plugin ${this.#pluginId} requested selected task`);
const task = await this.#boundMethods.getSelectedTask();
return task ? taskCopyToTaskData(task) : null;
}
async getFocusedTask(): Promise<Task | null> {
PluginLog.log(`Plugin ${this.#pluginId} requested focused task`);
const task = await this.#boundMethods.getFocusedTask();
return task ? taskCopyToTaskData(task) : null;
}
async getAppState(): Promise<PluginAppState> {
PluginLog.log(`Plugin ${this.#pluginId} requested app state snapshot`);
return this.#pluginBridge.getAppState();

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/naming-convention */
// Active tests for setCounter fix (issue #5812)
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { of } from 'rxjs';
@ -19,6 +20,8 @@ import { NotifyService } from '../core/notify/notify.service';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { PluginHooksService } from './plugin-hooks';
import { TaskService } from '../features/tasks/task.service';
import { TaskFocusService } from '../features/tasks/task-focus.service';
import { DEFAULT_TASK, TaskWithSubTasks } from '../features/tasks/task.model';
import { WorkContextService } from '../features/work-context/work-context.service';
import { ProjectService } from '../features/project/project.service';
import { TagService } from '../features/tag/tag.service';
@ -350,6 +353,90 @@ describe('PluginBridgeService - dispatchAction privacy (#7619)', () => {
});
});
describe('PluginBridgeService - iframe task selection methods', () => {
const focusedTask = {
...DEFAULT_TASK,
id: 'focused-task',
title: 'Focused Task',
projectId: 'INBOX_PROJECT',
};
const selectedTask: TaskWithSubTasks = {
...DEFAULT_TASK,
id: 'selected-task',
title: 'Selected Task',
projectId: 'INBOX_PROJECT',
subTasks: [],
};
let service: PluginBridgeService;
let taskService: jasmine.SpyObj<TaskService>;
beforeEach(() => {
taskService = jasmine.createSpyObj<TaskService>('TaskService', ['getByIdOnce$'], {
allTasks$: of([]),
selectedTask$: of(selectedTask),
});
taskService.getByIdOnce$.and.returnValue(of(focusedTask));
TestBed.configureTestingModule({
providers: [
PluginBridgeService,
provideMockStore(),
{ provide: SnackService, useValue: {} },
{ provide: NotifyService, useValue: {} },
{ provide: MatDialog, useValue: {} },
{ provide: PluginHooksService, useValue: {} },
{ provide: TaskService, useValue: taskService },
{
provide: TaskFocusService,
useValue: {
focusedTaskId: signal<string | null>(focusedTask.id),
},
},
{ provide: WorkContextService, useValue: { activeWorkContext$: of(null) } },
{ provide: ProjectService, useValue: {} },
{ provide: TagService, useValue: {} },
{ provide: PluginUserPersistenceService, useValue: {} },
{ provide: PluginConfigService, useValue: {} },
{ provide: TaskArchiveService, useValue: {} },
{ provide: Router, useValue: {} },
{ provide: TranslateService, useValue: {} },
{ provide: SyncWrapperService, useValue: {} },
{ provide: GlobalThemeService, useValue: {} },
{ provide: PluginIssueProviderRegistryService, useValue: {} },
{ provide: IssueSyncAdapterRegistryService, useValue: {} },
{ provide: PluginHttpService, useValue: {} },
{ provide: DataInitService, useValue: {} },
],
});
service = TestBed.inject(PluginBridgeService);
});
it('exposes selected and focused task readers on iframe bound methods', async () => {
const bound = service.createBoundMethods('iframe-plugin');
const selectedResult = await bound.getSelectedTask();
const selectedTaskWithoutSubTasks = Object.fromEntries(
Object.entries(selectedTask).filter(([key]) => key !== 'subTasks'),
) as typeof selectedResult;
expect(selectedResult).toEqual(selectedTaskWithoutSubTasks);
expect((selectedResult as { subTasks?: unknown } | null)?.subTasks).toBeUndefined();
await expectAsync(bound.getFocusedTask()).toBeResolvedTo(focusedTask);
expect(taskService.getByIdOnce$).toHaveBeenCalledOnceWith(focusedTask.id);
});
it('returns null for stale focused task ids', async () => {
taskService.getByIdOnce$.and.returnValue(
of(undefined as unknown as TaskWithSubTasks),
);
const bound = service.createBoundMethods('iframe-plugin');
await expectAsync(bound.getFocusedTask()).toBeResolvedTo(null);
expect(taskService.getByIdOnce$).toHaveBeenCalledOnceWith(focusedTask.id);
});
});
describe('PluginBridgeService - openDialog', () => {
let service: PluginBridgeService;
let matDialog: jasmine.SpyObj<MatDialog>;

View file

@ -43,6 +43,7 @@ import {
import { snackCfgToSnackParams } from './plugin-api-mapper';
import { PluginHooksService } from './plugin-hooks';
import { TaskService } from '../features/tasks/task.service';
import { TaskFocusService } from '../features/tasks/task-focus.service';
import { addSubTask } from '../features/tasks/store/task.actions';
import { selectTaskFeatureState } from '../features/tasks/store/task.selectors';
import { parseTimeSpentChanges } from '../features/tasks/short-syntax';
@ -88,6 +89,18 @@ import { PluginService } from './plugin.service';
import { PluginI18nService } from './plugin-i18n.service';
import { formatDateForPlugin } from './plugin-i18n-date.util';
const toPluginTaskCopy = (
task: (TaskCopy & { subTasks?: unknown }) | null | undefined,
): TaskCopy | null => {
if (!task) {
return null;
}
const taskCopy = { ...task };
delete taskCopy.subTasks;
return taskCopy;
};
// New imports for simple counters
import { selectAllSimpleCounters } from '../features/simple-counter/store/simple-counter.reducer';
import { selectTaskRepeatCfgFeatureState } from '../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
@ -135,6 +148,7 @@ export class PluginBridgeService implements OnDestroy {
private _store = inject(Store);
private _pluginHooksService = inject(PluginHooksService);
private _taskService = inject(TaskService);
private _taskFocusService = inject(TaskFocusService);
private _workContextService = inject(WorkContextService);
private _projectService = inject(ProjectService);
private _tagService = inject(TagService);
@ -238,6 +252,8 @@ export class PluginBridgeService implements OnDestroy {
showInWorkContext: () => void;
closeWorkContextView: () => void;
getActiveWorkContext: () => Promise<ActiveWorkContext | null>;
getSelectedTask: () => Promise<TaskCopy | null>;
getFocusedTask: () => Promise<TaskCopy | null>;
triggerSync: () => Promise<void>;
dispatchAction: (action: { type: string; [key: string]: unknown }) => void;
executeNodeScript: (
@ -296,6 +312,8 @@ export class PluginBridgeService implements OnDestroy {
showInWorkContext: () => this._showInWorkContext(pluginId),
closeWorkContextView: () => this._closeWorkContextView(pluginId),
getActiveWorkContext: () => this.getActiveWorkContext(),
getSelectedTask: () => this.getSelectedTask(),
getFocusedTask: () => this.getFocusedTask(),
// Sync
triggerSync: () => this._triggerSync(pluginId),
@ -1087,6 +1105,21 @@ export class PluginBridgeService implements OnDestroy {
PluginLog.log('PluginBridge: Task selected', { taskId });
}
async getSelectedTask(): Promise<TaskCopy | null> {
return toPluginTaskCopy(await firstValueFrom(this._taskService.selectedTask$));
}
async getFocusedTask(): Promise<TaskCopy | null> {
const focusedTaskId = this._taskFocusService.focusedTaskId();
if (!focusedTaskId) {
return null;
}
return toPluginTaskCopy(
await firstValueFrom(this._taskService.getByIdOnce$(focusedTaskId)),
);
}
/**
* Batch update tasks for a project
* Only generate IDs here - let the reducer handle all validation

View file

@ -291,6 +291,86 @@ describe('handlePluginMessage()', () => {
);
});
it('routes getFocusedTask iframe API calls through plugin-bound methods', async () => {
const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>(
'sourceWindow',
['postMessage'],
);
const focusedTask = { id: 'focused-task', title: 'Focused Task' };
const getFocusedTask = jasmine.createSpy('getFocusedTask').and.resolveTo(focusedTask);
const pluginBridge = {
createBoundMethods: () => ({
getFocusedTask,
}),
} as unknown as PluginBridgeService;
await handlePluginMessage(
{
data: {
type: PluginIframeMessageType.API_CALL,
bridgeToken: 'test-bridge-token',
bridgeGeneration: 4,
method: 'getFocusedTask',
callId: 15,
args: [],
},
source: sourceWindow,
} as unknown as MessageEvent,
createConfig(pluginBridge),
);
expect(getFocusedTask).toHaveBeenCalledTimes(1);
expect(sourceWindow.postMessage).toHaveBeenCalledWith(
{
type: PluginIframeMessageType.API_RESPONSE,
callId: 15,
result: focusedTask,
},
'*',
);
});
it('routes getSelectedTask iframe API calls through plugin-bound methods', async () => {
const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>(
'sourceWindow',
['postMessage'],
);
const selectedTask = { id: 'selected-task', title: 'Selected Task' };
const getSelectedTask = jasmine
.createSpy('getSelectedTask')
.and.resolveTo(selectedTask);
const pluginBridge = {
createBoundMethods: () => ({
getSelectedTask,
}),
} as unknown as PluginBridgeService;
await handlePluginMessage(
{
data: {
type: PluginIframeMessageType.API_CALL,
bridgeToken: 'test-bridge-token',
bridgeGeneration: 4,
method: 'getSelectedTask',
callId: 16,
args: [],
},
source: sourceWindow,
} as unknown as MessageEvent,
createConfig(pluginBridge),
);
expect(getSelectedTask).toHaveBeenCalledTimes(1);
expect(sourceWindow.postMessage).toHaveBeenCalledWith(
{
type: PluginIframeMessageType.API_RESPONSE,
callId: 16,
result: selectedTask,
},
'*',
);
});
it('generates iframe code that waits for async dialog button handlers', () => {
const script = createPluginApiScript(
createConfig({ createBoundMethods: () => ({}) } as unknown as PluginBridgeService),
@ -301,6 +381,8 @@ describe('handlePluginMessage()', () => {
expect(script).toContain('Unknown dialog button error');
expect(script).toContain('const bridgeToken = "test-bridge-token"');
expect(script).toContain('const bridgeGeneration = 4');
expect(script).toContain("getSelectedTask: () => callApi('getSelectedTask')");
expect(script).toContain("getFocusedTask: () => callApi('getFocusedTask')");
expect(script).toContain(
"registerHeaderButton: unsupportedIframeRegistration('registerHeaderButton')",
);

View file

@ -29,6 +29,8 @@ const ALLOWED_IFRAME_API_METHODS = new Set([
'getTasks',
'getArchivedTasks',
'getCurrentContextTasks',
'getSelectedTask',
'getFocusedTask',
'selectTask',
'reInitData',
'updateTask',
@ -395,6 +397,8 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => {
getTasks: () => callApi('getTasks'),
getArchivedTasks: () => callApi('getArchivedTasks'),
getCurrentContextTasks: () => callApi('getCurrentContextTasks'),
getSelectedTask: () => callApi('getSelectedTask'),
getFocusedTask: () => callApi('getFocusedTask'),
selectTask: (taskId) => callApi('selectTask', [taskId]),
reInitData: () => callApi('reInitData'),
updateTask: (taskId, updates) => callApi('updateTask', [taskId, updates]),