feat(kanban): requester input empty + keyboard nav (issue 0088)

CardForm: drop pre-fill of requester from logged user; Enter inside the
Autocomplete no longer submits the form (Mantine handles dropdown
selection; arrows + Enter pick option without closing modal). Submit
remains via "Crear" button or Ctrl+Enter from description.

Adds data-field="requester" and data-test="add-card" selectors for stable
e2e queries.

Tests:
- vitest component test (CardForm.test.tsx): empty input, Enter does not
  submit, submit only via button. Dropdown arrow nav covered by e2e
  (jsdom portal handling is brittle).
- Playwright e2e (requester-input.spec.ts) using new browser capability
  group (pw_kanban_login, pw_keyboard_sequence) from registry.
- seed_e2e_user CLI to create deterministic test user against
  operations.db (bcrypt via standard backend hash).

Setup additions (frontend/):
- vitest + @testing-library + jsdom devDeps
- @playwright/test devDep + playwright.config.ts
- src/test/setup.ts polyfills jsdom for Mantine (matchMedia,
  visualViewport, document.fonts, ResizeObserver)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:57:00 +02:00
parent a34a8142cc
commit eb1c13d82c
14 changed files with 1224 additions and 61 deletions
+2
View File
@@ -18,3 +18,5 @@ local_files/
# Logs
*.log
frontend/test-results/
frontend/playwright-report/
+71
View File
@@ -0,0 +1,71 @@
// seed_e2e_user creates or updates a deterministic test user for Playwright e2e.
// Usage: go run ./backend/cmd/seed_e2e_user --db apps/kanban/operations.db
//
// Idempotent: safe to run repeatedly. The user "e2e_user" / password "e2e_test_pw_2026"
// is intentional and used by apps/kanban/frontend/e2e/*.spec.ts when env vars are not set.
package main
import (
"database/sql"
"errors"
"flag"
"fmt"
"os"
"time"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
)
func main() {
dbPath := flag.String("db", "operations.db", "path to kanban operations.db")
username := flag.String("username", "e2e_user", "username")
password := flag.String("password", "e2e_test_pw_2026", "password")
displayName := flag.String("display", "E2E Test", "display name")
flag.Parse()
db, err := sql.Open("sqlite3", *dbPath)
if err != nil {
fail(err)
}
defer db.Close()
hash, err := bcrypt.GenerateFromPassword([]byte(*password), bcrypt.DefaultCost)
if err != nil {
fail(err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
id := "e2etest" + fmt.Sprintf("%x", time.Now().UnixNano())[:9]
// Try update first
res, err := db.Exec(
`UPDATE users SET password_hash=?, display_name=? WHERE username=?`,
string(hash), *displayName, *username,
)
if err != nil {
fail(err)
}
n, _ := res.RowsAffected()
if n > 0 {
fmt.Printf("updated existing user %q\n", *username)
return
}
_, err = db.Exec(
`INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`,
id, *username, string(hash), *displayName, now,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
fail(err)
}
fail(err)
}
fmt.Printf("created user %q (id=%s)\n", *username, id)
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "seed_e2e_user:", err)
os.Exit(1)
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-BETde3Km.js"></script>
<script type="module" crossorigin src="/assets/index-CUPtTPZl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
</head>
<body>
+68
View File
@@ -0,0 +1,68 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
import { pw_keyboard_sequence } from "../../../../frontend/functions/browser/pw_keyboard_sequence";
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
const USER = process.env.KANBAN_USER || "egutierrez";
const PWD = process.env.KANBAN_PWD || "egutierrez";
test.describe("Issue 0088 — requester input vacio + nav teclado", () => {
test("input solicitante entra vacio y ArrowDown+Enter no cierra modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Abrir Nueva tarjeta del primer "+" disponible en alguna columna del board.
const addBtn = page.locator('[data-test="add-card"]').first();
await addBtn.dispatchEvent("click");
// Modal de Mantine abierto.
const dialog = page.locator("[role=dialog]");
await expect(dialog).toBeVisible();
// Solicitante vacio.
const requester = dialog.locator('input[data-field="requester"]');
await expect(requester).toHaveValue("");
// Necesario titulo para que un eventual submit no se descarte por el guard.
await dialog.locator("textarea").first().fill("e2e test card");
// Tipear + navegar dropdown + Enter.
await requester.focus();
await pw_keyboard_sequence(page, [
{ kind: "type", text: "a", delayMs: 50 },
{ kind: "wait", ms: 300 },
{ kind: "press", key: "ArrowDown" },
{ kind: "press", key: "Enter" },
]);
// Modal sigue visible: Enter no ha cerrado el form.
await page.waitForTimeout(300);
await expect(dialog).toBeVisible();
// Cancelar para limpiar estado.
await dialog.locator("button:has-text('Cancelar')").click();
await expect(dialog).toBeHidden();
});
test("Enter en requester con dropdown cerrado NO cierra modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const addBtn = page.locator('[data-test="add-card"]').first();
await addBtn.dispatchEvent("click");
const dialog = page.locator("[role=dialog]");
await expect(dialog).toBeVisible();
await dialog.locator("textarea").first().fill("e2e test card 2");
const requester = dialog.locator('input[data-field="requester"]');
await requester.focus();
// Press Escape para asegurar dropdown cerrado, luego Enter.
await page.keyboard.press("Escape");
await page.keyboard.press("Enter");
await page.waitForTimeout(200);
await expect(dialog).toBeVisible();
await dialog.locator("button:has-text('Cancelar')").click();
await expect(dialog).toBeHidden();
});
});
+11 -2
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -30,12 +32,19 @@
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/ui": "^4.1.6",
"jsdom": "^29.1.1",
"postcss": "^8.5.4",
"postcss-preset-mantine": "^1.17.0",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^4.1.6"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: process.env.KANBAN_BASE_URL || "http://localhost:5180",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "off",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+841
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -545,7 +545,7 @@ export function App() {
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
initial={{ requester: "" }}
submitLabel="Crear"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MantineProvider } from "@mantine/core";
import { CardForm } from "./CardForm";
function renderForm(overrides: Partial<Parameters<typeof CardForm>[0]> = {}) {
const onSubmit = vi.fn();
const onCancel = vi.fn();
render(
<MantineProvider>
<CardForm
requesterOptions={["Alice", "Anna", "Bob", "Enmanuel"]}
onSubmit={onSubmit}
onCancel={onCancel}
{...overrides}
/>
</MantineProvider>
);
return { onSubmit, onCancel };
}
describe("CardForm — requester input (issue 0088)", () => {
it("solicitante entra vacio cuando initial.requester no se pasa", () => {
renderForm();
const requesterInput = (document.querySelector('input[data-field="requester"]') as HTMLInputElement) as HTMLInputElement;
expect(requesterInput.value).toBe("");
});
it("Enter dentro del requester NO dispara onSubmit (dropdown cerrado o abierto)", async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
// Necesita un titulo valido para que un eventual submit no se ignore por el guard.
const title = screen.getByLabelText(/Tarea/i);
await user.type(title, "Mi tarea");
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
await user.click(requester);
await user.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
await user.type(requester, "An");
await user.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
});
// Navegacion ArrowDown + Enter del dropdown la maneja Mantine internamente.
// Validar eso en jsdom es fragil (portals + virtual focus). Cubierto en e2e
// Playwright donde corre browser real.
it("submit solo via boton Crear", async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm({ submitLabel: "Crear" });
const title = screen.getByLabelText(/Tarea/i);
await user.type(title, "Mi tarea");
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
await user.type(requester, "Anna");
await user.click(screen.getByRole("button", { name: /Crear/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit.mock.calls[0][0]).toMatchObject({
title: "Mi tarea",
requester: "Anna",
});
});
});
+9 -7
View File
@@ -2,6 +2,11 @@ import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from
import { FormEvent, KeyboardEvent, useState } from "react";
import type { User } from "../types";
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
// Enter dentro del Autocomplete deja que Mantine seleccione el item resaltado del
// dropdown sin cerrar el formulario. Submit solo via boton "Crear" o Ctrl+Enter
// en descripcion. Ver issue 0088.
export interface CardFormValues {
requester: string;
title: string;
@@ -48,12 +53,6 @@ export function CardForm({
});
};
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const textareaEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -89,9 +88,12 @@ export function CardForm({
data={requesterOptions}
tabIndex={2}
autoComplete="off"
onKeyDown={enterSubmit}
data-field="requester"
placeholder="Empieza a escribir y elige uno existente"
limit={10}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
/>
<Textarea
label="Descripcion"
+1
View File
@@ -455,6 +455,7 @@ function KanbanColumnImpl({
onClick={() => onAddCard(column.id)}
mt="xs"
fullWidth
data-test="add-card"
>
Anadir tarjeta
</Button>
+58
View File
@@ -0,0 +1,58 @@
import "@testing-library/jest-dom/vitest";
// jsdom does not implement matchMedia; Mantine reads it on mount.
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
// Mantine Textarea autosize reads window.visualViewport on mount; jsdom lacks it.
if (typeof window !== "undefined" && !window.visualViewport) {
Object.defineProperty(window, "visualViewport", {
writable: true,
value: {
width: 1280,
height: 800,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
},
});
}
// jsdom does not implement document.fonts; Mantine Autosize reads it on mount.
if (typeof document !== "undefined" && !(document as Document & { fonts?: unknown }).fonts) {
Object.defineProperty(document, "fonts", {
writable: true,
value: {
ready: Promise.resolve(),
addEventListener: () => {},
removeEventListener: () => {},
},
});
}
// ResizeObserver is used by some Mantine components and is not in jsdom.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["e2e/**", "node_modules/**"],
},
});