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
77 changes: 77 additions & 0 deletions app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <input> 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();
});
});
5 changes: 5 additions & 0 deletions app/src/pages/custom_rules/RuleComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ const selectStyles: StylesConfig<RuleOption, true, RuleOptionGroup> = {
color: "var(--tailwind-colors-slate-100)",
caretColor: "var(--tailwind-colors-rdns-400)",
minWidth: "6rem",
// react-select sizes the inner <input> 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 <input> itself, so let it fill the wrapper instead.
gridTemplateColumns: "0 1fr",
}),
};

Expand Down
Loading