fix: effect errors and e2e tests

This commit is contained in:
Johannes Millan 2025-11-14 15:23:17 +01:00
parent 7137b64677
commit db4dbb1aa9
6 changed files with 121 additions and 64 deletions

View file

@ -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);
});
});

View file

@ -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();

View file

@ -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 {

View file

@ -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"

View file

@ -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();

View file

@ -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);
}
}
}
}