mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
Wrap drawer sentences and open fleet details under the tile (#3120)
Wrap drawer sentences and open fleet details under the tile
Two leftovers from the drawer consistency pass.
TrueNAS pool and disk drawers cut their summary sentences off with an
ellipsis ("ZFS pool archive is DEGRADED and ..."), so the one line that
explains the pool's state was the one a user could not read. Those
rows, and vSphere's hardware upgrade and Tools install errors, now
wrap; short values stay on one line.
The availability fleet view opened its drawer below the whole grid, so
clicking a tile near the top of a large fleet showed nothing nearby.
The drawer is now a full-width grid item right after the tile it
belongs to, so it opens beneath that tile's row. The fleet list is
reconciled by id so live updates refresh the open drawer instead of
remounting it, and closing it returns focus to the tile.
This commit is contained in:
@@ -8656,3 +8656,15 @@ other platforms. A TrueNAS system still opens the Overview tab unfiltered,
|
||||
because its table has no search box, so service drawers keep no owner link.
|
||||
The Overview alerts table keeps its own search. No resource identity, status
|
||||
or alert rule changes.
|
||||
|
||||
Drawer detail rows that carry a sentence rather than a value wrap instead of
|
||||
cutting off with an ellipsis: the TrueNAS pool and disk storage, protection,
|
||||
rebuild, risk, posture and scan summaries, and the vSphere hardware upgrade and
|
||||
VMware Tools install errors, set the row's `wrap`. Short values stay on one
|
||||
line. The availability fleet view opens the shared resource drawer as a
|
||||
full-width grid item directly after the selected tile (a dense grid keeps the
|
||||
other tiles packed), so it lands beneath that tile's row instead of below the
|
||||
whole fleet; the fleet list is reconciled by resource id so a live snapshot
|
||||
updates the open drawer in place rather than remounting it, and closing it from
|
||||
its header returns focus to the tile. No resource identity, status or
|
||||
availability rule changes.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "2130d8962b09529c5fb0f0551d6adf3d5a9d7339",
|
||||
"verified_at": "2026-10-22T18:13:54Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts",
|
||||
"frontend-modern/src/features/standalone/AvailabilityFleetView.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "27cb22683f596c8c7d4f0db9e2793f236b2f70cd0daeb21be6b41a94d6523680",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts": "0e7e6412fc453a8a1b85969c87da13f420d57f5beaa53422968a67994bcaa850",
|
||||
"frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "81a497f53b7f54a22187d5fdb3c08bcea794c0f996ead5b6d49317c54b2532d1"
|
||||
},
|
||||
"routes": [
|
||||
"/standalone/availability?view=fleet",
|
||||
"/truenas/storage"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded production build of this change on the full mock estate, signed in through the real login",
|
||||
"Availability fleet: opening the second tile puts the shared resource drawer 8px beneath that tile's row, spanning the full grid width, with the remaining tiles packed after it",
|
||||
"Closing the fleet drawer from its header collapse button removes it and returns keyboard focus to the tile",
|
||||
"TrueNAS storage pool drawer: the storage, protection and posture sentences (\"ZFS pool archive is DEGRADED...\") wrap with no clipped values at desktop width",
|
||||
"No console or page errors at either width"
|
||||
],
|
||||
"interactions": [
|
||||
"Clicking a fleet tile opens the drawer below it; the drawer header's collapse button closes it",
|
||||
"Expanding a TrueNAS pool row opens its drawer"
|
||||
],
|
||||
"browser": {
|
||||
"client": "playwright 1.62.1",
|
||||
"engine": "Chromium",
|
||||
"version": "chromium-1234 headless"
|
||||
},
|
||||
"scope": "Full authenticated application on a mock estate; mock data, not live provider connections."
|
||||
}
|
||||
+2
@@ -325,6 +325,7 @@ describe('hardwareRows (via Virtual hardware section)', () => {
|
||||
label: 'Upgrade error',
|
||||
value: 'timed out',
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -400,6 +401,7 @@ describe('toolsRows (via VMware Tools section)', () => {
|
||||
label: 'Last install error',
|
||||
value: 'boom',
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,9 @@ const compactRows = compactDetailRows;
|
||||
const compactSections = compactDetailSections;
|
||||
|
||||
// Identifiers and paths an operator copies (hostnames, serials, dataset
|
||||
// paths, addresses) wrap in full instead of ending in an ellipsis.
|
||||
// paths, addresses) and the sentences TrueNAS reports (risk, posture,
|
||||
// protection, rebuild and scan summaries) wrap in full instead of ending in an
|
||||
// ellipsis that a phone cannot hover to read.
|
||||
const wrapRow = (
|
||||
label: string,
|
||||
value?: string | null,
|
||||
@@ -193,20 +195,20 @@ const buildTrueNASSystemSections = (
|
||||
row('Storage risk', normalizeDelimitedLabel(truenas.storageRisk?.level), {
|
||||
tone: truenas.storageRisk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
|
||||
}),
|
||||
row('Storage summary', asString(truenas.storageRiskSummary), { tone: 'warning' }),
|
||||
row('Storage posture', asString(truenas.storagePostureSummary), {
|
||||
wrapRow('Storage summary', asString(truenas.storageRiskSummary), { tone: 'warning' }),
|
||||
wrapRow('Storage posture', asString(truenas.storagePostureSummary), {
|
||||
tone: truenas.storagePostureSummary ? 'warning' : 'default',
|
||||
}),
|
||||
row('Protection reduced', yesNoValue(truenas.protectionReduced), {
|
||||
tone: truenas.protectionReduced ? 'warning' : 'success',
|
||||
}),
|
||||
row('Protection summary', asString(truenas.protectionSummary), {
|
||||
wrapRow('Protection summary', asString(truenas.protectionSummary), {
|
||||
tone: truenas.protectionSummary ? 'warning' : 'default',
|
||||
}),
|
||||
row('Rebuild active', yesNoValue(truenas.rebuildInProgress), {
|
||||
tone: truenas.rebuildInProgress ? 'warning' : 'success',
|
||||
}),
|
||||
row('Rebuild', asString(truenas.rebuildSummary)),
|
||||
wrapRow('Rebuild', asString(truenas.rebuildSummary)),
|
||||
]);
|
||||
|
||||
const serviceRows = compactRows([
|
||||
@@ -383,18 +385,18 @@ const buildTrueNASStorageSections = (
|
||||
row('Risk', normalizeDelimitedLabel(storage.risk?.level), {
|
||||
tone: storage.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
|
||||
}),
|
||||
row('Risk summary', asString(storage.riskSummary), { tone: 'warning' }),
|
||||
row('Posture', asString(storage.postureSummary), {
|
||||
wrapRow('Risk summary', asString(storage.riskSummary), { tone: 'warning' }),
|
||||
wrapRow('Posture', asString(storage.postureSummary), {
|
||||
tone: storage.postureSummary ? 'warning' : 'default',
|
||||
}),
|
||||
row('Protection reduced', yesNoValue(storage.protectionReduced), {
|
||||
tone: storage.protectionReduced ? 'warning' : 'success',
|
||||
}),
|
||||
row('Protection summary', asString(storage.protectionSummary), {
|
||||
wrapRow('Protection summary', asString(storage.protectionSummary), {
|
||||
tone: storage.protectionSummary ? 'warning' : 'default',
|
||||
}),
|
||||
row('Rebuild', asString(storage.rebuildSummary)),
|
||||
row('Scan / resilver', zfsScanLabel(storage), {
|
||||
wrapRow('Rebuild', asString(storage.rebuildSummary)),
|
||||
wrapRow('Scan / resilver', zfsScanLabel(storage), {
|
||||
tone: storage.zfsPool?.scanDetails?.errors ? 'warning' : 'default',
|
||||
}),
|
||||
row('ZFS errors', zfsErrorLabel(storage), { tone: 'warning' }),
|
||||
|
||||
@@ -241,7 +241,10 @@ const hardwareRows = (vmware?: ResourceVMwareMeta): DetailRow[] => {
|
||||
makeRow('Upgrade status', upgradeAttention ? formatEnumLabel(hardware.upgradeStatus) : '', {
|
||||
tone: getWarningTone(upgradeAttention),
|
||||
}),
|
||||
makeRow('Upgrade error', asTrimmedString(hardware.upgradeErrorMessage), { tone: 'warning' }),
|
||||
makeRow('Upgrade error', asTrimmedString(hardware.upgradeErrorMessage), {
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
}),
|
||||
makeRow(
|
||||
'Instant clone frozen',
|
||||
hardware.instantCloneFrozen === true ? formatBoolLabel(true) : '',
|
||||
@@ -291,7 +294,10 @@ const toolsRows = (tools: ResourceVMwareTools | undefined, poweredOff: boolean):
|
||||
makeRow('Guest reboot', tools.guestRebootRequested === true ? 'Requested' : '', {
|
||||
tone: getWarningTone(tools.guestRebootRequested === true),
|
||||
}),
|
||||
makeRow('Last install error', asTrimmedString(tools.errorMessage), { tone: 'warning' }),
|
||||
makeRow('Last install error', asTrimmedString(tools.errorMessage), {
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { For, Show, createEffect, createMemo, createSignal, type Component } from 'solid-js';
|
||||
import {
|
||||
createComputed,
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
type Component,
|
||||
} from 'solid-js';
|
||||
import { createStore, reconcile } from 'solid-js/store';
|
||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
|
||||
import { Button } from '@/components/shared/Button';
|
||||
@@ -230,6 +239,11 @@ export const AvailabilityFleetView: Component<{
|
||||
onRetryHistory?: () => void;
|
||||
}> = (props) => {
|
||||
const now = useRelativeTimeNow();
|
||||
// Live snapshots replace resource objects. Reconciled by id, each tile (and
|
||||
// an open drawer) keeps its identity and updates in place instead of
|
||||
// remounting, and losing the drawer's tab, on every poll.
|
||||
const [fleet, setFleet] = createStore<Resource[]>([]);
|
||||
createComputed(() => setFleet(reconcile([...props.resources], { key: 'id' })));
|
||||
const [selectedResourceId, setSelectedResourceId] = createSignal<string>();
|
||||
const selectedResource = createMemo(() =>
|
||||
props.resources.find((resource) => resource.id === selectedResourceId()),
|
||||
@@ -241,10 +255,20 @@ export const AvailabilityFleetView: Component<{
|
||||
});
|
||||
const resolveResourceLabel = (resourceId: string): string | undefined =>
|
||||
props.resources.find((resource) => resource.id === resourceId)?.name;
|
||||
// Tiles are disclosures for the one drawer below the grid: a second click
|
||||
// on the open tile closes it, as a second click on a table row does.
|
||||
// Tiles are disclosures for one drawer: a second click on the open tile
|
||||
// closes it, as a second click on a table row does.
|
||||
const toggleResource = (resourceId: string) =>
|
||||
setSelectedResourceId((current) => (current === resourceId ? undefined : resourceId));
|
||||
// Closing from the drawer's header removes the focused control, so focus
|
||||
// returns to the tile that opened it, as a table drawer returns it to its row.
|
||||
const closeResource = (resourceId: string) => {
|
||||
setSelectedResourceId(undefined);
|
||||
queueMicrotask(() =>
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('[data-availability-fleet-tile]'))
|
||||
.find((tile) => tile.dataset.availabilityFleetTile === resourceId)
|
||||
?.focus({ preventScroll: true }),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Availability fleet" class="space-y-3">
|
||||
@@ -276,11 +300,15 @@ export const AvailabilityFleetView: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* The open check's drawer is a full-width grid item right after its
|
||||
tile. Dense packing lets the rest of that tile's row fill in first,
|
||||
so the drawer opens directly beneath the tile at every column count
|
||||
instead of below the whole fleet. */}
|
||||
<div
|
||||
class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
class="grid grid-flow-row-dense grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
data-testid="availability-fleet-grid"
|
||||
>
|
||||
<For each={props.resources}>
|
||||
<For each={fleet}>
|
||||
{(resource) => {
|
||||
const availability = () => availabilityFor(resource);
|
||||
const probe = () => getAvailabilityProbePresentation(resource, new Date(now()));
|
||||
@@ -314,104 +342,103 @@ export const AvailabilityFleetView: Component<{
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group min-w-0 rounded-md border bg-surface p-3 text-left shadow-xs transition hover:border-blue-400/60 hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
||||
classList={{
|
||||
'border-border': !isSelected(),
|
||||
'border-blue-500 bg-surface-hover ring-1 ring-blue-500/40': isSelected(),
|
||||
}}
|
||||
aria-label={`Open details for ${resource.name}`}
|
||||
aria-expanded={isSelected()}
|
||||
aria-controls={AVAILABILITY_FLEET_DETAIL_ID}
|
||||
data-availability-fleet-tile={resource.id}
|
||||
onClick={() => toggleResource(resource.id)}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={indicator().variant}
|
||||
title={indicator().label}
|
||||
ariaHidden
|
||||
/>
|
||||
<span
|
||||
class="truncate text-sm font-semibold text-base-content"
|
||||
title={resource.name}
|
||||
>
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-[11px] text-muted" title={endpoint()}>
|
||||
{endpoint()}
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="group min-w-0 rounded-md border bg-surface p-3 text-left shadow-xs transition hover:border-blue-400/60 hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
||||
classList={{
|
||||
'border-border': !isSelected(),
|
||||
'border-blue-500 bg-surface-hover ring-1 ring-blue-500/40': isSelected(),
|
||||
}}
|
||||
aria-label={`Open details for ${resource.name}`}
|
||||
aria-expanded={isSelected()}
|
||||
aria-controls={AVAILABILITY_FLEET_DETAIL_ID}
|
||||
data-availability-fleet-tile={resource.id}
|
||||
onClick={() => toggleResource(resource.id)}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={indicator().variant}
|
||||
title={indicator().label}
|
||||
ariaHidden
|
||||
/>
|
||||
<span
|
||||
class="truncate text-sm font-semibold text-base-content"
|
||||
title={resource.name}
|
||||
>
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-[11px] text-muted" title={endpoint()}>
|
||||
{endpoint()}
|
||||
</div>
|
||||
</div>
|
||||
<span class={probe()?.toneClassName ?? 'text-muted'}>
|
||||
{probe()?.resultLabel ?? indicator().label}
|
||||
</span>
|
||||
</div>
|
||||
<span class={probe()?.toneClassName ?? 'text-muted'}>
|
||||
{probe()?.resultLabel ?? indicator().label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
|
||||
<MetadataBadge tone="muted" size="xs" appearance="outline">
|
||||
{probe()?.methodLabel ?? availability()?.protocol ?? 'Probe'}
|
||||
</MetadataBadge>
|
||||
<MetadataBadge tone="muted" size="xs" appearance="outline">
|
||||
{source()}
|
||||
</MetadataBadge>
|
||||
<span>Checked {checked()}</span>
|
||||
<Show when={latency()}>{(value) => <span>{value()}</span>}</Show>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
|
||||
<MetadataBadge tone="muted" size="xs" appearance="outline">
|
||||
{probe()?.methodLabel ?? availability()?.protocol ?? 'Probe'}
|
||||
</MetadataBadge>
|
||||
<MetadataBadge tone="muted" size="xs" appearance="outline">
|
||||
{source()}
|
||||
</MetadataBadge>
|
||||
<span>Checked {checked()}</span>
|
||||
<Show when={latency()}>{(value) => <span>{value()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<Show
|
||||
when={!props.historyLoading}
|
||||
fallback={
|
||||
<div
|
||||
class="h-3 animate-pulse rounded-xs bg-surface-hover"
|
||||
aria-label="Loading history"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AvailabilityStateStrip buckets={buckets()} />
|
||||
</Show>
|
||||
<AvailabilityLatencyLine buckets={buckets()} />
|
||||
<div class="flex items-center justify-between gap-2 text-[10px] text-muted">
|
||||
<span>{availabilityText(history())}</span>
|
||||
<Show when={history()?.revisionBoundaries?.length}>
|
||||
<span title="The check configuration changed during this window">
|
||||
{history()?.revisionBoundaries?.length} revision
|
||||
{history()?.revisionBoundaries?.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<Show
|
||||
when={!props.historyLoading}
|
||||
fallback={
|
||||
<div
|
||||
class="h-3 animate-pulse rounded-xs bg-surface-hover"
|
||||
aria-label="Loading history"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AvailabilityStateStrip buckets={buckets()} />
|
||||
</Show>
|
||||
<AvailabilityLatencyLine buckets={buckets()} />
|
||||
<div class="flex items-center justify-between gap-2 text-[10px] text-muted">
|
||||
<span>{availabilityText(history())}</span>
|
||||
<Show when={history()?.revisionBoundaries?.length}>
|
||||
<span title="The check configuration changed during this window">
|
||||
{history()?.revisionBoundaries?.length} revision
|
||||
{history()?.revisionBoundaries?.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
{/* The same check reads the same here as in the table view: the
|
||||
table-row drawer, headed by the dot its tile shows. Keyed by
|
||||
id, so switching tiles resets the drawer's tabs. */}
|
||||
<Show when={isSelected()}>
|
||||
<div
|
||||
id={AVAILABILITY_FLEET_DETAIL_ID}
|
||||
data-testid="availability-fleet-detail"
|
||||
class="col-span-full rounded-sm border border-border bg-surface px-2 py-3 sm:px-4 sm:py-4"
|
||||
>
|
||||
<ResourceDetailDrawer
|
||||
resource={resource}
|
||||
statusIndicator={indicator()}
|
||||
presentation="table-row"
|
||||
resolveResourceLabel={resolveResourceLabel}
|
||||
onClose={() => closeResource(resource.id)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* The same check reads the same here as in the table view: the
|
||||
table-row drawer, headed by the dot its tile shows. Keyed by id, so
|
||||
switching tiles resets the drawer's tabs. */}
|
||||
<div id={AVAILABILITY_FLEET_DETAIL_ID} data-testid="availability-fleet-detail">
|
||||
<Show when={selectedResourceId()} keyed>
|
||||
<Show when={selectedResource()}>
|
||||
{(resource) => (
|
||||
<div class="rounded-sm border border-border bg-surface px-2 py-3 sm:px-4 sm:py-4">
|
||||
<ResourceDetailDrawer
|
||||
resource={resource()}
|
||||
statusIndicator={getStandaloneResourceStatusIndicator(resource(), now())}
|
||||
presentation="table-row"
|
||||
resolveResourceLabel={resolveResourceLabel}
|
||||
onClose={() => setSelectedResourceId(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { createSignal, onCleanup } from 'solid-js';
|
||||
import type { AvailabilityHistoryTarget } from '@/api/availabilityHistory';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { AvailabilityFleetView } from '../AvailabilityFleetView';
|
||||
import { getStandaloneResourceStatusIndicator } from '../standalonePageModel';
|
||||
|
||||
const drawerMounts = vi.hoisted(() => ({ count: 0 }));
|
||||
|
||||
vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
|
||||
ResourceDetailDrawer: (props: {
|
||||
resource: Resource;
|
||||
onClose?: () => void;
|
||||
presentation?: string;
|
||||
statusIndicator?: { variant: string; label: string };
|
||||
}) => (
|
||||
<div
|
||||
data-testid="resource-detail-drawer"
|
||||
data-resource-id={props.resource.id}
|
||||
data-resource-status={props.resource.status}
|
||||
data-presentation={props.presentation}
|
||||
data-status-variant={props.statusIndicator?.variant}
|
||||
>
|
||||
<button type="button" onClick={props.onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}) => {
|
||||
drawerMounts.count += 1;
|
||||
onCleanup(() => {
|
||||
drawerMounts.count -= 1;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
data-testid="resource-detail-drawer"
|
||||
data-resource-id={props.resource.id}
|
||||
data-resource-status={props.resource.status}
|
||||
data-presentation={props.presentation}
|
||||
data-status-variant={props.statusIndicator?.variant}
|
||||
>
|
||||
<button type="button" onClick={props.onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const resource = (index: number): Resource =>
|
||||
@@ -174,11 +182,17 @@ describe('AvailabilityFleetView', () => {
|
||||
|
||||
const first = screen.getByRole('button', { name: 'Open details for Service 1' });
|
||||
const second = screen.getByRole('button', { name: 'Open details for Service 5' });
|
||||
const detail = screen.getByTestId('availability-fleet-detail');
|
||||
expect(first).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(first).toHaveAttribute('aria-controls', detail.id);
|
||||
expect(screen.queryByTestId('availability-fleet-detail')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(first);
|
||||
// The drawer opens right after its tile as a full-width grid item, so it
|
||||
// lands beneath that tile's row rather than below the whole fleet.
|
||||
const detail = screen.getByTestId('availability-fleet-detail');
|
||||
expect(first).toHaveAttribute('aria-controls', detail.id);
|
||||
expect(first.nextElementSibling).toBe(detail);
|
||||
expect(detail).toHaveClass('col-span-full');
|
||||
expect(screen.getByTestId('availability-fleet-grid')).toHaveClass('grid-flow-row-dense');
|
||||
expect(first).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(first).toHaveClass('border-blue-500');
|
||||
// The same drawer the checks table opens, headed by the tile's own dot.
|
||||
@@ -202,6 +216,46 @@ describe('AvailabilityFleetView', () => {
|
||||
expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns focus to the tile when the drawer is closed from its header', async () => {
|
||||
render(() => (
|
||||
<AvailabilityFleetView
|
||||
resources={[resource(1), resource(2)]}
|
||||
historyByTarget={new Map()}
|
||||
historyLoading={false}
|
||||
/>
|
||||
));
|
||||
|
||||
const tile = screen.getByRole('button', { name: 'Open details for Service 2' });
|
||||
fireEvent.click(tile);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument();
|
||||
expect(tile).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(tile).toHaveFocus();
|
||||
});
|
||||
|
||||
it('keeps an open drawer mounted when a live snapshot replaces the resources', () => {
|
||||
const [resources, setResources] = createSignal([resource(1), resource(2)]);
|
||||
render(() => (
|
||||
<AvailabilityFleetView
|
||||
resources={resources()}
|
||||
historyByTarget={new Map()}
|
||||
historyLoading={false}
|
||||
/>
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open details for Service 2' }));
|
||||
expect(drawerMounts.count).toBe(1);
|
||||
const drawer = screen.getByTestId('resource-detail-drawer');
|
||||
|
||||
// New objects for the same checks, as the websocket store delivers them.
|
||||
setResources([resource(1), { ...resource(2), status: 'offline' } as Resource]);
|
||||
|
||||
expect(screen.getByTestId('resource-detail-drawer')).toBe(drawer);
|
||||
expect(drawerMounts.count).toBe(1);
|
||||
expect(drawer).toHaveAttribute('data-resource-status', 'offline');
|
||||
});
|
||||
|
||||
it('keeps current health visible when history is unavailable', () => {
|
||||
render(() => (
|
||||
<AvailabilityFleetView
|
||||
|
||||
Reference in New Issue
Block a user