mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-29 02:30:03 +00:00
fix: effect errors and e2e tests
This commit is contained in:
parent
7137b64677
commit
db4dbb1aa9
6 changed files with 121 additions and 64 deletions
|
|
@ -4,8 +4,9 @@ import { expect, test } from '../../fixtures/test.fixture';
|
|||
const ADD_TASK_BAR = 'add-task-bar.global';
|
||||
const ADD_TASK_INPUT = `${ADD_TASK_BAR} input`;
|
||||
const DUE_BUTTON = `${ADD_TASK_BAR} [data-test="add-task-bar-due-btn"]`;
|
||||
const CLEAR_DUE_BUTTON = `${ADD_TASK_BAR} [data-test="add-task-bar-clear-due-btn"]`;
|
||||
const SCHEDULE_DIALOG = 'dialog-schedule-task';
|
||||
const QUICK_ACCESS_BTN = `${SCHEDULE_DIALOG} .quick-access button`;
|
||||
const QUICK_ACCESS_BTN = '.quick-access button';
|
||||
|
||||
const ensureGlobalAddTaskBarOpen = async (page: Page): Promise<Locator> => {
|
||||
const addTaskInput = page.locator(ADD_TASK_INPUT).first();
|
||||
|
|
@ -34,6 +35,40 @@ const openScheduleDialogFromBar = async (
|
|||
return { dialog, dueButton };
|
||||
};
|
||||
|
||||
const resetDueDateIfNeeded = async (page: Page): Promise<void> => {
|
||||
const dueButton = page.locator(DUE_BUTTON).first();
|
||||
await dueButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
const clearButton = page.locator(CLEAR_DUE_BUTTON).first();
|
||||
if (await clearButton.isVisible().catch(() => false)) {
|
||||
await clearButton.click();
|
||||
await expect(dueButton).not.toHaveClass(/has-value/);
|
||||
}
|
||||
};
|
||||
|
||||
const getDueStateFromLocator = async (
|
||||
dueButton: Locator,
|
||||
): Promise<{ hasValue: boolean; label: string }> => {
|
||||
const classAttr = (await dueButton.getAttribute('class')) || '';
|
||||
const hasValue = classAttr.includes('has-value');
|
||||
const label = ((await dueButton.textContent()) || '').replace(/\s+/g, ' ').trim();
|
||||
return { hasValue, label };
|
||||
};
|
||||
|
||||
const getDueState = async (page: Page): Promise<{ hasValue: boolean; label: string }> => {
|
||||
const dueButton = page.locator(DUE_BUTTON).first();
|
||||
await dueButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
return getDueStateFromLocator(dueButton);
|
||||
};
|
||||
|
||||
const expectDueStateUnchanged = async (
|
||||
dueButton: Locator,
|
||||
initial: { hasValue: boolean; label: string },
|
||||
): Promise<void> => {
|
||||
const current = await getDueStateFromLocator(dueButton);
|
||||
expect(current.hasValue).toBe(initial.hasValue);
|
||||
expect(current.label).toBe(initial.label);
|
||||
};
|
||||
|
||||
test.describe('Add Task Bar date picker', () => {
|
||||
test.beforeEach(async ({ workViewPage }) => {
|
||||
await workViewPage.waitForTaskList();
|
||||
|
|
@ -44,6 +79,7 @@ test.describe('Add Task Bar date picker', () => {
|
|||
testPrefix,
|
||||
}) => {
|
||||
const input = await ensureGlobalAddTaskBarOpen(page);
|
||||
await resetDueDateIfNeeded(page);
|
||||
await input.fill(`${testPrefix}-today quick date`);
|
||||
|
||||
const { dialog, dueButton } = await openScheduleDialogFromBar(page);
|
||||
|
|
@ -60,6 +96,7 @@ test.describe('Add Task Bar date picker', () => {
|
|||
testPrefix,
|
||||
}) => {
|
||||
const input = await ensureGlobalAddTaskBarOpen(page);
|
||||
await resetDueDateIfNeeded(page);
|
||||
await input.fill(`${testPrefix}-tomorrow quick date`);
|
||||
|
||||
const { dialog, dueButton } = await openScheduleDialogFromBar(page);
|
||||
|
|
@ -76,14 +113,15 @@ test.describe('Add Task Bar date picker', () => {
|
|||
testPrefix,
|
||||
}) => {
|
||||
const input = await ensureGlobalAddTaskBarOpen(page);
|
||||
await resetDueDateIfNeeded(page);
|
||||
await input.fill(`${testPrefix}-cancel quick date`);
|
||||
const initialDueState = await getDueState(page);
|
||||
|
||||
const { dialog, dueButton } = await openScheduleDialogFromBar(page);
|
||||
await dialog.locator('button:has-text("Cancel")').click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
await expect(page.locator(ADD_TASK_INPUT)).toBeVisible();
|
||||
await expect(dueButton).not.toHaveClass(/has-value/);
|
||||
await expect(dueButton).toContainText(/due/i);
|
||||
await expectDueStateUnchanged(dueButton, initialDueState);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Locator } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/test.fixture';
|
||||
import { WorkViewPage } from '../../pages/work-view.page';
|
||||
|
||||
|
|
@ -21,7 +22,6 @@ test.describe('Drag Task to change project and labels', () => {
|
|||
await workViewPage.addTask('TestTask');
|
||||
await page.waitForSelector('task', { state: 'visible' });
|
||||
|
||||
const projectNameInput = page.locator('dialog-create-project input').first();
|
||||
const project1NavItem = page
|
||||
.getByRole('menuitem')
|
||||
.filter({ hasText: `${testPrefix}-TestProject 1` });
|
||||
|
|
@ -29,35 +29,27 @@ test.describe('Drag Task to change project and labels', () => {
|
|||
.getByRole('menuitem')
|
||||
.filter({ hasText: `${testPrefix}-TestProject 2` });
|
||||
|
||||
// Add first project
|
||||
await page.keyboard.press('Shift+P');
|
||||
// Helper to create projects reliably by clicking the dialog's Submit button
|
||||
const createProject = async (
|
||||
name: string,
|
||||
expectedNavItem: Locator,
|
||||
): Promise<void> => {
|
||||
const dialog = page.locator('dialog-create-project');
|
||||
const projectNameInput = dialog.locator('input').first();
|
||||
const saveBtn = dialog.locator('button[type="submit"]');
|
||||
|
||||
// Wait for dialog and input to be visible
|
||||
await page.waitForSelector('dialog-create-project', { state: 'visible' });
|
||||
await projectNameInput.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await projectNameInput.fill(`${testPrefix}-TestProject 1`);
|
||||
await page.keyboard.press('Enter');
|
||||
// Wait for dialog to close and nav item to appear
|
||||
await page.waitForSelector('dialog-create-project', {
|
||||
state: 'hidden',
|
||||
timeout: 5000,
|
||||
});
|
||||
await project1NavItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await page.keyboard.press('Shift+P');
|
||||
await dialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await projectNameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await projectNameInput.fill(name);
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 5000 });
|
||||
await saveBtn.click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
await expectedNavItem.waitFor({ state: 'visible', timeout: 10000 });
|
||||
};
|
||||
|
||||
// Add another project
|
||||
await page.keyboard.press('Shift+P');
|
||||
|
||||
// Wait for dialog and input to be visible
|
||||
await page.waitForSelector('dialog-create-project', { state: 'visible' });
|
||||
await projectNameInput.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await projectNameInput.fill(`${testPrefix}-TestProject 2`);
|
||||
await page.keyboard.press('Enter');
|
||||
// Wait for dialog to close and nav item to appear
|
||||
await page.waitForSelector('dialog-create-project', {
|
||||
state: 'hidden',
|
||||
timeout: 5000,
|
||||
});
|
||||
await project2NavItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await createProject(`${testPrefix}-TestProject 1`, project1NavItem);
|
||||
await createProject(`${testPrefix}-TestProject 2`, project2NavItem);
|
||||
|
||||
// find drag handle of task
|
||||
const firstTask = page.locator('task').first();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { effect, inject, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
effect,
|
||||
EnvironmentInjector,
|
||||
inject,
|
||||
Injectable,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { BodyClass, IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_MAC } from '../../util/is-mac';
|
||||
|
|
@ -43,6 +50,8 @@ export class GlobalThemeService {
|
|||
private _imexMetaService = inject(ImexViewService);
|
||||
private _http = inject(HttpClient);
|
||||
private _customThemeService = inject(CustomThemeService);
|
||||
private _environmentInjector = inject(EnvironmentInjector);
|
||||
private _hasInitialized = false;
|
||||
|
||||
darkMode = signal<DarkModeCfg>(
|
||||
(localStorage.getItem(LS.DARK_MODE) as DarkModeCfg) || 'system',
|
||||
|
|
@ -81,20 +90,27 @@ export class GlobalThemeService {
|
|||
backgroundImg = toSignal(this._backgroundImgObs$);
|
||||
|
||||
init(): void {
|
||||
// This is here to make web page reloads on non-work-context pages at least usable
|
||||
this._setBackgroundTint(true);
|
||||
this._initIcons();
|
||||
this._initHandlersForInitialBodyClasses();
|
||||
this._initThemeWatchers();
|
||||
if (this._hasInitialized) {
|
||||
return;
|
||||
}
|
||||
this._hasInitialized = true;
|
||||
|
||||
// Set up dark mode persistence effect
|
||||
effect(() => {
|
||||
const darkMode = this.darkMode();
|
||||
localStorage.setItem(LS.DARK_MODE, darkMode);
|
||||
runInInjectionContext(this._environmentInjector, () => {
|
||||
// This is here to make web page reloads on non-work-context pages at least usable
|
||||
this._setBackgroundTint(true);
|
||||
this._initIcons();
|
||||
this._initHandlersForInitialBodyClasses();
|
||||
this._initThemeWatchers();
|
||||
|
||||
// Set up dark mode persistence effect
|
||||
effect(() => {
|
||||
const darkMode = this.darkMode();
|
||||
localStorage.setItem(LS.DARK_MODE, darkMode);
|
||||
});
|
||||
|
||||
// Set up reactive custom theme updates
|
||||
this._setupCustomThemeEffect();
|
||||
});
|
||||
|
||||
// Set up reactive custom theme updates
|
||||
this._setupCustomThemeEffect();
|
||||
}
|
||||
|
||||
private _setDarkTheme(isDarkTheme: boolean): void {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
<button
|
||||
mat-button
|
||||
class="clear-btn"
|
||||
data-test="add-task-bar-clear-due-btn"
|
||||
(click)="clearDateWithSyntax(); $event.stopPropagation()"
|
||||
[matTooltip]="T.F.TASK.ADD_TASK_BAR.TOOLTIP_CLEAR_DATE | translate"
|
||||
matTooltipPosition="above"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
OnInit,
|
||||
output,
|
||||
viewChild,
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ControlValueAccessor,
|
||||
|
|
@ -58,7 +58,7 @@ import {
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SelectTaskMinimalComponent
|
||||
implements OnInit, OnDestroy, ControlValueAccessor
|
||||
implements OnInit, AfterViewInit, OnDestroy, ControlValueAccessor
|
||||
{
|
||||
private _workContextService = inject(WorkContextService);
|
||||
private _store = inject(Store);
|
||||
|
|
@ -90,22 +90,6 @@ export class SelectTaskMinimalComponent
|
|||
private _isAutocompleteOpen = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Set up autocomplete event listeners when autocomplete becomes available
|
||||
effect(() => {
|
||||
const autocomplete = this.autocomplete();
|
||||
if (autocomplete) {
|
||||
autocomplete.opened.pipe(takeUntil(this._destroy$)).subscribe(() => {
|
||||
this._isAutocompleteOpen = true;
|
||||
this.autocompleteOpened.emit();
|
||||
});
|
||||
|
||||
autocomplete.closed.pipe(takeUntil(this._destroy$)).subscribe(() => {
|
||||
this._isAutocompleteOpen = false;
|
||||
this.autocompleteClosed.emit();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Use the same task selection logic as the original SelectTaskComponent
|
||||
const tasks$: Observable<Task[]> = this.isLimitToProject()
|
||||
? this.isIncludeDoneTasks()
|
||||
|
|
@ -148,6 +132,23 @@ export class SelectTaskMinimalComponent
|
|||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const autocomplete = this.autocomplete();
|
||||
if (!autocomplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
autocomplete.opened.pipe(takeUntil(this._destroy$)).subscribe(() => {
|
||||
this._isAutocompleteOpen = true;
|
||||
this.autocompleteOpened.emit();
|
||||
});
|
||||
|
||||
autocomplete.closed.pipe(takeUntil(this._destroy$)).subscribe(() => {
|
||||
this._isAutocompleteOpen = false;
|
||||
this.autocompleteClosed.emit();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._destroy$.next();
|
||||
this._destroy$.complete();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable max-len */
|
||||
import { inject, Injectable, signal, OnDestroy } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { PluginRunner } from './plugin-runner';
|
||||
import { PluginHooksService } from './plugin-hooks';
|
||||
|
|
@ -139,7 +139,16 @@ export class PluginService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
PluginLog.err(`Failed to discover plugin at ${path}:`, error);
|
||||
if (
|
||||
error instanceof HttpErrorResponse &&
|
||||
(error.status === 0 || error.status === 404)
|
||||
) {
|
||||
PluginLog.warn(
|
||||
`Optional built-in plugin manifest missing at ${path} (status ${error.status}). Skipping.`,
|
||||
);
|
||||
} else {
|
||||
PluginLog.err(`Failed to discover plugin at ${path}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue