Two concrete problems the user hit:
1) /tasks overview had no visible "create" button — only an enter-to-submit
input hidden behind a decorative Plus icon.
2) /tasks/[id] add-task form had no visible submit button either.
Both now mirror the /lists + /notes pattern:
/tasks — new "New list" button in the masthead (bg-slate-900 primary
style matching "New list" on /lists and "New note" on /notes). Click
creates an empty task_list and navigates to /tasks/[id] where the
user names it inline via the new big editable title input
(autosaves after NAME_SAVE_DEBOUNCE_MS, 500 ms — same hook as
/lists/[id] and /notes/[id]).
/tasks/[id] — sticky add-task footer gains a filled "+" submit button
on the right; clicking it or pressing Enter both fire handleAdd.
Input is now wrapped in the same rounded-lg card used on /lists/[id]
so the two screens look identical.
i18n
tasks_new_list = "New list" / "Nueva lista" (en / es).
Tests
tasks.test.ts helper updated to drive the new button-then-title flow
(same shape as lists.test.ts). T-UI-01..03 all pass.
just test-all → exit 0, 228 green, 2 skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
/**
|
|
* T-series (UI) — Fase 3.1
|
|
*
|
|
* Task lists overview at /tasks; per-list detail at /tasks/[id]. Members can
|
|
* create, complete, edit, delete, and reorder tasks. Guests see them but
|
|
* cannot mutate. Completed tasks sink to the bottom and show "Completed by …".
|
|
*/
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
import { USERS } from '../fixtures/users.js';
|
|
import { loginAs } from '../fixtures/login.js';
|
|
|
|
const NEW_LIST_PLACEHOLDER = /new task list|nueva lista de tareas/i;
|
|
const NEW_TASK_PLACEHOLDER = /add task|añadir tarea/i;
|
|
|
|
async function gotoTasksClean(page: Page) {
|
|
await page.goto('/tasks');
|
|
await expect(
|
|
page.getByRole('button', { name: /new list|nueva lista/i })
|
|
).toBeVisible({ timeout: 15_000 });
|
|
}
|
|
|
|
async function createTaskList(page: Page, name: string) {
|
|
// Masthead "New list" button → creates empty list and navigates to the detail.
|
|
await page.getByRole('button', { name: /new list|nueva lista/i }).click();
|
|
await expect(page).toHaveURL(/\/tasks\/[0-9a-f-]+$/, { timeout: 10_000 });
|
|
const titleInput = page.getByPlaceholder(NEW_LIST_PLACEHOLDER);
|
|
await expect(titleInput).toBeVisible({ timeout: 10_000 });
|
|
await titleInput.fill(name);
|
|
await titleInput.blur();
|
|
await page.waitForTimeout(900); // autosave debounce
|
|
}
|
|
|
|
test.describe('Tasks — member (Borja)', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page, USERS.borja);
|
|
});
|
|
|
|
test('T-UI-01: create list, add task, complete it, see "Completed by …" tooltip', async ({
|
|
page
|
|
}) => {
|
|
await gotoTasksClean(page);
|
|
const listName = `Tasks ${Date.now()}`;
|
|
await createTaskList(page, listName);
|
|
|
|
const taskInput = page.getByPlaceholder(NEW_TASK_PLACEHOLDER);
|
|
await expect(taskInput).toBeVisible({ timeout: 5_000 });
|
|
const taskTitle = `Buy milk ${Date.now()}`;
|
|
await taskInput.fill(taskTitle);
|
|
await taskInput.press('Enter');
|
|
|
|
const row = page.locator('[role="listitem"]').filter({ hasText: taskTitle });
|
|
await expect(row).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Toggle complete
|
|
await row.getByRole('button', { name: /toggle task|alternar tarea/i }).click();
|
|
|
|
// Title is decorated as completed (line-through). Check via aria.
|
|
await expect(
|
|
page
|
|
.locator('[role="listitem"]')
|
|
.filter({ hasText: taskTitle })
|
|
.getByRole('button', { name: /uncheck task|desmarcar tarea/i })
|
|
).toBeVisible({ timeout: 5_000 });
|
|
|
|
// "Completed by Borja" tooltip / metadata visible somewhere on the row.
|
|
await expect(
|
|
page.locator('[role="listitem"]').filter({ hasText: taskTitle }).getByText(/borja/i)
|
|
).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('T-UI-02: delete a task removes it from the list', async ({ page }) => {
|
|
await gotoTasksClean(page);
|
|
const listName = `Tasks-del ${Date.now()}`;
|
|
await createTaskList(page, listName);
|
|
|
|
const taskInput = page.getByPlaceholder(NEW_TASK_PLACEHOLDER);
|
|
const taskTitle = `Delete me ${Date.now()}`;
|
|
await taskInput.fill(taskTitle);
|
|
await taskInput.press('Enter');
|
|
|
|
const row = page.locator('[role="listitem"]').filter({ hasText: taskTitle });
|
|
await expect(row).toBeVisible({ timeout: 5_000 });
|
|
|
|
await row.getByRole('button', { name: /delete task|eliminar tarea/i }).click();
|
|
await expect(
|
|
page.locator('[role="listitem"]').filter({ hasText: taskTitle })
|
|
).toHaveCount(0, { timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Tasks — guest (David, read-only)', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page, USERS.david);
|
|
});
|
|
|
|
test('T-UI-03: guest can navigate to /tasks but cannot create lists', async ({ page }) => {
|
|
await page.goto('/tasks');
|
|
// Either the create input is hidden, or it exists but typing into it
|
|
// must NOT produce a committed list (RLS blocks the insert).
|
|
const input = page.getByPlaceholder(NEW_LIST_PLACEHOLDER);
|
|
if (await input.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
|
const name = `David sneaky ${Date.now()}`;
|
|
await input.fill(name);
|
|
await input.press('Enter');
|
|
await page.waitForTimeout(1_500);
|
|
await expect(
|
|
page.getByRole('heading', { name: new RegExp(`^${name}$`) })
|
|
).not.toBeVisible({ timeout: 2_000 });
|
|
}
|
|
});
|
|
});
|