diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 6c76127006..47621514b8 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -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 diff --git a/docs/wiki/3.01-API.md b/docs/wiki/3.01-API.md index b9353d5428..d10bddd2cf 100755 --- a/docs/wiki/3.01-API.md +++ b/docs/wiki/3.01-API.md @@ -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. diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index 2e33381a0d..fb5b5db9e2 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -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 diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index 3dcc26f7f4..d1444c332a 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -571,6 +571,21 @@ export interface PluginAPI { getCurrentContextTasks(): Promise; + /** + * 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; + + /** + * 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; + /** * Returns a complete read-only snapshot of the application state including * tasks, projects, tags, notes, task repeat configurations, simple counters diff --git a/src/app/plugins/plugin-api.spec.ts b/src/app/plugins/plugin-api.spec.ts index 0c05fbfdc8..5c422aa008 100644 --- a/src/app/plugins/plugin-api.spec.ts +++ b/src/app/plugins/plugin-api.spec.ts @@ -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; getAppState: () => Promise; @@ -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[5]>; diff --git a/src/app/plugins/plugin-api.ts b/src/app/plugins/plugin-api.ts index c0c8c3e2e0..d765b21fef 100644 --- a/src/app/plugins/plugin-api.ts +++ b/src/app/plugins/plugin-api.ts @@ -207,6 +207,18 @@ export class PluginAPI implements PluginAPIInterface { return tasks.map(taskCopyToTaskData); } + async getSelectedTask(): Promise { + PluginLog.log(`Plugin ${this.#pluginId} requested selected task`); + const task = await this.#boundMethods.getSelectedTask(); + return task ? taskCopyToTaskData(task) : null; + } + + async getFocusedTask(): Promise { + PluginLog.log(`Plugin ${this.#pluginId} requested focused task`); + const task = await this.#boundMethods.getFocusedTask(); + return task ? taskCopyToTaskData(task) : null; + } + async getAppState(): Promise { PluginLog.log(`Plugin ${this.#pluginId} requested app state snapshot`); return this.#pluginBridge.getAppState(); diff --git a/src/app/plugins/plugin-bridge.service.spec.ts b/src/app/plugins/plugin-bridge.service.spec.ts index cfef011609..c04c824efa 100644 --- a/src/app/plugins/plugin-bridge.service.spec.ts +++ b/src/app/plugins/plugin-bridge.service.spec.ts @@ -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; + + beforeEach(() => { + taskService = jasmine.createSpyObj('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(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; diff --git a/src/app/plugins/plugin-bridge.service.ts b/src/app/plugins/plugin-bridge.service.ts index 6d4dd6c77f..29c6130ef5 100644 --- a/src/app/plugins/plugin-bridge.service.ts +++ b/src/app/plugins/plugin-bridge.service.ts @@ -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; + getSelectedTask: () => Promise; + getFocusedTask: () => Promise; triggerSync: () => Promise; 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 { + return toPluginTaskCopy(await firstValueFrom(this._taskService.selectedTask$)); + } + + async getFocusedTask(): Promise { + 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 diff --git a/src/app/plugins/util/plugin-iframe.util.spec.ts b/src/app/plugins/util/plugin-iframe.util.spec.ts index d546751a3d..5cff381ab4 100644 --- a/src/app/plugins/util/plugin-iframe.util.spec.ts +++ b/src/app/plugins/util/plugin-iframe.util.spec.ts @@ -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')", ); diff --git a/src/app/plugins/util/plugin-iframe.util.ts b/src/app/plugins/util/plugin-iframe.util.ts index e5bec6833f..5c4de58217 100644 --- a/src/app/plugins/util/plugin-iframe.util.ts +++ b/src/app/plugins/util/plugin-iframe.util.ts @@ -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]),