Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions web/e2e/bug-3473-write-then-open.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ test.describe('write, then open at once (BUG-3473)', () => {
const created = page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/collections\/docs\/items$/.test(r.url()) && r.ok(),
);
await dialog.locator('textarea.quick-add-input').press('Enter');
// Add and open (TASK-3590: plain Enter adds and stays on the list).
await dialog.locator('textarea.quick-add-input').press('ControlOrMeta+Enter');
await created;
await expect(dialog).toBeHidden();
// The quick-add opens the new item's page; go straight back to the list.
// Add and open lands on the new item's page; go straight back to the list.
await expect(page).toHaveURL(/\/docs\/[^/?]+$/);
await page.goBack();

Expand Down
85 changes: 85 additions & 0 deletions web/e2e/task-3590-quick-capture-stays.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { test, expect } from './fixtures';
import { browserLogin } from './lib/collab-helpers';

/**
* TASK-3590: quick capture keeps your place. Adding an item used to open it,
* so jotting down an interruption was a context switch. "Add" now saves,
* stays on the current screen ready for the next capture, and confirms with a
* toast whose Open action goes to the item. "Add and open" (Ctrl/Cmd+Enter,
* or the mobile sheet's secondary button) keeps the old behaviour.
*/
test.describe('TASK-3590: quick capture keeps your place', () => {
test('desktop sidebar: Enter adds twice and stays; the toast opens the item; Ctrl/Cmd+Enter opens', async ({ page, fixture }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'the sidebar quick-add');
const stamp = `qc${Date.now().toString(36)}`;
await browserLogin(page);
const listUrl = `/${fixture.adminUsername}/${fixture.workspaceSlug}/tasks`;
await page.goto(listUrl);

const add = page.locator('button.nav-quick-add[title="New Task"]');
await add.hover({ force: true });
await add.click({ force: true });
const dialog = page.locator('.quick-add-modal');
const input = dialog.locator('textarea.quick-add-input');
await expect(dialog).toBeVisible();

for (const n of [1, 2]) {
await input.fill(`${stamp} stay ${n}`);
const created = page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/collections\/tasks\/items$/.test(new URL(r.url()).pathname) && r.ok(),
);
await input.press('Enter');
await created;
await expect(input).toHaveValue('');
await expect(input).toBeFocused();
}
await expect(dialog).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${listUrl}(\\?.*)?$`));

const toast = page.locator('.toast', { hasText: /Added TASK-\d+/ }).last();
await expect(toast).toBeVisible();
await input.press('Escape');
await expect(dialog).toBeHidden();
await toast.getByRole('button', { name: 'Open' }).click();
await expect(page).toHaveURL(/\/tasks\/TASK-\d+/);
await expect(page.getByText(`${stamp} stay 2`).first()).toBeVisible();

// Add and open.
await page.goto(listUrl);
await add.hover({ force: true });
await add.click({ force: true });
await input.fill(`${stamp} open`);
await input.press('ControlOrMeta+Enter');
await expect(dialog).toBeHidden();
await expect(page).toHaveURL(/\/tasks\/TASK-\d+/);
await expect(page.getByText(`${stamp} open`).first()).toBeVisible();
});

test('mobile sheet: Add stays open for the next capture; Add and open navigates', async ({ page, fixture }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'the mobile capture sheet');
const stamp = `qm${Date.now().toString(36)}`;
await browserLogin(page);
const listUrl = `/${fixture.adminUsername}/${fixture.workspaceSlug}/tasks`;
await page.goto(listUrl);

await page.getByRole('button', { name: 'Quick capture' }).click();
const sheet = page.getByRole('dialog', { name: 'Quick capture' });
const title = sheet.getByRole('textbox', { name: 'New item title' });
await expect(title).toBeVisible();
await title.fill(`${stamp} stay`);
const created = page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/collections\/tasks\/items$/.test(new URL(r.url()).pathname) && r.ok(),
);
await sheet.getByRole('button', { name: 'Add', exact: true }).click();
await created;
await expect(title).toHaveValue('');
await expect(sheet).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${listUrl}(\\?.*)?$`));
await expect(page.locator('.toast', { hasText: /Added TASK-\d+/ }).last()).toBeVisible();

await title.fill(`${stamp} open`);
await sheet.getByRole('button', { name: 'Add and open' }).click();
await expect(page).toHaveURL(/\/tasks\/TASK-\d+/);
await expect(page.getByText(`${stamp} open`).first()).toBeVisible();
});
});
38 changes: 32 additions & 6 deletions web/src/lib/components/layout/QuickCaptureSheet.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<!--
QuickCaptureSheet — the mobile BottomNav's center "+" action (PLAN-1694,
TASK-1698). A self-contained capture flow: pick a collection, type a title,
create. Deliberately does NOT reuse the Sidebar's quickAdd overlay
create. "Add" stays on the current screen and readies the sheet for the
next capture; "Add and open" goes to the new item (TASK-3590). Deliberately does NOT reuse the Sidebar's quickAdd overlay
(uiStore.requestQuickAdd) — that path only works while the Sidebar is
mounted and renders a desktop-ish overlay (Codex review of PLAN-1694). The
create logic mirrors Sidebar.svelte's submitQuickAdd so behaviour (default
status, content template, navigate to the new item) stays consistent.
status, content template, add vs add-and-open) stays consistent.

Presents as a DockedSheet (anchored above the bottom nav) like the
Workspace/You sheets, so the nav stays visible/tappable, the + slot stays
Expand All @@ -22,6 +23,7 @@
import { uiStore } from '$lib/stores/ui.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import { titleLimitError } from '$lib/items/titleLimit';
import { announceCaptured } from '$lib/items/quickCapture';
import { parseSettings, itemUrlId, isAgentCollection } from '$lib/types';
import { createDefaultFields } from '$lib/collections/createDefaults';
import DockedSheet from '$lib/components/layout/DockedSheet.svelte';
Expand Down Expand Up @@ -52,6 +54,7 @@
let selectedSlug = $state('');
let title = $state('');
let submitting = $state(false);
let titleEl: HTMLTextAreaElement | undefined = $state();

// Default to tasks (or the first collection) when the sheet opens. Also
// re-default if the remembered slug is no longer a valid collection (e.g.
Expand All @@ -69,7 +72,7 @@
onclose();
}

async function submit() {
async function submit(openAfter = false) {
if (!wsSlug || !selectedSlug || !title.trim() || submitting) return;
const coll = collections.find((c) => c.slug === selectedSlug);
if (!coll) return;
Expand All @@ -91,10 +94,19 @@
fields: JSON.stringify(defaultFields),
source: 'web'
});
const url = `${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`;
if (!openAfter) {
announceCaptured(item, url);
// Clear only what was sent: text typed while the request was
// in flight is the next capture, not this one.
if (title.trim() === t) title = '';
if (open) requestAnimationFrame(() => titleEl?.focus());
return;
}
title = '';
onclose();
uiStore.onNavigate();
goto(`${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`);
goto(url);
} catch (err: any) {
if (isPlanLimitError(err)) {
showPlanLimitToast(err);
Expand All @@ -113,7 +125,7 @@
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
submit(e.metaKey || e.ctrlKey);
}
}
</script>
Expand All @@ -135,17 +147,24 @@
<textarea aria-label="New item title"
class="capture-title"
bind:value={title}
bind:this={titleEl}
use:autofocus
{onkeydown}
placeholder="What needs doing?"
rows="3"
></textarea>
<div class="capture-actions">
<button class="capture-cancel" type="button" onclick={close}>Cancel</button>
<button
class="capture-open"
type="button"
onclick={() => submit(true)}
disabled={!title.trim() || submitting}
>Add and open</button>
<button
class="capture-submit"
type="button"
onclick={submit}
onclick={() => submit()}
disabled={!title.trim() || submitting}
>
{submitting ? 'Adding…' : 'Add'}
Expand Down Expand Up @@ -203,6 +222,7 @@
gap: var(--space-2);
}
.capture-cancel,
.capture-open,
.capture-submit {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
Expand All @@ -220,6 +240,12 @@
color: #fff;
border-color: var(--accent-blue);
}
/* TASK-3590: the secondary "Add and open". */
.capture-open {
background: var(--bg-primary);
color: var(--text-primary);
}
.capture-open:disabled,
.capture-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
Expand Down
109 changes: 109 additions & 0 deletions web/src/lib/components/layout/QuickCaptureSheet.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Runs in the jsdom vitest project (filename ends `.svelte.test.ts`).
//
// TASK-3590: the mobile quick-capture sheet keeps the user's place. "Add"
// creates the item, stays open with an empty title for the next capture and
// confirms with a toast that links to the item. "Add and open" closes and
// navigates as the sheet always did.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, cleanup, fireEvent, screen } from '@testing-library/svelte';
import { tick } from 'svelte';

