mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Restore first-tap guest details on touch browsers
Reuse the shared non-activating native row marker without moving actions out of delegated event ordering. Preserve nested controls, keyboard disclosure, row identity and conditional action removal. Cover guest/full-drawer touch input and safe backup guidance with exact browser proof and source regressions. Change-source: pulse-maintainer
This commit is contained in:
@@ -623,6 +623,20 @@ scrolling. Explicit caller-owned native listeners remain caller-owned.
|
|||||||
isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance.
|
isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance.
|
||||||
The production PBS table/drawer browser fixture exercises first-tap disclosure
|
The production PBS table/drawer browser fixture exercises first-tap disclosure
|
||||||
and History in WebKit phone emulation, not installed collection or devices.
|
and History in WebKit phone emulation, not installed collection or devices.
|
||||||
|
The custom Workloads `GuestRow` shell reuses `Table`'s exported
|
||||||
|
`nativeRowClickTarget` marker, conditional on its own action, without inheriting
|
||||||
|
shared row styling or changing alert/highlight treatment. Its name and row body
|
||||||
|
must open the full guest drawer on the first touch even when the compact-layout
|
||||||
|
disclosure button is visually hidden. Keep the action document-delegated;
|
||||||
|
nested disclosure SVGs, external links and keyboard controls must still perform
|
||||||
|
only their own action. Removing the action removes the native marker, and
|
||||||
|
re-enabling it must work without replacing the row. Pointer-up and touch-end
|
||||||
|
alone never perform the action. `GuestRow.test.tsx` covers native binding,
|
||||||
|
non-activation, dynamic removal and child ordering;
|
||||||
|
`browser-tests/guest-row-touch.cjs` covers trusted row/name taps, the production
|
||||||
|
full drawer's backup/read guidance, nested controls and shared-row compatibility
|
||||||
|
on Chromium desktop/phone and WebKit phone VM/CT fixtures. This is emulated
|
||||||
|
input/presentation proof, not physical-device, native thaw or released acceptance.
|
||||||
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
|
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
|
||||||
is visually removed on phone layouts where the complete row is the touch target;
|
is visually removed on phone layouts where the complete row is the touch target;
|
||||||
provider tables must not add a second mobile chevron. When row activation
|
provider tables must not add a second mobile chevron. When row activation
|
||||||
|
|||||||
@@ -198,6 +198,15 @@ poller, per-agent goroutine, inventory scan, or action-session lookup to normal
|
|||||||
request routing; local assets are streamed once and published fallbacks retain
|
request routing; local assets are streamed once and published fallbacks retain
|
||||||
the existing bounded HTTP client and artifact-size checks.
|
the existing bounded HTTP client and artifact-size checks.
|
||||||
|
|
||||||
|
The custom Workloads guest row shares `Table`'s singleton non-activating native
|
||||||
|
click marker for WebKit touch eligibility. Bind it only while a row action
|
||||||
|
exists, without changing row identity, alert/highlight classes, metric derivation
|
||||||
|
or delegated child-control ordering. The marker must not perform actions, read
|
||||||
|
telemetry, start History work, add polling/timers or activate on pointer-up or
|
||||||
|
touch-end. `GuestRow.test.tsx` verifies its conditional lifecycle and
|
||||||
|
non-activation; the production row/full-drawer touch fixture verifies first
|
||||||
|
activation and unchanged identity independently of native collector acceptance.
|
||||||
|
|
||||||
## Canonical Files
|
## Canonical Files
|
||||||
|
|
||||||
1. `pkg/metrics/store.go`
|
1. `pkg/metrics/store.go`
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const { chromium, webkit } = require('playwright');
|
||||||
|
|
||||||
|
const root = '/workspace/frontend-modern';
|
||||||
|
const output = '/workspace/tmp/guest-row-touch';
|
||||||
|
const origin = 'http://127.0.0.1:5313';
|
||||||
|
const runtime = [
|
||||||
|
'frontend-modern/src/components/Workloads/GuestRow.tsx',
|
||||||
|
'frontend-modern/src/components/shared/Table.tsx',
|
||||||
|
];
|
||||||
|
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
fs.mkdirSync(output, { recursive: true });
|
||||||
|
const result = {
|
||||||
|
playwright: require('playwright/package.json').version,
|
||||||
|
runtime_hashes: Object.fromEntries(runtime.map((file) => [file, sha256('/workspace/' + file)])),
|
||||||
|
results: [],
|
||||||
|
screenshots: [],
|
||||||
|
result: 'incomplete',
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
result.playwright,
|
||||||
|
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||||
|
'node_modules/@playwright/test'
|
||||||
|
].version,
|
||||||
|
);
|
||||||
|
process.chdir(root);
|
||||||
|
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||||
|
const server = await createServer({
|
||||||
|
root,
|
||||||
|
configFile: path.join(root, 'vite.config.ts'),
|
||||||
|
cacheDir: output + '/cache',
|
||||||
|
server: { host: '127.0.0.1', port: 5313, strictPort: true },
|
||||||
|
});
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
await server.listen();
|
||||||
|
for (const [name, engine, width, dark, kinds] of [
|
||||||
|
['webkit-phone', webkit, 390, true, ['qemu', 'lxc']],
|
||||||
|
['chromium-desktop', chromium, 1365, false, ['qemu', 'lxc']],
|
||||||
|
['chromium-phone', chromium, 390, false, ['qemu']],
|
||||||
|
['webkit-narrow', webkit, 360, false, ['lxc']],
|
||||||
|
]) {
|
||||||
|
browser = await engine.launch(
|
||||||
|
engine === chromium
|
||||||
|
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||||
|
: { headless: true },
|
||||||
|
);
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: width <= 768 ? 844 : 900 },
|
||||||
|
isMobile: width <= 768,
|
||||||
|
hasTouch: width <= 768,
|
||||||
|
locale: 'en-GB',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
});
|
||||||
|
for (const kind of kinds) {
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(15000);
|
||||||
|
page.setDefaultNavigationTimeout(60000);
|
||||||
|
const record = {
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
browser: browser.version(),
|
||||||
|
viewport: { width, height: width <= 768 ? 844 : 900 },
|
||||||
|
dark,
|
||||||
|
errors: [],
|
||||||
|
requests: [],
|
||||||
|
events: [],
|
||||||
|
checks: [],
|
||||||
|
};
|
||||||
|
result.results.push(record);
|
||||||
|
page.on('pageerror', (error) => record.errors.push(error.message));
|
||||||
|
await page.route('**/*', (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
record.requests.push({
|
||||||
|
method: request.method(),
|
||||||
|
origin: url.origin,
|
||||||
|
path: url.pathname,
|
||||||
|
});
|
||||||
|
if (url.origin !== origin) return route.abort();
|
||||||
|
if (url.pathname.startsWith('/api/'))
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
alerts: [],
|
||||||
|
anomalies: [],
|
||||||
|
config: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await page.addInitScript((isDark) => {
|
||||||
|
window.__inputEvents = [];
|
||||||
|
for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'click'])
|
||||||
|
document.addEventListener(
|
||||||
|
type,
|
||||||
|
(event) =>
|
||||||
|
window.__inputEvents.push({
|
||||||
|
type,
|
||||||
|
tag: event.target.tagName,
|
||||||
|
trusted: event.isTrusted,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (isDark) document.documentElement.classList.add('dark');
|
||||||
|
});
|
||||||
|
}, dark);
|
||||||
|
await page.goto(`${origin}/browser-tests/guest-row-touch.html?kind=${kind}`);
|
||||||
|
await page.waitForFunction(() => window.__guestRowTouch);
|
||||||
|
assert.equal(await page.evaluate(() => innerWidth), width);
|
||||||
|
const row = page.locator('[data-guest-id]');
|
||||||
|
const nameTarget = row.getByText('backup-guest', { exact: true });
|
||||||
|
const drawer = page.getByRole('region', { name: 'Guest details' });
|
||||||
|
const disclosure = row.locator('[data-row-action]');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__originalGuestRow = document.querySelector('[data-guest-id]');
|
||||||
|
});
|
||||||
|
const state = () =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
count: window.__guestRowTouch.actionCount(),
|
||||||
|
expanded: window.__guestRowTouch.expanded(),
|
||||||
|
closeCount: window.__guestRowTouch.closeCount(),
|
||||||
|
disclosureExpanded: document
|
||||||
|
.querySelector('[data-row-action]')
|
||||||
|
?.getAttribute('aria-expanded'),
|
||||||
|
sameRow: document.querySelector('[data-guest-id]') === window.__originalGuestRow,
|
||||||
|
}));
|
||||||
|
const activate = async (target) => {
|
||||||
|
if (width <= 768) await target.tap();
|
||||||
|
else await target.click();
|
||||||
|
await page.waitForTimeout(650);
|
||||||
|
};
|
||||||
|
const expectState = async (count, expanded) => {
|
||||||
|
const value = await state();
|
||||||
|
record.state = value;
|
||||||
|
record.events = await page.evaluate(() => window.__inputEvents);
|
||||||
|
assert.equal(value.count, count, `${name}/${kind}: single row activation`);
|
||||||
|
assert.equal(value.expanded, expanded);
|
||||||
|
assert.equal(value.disclosureExpanded, String(expanded));
|
||||||
|
assert.equal(value.sameRow, true);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await activate(width === 360 ? row : nameTarget);
|
||||||
|
await expectState(1, true);
|
||||||
|
assert(record.events.some((event) => event.type === 'click' && event.trusted));
|
||||||
|
if (width <= 768) {
|
||||||
|
assert(record.events.some((event) => event.type === 'touchend' && event.trusted));
|
||||||
|
assert.equal(await row.getAttribute('data-history-lens-active'), null);
|
||||||
|
}
|
||||||
|
await drawer.getByText('No completed backup found', { exact: true }).waitFor();
|
||||||
|
await drawer.getByText('Running · not completed yet', { exact: true }).waitFor();
|
||||||
|
if (kind === 'qemu')
|
||||||
|
await drawer.getByText(/Using last known disk stats\. Guest reads paused/).waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await disclosure.getAttribute('aria-controls'),
|
||||||
|
await drawer.getAttribute('id'),
|
||||||
|
);
|
||||||
|
assert.equal(await row.getAttribute('tabindex'), null);
|
||||||
|
assert.equal(await row.getAttribute('aria-expanded'), null);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
record.checks.push(
|
||||||
|
`first trusted ${width === 360 ? 'row-body' : 'name'} activation opens production full drawer with safe backup/read context`,
|
||||||
|
);
|
||||||
|
const screenshot = `${output}/${name}-${kind}-open.png`;
|
||||||
|
await page.screenshot({ path: screenshot, fullPage: true });
|
||||||
|
result.screenshots.push({
|
||||||
|
path: path.relative('/workspace', screenshot),
|
||||||
|
sha256: sha256(screenshot),
|
||||||
|
});
|
||||||
|
await activate(width === 360 ? row : nameTarget);
|
||||||
|
await expectState(2, false);
|
||||||
|
await activate(row);
|
||||||
|
await expectState(3, true);
|
||||||
|
await activate(page.getByRole('button', { name: 'Collapse backup-guest details' }));
|
||||||
|
await expectState(3, false);
|
||||||
|
assert.equal((await state()).closeCount, 1);
|
||||||
|
record.checks.push(
|
||||||
|
'repeated name and row-centre activation toggle once; nested full-drawer collapse stays independent',
|
||||||
|
);
|
||||||
|
|
||||||
|
await disclosure.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expectState(4, true);
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await expectState(5, false);
|
||||||
|
await disclosure.locator('svg').click();
|
||||||
|
await expectState(6, true);
|
||||||
|
// Keyboard focus makes the compact disclosure button visible and
|
||||||
|
// can consume the name's space; the row body stays the touch target.
|
||||||
|
await activate(row);
|
||||||
|
await expectState(7, false);
|
||||||
|
record.checks.push(
|
||||||
|
'native disclosure keyboard Enter/Space and nested SVG click do not double-activate',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (width >= 1024) {
|
||||||
|
// The adjacent link is intentionally hidden in condensed phone
|
||||||
|
// identity. Exercise its real wide-layout action, preventing only
|
||||||
|
// external navigation, not production propagation handling.
|
||||||
|
const link = row.getByRole('link', { name: 'Open web interface for backup-guest' });
|
||||||
|
await link.evaluate((anchor) =>
|
||||||
|
anchor.addEventListener('click', (event) => event.preventDefault()),
|
||||||
|
);
|
||||||
|
await activate(link.locator('svg'));
|
||||||
|
await expectState(7, false);
|
||||||
|
record.checks.push(
|
||||||
|
'real external-link SVG retains its separate action; navigation prevented in fixture only',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__guestRowTouch.enabled(false));
|
||||||
|
assert.equal(await disclosure.count(), 0);
|
||||||
|
await activate(row);
|
||||||
|
assert.equal((await state()).count, 7);
|
||||||
|
assert.equal((await state()).expanded, false);
|
||||||
|
await page.evaluate(() => window.__guestRowTouch.enabled(true));
|
||||||
|
await activate(row);
|
||||||
|
await expectState(8, true);
|
||||||
|
record.checks.push(
|
||||||
|
'disabling/re-enabling the row action preserves node identity and first-tap activation',
|
||||||
|
);
|
||||||
|
assert.deepEqual(record.errors, []);
|
||||||
|
assert(
|
||||||
|
record.requests.every(
|
||||||
|
(request) => request.origin === origin && request.method === 'GET',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
record.result = 'passed';
|
||||||
|
} catch (error) {
|
||||||
|
record.result = 'failed';
|
||||||
|
record.error = error.message;
|
||||||
|
const screenshot = `${output}/${name}-${kind}-failed.png`;
|
||||||
|
await page.screenshot({ path: screenshot, fullPage: true });
|
||||||
|
result.screenshots.push({
|
||||||
|
path: path.relative('/workspace', screenshot),
|
||||||
|
sha256: sha256(screenshot),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exporting the compatibility marker must preserve the original shared
|
||||||
|
// TableRow's own single-action and embedded-control ordering.
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`${origin}/browser-tests/table-row-touch.html`);
|
||||||
|
const sharedState = () => page.getByRole('status', { name: 'Action state' }).textContent();
|
||||||
|
const shared = page.locator('[data-testid="control-row"]');
|
||||||
|
if (width <= 768) await shared.getByText('Shared resource name', { exact: true }).tap();
|
||||||
|
else await shared.getByText('Shared resource name', { exact: true }).click();
|
||||||
|
const sharedValue = JSON.parse(await sharedState());
|
||||||
|
assert.equal(sharedValue.rowActions, 1);
|
||||||
|
await shared.getByRole('button', { name: 'Child action' }).click();
|
||||||
|
const childValue = JSON.parse(await sharedState());
|
||||||
|
assert.equal(childValue.rowActions, 1);
|
||||||
|
assert.equal(childValue.childActions, 1);
|
||||||
|
result.results.push({
|
||||||
|
name,
|
||||||
|
control: 'shared-TableRow',
|
||||||
|
single: sharedValue,
|
||||||
|
child: childValue,
|
||||||
|
result: 'passed',
|
||||||
|
});
|
||||||
|
await page.close();
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
browser = null;
|
||||||
|
}
|
||||||
|
result.result = 'passed';
|
||||||
|
result.verified_at = new Date().toISOString();
|
||||||
|
console.log(
|
||||||
|
'PASS: trusted guest row/disclosure activation, full drawer safety context and child isolation',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
result.result = 'failed';
|
||||||
|
result.error = error.message;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
|
||||||
|
if (browser) await browser.close();
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Guest row touch verification</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./guest-row-touch.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Production guest row and full drawer with synthetic observations.
|
||||||
|
// No collector, backup command, QGA/thaw, installed device or release proof.
|
||||||
|
import { createSignal, Show } from 'solid-js';
|
||||||
|
import { render } from 'solid-js/web';
|
||||||
|
import { Router, Route } from '@solidjs/router';
|
||||||
|
import { GuestRow } from '../src/components/Workloads/GuestRow';
|
||||||
|
import { buildSummaryDisclosureControlsId } from '../src/components/shared/summaryInteractionA11y';
|
||||||
|
import type { WorkloadTableLayoutMode } from '../src/components/Workloads/guestRowModel';
|
||||||
|
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
|
||||||
|
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||||
|
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||||
|
import type { WorkloadGuest } from '../src/types/workloads';
|
||||||
|
import '../src/index.css';
|
||||||
|
|
||||||
|
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||||
|
|
||||||
|
function Fixture() {
|
||||||
|
const [observation, setObservation] = createSignal({
|
||||||
|
lastBackup: 0,
|
||||||
|
running: true,
|
||||||
|
reason: 'prev-vm-locked',
|
||||||
|
});
|
||||||
|
const [closeCount, setCloseCount] = createSignal(0);
|
||||||
|
const [actionCount, setActionCount] = createSignal(0);
|
||||||
|
const [expanded, setExpanded] = createSignal(false);
|
||||||
|
const [enabled, setEnabled] = createSignal(true);
|
||||||
|
const [layout, setLayout] = createSignal<WorkloadTableLayoutMode>(
|
||||||
|
window.innerWidth <= 768 ? 'phone' : 'wide',
|
||||||
|
);
|
||||||
|
const toggle = () => {
|
||||||
|
setActionCount((count) => count + 1);
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
};
|
||||||
|
const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu';
|
||||||
|
const guest = (): WorkloadGuest => ({
|
||||||
|
id: 'fixture-pve:pve-a:101',
|
||||||
|
vmid: 101,
|
||||||
|
name: 'backup-guest',
|
||||||
|
node: 'pve-a',
|
||||||
|
instance: 'fixture-pve',
|
||||||
|
status: 'running',
|
||||||
|
type,
|
||||||
|
cpu: 0.1,
|
||||||
|
cpus: 2,
|
||||||
|
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
|
||||||
|
disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
|
||||||
|
disks: [
|
||||||
|
{ mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
|
||||||
|
],
|
||||||
|
diskStatusReason: observation().reason,
|
||||||
|
networkIn: 0,
|
||||||
|
networkOut: 0,
|
||||||
|
diskRead: 0,
|
||||||
|
diskWrite: 0,
|
||||||
|
uptime: 3600,
|
||||||
|
template: false,
|
||||||
|
lastBackup: observation().lastBackup,
|
||||||
|
backupInProgress: observation().running,
|
||||||
|
tags: [],
|
||||||
|
lock: observation().running ? 'backup' : '',
|
||||||
|
lastSeen: '2026-10-03T12:00:00Z',
|
||||||
|
});
|
||||||
|
(window as any).__guestRowTouch = {
|
||||||
|
update: setObservation,
|
||||||
|
closeCount,
|
||||||
|
actionCount,
|
||||||
|
expanded,
|
||||||
|
enabled: setEnabled,
|
||||||
|
layout: setLayout,
|
||||||
|
identity: () => guest().id,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
|
||||||
|
<h1 class="text-base font-semibold">Guest row touch verification</h1>
|
||||||
|
<p class="text-xs text-muted">Synthetic observations; no backup command is sent.</p>
|
||||||
|
<table class="w-full table-fixed">
|
||||||
|
<tbody>
|
||||||
|
<GuestRow
|
||||||
|
guest={guest()}
|
||||||
|
visibleColumnIds={['name', 'disk']}
|
||||||
|
onClick={enabled() ? toggle : undefined}
|
||||||
|
isExpanded={expanded()}
|
||||||
|
workloadTableLayoutMode={layout()}
|
||||||
|
customUrl="https://guest.example.invalid/"
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<section
|
||||||
|
id={buildSummaryDisclosureControlsId(guest().id)}
|
||||||
|
aria-label="Guest details"
|
||||||
|
class="mx-auto max-w-4xl"
|
||||||
|
>
|
||||||
|
<GuestDrawer
|
||||||
|
guest={guest()}
|
||||||
|
onClose={() => {
|
||||||
|
setCloseCount((count) => count + 1);
|
||||||
|
setExpanded(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
() => (
|
||||||
|
<Router>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
component={() => (
|
||||||
|
<DarkModeContext.Provider
|
||||||
|
value={() => document.documentElement.classList.contains('dark')}
|
||||||
|
>
|
||||||
|
<Fixture />
|
||||||
|
</DarkModeContext.Provider>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Router>
|
||||||
|
),
|
||||||
|
document.getElementById('root')!,
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"base_sha": "888d918ba1a0a4bf448542630bc0f47af72e4572",
|
||||||
|
"verified_at": "2026-10-03T23:27:41.013Z",
|
||||||
|
"result": "passed",
|
||||||
|
"changed_paths": [
|
||||||
|
"frontend-modern/src/components/Workloads/GuestRow.tsx",
|
||||||
|
"frontend-modern/src/components/shared/Table.tsx"
|
||||||
|
],
|
||||||
|
"content_sha256": {
|
||||||
|
"frontend-modern/src/components/Workloads/GuestRow.tsx": "1096ad3669baea8bec5c6a80b016b64f2c428d2e5a0b7c85746ff6bf21e103fa",
|
||||||
|
"frontend-modern/src/components/shared/Table.tsx": "014d9c4382d4de03a547f171e410fb9207686bf00dd76695b50c98249d6eacbd"
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
"/browser-tests/guest-row-touch.html?kind=qemu",
|
||||||
|
"/browser-tests/guest-row-touch.html?kind=lxc",
|
||||||
|
"/browser-tests/table-row-touch.html"
|
||||||
|
],
|
||||||
|
"viewports": [
|
||||||
|
{
|
||||||
|
"width": 1365,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 360,
|
||||||
|
"height": 844
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"states": [
|
||||||
|
"Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.",
|
||||||
|
"Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.",
|
||||||
|
"Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.",
|
||||||
|
"Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately."
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
"Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.",
|
||||||
|
"Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.",
|
||||||
|
"Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.",
|
||||||
|
"Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
createSummaryInteractiveRowPreviewHandlers,
|
createSummaryInteractiveRowPreviewHandlers,
|
||||||
} from '@/components/shared/summaryInteractionA11y';
|
} from '@/components/shared/summaryInteractionA11y';
|
||||||
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
|
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
|
||||||
|
import { nativeRowClickTarget } from '@/components/shared/Table';
|
||||||
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
|
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
|
||||||
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
|
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
|
||||||
import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink';
|
import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink';
|
||||||
@@ -241,6 +242,7 @@ export function GuestRow(props: GuestRowProps) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
|
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
|
||||||
|
on:click={props.onClick ? nativeRowClickTarget : undefined}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
{...interactiveRowHandlers}
|
{...interactiveRowHandlers}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ afterEach(() => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.restoreAllMocks();
|
||||||
isMobileMock.mockReturnValue(false);
|
isMobileMock.mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -835,6 +836,70 @@ describe('GuestRow', () => {
|
|||||||
expect(onClick).toHaveBeenCalledOnce();
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks only actionable guest rows as native touch click targets', () => {
|
||||||
|
const addListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
|
||||||
|
const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener');
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const [enabled, setEnabled] = createSignal(false);
|
||||||
|
const { container } = render(() => (
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<GuestRow
|
||||||
|
guest={makeGuest()}
|
||||||
|
workloadTableLayoutMode="phone"
|
||||||
|
onClick={enabled() ? onClick : undefined}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
));
|
||||||
|
const row = container.querySelector('tr')!;
|
||||||
|
const nativeClickCalls = () => addListener.mock.calls.filter(([type]) => type === 'click');
|
||||||
|
|
||||||
|
expect(nativeClickCalls()).toHaveLength(0);
|
||||||
|
setEnabled(true);
|
||||||
|
expect(nativeClickCalls()).toHaveLength(1);
|
||||||
|
expect(row).not.toHaveAttribute('tabindex');
|
||||||
|
expect(row).not.toHaveAttribute('role', 'button');
|
||||||
|
expect(row).not.toHaveAttribute('aria-expanded');
|
||||||
|
|
||||||
|
// The native compatibility target must never perform the action itself.
|
||||||
|
const nativeClick = nativeClickCalls()[0][1] as EventListener;
|
||||||
|
nativeClick.call(row, new MouseEvent('click'));
|
||||||
|
fireEvent.touchEnd(row);
|
||||||
|
fireEvent.pointerUp(row, { pointerType: 'touch' });
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByText('test-vm'));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
setEnabled(false);
|
||||||
|
expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
|
||||||
|
fireEvent.click(row);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
setEnabled(true);
|
||||||
|
fireEvent.click(screen.getByText('test-vm'));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps child disclosure and external-link actions ahead of guest row activation', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const { container } = renderGuestRow({
|
||||||
|
guest: makeGuest(),
|
||||||
|
customUrl: 'https://guest.example.invalid/',
|
||||||
|
onClick,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('link', { name: 'Open web interface for test-vm' }));
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
const button = screen.getByRole('button', { name: 'Expand test-vm' });
|
||||||
|
fireEvent.click(button.querySelector('svg')!);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.keyDown(button, { key: 'Enter' });
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(2);
|
||||||
|
fireEvent.keyDown(button, { key: ' ', code: 'Space' });
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(3);
|
||||||
|
fireEvent.keyDown(container.querySelector('tr')!, { key: 'Enter' });
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('calls onHoverChange with canonical guestId on fine-pointer preview', () => {
|
it('calls onHoverChange with canonical guestId on fine-pointer preview', () => {
|
||||||
const onHoverChange = vi.fn();
|
const onHoverChange = vi.fn();
|
||||||
const { container } = renderGuestRow({
|
const { container } = renderGuestRow({
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export type TableRowProps = JSX.HTMLAttributes<HTMLTableRowElement>;
|
|||||||
// WebKit touch needs a native click target on otherwise-static table rows.
|
// WebKit touch needs a native click target on otherwise-static table rows.
|
||||||
// Keep actions document-delegated: nested controls must run first and retain
|
// Keep actions document-delegated: nested controls must run first and retain
|
||||||
// their existing stopPropagation behaviour instead of opening the row too.
|
// their existing stopPropagation behaviour instead of opening the row too.
|
||||||
const nativeRowClickTarget = () => undefined;
|
// Custom row shells reuse this marker without inheriting TableRow's styling.
|
||||||
|
export const nativeRowClickTarget = () => undefined;
|
||||||
|
|
||||||
export function TableRow(props: TableRowProps) {
|
export function TableRow(props: TableRowProps) {
|
||||||
const [local, rest] = splitProps(props, ['class', 'children']);
|
const [local, rest] = splitProps(props, ['class', 'children']);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
|
|||||||
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
|
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
|
||||||
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
|
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
|
||||||
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
|
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
|
||||||
import { TableCell } from '@/components/shared/Table';
|
import { TableCell, nativeRowClickTarget } from '@/components/shared/Table';
|
||||||
import tableSource from '@/components/shared/Table.tsx?raw';
|
import tableSource from '@/components/shared/Table.tsx?raw';
|
||||||
|
|
||||||
type TestRow = {
|
type TestRow = {
|
||||||
@@ -105,16 +105,23 @@ describe('PulseDataGrid', () => {
|
|||||||
|
|
||||||
it('inherits the native touch click target from the shared TableRow', () => {
|
it('inherits the native touch click target from the shared TableRow', () => {
|
||||||
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
|
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
|
||||||
|
const onRowClick = vi.fn();
|
||||||
try {
|
try {
|
||||||
render(() => (
|
render(() => (
|
||||||
<PulseDataGrid<TestRow>
|
<PulseDataGrid<TestRow>
|
||||||
data={[{ id: '1', name: 'Touch tower' }]}
|
data={[{ id: '1', name: 'Touch tower' }]}
|
||||||
columns={[{ key: 'name', label: 'Name' }]}
|
columns={[{ key: 'name', label: 'Name' }]}
|
||||||
keyExtractor={(row) => row.id}
|
keyExtractor={(row) => row.id}
|
||||||
onRowClick={vi.fn()}
|
onRowClick={onRowClick}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
|
expect(nativeListener.mock.calls.find(([type]) => type === 'click')?.[1]).toBe(
|
||||||
|
nativeRowClickTarget,
|
||||||
|
);
|
||||||
|
nativeRowClickTarget();
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByText('Touch tower'));
|
||||||
|
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
} finally {
|
||||||
nativeListener.mockRestore();
|
nativeListener.mockRestore();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user