feat: update launch configurations and add outside click handling for date input component

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Marco 2026-04-22 23:58:55 +02:00
commit c0180aab13
7 changed files with 193 additions and 14 deletions

134
.vscode/launch.json vendored
View file

@ -3,7 +3,88 @@
"compounds": [], "compounds": [],
"configurations": [ "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", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "WorkTracker: Docker Debug Prepare", "preLaunchTask": "WorkTracker: Docker Debug Prepare",
@ -38,58 +119,19 @@
}, },
"serverReadyAction": { "serverReadyAction": {
"action": "debugWithEdge", "action": "debugWithEdge",
"pattern": "Now listening on:\\s+https?://\\S+:(\\d+)", "pattern": "\\bNow listening on:\\s+https?://\\S+",
"uriFormat": "http://localhost:8002/?ready=%s" "uriFormat": "http://localhost:8002",
"webRoot": "${workspaceFolder}"
}, },
"justMyCode": true, "justMyCode": true,
"requireExactSource": false, "requireExactSource": false,
"console": "internalConsole" "console": "internalConsole"
}, },
{ {
"name": "WorkTracker: Debug App in Docker", "name": "WorkTracker: Launch Integrated Browser for Running Docker App",
"type": "coreclr", "type": "editor-browser",
"request": "launch", "request": "launch",
"preLaunchTask": "WorkTracker: Docker Debug Prepare", "url": "http://localhost:8002"
"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"
} }
] ]
} }

View file

@ -197,6 +197,8 @@ else
{ {
selectedDate = value.Value; selectedDate = value.Value;
statusMessage = null; statusMessage = null;
selectedDay = await WorkDayService.GetAsync(selectedDate);
RecomputePreview();
} }
} }

View file

@ -1,6 +1,8 @@
@using System.Globalization @using System.Globalization
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="localized-date-input" @onkeydown="HandleKeyDown"> <div class="localized-date-input" @ref="rootElement" @onkeydown="HandleKeyDown">
<div class="input-group"> <div class="input-group">
<input id="@InputId" <input id="@InputId"
data-testid="@GetInputTestId()" data-testid="@GetInputTestId()"
@ -75,8 +77,28 @@
private DateOnly? lastValue; private DateOnly? lastValue;
private string displayValue = string.Empty; private string displayValue = string.Empty;
private bool isOpen; private bool isOpen;
private bool outsideClickListenerActive;
private DateOnly visibleMonth; private DateOnly visibleMonth;
private IReadOnlyList<CalendarDayCell> calendarDays = []; private IReadOnlyList<CalendarDayCell> calendarDays = [];
private ElementReference rootElement;
private DotNetObjectReference<LocalizedDateInput>? 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() 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 GetInputTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-input" : $"{TestId}-input";
private string GetPopoverTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-popover" : $"{TestId}-popover"; private string GetPopoverTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-popover" : $"{TestId}-popover";

View file

@ -1,4 +1,8 @@
services: services:
worktracker:
build:
context: .
dockerfile: Dockerfile
playwright: playwright:
build: build:
context: . context: .

View file

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

View file

@ -1,10 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.use({ test.use({
locale: 'en-US' locale: 'en-US'
}); });
async function waitForBlazorConnection(page: Parameters<typeof test>[0]['page']) { async function waitForBlazorConnection(page: Page) {
await page.waitForTimeout(750); 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'); const weekdayHeaders = popup.getByTestId('date-picker-weekday');
await expect(weekdayHeaders).toHaveCount(7); 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 page.getByTestId('calendar-event-start-date-day-2026-04-22').click();
await expect(startDateInput).toHaveValue(/^22\/\d{2}\/\d{4}$/); await expect(startDateInput).toHaveValue(/^22\/\d{2}\/\d{4}$/);
}); });

View file

@ -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(); window.workTrackerTheme.init();