const created = vi.hoisted(() => ({ n: 0 }));

vi.mock('$lib/api/client', () => ({
api: {
items: {
create: vi.fn(async (_ws: string, _coll: string, body: { title: string }) => {
created.n++;
return { id: `i${created.n}`, slug: `s${created.n}`, title: body.title, collection_prefix: 'TASK', item_number: created.n };
}),
},
},
isPlanLimitError: () => false,
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/stores/workspace.svelte', () => ({
workspaceStore: { canEditCollection: () => true },
}));
vi.mock('$lib/stores/collections.svelte', async () => await import('./sidebarQuickAdd.fixture.svelte'));

import QuickCaptureSheet from './QuickCaptureSheet.svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { toastStore } from '$lib/stores/toast.svelte';

function title(): HTMLTextAreaElement {
return screen.getByRole('textbox', { name: 'New item title' }) as HTMLTextAreaElement;
}

async function settle() {
for (let i = 0; i < 4; i++) await tick();
}

function mount() {
const onclose = vi.fn();
render(QuickCaptureSheet, { open: true, onclose, wsSlug: 'ws', wsPrefix: '/u/ws' });
return onclose;
}

beforeEach(() => {
cleanup();
created.n = 0;
vi.mocked(goto).mockClear();
vi.mocked(api.items.create).mockClear();
for (const t of [...toastStore.toasts]) toastStore.dismiss(t.id);
});

describe('QuickCaptureSheet keeps your place (TASK-3590)', () => {
it('Add creates, stays open with an empty title and confirms with a linked toast', async () => {
const onclose = mount();
await tick();
await fireEvent.input(title(), { target: { value: 'jot this down' } });
await fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await settle();

expect(api.items.create).toHaveBeenCalledTimes(1);
expect(goto, 'Add navigated away').not.toHaveBeenCalled();
expect(onclose, 'Add closed the sheet').not.toHaveBeenCalled();
expect(title().value).toBe('');
const toast = toastStore.toasts.at(-1);
expect(toast?.message).toBe('Added TASK-1');
expect(toast?.link).toBe('/u/ws/tasks/TASK-1?new=1');
expect(toast?.action?.label).toBe('Open');
});

it('two Adds in a row create two items without navigating', async () => {
mount();
await tick();
for (const t of ['first', 'second']) {
await fireEvent.input(title(), { target: { value: t } });
await fireEvent.keyDown(title(), { key: 'Enter' });
await settle();
}
expect(vi.mocked(api.items.create).mock.calls.map((c) => (c[2] as { title: string }).title)).toEqual(['first', 'second']);
expect(goto).not.toHaveBeenCalled();
});

it('Add and open closes the sheet and navigates to the new item', async () => {
const onclose = mount();
await tick();
await fireEvent.input(title(), { target: { value: 'open me' } });
await fireEvent.click(screen.getByRole('button', { name: 'Add and open' }));
await settle();
expect(onclose).toHaveBeenCalled();
expect(goto).toHaveBeenCalledWith('/u/ws/tasks/TASK-1?new=1');
});

it('text typed while the create was in flight is kept, not cleared', async () => {
let resolve!: (v: unknown) => void;
vi.mocked(api.items.create).mockImplementationOnce(() => new Promise((r) => (resolve = r)));
mount();
await tick();
await fireEvent.input(title(), { target: { value: 'first' } });
await fireEvent.keyDown(title(), { key: 'Enter' });
await fireEvent.input(title(), { target: { value: 'next thought' } });
resolve({ id: 'i1', slug: 's1', title: 'first', collection_prefix: 'TASK', item_number: 1 });
await settle();
expect(title().value).toBe('next thought');
});
});
Loading
Loading