diff --git a/app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts b/app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts new file mode 100644 index 00000000..f8deedc9 --- /dev/null +++ b/app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// The browser only offers Copy/Paste in its context or long-press menu when the +// pointer lands on the itself. The empty add-rule box must therefore keep +// its input stretched across the visible field rather than collapsing to the +// width of the (empty) typed text behind the placeholder. +test.describe('@functional custom rules add box exposes a pasteable input', () => { + test('the empty add-rule field is hit by the input, not its wrapper', async ({ page }) => { + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { custom_rules: [] } }], + }); + await page.goto('/custom-rules'); + + const input = page.locator('input#react-select-rule-composer-denylist-input'); + await expect(input).toBeAttached(); + + const control = page.locator('.rule-composer__control').first(); + const placeholder = page.locator('.rule-composer__placeholder').first(); + await expect(placeholder).toBeVisible(); + // Centre the field so neither the sticky header nor the mobile bottom nav overlaps it. + await control.evaluate((el) => el.scrollIntoView({ block: 'center' })); + + const box = await placeholder.boundingBox(); + const controlBox = await control.boundingBox(); + if (!box || !controlBox) throw new Error('add-rule field did not render'); + + // Right-click / long-press where the placeholder text is drawn. + const hit = await page.evaluate( + ([x, y]) => { const el = document.elementFromPoint(x, y); return el ? el.tagName + '.' + el.className : null; }, + [box.x + box.width / 2, box.y + box.height / 2], + ); + expect(hit).toMatch(/^INPUT\b/); + + // And the input spans the free space of the field, not a few pixels. + const inputBox = await input.boundingBox(); + if (!inputBox) throw new Error('input has no box'); + expect(inputBox.width).toBeGreaterThan(controlBox.width * 0.5); + }); + + test('a real right-click on the empty field reaches the input (desktop)', async ({ page }) => { + test.skip(!/chromium-desktop/i.test(test.info().project.name), 'context menus are a desktop pointer path'); + + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { custom_rules: [] } }], + }); + await page.goto('/custom-rules'); + + const control = page.locator('.rule-composer__control').first(); + const placeholder = page.locator('.rule-composer__placeholder').first(); + await expect(placeholder).toBeVisible(); + await control.evaluate((el) => el.scrollIntoView({ block: 'center' })); + + // Record what the contextmenu event lands on and whether anything cancels it. + await page.evaluate(() => { + const w = window as Window & { __ctx?: { tag: string; id: string; prevented: boolean } }; + document.addEventListener( + 'contextmenu', + (e) => { + const t = e.target as HTMLElement; + w.__ctx = { tag: t.tagName, id: t.id, prevented: e.defaultPrevented }; + }, + { capture: false }, + ); + }); + + const box = await placeholder.boundingBox(); + if (!box) throw new Error('add-rule field did not render'); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }); + + const ctx = await page.evaluate(() => (window as Window & { __ctx?: unknown }).__ctx); + expect(ctx).toEqual({ tag: 'INPUT', id: 'react-select-rule-composer-denylist-input', prevented: false }); + await expect(page.locator('input#react-select-rule-composer-denylist-input')).toBeFocused(); + }); +}); diff --git a/app/src/pages/custom_rules/RuleComposer.tsx b/app/src/pages/custom_rules/RuleComposer.tsx index 0543863d..7f30e450 100644 --- a/app/src/pages/custom_rules/RuleComposer.tsx +++ b/app/src/pages/custom_rules/RuleComposer.tsx @@ -146,6 +146,11 @@ const selectStyles: StylesConfig = { color: "var(--tailwind-colors-slate-100)", caretColor: "var(--tailwind-colors-rdns-400)", minWidth: "6rem", + // react-select sizes the inner to the mirrored typed text (0 min-content), + // so an empty field leaves a 2px-wide input under the placeholder. Browsers only + // offer Copy/Paste in the context or long-press menu when the pointer lands on + // the itself, so let it fill the wrapper instead. + gridTemplateColumns: "0 1fr", }), };