mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
a4c0dc7d46
commit
3db96fd8a3
10 changed files with 267 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue