diff --git a/integration/tests/27-month-drag-create.spec.ts b/integration/tests/27-month-drag-create.spec.ts new file mode 100644 index 000000000..c9bd255e8 --- /dev/null +++ b/integration/tests/27-month-drag-create.spec.ts @@ -0,0 +1,367 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { addDays, format, startOfWeek } from 'date-fns'; +import { mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability report 2026-11-04: creating by dragging over the calendar. + * - Month view: a drag over days (across week rows, either way), Shift+ + * arrows and Enter, a long press and drag on a phone, all open the quick + * editor (the full one on a phone) for an all-day event over the days. + * - Week view: the event created is the range the selection showed, the + * selection is drawn over the events it covers, the new event takes a + * column beside an overlapping one, and Save works without a title. + * Against a VM 106 Stalwart (1.0 and 0.16); every save is checked on the + * server and removed again. + * + * Account: dso-ux-pim, password IT_DSO_PASSWORD. Items are titled + * "MDRAG …" (the untitled one is found by its time) and deleted. + * Screenshots go to IT_SHOTS_DIR, named month-drag--…. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const CAL = 'urn:ietf:params:jmap:calendars'; +const RUN = Date.now().toString(36); +const TZ = 'Europe/Berlin'; +const prefix = `MDRAG ${RUN}`; +const SERVER = process.env.IT_ENT_SERVER ?? 'unknown'; +const SHOTS = process.env.IT_SHOTS_DIR ?? path.join(tmpdir(), 'bulwark-month-drag'); + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Obj = { id: string; title?: string; start?: string; duration?: string; showWithoutTime?: boolean; calendarIds?: Record }; + +async function events(c: JmapClient): Promise { + const r = await c.request([ + ['CalendarEvent/query', { accountId: c.accountId, limit: 2000 }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'title', 'start', 'duration', 'showWithoutTime', 'calendarIds'], + }, 'g'], + ], [CORE, CAL]); + return r.methodResponses[1][1].list as Obj[]; +} + +async function destroy(c: JmapClient, ids: string[]): Promise { + if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]); +} + +async function defaultCalendar(c: JmapClient): Promise { + const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'isDefault'] }, '0']], [CORE, CAL]); + const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>; + return (list.find((cal) => cal.isDefault) ?? list[0]).id; +} + +async function create(c: JmapClient, title: string, start: string, duration: string, allDay = false): Promise { + await c.request([['CalendarEvent/set', { + accountId: c.accountId, + create: { n: { '@type': 'Event', title, start, duration, timeZone: allDay ? null : TZ, showWithoutTime: allDay, calendarIds: { [await defaultCalendar(c)]: true } } }, + }, '0']], [CORE, CAL]); +} + +/** Waits for the server to hold an event that `match` accepts. */ +async function serverEvent(c: JmapClient, match: (o: Obj) => boolean): Promise { + let found: Obj | undefined; + await expect.poll(async () => { + found = (await events(c)).find(match); + return !!found; + }, { timeout: 20000 }).toBe(true); + return found!; +} + +async function signIn(browser: Browser, opts: { dark?: boolean; width?: number; phone?: boolean } = {}): Promise { + const base = contextOptions(opts.phone); + const context = await browser.newContext({ + ...base, + timezoneId: TZ, + colorScheme: opts.dark ? 'dark' : 'light', + viewport: opts.phone ? base.viewport : { width: opts.width ?? 1366, height: 860 }, + }); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await page.fill('#username', A); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await page.locator('a[href$="/calendar"]').first().waitFor({ timeout: 60000 }); + await page.locator('a[href$="/calendar"]').first().click(); + await page.locator('[data-testid="calendar-item"]').first().waitFor({ timeout: 60000 }).catch(() => {}); + return page; +} + +async function view(page: Page, key: 'm' | 'w'): Promise { + await page.locator('body').click({ position: { x: 1, y: 1 } }).catch(() => {}); + await page.keyboard.press(key); + await page.keyboard.press('t'); + await page.waitForTimeout(800); +} + +async function shot(page: Page, name: string): Promise { + mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: path.join(SHOTS, `month-drag-${SERVER}-${name}.png`) }); +} + +const cell = (page: Page, day: Date) => page.locator(`[data-month-day="${format(day, 'yyyy-MM-dd')}"]`); +const picked = (page: Page) => page.$$eval('[data-month-day][aria-selected="true"]', (els) => els.map((e) => e.getAttribute('data-month-day'))); +const quick = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Add title') }); + +/** Empty space of a day: just above the bottom edge of its cell. */ +async function emptySpot(page: Page, day: Date): Promise<{ x: number; y: number }> { + const box = (await cell(page, day).boundingBox())!; + return { x: box.x + box.width / 2, y: box.y + box.height - 5 }; +} + +async function dragDays(page: Page, from: Date, to: Date, release = true): Promise { + const a = await emptySpot(page, from); + const b = await emptySpot(page, to); + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) await page.mouse.move(a.x + ((b.x - a.x) * i) / 10, a.y + ((b.y - a.y) * i) / 10); + if (release) await page.mouse.up(); +} + +const days = (from: Date, to: Date) => { + const out: string[] = []; + for (let d = from; d <= to; d = addDays(d, 1)) out.push(format(d, 'yyyy-MM-dd')); + return out; +}; + +// Next week's Thursday to the Tuesday after: two week rows. +const nextMonday = addDays(startOfWeek(new Date(), { weekStartsOn: 1 }), 7); +const thursday = addDays(nextMonday, 3); +const tuesday = addDays(nextMonday, 8); +const thisThursday = addDays(nextMonday, -4); + +test.describe('Creating by dragging over the calendar', () => { + test.describe.configure({ mode: 'serial' }); + let c: JmapClient; + const untitled: string[] = []; + + test.beforeAll(async () => { + c = await JmapClient.connect(A, PASSWORD); + }); + + test.afterAll(async () => { + const all = await events(c); + await destroy(c, [...all.filter((o) => o.title?.startsWith(prefix)).map((o) => o.id), ...untitled]); + }); + + test('month: a drag from Thursday to next Tuesday creates one all-day event over the days', async ({ browser }) => { + test.setTimeout(180_000); + const page = await signIn(browser); + await view(page, 'm'); + await dragDays(page, thursday, tuesday, false); + // The days in reading order, wrapping at the end of the row. + expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, tuesday))); + await shot(page, 'dragging-light-1366'); + await page.mouse.up(); + + const editor = quick(page); + await expect(editor).toBeVisible(); + await expect(editor).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(tuesday, 'EEEE, MMMM d')}`); + await expect(editor).toContainText('All day'); + // The placeholder spans the days while the editor is open. + await expect(page.locator('[data-pending-preview]')).toHaveCount(2); + await shot(page, 'popover-light-1366'); + await editor.getByPlaceholder('Add title').fill(`${prefix} trip`); + await editor.getByRole('button', { name: 'Save' }).click(); + await expect(editor).toBeHidden(); + + const saved = await serverEvent(c, (o) => o.title === `${prefix} trip`); + expect(saved).toMatchObject({ start: `${format(thursday, 'yyyy-MM-dd')}T00:00:00`, duration: 'P6D', showWithoutTime: true }); + expect(Object.keys(saved.calendarIds ?? {})).toEqual([await defaultCalendar(c)]); + await expect(page.locator('[data-month-day] [role="button"], [data-month-day] button').filter({ hasText: `${prefix} trip` }).first()).toBeVisible(); + await shot(page, 'saved-light-1366'); + await destroy(c, [saved.id]); + await page.context().close(); + }); + + test('month: backwards works, and Escape cancels and clears the mark (dark)', async ({ browser }) => { + test.setTimeout(180_000); + const page = await signIn(browser, { dark: true }); + await view(page, 'm'); + await dragDays(page, tuesday, thursday, false); + await shot(page, 'dragging-dark-1366'); + await page.mouse.up(); + const editor = quick(page); + await expect(editor).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(tuesday, 'EEEE, MMMM d')}`); + await shot(page, 'popover-dark-1366'); + await page.keyboard.press('Escape'); + await expect(editor).toBeHidden(); + expect((await picked(page)).filter((d) => days(thursday, tuesday).includes(d!))).toEqual([]); + await page.context().close(); + }); + + test('month: "More options" opens the full editor on the same days; a click still opens one day', async ({ browser }) => { + test.setTimeout(180_000); + const page = await signIn(browser); + await view(page, 'm'); + await dragDays(page, thursday, tuesday); + await quick(page).getByRole('button', { name: 'More options' }).click(); + await expect(page.locator('input[type="date"]').first()).toHaveValue(format(thursday, 'yyyy-MM-dd')); + await expect(page.locator('input[type="date"]').nth(1)).toHaveValue(format(tuesday, 'yyyy-MM-dd')); + await expect(page.getByLabel('All-day event')).toBeChecked(); + await shot(page, 'more-options-light-1366'); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // A single click on empty space: the quick editor for that day, all day. + const spot = await emptySpot(page, thursday); + await page.mouse.click(spot.x, spot.y); + await expect(quick(page)).toContainText(format(thursday, 'EEEE, MMMM d')); + await expect(quick(page)).not.toContainText(' – '); + await expect(quick(page)).toContainText('All day'); + await page.keyboard.press('Escape'); + await page.context().close(); + }); + + test('month: Shift+arrows pick days and Enter opens the quick editor for them', async ({ browser }) => { + test.setTimeout(180_000); + const page = await signIn(browser); + await view(page, 'm'); + await page.locator(`[data-day-button="${format(thursday, 'yyyy-MM-dd')}"]`).focus(); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, addDays(thursday, 2)))); + await page.keyboard.press('Enter'); + await expect(quick(page)).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(addDays(thursday, 2), 'EEEE, MMMM d')}`); + await shot(page, 'keyboard-light-1366'); + await page.keyboard.press('Escape'); + await page.context().close(); + }); + + test('month: moving an event by drag and opening it still work', async ({ browser }) => { + test.setTimeout(180_000); + // Next week's Sunday, a quiet day, to its Saturday. + const sunday = addDays(nextMonday, 6); + await create(c, `${prefix} move`, `${format(sunday, 'yyyy-MM-dd')}T00:00:00`, 'P1D', true); + const page = await signIn(browser); + await view(page, 'm'); + const bar = page.getByRole('button', { name: new RegExp(`${prefix} move`) }).first(); + await bar.dragTo(cell(page, addDays(sunday, -1)), { targetPosition: { x: 40, y: 90 } }); + const moved = await serverEvent(c, (o) => o.title === `${prefix} move` && o.start === `${format(addDays(sunday, -1), 'yyyy-MM-dd')}T00:00:00`); + expect(moved.duration).toBe('P1D'); + await page.getByRole('button', { name: new RegExp(`${prefix} move`) }).first().click(); + await expect(page.getByRole('dialog').filter({ hasText: `${prefix} move` }).first()).toBeVisible(); + await page.keyboard.press('Escape'); + await page.context().close(); + }); + + for (const dark of [false, true]) { + test(`month at 800 px (${dark ? 'dark' : 'light'}): a drag over days opens an editor on them`, async ({ browser }) => { + test.setTimeout(180_000); + const page = await signIn(browser, { width: 800, dark }); + await view(page, 'm'); + await dragDays(page, thursday, tuesday, false); + expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, tuesday))); + await shot(page, `dragging-${dark ? 'dark' : 'light'}-800`); + await page.mouse.up(); + // A narrow pane is the phone layout: the full editor; else the quick one. + const full = page.locator('input[type="date"]').first(); + await expect(quick(page).or(full)).toBeVisible(); + if (await full.isVisible()) { + await expect(full).toHaveValue(format(thursday, 'yyyy-MM-dd')); + await expect(page.locator('input[type="date"]').nth(1)).toHaveValue(format(tuesday, 'yyyy-MM-dd')); + } else { + await expect(quick(page)).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(tuesday, 'EEEE, MMMM d')}`); + } + await shot(page, `editor-${dark ? 'dark' : 'light'}-800`); + await page.context().close(); + }); + } + + test('phone: a long press and drag picks days; a swipe still scrolls', async ({ browser }) => { + test.setTimeout(180_000); + const page = await signIn(browser, { phone: true }); + await view(page, 'm'); + const cdp = await page.context().newCDPSession(page); + const touch = (type: string, x?: number, y?: number) => cdp.send('Input.dispatchTouchEvent', { + type, touchPoints: x === undefined ? [] : [{ x, y }], + } as never); + await cell(page, thursday).scrollIntoViewIfNeeded(); + const a = await emptySpot(page, thursday); + const b = await emptySpot(page, tuesday); + await touch('touchStart', a.x, a.y); + await page.waitForTimeout(700); + for (let i = 1; i <= 8; i++) await touch('touchMove', a.x + ((b.x - a.x) * i) / 8, a.y + ((b.y - a.y) * i) / 8); + expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, tuesday))); + await shot(page, 'phone-long-press'); + await touch('touchEnd'); + // Phones get the full editor, on the same days. + await expect(page.locator('input[type="date"]').first()).toHaveValue(format(thursday, 'yyyy-MM-dd')); + await expect(page.locator('input[type="date"]').nth(1)).toHaveValue(format(tuesday, 'yyyy-MM-dd')); + await page.getByRole('button', { name: 'Cancel' }).click(); + + const scroller = page.locator('[role="grid"] > div').first(); + const before = await scroller.evaluate((el) => el.scrollTop); + const s = await emptySpot(page, thursday); + await touch('touchStart', s.x, s.y); + for (let i = 1; i <= 6; i++) await touch('touchMove', s.x, s.y - i * 40); + await touch('touchEnd'); + await page.waitForTimeout(600); + expect(await scroller.evaluate((el) => el.scrollTop)).not.toBe(before); + expect((await picked(page)).length).toBeLessThanOrEqual(1); + await expect(page.locator('input[type="date"]')).toHaveCount(0); + await page.context().close(); + }); + + test('week: the event created is the range the selection showed, beside the event it overlaps', async ({ browser }) => { + test.setTimeout(180_000); + const day = format(thisThursday, 'yyyy-MM-dd'); + await create(c, `${prefix} Lernblock`, `${day}T12:30:00`, 'PT1H'); + for (const dark of [false, true]) { + const page = await signIn(browser, { dark }); + await view(page, 'w'); + const slot = page.locator(`[data-slot="${day}T11"]`); + await slot.scrollIntoViewIfNeeded(); + const box = (await slot.boundingBox())!; + const y = (minutes: number) => box.y + ((minutes - 660) / 60) * box.height; + const x = box.x + box.width / 2; + await page.mouse.move(x, y(675)); + await page.mouse.down(); + for (let m = 690; m <= 975; m += 15) await page.mouse.move(x, y(m)); + const selection = page.locator('[data-drag-selection]'); + await expect(selection).toContainText('11:15 – 16:15'); + // Drawn over the event it covers: the event doesn't show through. + // (Both sit in the day's column: the higher z-index is on top, and the fill has no transparency.) + const looks = await page.evaluate((title) => { + const sel = document.querySelector('[data-drag-selection]')!; + const ev = Array.from(document.querySelectorAll('[data-calendar-event]')).find((el) => el.textContent?.includes(title))!; + return { selZ: Number(getComputedStyle(sel).zIndex), evZ: Number(getComputedStyle(ev).zIndex), fill: getComputedStyle(sel).backgroundColor, sameColumn: sel.parentElement === ev.parentElement }; + }, `${prefix} Lernblock`); + expect(looks.sameColumn).toBe(true); + expect(looks.selZ).toBeGreaterThan(looks.evZ); + expect(looks.fill).not.toMatch(/rgba\(.*,\s*0(\.\d+)?\)|\/\s*0?\.\d+\)|transparent/); + await shot(page, `week-dragging-${dark ? 'dark' : 'light'}-1366`); + await page.mouse.up(); + const editor = quick(page); + await expect(editor).toContainText('11:15 – 16:15'); + // The new event and the one it overlaps share the day's width. + const placeholder = (await page.locator('[data-pending-preview]').boundingBox())!; + const lernblock = (await page.locator('div[data-calendar-event]').filter({ hasText: `${prefix} Lernblock` }).first().boundingBox())!; + expect(placeholder.x + placeholder.width <= lernblock.x + 1 || lernblock.x + lernblock.width <= placeholder.x + 1).toBe(true); + await shot(page, `week-popover-${dark ? 'dark' : 'light'}-1366`); + if (!dark) { + // Save without a title. + await editor.getByRole('button', { name: 'Save' }).click(); + const saved = await serverEvent(c, (o) => o.start === `${day}T11:15:00` && o.title === '(No title)'); + untitled.push(saved.id); + expect(saved.duration).toBe('PT5H'); + await page.waitForTimeout(1000); + await shot(page, 'week-saved-light-1366'); + // Out of the way of the next drag, which starts at the same spot. + await destroy(c, [saved.id]); + } else { + await page.keyboard.press('Escape'); + } + await page.context().close(); + } + }); +});