From c0180aab13175b3f87674a58967db74936155ed0 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Apr 2026 23:58:55 +0200 Subject: [PATCH] feat: update launch configurations and add outside click handling for date input component Co-authored-by: Copilot --- .vscode/launch.json | 134 ++++++++++++++------- Components/Pages/WorkDayEditor.razor | 2 + Components/Shared/LocalizedDateInput.razor | 52 +++++++- docker-compose.tests.yml | 4 + tests/playwright/calendar-pickers.spec.ts | 43 +++++++ tests/playwright/date-locale.spec.ts | 6 +- wwwroot/theme.js | 38 ++++++ 7 files changed, 229 insertions(+), 50 deletions(-) create mode 100644 tests/playwright/calendar-pickers.spec.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 4e1e71d..a82168c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,88 @@ "compounds": [], "configurations": [ { - "name": "WorkTracker: Debug in Docker", + "name": "WorkTracker: Debug Docker App + Browser", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "WorkTracker: Docker Debug Prepare", + "postDebugTask": "WorkTracker: Docker Debug Down", + "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll", + "args": [ + "--urls", + "http://+:8080" + ], + "cwd": "/workspace", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://+:8080", + "DOTNET_USE_POLLING_FILE_WATCHER": "1", + "UseHttpsRedirection": "false" + }, + "sourceFileMap": { + "/workspace": "${workspaceFolder}" + }, + "pipeTransport": { + "pipeProgram": "docker", + "pipeArgs": [ + "exec", + "-i", + "worktracker-dev", + "sh", + "-c" + ], + "debuggerPath": "/vsdbg/vsdbg", + "pipeCwd": "${workspaceFolder}", + "quoteArgs": false + }, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+https?://\\S+", + "uriFormat": "http://localhost:8002" + }, + "justMyCode": true, + "requireExactSource": false, + "console": "internalConsole" + }, + { + "name": "WorkTracker: Debug Docker App", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "WorkTracker: Docker Debug Prepare", + "postDebugTask": "WorkTracker: Docker Debug Down", + "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll", + "args": [ + "--urls", + "http://+:8080" + ], + "cwd": "/workspace", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://+:8080", + "DOTNET_USE_POLLING_FILE_WATCHER": "1", + "UseHttpsRedirection": "false" + }, + "sourceFileMap": { + "/workspace": "${workspaceFolder}" + }, + "pipeTransport": { + "pipeProgram": "docker", + "pipeArgs": [ + "exec", + "-i", + "worktracker-dev", + "sh", + "-c" + ], + "debuggerPath": "/vsdbg/vsdbg", + "pipeCwd": "${workspaceFolder}", + "quoteArgs": false + }, + "justMyCode": true, + "requireExactSource": false, + "console": "internalConsole" + }, + { + "name": "WorkTracker: Debug Docker App + Edge", "type": "coreclr", "request": "launch", "preLaunchTask": "WorkTracker: Docker Debug Prepare", @@ -38,58 +119,19 @@ }, "serverReadyAction": { "action": "debugWithEdge", - "pattern": "Now listening on:\\s+https?://\\S+:(\\d+)", - "uriFormat": "http://localhost:8002/?ready=%s" + "pattern": "\\bNow listening on:\\s+https?://\\S+", + "uriFormat": "http://localhost:8002", + "webRoot": "${workspaceFolder}" }, "justMyCode": true, "requireExactSource": false, "console": "internalConsole" }, { - "name": "WorkTracker: Debug App in Docker", - "type": "coreclr", + "name": "WorkTracker: Launch Integrated Browser for Running Docker App", + "type": "editor-browser", "request": "launch", - "preLaunchTask": "WorkTracker: Docker Debug Prepare", - "postDebugTask": "WorkTracker: Docker Debug Down", - "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll", - "args": [ - "--urls", - "http://+:8080" - ], - "cwd": "/workspace", - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://+:8080", - "DOTNET_USE_POLLING_FILE_WATCHER": "1", - "UseHttpsRedirection": "false" - }, - "sourceFileMap": { - "/workspace": "${workspaceFolder}" - }, - "pipeTransport": { - "pipeProgram": "docker", - "pipeArgs": [ - "exec", - "-i", - "worktracker-dev", - "sh", - "-c" - ], - "debuggerPath": "/vsdbg/vsdbg", - "pipeCwd": "${workspaceFolder}", - "quoteArgs": false - }, - "justMyCode": true, - "requireExactSource": false, - "console": "internalConsole" - }, - { - "name": "WorkTracker: Debug Edge", - "type": "msedge", - "request": "launch", - "url": "http://localhost:8002", - "webRoot": "${workspaceFolder}", - "internalConsoleOptions": "neverOpen" + "url": "http://localhost:8002" } ] } \ No newline at end of file diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor index 969580a..a19fce2 100644 --- a/Components/Pages/WorkDayEditor.razor +++ b/Components/Pages/WorkDayEditor.razor @@ -197,6 +197,8 @@ else { selectedDate = value.Value; statusMessage = null; + selectedDay = await WorkDayService.GetAsync(selectedDate); + RecomputePreview(); } } diff --git a/Components/Shared/LocalizedDateInput.razor b/Components/Shared/LocalizedDateInput.razor index 427d63a..f2c6a72 100644 --- a/Components/Shared/LocalizedDateInput.razor +++ b/Components/Shared/LocalizedDateInput.razor @@ -1,6 +1,8 @@ @using System.Globalization +@implements IAsyncDisposable +@inject IJSRuntime JS -
+
calendarDays = []; + private ElementReference rootElement; + private DotNetObjectReference? dotNetReference; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (isOpen && !outsideClickListenerActive) + { + dotNetReference ??= DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("workTrackerDateInput.registerOutsideClick", rootElement, dotNetReference); + outsideClickListenerActive = true; + } + else if (!isOpen && outsideClickListenerActive) + { + await JS.InvokeVoidAsync("workTrackerDateInput.unregisterOutsideClick", rootElement); + outsideClickListenerActive = false; + } + + await base.OnAfterRenderAsync(firstRender); + } protected override void OnParametersSet() { @@ -190,6 +212,34 @@ } } + [JSInvokable] + public Task ClosePopupFromOutsideClickAsync() + { + if (!isOpen) + { + return Task.CompletedTask; + } + + isOpen = false; + return InvokeAsync(StateHasChanged); + } + + public async ValueTask DisposeAsync() + { + if (outsideClickListenerActive) + { + try + { + await JS.InvokeVoidAsync("workTrackerDateInput.unregisterOutsideClick", rootElement); + } + catch (JSDisconnectedException) + { + } + } + + dotNetReference?.Dispose(); + } + private string GetInputTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-input" : $"{TestId}-input"; private string GetPopoverTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-popover" : $"{TestId}-popover"; diff --git a/docker-compose.tests.yml b/docker-compose.tests.yml index 9c4c27a..16b7a98 100644 --- a/docker-compose.tests.yml +++ b/docker-compose.tests.yml @@ -1,4 +1,8 @@ services: + worktracker: + build: + context: . + dockerfile: Dockerfile playwright: build: context: . diff --git a/tests/playwright/calendar-pickers.spec.ts b/tests/playwright/calendar-pickers.spec.ts new file mode 100644 index 0000000..97b61c2 --- /dev/null +++ b/tests/playwright/calendar-pickers.spec.ts @@ -0,0 +1,43 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function waitForBlazorConnection(page: Page) { + await page.waitForTimeout(750); +} + +test('calendar modal saves and deletes an event with picker ranges', async ({ page }) => { + const eventTitle = `Smoke ${Date.now()}`; + + await page.setViewportSize({ width: 1440, height: 960 }); + await page.goto('/calendar-event/2026-04-22', { waitUntil: 'networkidle' }); + await waitForBlazorConnection(page); + + const startDateInput = page.getByTestId('calendar-event-start-date-input'); + const endDateInput = page.getByTestId('calendar-event-end-date-input'); + await expect(startDateInput).toBeVisible(); + await expect(endDateInput).toBeVisible(); + + await endDateInput.click(); + await page.getByTestId('calendar-event-end-date-day-2026-04-23').click(); + await expect(endDateInput).toHaveValue('23/04/2026'); + + const editorTimeInputs = page.locator('input[type="time"]'); + await expect(editorTimeInputs).toHaveCount(2); + await editorTimeInputs.nth(0).fill('09:00'); + await editorTimeInputs.nth(1).fill('12:00'); + + await page.locator('input[placeholder="Optional"]').fill(eventTitle); + await page.getByRole('button', { name: 'Save' }).click(); + + await waitForBlazorConnection(page); + await expect(page).toHaveURL(/\/calendar\/2026-04$/); + + const savedEventEntries = page.locator('.calendar-item-event', { hasText: eventTitle }); + await expect(savedEventEntries).toHaveCount(2); + await expect(savedEventEntries.first()).toContainText('09:00'); + + await savedEventEntries.first().click(); + page.once('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByText(eventTitle)).toHaveCount(0); +}); \ No newline at end of file diff --git a/tests/playwright/date-locale.spec.ts b/tests/playwright/date-locale.spec.ts index 2777946..e03fd34 100644 --- a/tests/playwright/date-locale.spec.ts +++ b/tests/playwright/date-locale.spec.ts @@ -1,10 +1,10 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; test.use({ locale: 'en-US' }); -async function waitForBlazorConnection(page: Parameters[0]['page']) { +async function waitForBlazorConnection(page: Page) { await page.waitForTimeout(750); } @@ -23,8 +23,8 @@ test('date picker popup is monday-first and uses european formatting', async ({ const weekdayHeaders = popup.getByTestId('date-picker-weekday'); await expect(weekdayHeaders).toHaveCount(7); - await expect(weekdayHeaders.first()).toHaveText(/^(Mon|Lun)$/); + await expect(weekdayHeaders.first()).toHaveText(/^(Mon|Lun)$/); await page.getByTestId('calendar-event-start-date-day-2026-04-22').click(); await expect(startDateInput).toHaveValue(/^22\/\d{2}\/\d{4}$/); }); \ No newline at end of file diff --git a/wwwroot/theme.js b/wwwroot/theme.js index cf7b2a2..6007550 100644 --- a/wwwroot/theme.js +++ b/wwwroot/theme.js @@ -86,4 +86,42 @@ window.workTrackerPreferences = { } }; +window.workTrackerDateInput = (() => { + const listeners = new WeakMap(); + + function unregisterOutsideClick(root) { + const existingListener = listeners.get(root); + if (!existingListener) { + return; + } + + document.removeEventListener("pointerdown", existingListener, true); + listeners.delete(root); + } + + function registerOutsideClick(root, dotNetReference) { + if (!root || !dotNetReference) { + return; + } + + unregisterOutsideClick(root); + + const listener = (event) => { + if (root.contains(event.target)) { + return; + } + + dotNetReference.invokeMethodAsync("ClosePopupFromOutsideClickAsync"); + }; + + listeners.set(root, listener); + document.addEventListener("pointerdown", listener, true); + } + + return { + registerOutsideClick, + unregisterOutsideClick + }; +})(); + window.workTrackerTheme.init(); \ No newline at end of file