mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
fix(frontend): sync summary chart hover across cards
This commit is contained in:
parent
5dc644c650
commit
b5349716c9
24 changed files with 916 additions and 87 deletions
|
|
@ -145,7 +145,7 @@ work extends shared components instead of creating new local variants.
|
|||
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.
|
||||
6. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
|
||||
7. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic.
|
||||
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose hover- or focus-driven chart emphasis must derive the active series ID through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass the same resolved ID into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source.
|
||||
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, chart-hover, or route-focus-driven chart emphasis must derive the active series ID through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass the same resolved ID into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands.
|
||||
9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
|
||||
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, and local scroll preservation instead of rebuilding page-local `Set` filters, focused-label scans, or ad hoc scroll restoration in each surface.
|
||||
|
||||
|
|
|
|||
|
|
@ -196,10 +196,13 @@ are now part of the protected performance surface rather than proof-only
|
|||
context. Future hot-path filter/group/sort/windowing changes must route through
|
||||
the explicit dashboard performance proof policy in the subsystem registry.
|
||||
That same hot-path ownership now covers top-of-page summary emphasis: infrastructure
|
||||
and workloads summary cards must treat hover and focus as one shared active-series
|
||||
contract so time-range switches and row scrubbing reuse one existing chart path
|
||||
instead of repainting page-local “selected row” overlays on top of already
|
||||
downsampled summary history.
|
||||
and workloads summary cards must treat row hover, chart hover, and route focus
|
||||
as one shared active-series contract so time-range switches, row scrubbing, and
|
||||
chart cross-highlighting reuse one existing chart path instead of repainting
|
||||
page-local “selected row” overlays on top of already downsampled summary
|
||||
history. Hovering a sparkline or density map for one entity must promote that
|
||||
entity into the shared active series so sibling cards highlight the same object
|
||||
at once rather than maintaining chart-local hover state.
|
||||
For shared line charts on that hot path, the shared sparkline primitive may
|
||||
isolate the selected series inside the existing render budget, but that
|
||||
isolation must still reuse the same summary series set and timeline data rather
|
||||
|
|
|
|||
|
|
@ -222,6 +222,11 @@ the shared summary contract so pool usage, used capacity, and available space
|
|||
cards can isolate the active row through the shared sparkline primitive while
|
||||
non-matching cards such as disk temperature demote to inactive context instead
|
||||
of rebuilding a row-local summary surface.
|
||||
That same shared summary contract now also owns chart-driven emphasis.
|
||||
Hovering one storage summary chart must promote the same canonical metrics
|
||||
target ID through sibling cards, so pool charts cross-highlight the same pool
|
||||
while non-matching cards such as disk temperature demote to inactive context
|
||||
instead of keeping chart-local hover state.
|
||||
That same storage summary contract now uses the shared contextual-focus owner.
|
||||
`frontend-modern/src/components/Storage/StorageSummary.tsx` must route
|
||||
interactive-series filtering, focused-label lookup, and active-series
|
||||
|
|
|
|||
|
|
@ -174,10 +174,10 @@ assembly branch.
|
|||
canonical `agent`, `vm`, and `storage` resources instead of creating
|
||||
provider-only resource kinds, identities, or history schemas.
|
||||
9. Keep summary-surface emphasis on canonical resource IDs. Infrastructure
|
||||
summary hover/focus behavior must keep using the same unified-resource IDs
|
||||
that power the table rows, chart series, and detail-route handoffs instead
|
||||
of introducing page-local summary IDs or provider-local hover aliases when
|
||||
the selected series is highlighted.
|
||||
summary row-hover, chart-hover, and route-focus behavior must keep using the
|
||||
same unified-resource IDs that power the table rows, chart series, and
|
||||
detail-route handoffs instead of introducing page-local summary IDs or
|
||||
provider-local hover aliases when the selected series is highlighted.
|
||||
10. Keep infrastructure contextual focus route-backed and page-scoped. When an
|
||||
infrastructure row opens its detail drawer, the selection must stay on the
|
||||
same route through canonical resource query state, preserve scroll via the
|
||||
|
|
|
|||
|
|
@ -59,8 +59,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
|||
activeSeriesDisplay="isolate"
|
||||
yMode="percent"
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="cpu"
|
||||
hoverSync={state.chartHoverSync()}
|
||||
highlightSeriesId={state.activeSeriesId()}
|
||||
interactionState={state.interactionStateFor(state.seriesFor('cpu'))}
|
||||
onHoverSyncChange={state.setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -79,8 +82,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
|||
activeSeriesDisplay="isolate"
|
||||
yMode="percent"
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="memory"
|
||||
hoverSync={state.chartHoverSync()}
|
||||
highlightSeriesId={state.activeSeriesId()}
|
||||
interactionState={state.interactionStateFor(state.seriesFor('memory'))}
|
||||
onHoverSyncChange={state.setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -106,8 +112,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
|||
rangeLabel={rangeLabel()}
|
||||
timeRange={props.timeRange}
|
||||
formatValue={formatThroughputRate}
|
||||
hoverSourceKey="diskio"
|
||||
hoverSync={state.chartHoverSync()}
|
||||
highlightSeriesId={state.activeSeriesId()}
|
||||
interactionState={state.interactionStateFor(state.diskioSeries())}
|
||||
onHoverSyncChange={state.setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -178,8 +187,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
|||
rangeLabel={rangeLabel()}
|
||||
timeRange={props.timeRange}
|
||||
formatValue={formatThroughputRate}
|
||||
hoverSourceKey="network"
|
||||
hoverSync={state.chartHoverSync()}
|
||||
highlightSeriesId={state.activeSeriesId()}
|
||||
interactionState={state.interactionStateFor(state.networkSeries())}
|
||||
onHoverSyncChange={state.setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -173,6 +173,13 @@ describe('infrastructureSummaryModel', () => {
|
|||
});
|
||||
|
||||
it('uses one canonical active series id across hover and focused summary selection', () => {
|
||||
expect(
|
||||
resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: 'agent-3',
|
||||
hoveredSeriesId: 'agent-1',
|
||||
focusedSeriesId: 'agent-2',
|
||||
}),
|
||||
).toBe('agent-3');
|
||||
expect(
|
||||
resolveSummaryActiveSeriesId({
|
||||
hoveredSeriesId: 'agent-1',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import type { ChartData, TimeRange } from '@/api/charts';
|
||||
import { useResources } from '@/hooks/useResources';
|
||||
import { useSummaryContextualFocusState } from '@/components/shared/contextualFocus';
|
||||
import {
|
||||
useSummaryContextualFocusState,
|
||||
type SummaryChartHoverSync,
|
||||
} from '@/components/shared/contextualFocus';
|
||||
import {
|
||||
fetchInfrastructureSummaryAndCache,
|
||||
readInfrastructureSummaryCache,
|
||||
|
|
@ -59,6 +62,7 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps)
|
|||
const [loadedRange, setLoadedRange] = createSignal<TimeRange | null>(null);
|
||||
const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal<number | null>(null);
|
||||
const [fetchFailed, setFetchFailed] = createSignal(false);
|
||||
const [chartHoverSync, setChartHoverSync] = createSignal<SummaryChartHoverSync | null>(null);
|
||||
const selectedRange = createMemo<TimeRange>(() => props.timeRange || '1h');
|
||||
const hasCurrentRangeCharts = createMemo(() => chartRange() === selectedRange());
|
||||
const isCurrentRangeLoaded = createMemo(() => loadedRange() === selectedRange());
|
||||
|
|
@ -229,6 +233,7 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps)
|
|||
: buildInfrastructureSummarySeries(props.resources, new Map(), agentResources()),
|
||||
);
|
||||
const summaryFocus = useSummaryContextualFocusState({
|
||||
chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null,
|
||||
interactiveSeries: resourceSeries,
|
||||
hoveredSeriesId: () => props.hoveredResourceId,
|
||||
focusedSeriesId: () => props.focusedResourceId,
|
||||
|
|
@ -244,7 +249,15 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps)
|
|||
});
|
||||
|
||||
const focusedResourceName = createMemo(() => {
|
||||
return summaryFocus.getFocusedSeriesName(resourceSeries());
|
||||
return summaryFocus.getActiveSeriesName(resourceSeries());
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const hovered = chartHoverSync();
|
||||
if (!hovered) return;
|
||||
if (!summaryFocus.hasInteractiveSeriesId(hovered.seriesId)) {
|
||||
setChartHoverSync(null);
|
||||
}
|
||||
});
|
||||
|
||||
const singleDisplayedOnlineResource = createMemo(() => {
|
||||
|
|
@ -291,10 +304,12 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps)
|
|||
|
||||
return {
|
||||
selectedRange,
|
||||
chartHoverSync,
|
||||
isCurrentRangeLoaded,
|
||||
emptyHistoryLabel,
|
||||
emptyMessage,
|
||||
activeSeriesId: summaryFocus.activeSeriesId,
|
||||
setChartHoverSync,
|
||||
effectiveFocusedResourceId: summaryFocus.effectiveFocusedSeriesId,
|
||||
focusedResourceName,
|
||||
hasInteractiveResourceId: summaryFocus.hasInteractiveSeriesId,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import { InteractiveSparkline } from '@/components/shared/InteractiveSparkline';
|
||||
import type { InteractiveSparklineSeries } from '@/components/shared/InteractiveSparkline';
|
||||
import { useSummaryContextualFocusState } from '@/components/shared/contextualFocus';
|
||||
import {
|
||||
useSummaryContextualFocusState,
|
||||
type SummaryChartHoverSync,
|
||||
} from '@/components/shared/contextualFocus';
|
||||
import { SummaryPanel } from '@/components/shared/SummaryPanel';
|
||||
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
|
||||
import {
|
||||
|
|
@ -64,6 +67,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|||
const [data, setData] = createSignal<StorageSummaryChartsResponse | null>(null);
|
||||
const [loaded, setLoaded] = createSignal(false);
|
||||
const [fetchFailed, setFetchFailed] = createSignal(false);
|
||||
const [chartHoverSync, setChartHoverSync] = createSignal<SummaryChartHoverSync | null>(null);
|
||||
|
||||
// Track org switches so the effect re-runs when the org changes.
|
||||
const [orgVersion, setOrgVersion] = createSignal(0);
|
||||
|
|
@ -230,11 +234,20 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|||
...diskTempSeries(),
|
||||
]);
|
||||
const summaryFocus = useSummaryContextualFocusState({
|
||||
chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null,
|
||||
interactiveSeries: interactiveSummarySeries,
|
||||
hoveredSeriesId: () => props.hoveredResourceId,
|
||||
focusedSeriesId: () => props.focusedResourceId,
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const hovered = chartHoverSync();
|
||||
if (!hovered) return;
|
||||
if (!summaryFocus.hasInteractiveSeriesId(hovered.seriesId)) {
|
||||
setChartHoverSync(null);
|
||||
}
|
||||
});
|
||||
|
||||
const hasPoolUsage = () => poolUsageSeries().length > 0;
|
||||
const hasDiskTemp = () => diskTempSeries().length > 0;
|
||||
const hasPoolUsed = () => poolUsedSeries().length > 0;
|
||||
|
|
@ -248,7 +261,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|||
|
||||
const showComponent = () => props.poolCount > 0 || props.diskCount > 0;
|
||||
const getFocusedSeriesName = (series: InteractiveSparklineSeries[]): string | null =>
|
||||
summaryFocus.getFocusedSeriesName(series);
|
||||
summaryFocus.getActiveSeriesName(series);
|
||||
const focusedLabel = (series: InteractiveSparklineSeries[]) => {
|
||||
const name = getFocusedSeriesName(series);
|
||||
if (!name) return undefined;
|
||||
|
|
@ -290,8 +303,11 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|||
activeSeriesDisplay="isolate"
|
||||
yMode="percent"
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="pool-usage"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(poolUsageSeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -312,8 +328,11 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|||
formatValue={formatTemp}
|
||||
formatTopLabel={(max) => `${max.toFixed(0)}°C`}
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="disk-temperature"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(diskTempSeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -334,8 +353,11 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|||
formatValue={(v) => formatBytes(v)}
|
||||
formatTopLabel={(max) => formatBytes(max)}
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="used-capacity"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(poolUsedSeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -356,8 +378,11 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|||
formatValue={(v) => formatBytes(v)}
|
||||
formatTopLabel={(max) => formatBytes(max)}
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="available-space"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(poolAvailSeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
</SummaryPanel>
|
||||
|
|
|
|||
|
|
@ -467,6 +467,51 @@ describe('Storage', () => {
|
|||
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(0);
|
||||
});
|
||||
|
||||
const poolUsageChart = screen
|
||||
.getByText('Pool Usage')
|
||||
.closest('[data-summary-card-state]')
|
||||
?.querySelector('svg');
|
||||
expect(poolUsageChart).not.toBeNull();
|
||||
if (!poolUsageChart) {
|
||||
storageSummarySpy.mockRestore();
|
||||
return;
|
||||
}
|
||||
|
||||
(
|
||||
poolUsageChart as unknown as {
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
}
|
||||
).getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 50,
|
||||
right: 200,
|
||||
bottom: 50,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as unknown as DOMRect;
|
||||
|
||||
fireEvent.mouseMove(poolUsageChart, { clientX: 199, clientY: 26 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
summary.querySelectorAll(
|
||||
'[data-highlight-series-active="true"][data-highlight-series-id="pool:alpha"]',
|
||||
).length,
|
||||
).toBe(3);
|
||||
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(1);
|
||||
});
|
||||
|
||||
fireEvent.mouseLeave(poolUsageChart);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(summary.querySelectorAll('[data-highlight-series-active="true"]').length).toBe(0);
|
||||
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(0);
|
||||
});
|
||||
|
||||
storageSummarySpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -30,17 +30,44 @@ vi.mock('@/components/shared/InteractiveSparkline', () => ({
|
|||
highlightSeriesId?: string | null;
|
||||
interactionState?: string;
|
||||
activeSeriesDisplay?: string;
|
||||
hoverSourceKey?: string;
|
||||
hoverSync?: { seriesId: string } | null;
|
||||
onHoverSyncChange?: (value: {
|
||||
sourceKey: string;
|
||||
seriesId: string;
|
||||
timestamp: number;
|
||||
} | null) => void;
|
||||
}) => {
|
||||
const series = props.series ?? [];
|
||||
const triggerHover = () => {
|
||||
const seriesId = series[0]?.id;
|
||||
if (!props.onHoverSyncChange || !seriesId || !props.hoverSourceKey) {
|
||||
return;
|
||||
}
|
||||
props.onHoverSyncChange({
|
||||
sourceKey: props.hoverSourceKey,
|
||||
seriesId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid="sparkline"
|
||||
data-series-count={series.length}
|
||||
data-series-ids={series.map((current) => current.id || '').join('|')}
|
||||
data-highlight-series-id={props.highlightSeriesId || ''}
|
||||
data-interaction-state={props.interactionState || 'default'}
|
||||
data-active-series-display={props.activeSeriesDisplay || ''}
|
||||
/>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`chart-hover-${props.hoverSourceKey || 'unknown'}`}
|
||||
onClick={triggerHover}
|
||||
/>
|
||||
<div
|
||||
data-testid="sparkline"
|
||||
data-series-count={series.length}
|
||||
data-series-ids={series.map((current) => current.id || '').join('|')}
|
||||
data-highlight-series-id={props.highlightSeriesId || ''}
|
||||
data-hover-source-key={props.hoverSourceKey || ''}
|
||||
data-hover-sync-series-id={props.hoverSync?.seriesId || ''}
|
||||
data-interaction-state={props.interactionState || 'default'}
|
||||
data-active-series-display={props.activeSeriesDisplay || ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
|
@ -97,4 +124,62 @@ describe('StorageSummary', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('treats chart hover as the shared active storage entity across cards', async () => {
|
||||
mockGetStorageSummaryCharts.mockResolvedValueOnce({
|
||||
pools: {
|
||||
'pool:alpha': {
|
||||
name: 'Alpha Pool',
|
||||
usage: twoPointSeries,
|
||||
used: twoPointSeries,
|
||||
avail: twoPointSeries,
|
||||
},
|
||||
'pool:beta': {
|
||||
name: 'Beta Pool',
|
||||
usage: twoPointSeries,
|
||||
used: twoPointSeries,
|
||||
avail: twoPointSeries,
|
||||
},
|
||||
},
|
||||
disks: {
|
||||
'disk:serial-1': {
|
||||
name: 'Disk 1',
|
||||
temperature: twoPointSeries,
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
oldestDataTimestamp: now - 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
render(() => <StorageSummary poolCount={2} diskCount={1} timeRange="1h" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('sparkline')).toHaveLength(4);
|
||||
});
|
||||
|
||||
screen.getByTestId('chart-hover-pool-usage').click();
|
||||
|
||||
await waitFor(() => {
|
||||
const sparklines = screen.getAllByTestId('sparkline');
|
||||
const poolCards = sparklines.filter((sparkline) =>
|
||||
['pool-usage', 'used-capacity', 'available-space'].includes(
|
||||
sparkline.getAttribute('data-hover-source-key') || '',
|
||||
),
|
||||
);
|
||||
const diskTempCard = sparklines.find(
|
||||
(sparkline) => sparkline.getAttribute('data-hover-source-key') === 'disk-temperature',
|
||||
);
|
||||
|
||||
expect(poolCards).toHaveLength(3);
|
||||
for (const sparkline of sparklines) {
|
||||
expect(sparkline.getAttribute('data-highlight-series-id')).toBe('pool:alpha');
|
||||
expect(sparkline.getAttribute('data-hover-sync-series-id')).toBe('pool:alpha');
|
||||
}
|
||||
for (const sparkline of poolCards) {
|
||||
expect(sparkline.getAttribute('data-interaction-state')).toBe('active');
|
||||
}
|
||||
expect(diskTempCard?.getAttribute('data-interaction-state')).toBe('inactive');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,20 +26,47 @@ vi.mock('@/components/shared/InteractiveSparkline', () => ({
|
|||
highlightSeriesId?: string | null;
|
||||
interactionState?: string;
|
||||
activeSeriesDisplay?: string;
|
||||
hoverSourceKey?: string;
|
||||
hoverSync?: { seriesId: string } | null;
|
||||
onHoverSyncChange?: (value: {
|
||||
sourceKey: string;
|
||||
seriesId: string;
|
||||
timestamp: number;
|
||||
} | null) => void;
|
||||
}) => {
|
||||
const series = props.series ?? [];
|
||||
const maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0);
|
||||
const triggerHover = () => {
|
||||
const seriesId = series[0]?.id;
|
||||
if (!props.onHoverSyncChange || !seriesId || !props.hoverSourceKey) {
|
||||
return;
|
||||
}
|
||||
props.onHoverSyncChange({
|
||||
sourceKey: props.hoverSourceKey,
|
||||
seriesId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid="sparkline"
|
||||
data-series-count={series.length}
|
||||
data-series-ids={series.map((current) => current.id || '').join('|')}
|
||||
data-series-names={series.map((current) => current.name || '').join('|')}
|
||||
data-max-points={maxPoints}
|
||||
data-highlight-series-id={props.highlightSeriesId || ''}
|
||||
data-interaction-state={props.interactionState || 'default'}
|
||||
data-active-series-display={props.activeSeriesDisplay || ''}
|
||||
/>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`chart-hover-${props.hoverSourceKey || 'unknown'}`}
|
||||
onClick={triggerHover}
|
||||
/>
|
||||
<div
|
||||
data-testid="sparkline"
|
||||
data-series-count={series.length}
|
||||
data-series-ids={series.map((current) => current.id || '').join('|')}
|
||||
data-series-names={series.map((current) => current.name || '').join('|')}
|
||||
data-max-points={maxPoints}
|
||||
data-highlight-series-id={props.highlightSeriesId || ''}
|
||||
data-hover-source-key={props.hoverSourceKey || ''}
|
||||
data-hover-sync-series-id={props.hoverSync?.seriesId || ''}
|
||||
data-interaction-state={props.interactionState || 'default'}
|
||||
data-active-series-display={props.activeSeriesDisplay || ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
|
@ -49,19 +76,46 @@ vi.mock('@/components/shared/DensityMap', () => ({
|
|||
series?: Array<{ id?: string; name?: string; data?: Array<unknown> }>;
|
||||
highlightSeriesId?: string | null;
|
||||
interactionState?: string;
|
||||
hoverSourceKey?: string;
|
||||
hoverSync?: { seriesId: string } | null;
|
||||
onHoverSyncChange?: (value: {
|
||||
sourceKey: string;
|
||||
seriesId: string;
|
||||
timestamp: number;
|
||||
} | null) => void;
|
||||
}) => {
|
||||
const series = props.series ?? [];
|
||||
const maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0);
|
||||
const triggerHover = () => {
|
||||
const seriesId = series[0]?.id;
|
||||
if (!props.onHoverSyncChange || !seriesId || !props.hoverSourceKey) {
|
||||
return;
|
||||
}
|
||||
props.onHoverSyncChange({
|
||||
sourceKey: props.hoverSourceKey,
|
||||
seriesId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid="sparkline"
|
||||
data-series-count={series.length}
|
||||
data-series-ids={series.map((current) => current.id || '').join('|')}
|
||||
data-series-names={series.map((current) => current.name || '').join('|')}
|
||||
data-max-points={maxPoints}
|
||||
data-highlight-series-id={props.highlightSeriesId || ''}
|
||||
data-interaction-state={props.interactionState || 'default'}
|
||||
/>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`chart-hover-${props.hoverSourceKey || 'unknown'}`}
|
||||
onClick={triggerHover}
|
||||
/>
|
||||
<div
|
||||
data-testid="sparkline"
|
||||
data-series-count={series.length}
|
||||
data-series-ids={series.map((current) => current.id || '').join('|')}
|
||||
data-series-names={series.map((current) => current.name || '').join('|')}
|
||||
data-max-points={maxPoints}
|
||||
data-highlight-series-id={props.highlightSeriesId || ''}
|
||||
data-hover-source-key={props.hoverSourceKey || ''}
|
||||
data-hover-sync-series-id={props.hoverSync?.seriesId || ''}
|
||||
data-interaction-state={props.interactionState || 'default'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
|
@ -314,6 +368,35 @@ describe('WorkloadsSummary performance behavior', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('promotes chart hover into shared summary focus across all workload cards', async () => {
|
||||
const workloadIds = ['cluster-a:pve1:101', 'cluster-a:pve1:102'];
|
||||
mockGetWorkloadCharts.mockResolvedValueOnce(makeChartsResponse(workloadIds));
|
||||
|
||||
render(() => (
|
||||
<WorkloadsSummary
|
||||
timeRange="1h"
|
||||
fallbackGuestCounts={{ total: workloadIds.length, running: workloadIds.length, stopped: 0 }}
|
||||
fallbackSnapshots={makeSnapshots(workloadIds)}
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('sparkline')).toHaveLength(4);
|
||||
});
|
||||
|
||||
screen.getByTestId('chart-hover-cpu').click();
|
||||
|
||||
await waitFor(() => {
|
||||
const sparklines = screen.getAllByTestId('sparkline');
|
||||
expect(sparklines).toHaveLength(4);
|
||||
for (const sparkline of sparklines) {
|
||||
expect(sparkline.getAttribute('data-highlight-series-id')).toBe(workloadIds[0]);
|
||||
expect(sparkline.getAttribute('data-hover-sync-series-id')).toBe(workloadIds[0]);
|
||||
expect(sparkline.getAttribute('data-interaction-state')).toBe('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores focused workload ids that do not have interactive history', async () => {
|
||||
const interactiveWorkloadId = 'cluster-a:pve1:101';
|
||||
const nonInteractiveWorkloadId = 'cluster-a:pve1:102';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import {
|
|||
InteractiveSparkline,
|
||||
type InteractiveSparklineSeries,
|
||||
} from '@/components/shared/InteractiveSparkline';
|
||||
import { useSummaryContextualFocusState } from '@/components/shared/contextualFocus';
|
||||
import {
|
||||
useSummaryContextualFocusState,
|
||||
type SummaryChartHoverSync,
|
||||
} from '@/components/shared/contextualFocus';
|
||||
import { DensityMap } from '@/components/shared/DensityMap';
|
||||
import { SummaryPanel } from '@/components/shared/SummaryPanel';
|
||||
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
|
||||
|
|
@ -405,6 +408,7 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
|
|||
const [loadedScopeKey, setLoadedScopeKey] = createSignal<string | null>(null);
|
||||
const [fetchFailed, setFetchFailed] = createSignal(false);
|
||||
const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID()));
|
||||
const [chartHoverSync, setChartHoverSync] = createSignal<SummaryChartHoverSync | null>(null);
|
||||
const selectedRange = createMemo<TimeRange>(() => props.timeRange || '1h');
|
||||
const selectedNodeScope = createMemo(() => props.selectedNodeId?.trim() || '');
|
||||
const activeScopeKey = createMemo(
|
||||
|
|
@ -648,6 +652,7 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
|
|||
|
||||
const displaySeries = createMemo(() => workloadSeries());
|
||||
const summaryFocus = useSummaryContextualFocusState({
|
||||
chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null,
|
||||
interactiveSeries: workloadSeries,
|
||||
hoveredSeriesId: () => props.hoveredWorkloadId,
|
||||
focusedSeriesId: () => props.focusedWorkloadId,
|
||||
|
|
@ -658,7 +663,15 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
|
|||
series.network.length >= 2,
|
||||
});
|
||||
|
||||
const focusedWorkloadName = createMemo(() => summaryFocus.getFocusedSeriesName(workloadSeries()));
|
||||
createEffect(() => {
|
||||
const hovered = chartHoverSync();
|
||||
if (!hovered) return;
|
||||
if (!summaryFocus.hasInteractiveSeriesId(hovered.seriesId)) {
|
||||
setChartHoverSync(null);
|
||||
}
|
||||
});
|
||||
|
||||
const focusedWorkloadName = createMemo(() => summaryFocus.getActiveSeriesName(workloadSeries()));
|
||||
|
||||
const cpuSeries = createMemo<InteractiveSparklineSeries[]>(() =>
|
||||
displaySeries().map((series) => ({
|
||||
|
|
@ -752,8 +765,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
|
|||
sortTooltipByValue
|
||||
maxTooltipRows={8}
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="cpu"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(cpuSeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -774,8 +790,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
|
|||
sortTooltipByValue
|
||||
maxTooltipRows={8}
|
||||
highlightNearestSeriesOnHover
|
||||
hoverSourceKey="memory"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(memorySeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -792,8 +811,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
|
|||
rangeLabel={selectedRange()}
|
||||
timeRange={selectedRange()}
|
||||
formatValue={formatThroughputRate}
|
||||
hoverSourceKey="diskio"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(diskioSeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
|
|
@ -810,8 +832,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
|
|||
rangeLabel={selectedRange()}
|
||||
timeRange={selectedRange()}
|
||||
formatValue={formatThroughputRate}
|
||||
hoverSourceKey="network"
|
||||
hoverSync={chartHoverSync()}
|
||||
highlightSeriesId={summaryFocus.activeSeriesId()}
|
||||
interactionState={summaryFocus.interactionStateFor(networkSeries())}
|
||||
onHoverSyncChange={setChartHoverSync}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
</SummaryPanel>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
|
|||
)}
|
||||
</For>
|
||||
|
||||
<Show when={sparkline.hoveredState()}>
|
||||
<Show when={sparkline.activeHoverState()}>
|
||||
{(hover) => (
|
||||
<line
|
||||
x1={hover().x}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import trialBannerModelSource from '@/components/shared/trialBannerModel.ts?raw'
|
|||
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
|
||||
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
|
||||
import contextualFocusSource from '@/components/shared/contextualFocus.ts?raw';
|
||||
import summaryCardInteractionSource from '@/components/shared/summaryCardInteraction.ts?raw';
|
||||
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
|
||||
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
|
||||
import infrastructureSelectorModelSource from '@/components/shared/infrastructureSelectorModel.ts?raw';
|
||||
|
|
@ -244,17 +245,24 @@ describe('shared primitive guardrails', () => {
|
|||
it('keeps contextual row focus on one shared helper across summary consumers', () => {
|
||||
expect(contextualFocusSource).toContain('export const preserveScrollableAncestorVerticalOffset');
|
||||
expect(contextualFocusSource).toContain('export function useSummaryContextualFocusState');
|
||||
expect(contextualFocusSource).toContain('chartHoveredSeriesId');
|
||||
expect(summaryCardInteractionSource).toContain('chartHoveredSeriesId');
|
||||
expect(interactiveSparklineModelSource).toContain('onHoverSyncChange');
|
||||
expect(densityMapModelSource).toContain('onHoverSyncChange');
|
||||
|
||||
expect(dashboardSelectionStateSource).toContain('preserveScrollableAncestorVerticalOffset');
|
||||
expect(dashboardSelectionStateSource).not.toContain('const scrollTop = scroller?.scrollTop');
|
||||
|
||||
expect(infrastructureSummaryStateSource).toContain('useSummaryContextualFocusState');
|
||||
expect(infrastructureSummaryStateSource).toContain('chartHoverSync');
|
||||
expect(infrastructureSummaryStateSource).not.toContain('const interactiveResourceIds = createMemo');
|
||||
|
||||
expect(storageSummarySource).toContain('useSummaryContextualFocusState');
|
||||
expect(storageSummarySource).toContain('chartHoverSync');
|
||||
expect(storageSummarySource).not.toContain('resolveSummaryActiveSeriesId');
|
||||
|
||||
expect(workloadsSummarySource).toContain('useSummaryContextualFocusState');
|
||||
expect(workloadsSummarySource).toContain('chartHoverSync');
|
||||
expect(workloadsSummarySource).not.toContain('const interactiveWorkloadIds = createMemo');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,60 @@ describe('DensityMap', () => {
|
|||
expect(await screen.findByText('CPU')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('publishes synchronized hover identity and clears it on leave', () => {
|
||||
const now = Date.now();
|
||||
const onHoverSyncChange = vi.fn();
|
||||
const { container } = render(() => (
|
||||
<DensityMap
|
||||
timeRange="1h"
|
||||
hoverSourceKey="diskio"
|
||||
onHoverSyncChange={onHoverSyncChange}
|
||||
series={[
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
color: '#10b981',
|
||||
data: [
|
||||
{ timestamp: now - 30_000, value: 25 },
|
||||
{ timestamp: now - 10_000, value: 55 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).not.toBeNull();
|
||||
if (!canvas) return;
|
||||
|
||||
(canvas as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 180,
|
||||
height: 80,
|
||||
right: 180,
|
||||
bottom: 80,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as unknown as DOMRect;
|
||||
|
||||
fireEvent.mouseMove(canvas, { clientX: 160, clientY: 20 });
|
||||
|
||||
const publishedHover = onHoverSyncChange.mock.calls.at(-1)?.[0];
|
||||
expect(publishedHover).toMatchObject({
|
||||
sourceKey: 'diskio',
|
||||
seriesId: 'alpha',
|
||||
});
|
||||
expect(typeof publishedHover?.timestamp).toBe('number');
|
||||
expect(publishedHover?.timestamp).toBeGreaterThanOrEqual(now - 3_600_000);
|
||||
expect(publishedHover?.timestamp).toBeLessThanOrEqual(now);
|
||||
|
||||
fireEvent.mouseLeave(canvas);
|
||||
expect(onHoverSyncChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('keeps an externally highlighted series visible when it falls outside the default density top set', () => {
|
||||
const now = Date.now();
|
||||
const series = Array.from({ length: 24 }, (_, index) => ({
|
||||
|
|
|
|||
|
|
@ -73,6 +73,62 @@ describe('InteractiveSparkline hover behavior', () => {
|
|||
expect(screen.queryByText('CPU')).toBeNull();
|
||||
});
|
||||
|
||||
it('publishes synchronized hover identity and clears it on leave', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
const now = Date.now();
|
||||
const onHoverSyncChange = vi.fn();
|
||||
|
||||
const { container } = render(() => (
|
||||
<InteractiveSparkline
|
||||
timeRange="1h"
|
||||
hoverSourceKey="cpu"
|
||||
onHoverSyncChange={onHoverSyncChange}
|
||||
highlightNearestSeriesOnHover
|
||||
series={[
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
color: '#ff0000',
|
||||
data: [
|
||||
{ timestamp: now - 30_000, value: 40 },
|
||||
{ timestamp: now - 10_000, value: 50 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).not.toBeNull();
|
||||
if (!svg) return;
|
||||
|
||||
(svg as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 50,
|
||||
right: 200,
|
||||
bottom: 150,
|
||||
x: 0,
|
||||
y: 100,
|
||||
toJSON: () => ({}),
|
||||
}) as unknown as DOMRect;
|
||||
|
||||
fireEvent.mouseMove(svg, { clientX: 199, clientY: 125 });
|
||||
|
||||
const publishedHover = onHoverSyncChange.mock.calls.at(-1)?.[0];
|
||||
expect(publishedHover).toMatchObject({
|
||||
sourceKey: 'cpu',
|
||||
seriesId: 'alpha',
|
||||
});
|
||||
expect(publishedHover?.timestamp).toBe(now - 10_000);
|
||||
|
||||
fireEvent.mouseLeave(svg);
|
||||
expect(onHoverSyncChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('limits tooltip rows and shows the "+N more series" affordance', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ describe('summaryCardInteraction', () => {
|
|||
});
|
||||
|
||||
it('prefers hovered series ids and falls back to focused ids', () => {
|
||||
expect(
|
||||
resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: 'gamma',
|
||||
hoveredSeriesId: 'beta',
|
||||
focusedSeriesId: 'alpha',
|
||||
}),
|
||||
).toBe('gamma');
|
||||
|
||||
expect(
|
||||
resolveSummaryActiveSeriesId({
|
||||
hoveredSeriesId: 'beta',
|
||||
|
|
@ -52,6 +60,7 @@ describe('summaryCardInteraction', () => {
|
|||
expect(
|
||||
resolveSummaryCardInteractionState({
|
||||
series: [{ id: 'alpha' }, { id: 'beta' }],
|
||||
chartHoveredSeriesId: 'beta',
|
||||
hoveredSeriesId: 'beta',
|
||||
focusedSeriesId: 'alpha',
|
||||
}),
|
||||
|
|
@ -71,9 +80,11 @@ describe('summaryCardInteraction', () => {
|
|||
it('filters contextual focus down to interactive series ids through one shared hook', () => {
|
||||
const [hoveredSeriesId] = createSignal<string | null>('gamma');
|
||||
const [focusedSeriesId] = createSignal<string | null>('alpha');
|
||||
const [chartHoveredSeriesId, setChartHoveredSeriesId] = createSignal<string | null>(null);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryContextualFocusState({
|
||||
chartHoveredSeriesId,
|
||||
interactiveSeries: () => [
|
||||
{ id: 'alpha', name: 'Alpha', interactive: true },
|
||||
{ id: 'beta', name: 'Beta', interactive: false },
|
||||
|
|
@ -97,6 +108,13 @@ describe('summaryCardInteraction', () => {
|
|||
).toBe('Alpha');
|
||||
expect(result.interactionStateFor([{ id: 'alpha' }])).toBe('active');
|
||||
expect(result.interactionStateFor([{ id: 'beta' }])).toBe('inactive');
|
||||
|
||||
setChartHoveredSeriesId('alpha');
|
||||
expect(result.effectiveChartHoveredSeriesId()).toBe('alpha');
|
||||
expect(result.activeSeriesId()).toBe('alpha');
|
||||
expect(result.getActiveSeriesName([{ id: 'alpha', name: 'Alpha', interactive: true }])).toBe(
|
||||
'Alpha',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the nearest scrollable ancestor when contextual focus changes locally', () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,14 @@ type ContextualFocusSeries = {
|
|||
name?: string | null;
|
||||
};
|
||||
|
||||
export interface SummaryChartHoverSync {
|
||||
sourceKey: string;
|
||||
seriesId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface UseSummaryContextualFocusStateOptions<T extends ContextualFocusSeries> {
|
||||
chartHoveredSeriesId?: Accessor<string | null | undefined>;
|
||||
focusedSeriesId?: Accessor<string | null | undefined>;
|
||||
getSeriesName?: (series: T) => string | null | undefined;
|
||||
hoveredSeriesId?: Accessor<string | null | undefined>;
|
||||
|
|
@ -62,6 +69,7 @@ export const preserveScrollableAncestorVerticalOffset = (
|
|||
export function useSummaryContextualFocusState<T extends ContextualFocusSeries>(
|
||||
options: UseSummaryContextualFocusStateOptions<T>,
|
||||
) {
|
||||
const chartHoveredSeriesId = options.chartHoveredSeriesId ?? (() => null);
|
||||
const hoveredSeriesId = options.hoveredSeriesId ?? (() => null);
|
||||
const focusedSeriesId = options.focusedSeriesId ?? (() => null);
|
||||
const getSeriesName =
|
||||
|
|
@ -93,16 +101,23 @@ export function useSummaryContextualFocusState<T extends ContextualFocusSeries>(
|
|||
return hasInteractiveSeriesId(hoveredSeriesId()) ? normalizeSeriesId(hoveredSeriesId()) : null;
|
||||
});
|
||||
|
||||
const effectiveChartHoveredSeriesId = createMemo<string | null>(() => {
|
||||
return hasInteractiveSeriesId(chartHoveredSeriesId())
|
||||
? normalizeSeriesId(chartHoveredSeriesId())
|
||||
: null;
|
||||
});
|
||||
|
||||
const effectiveFocusedSeriesId = createMemo<string | null>(() => {
|
||||
return hasInteractiveSeriesId(focusedSeriesId()) ? normalizeSeriesId(focusedSeriesId()) : null;
|
||||
});
|
||||
|
||||
const activeSeriesId = createMemo<string | null>(() =>
|
||||
resolveSummaryActiveSeriesId({
|
||||
const activeSeriesId = createMemo<string | null>(() => {
|
||||
return resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: effectiveChartHoveredSeriesId(),
|
||||
hoveredSeriesId: effectiveHoveredSeriesId(),
|
||||
focusedSeriesId: effectiveFocusedSeriesId(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const getFocusedSeriesName = (series: readonly T[]): string | null => {
|
||||
const focusedId = effectiveFocusedSeriesId();
|
||||
|
|
@ -113,19 +128,31 @@ export function useSummaryContextualFocusState<T extends ContextualFocusSeries>(
|
|||
return match ? getSeriesName(match) || null : null;
|
||||
};
|
||||
|
||||
const getActiveSeriesName = (series: readonly T[]): string | null => {
|
||||
const currentActiveId = activeSeriesId();
|
||||
if (!currentActiveId) {
|
||||
return null;
|
||||
}
|
||||
const match = series.find((entry) => normalizeSeriesId(entry.id) === currentActiveId);
|
||||
return match ? getSeriesName(match) || null : null;
|
||||
};
|
||||
|
||||
const interactionStateFor = (
|
||||
series: ReadonlyArray<{ id?: string | null }>,
|
||||
): SummaryCardInteractionState =>
|
||||
resolveSummaryCardInteractionState({
|
||||
series,
|
||||
chartHoveredSeriesId: effectiveChartHoveredSeriesId(),
|
||||
hoveredSeriesId: effectiveHoveredSeriesId(),
|
||||
focusedSeriesId: effectiveFocusedSeriesId(),
|
||||
});
|
||||
|
||||
return {
|
||||
activeSeriesId,
|
||||
effectiveChartHoveredSeriesId,
|
||||
effectiveFocusedSeriesId,
|
||||
effectiveHoveredSeriesId,
|
||||
getActiveSeriesName,
|
||||
getFocusedSeriesName,
|
||||
hasInteractiveSeriesId,
|
||||
interactionStateFor,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { TimeRange } from '@/api/charts';
|
||||
import { timeRangeToMs } from '@/utils/timeRange';
|
||||
import type { InteractiveSparklineSeries } from './InteractiveSparkline';
|
||||
import type { SummaryChartHoverSync } from './contextualFocus';
|
||||
import type { SummaryCardInteractionState } from './summaryCardInteraction';
|
||||
|
||||
export interface DensityMapProps {
|
||||
|
|
@ -8,11 +9,15 @@ export interface DensityMapProps {
|
|||
rangeLabel?: string;
|
||||
timeRange?: TimeRange;
|
||||
formatValue?: (value: number) => string;
|
||||
hoverSourceKey?: string;
|
||||
hoverSync?: SummaryChartHoverSync | null;
|
||||
onHoverSyncChange?: (value: SummaryChartHoverSync | null) => void;
|
||||
highlightSeriesId?: string | null;
|
||||
interactionState?: SummaryCardInteractionState;
|
||||
}
|
||||
|
||||
export interface DensityMapHoveredState {
|
||||
columnIndex: number;
|
||||
tooltipX: number;
|
||||
tooltipY: number;
|
||||
timestamp: number;
|
||||
|
|
@ -172,6 +177,7 @@ export function buildDensityMapHoveredState(options: {
|
|||
const cellHeight = rect.height / data.series.length;
|
||||
|
||||
return {
|
||||
columnIndex: column,
|
||||
tooltipX: rect.left + column * cellWidth + cellWidth / 2,
|
||||
tooltipY: rect.top + row * cellHeight,
|
||||
timestamp: data.windowStart + column * data.bucketDuration,
|
||||
|
|
@ -181,3 +187,40 @@ export function buildDensityMapHoveredState(options: {
|
|||
seriesIndex: row,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDensityMapSynchronizedHoveredState(options: {
|
||||
data: DensityMapChartData;
|
||||
hoverSync?: SummaryChartHoverSync | null;
|
||||
}): DensityMapHoveredState | null {
|
||||
const { data, hoverSync } = options;
|
||||
if (!hoverSync || data.series.length === 0 || data.rangeMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seriesIndex = data.series.findIndex((series) => series.id === hoverSync.seriesId);
|
||||
if (seriesIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedTimestamp = clampDensityMapValue(
|
||||
hoverSync.timestamp,
|
||||
data.windowStart,
|
||||
data.windowStart + data.rangeMs,
|
||||
);
|
||||
const column = clampDensityMapValue(
|
||||
Math.floor(((clampedTimestamp - data.windowStart) / data.rangeMs) * DENSITY_MAP_COLUMNS),
|
||||
0,
|
||||
DENSITY_MAP_COLUMNS - 1,
|
||||
);
|
||||
|
||||
return {
|
||||
columnIndex: column,
|
||||
tooltipX: 0,
|
||||
tooltipY: 0,
|
||||
timestamp: clampedTimestamp,
|
||||
value: data.grid[seriesIndex][column],
|
||||
seriesName: data.series[seriesIndex].name || 'Unknown',
|
||||
seriesColor: data.series[seriesIndex].color,
|
||||
seriesIndex,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { MetricPoint, TimeRange } from '@/api/charts';
|
||||
import { downsampleLTTB, calculateOptimalPoints } from '@/utils/downsample';
|
||||
import { timeRangeToMs } from '@/utils/timeRange';
|
||||
import type { SummaryChartHoverSync } from './contextualFocus';
|
||||
import type { SummaryCardInteractionState } from './summaryCardInteraction';
|
||||
|
||||
export interface InteractiveSparklineSeries {
|
||||
|
|
@ -25,6 +26,9 @@ export interface InteractiveSparklineProps {
|
|||
maxTooltipRows?: number;
|
||||
highlightNearestSeriesOnHover?: boolean;
|
||||
highlightSeriesId?: string | null;
|
||||
hoverSourceKey?: string;
|
||||
hoverSync?: SummaryChartHoverSync | null;
|
||||
onHoverSyncChange?: (value: SummaryChartHoverSync | null) => void;
|
||||
interactionState?: SummaryCardInteractionState;
|
||||
}
|
||||
|
||||
|
|
@ -452,6 +456,64 @@ export const getInteractiveSparklineActiveEmphasisSeriesIndex = ({
|
|||
return externalSeriesIndex;
|
||||
};
|
||||
|
||||
export const buildInteractiveSparklineSynchronizedHoverState = ({
|
||||
chartData,
|
||||
hoverSync,
|
||||
vbW,
|
||||
vbH,
|
||||
yMode,
|
||||
}: {
|
||||
chartData: InteractiveSparklineChartData;
|
||||
hoverSync?: SummaryChartHoverSync | null;
|
||||
vbW: number;
|
||||
vbH: number;
|
||||
yMode: 'percent' | 'auto';
|
||||
}): InteractiveSparklineHoverState | null => {
|
||||
if (!hoverSync || chartData.validSeries.length === 0 || chartData.rangeMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seriesIndex = chartData.validSeries.findIndex((series) => series.id === hoverSync.seriesId);
|
||||
if (seriesIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedTimestamp = clampInteractiveSparklineValue(
|
||||
hoverSync.timestamp,
|
||||
chartData.windowStart,
|
||||
chartData.windowStart + chartData.rangeMs,
|
||||
);
|
||||
const series = chartData.validSeries[seriesIndex];
|
||||
const nearest = findNearestMetricPoint(series.hoverData, clampedTimestamp);
|
||||
if (!nearest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueToChartY = createInteractiveSparklineValueToY(yMode, chartData.scaleMax, vbH);
|
||||
const pointY = valueToChartY(nearest.point.value);
|
||||
const chartX = ((clampedTimestamp - chartData.windowStart) / chartData.rangeMs) * vbW;
|
||||
|
||||
return {
|
||||
x: chartX,
|
||||
tooltipX: 0,
|
||||
tooltipY: 0,
|
||||
timestamp: clampedTimestamp,
|
||||
totalValues: 1,
|
||||
minY: pointY,
|
||||
nearestSeriesIndex: seriesIndex,
|
||||
highlightedSeriesIndex: seriesIndex,
|
||||
focusedTooltip: true,
|
||||
values: [
|
||||
{
|
||||
name: series.name || 'Series',
|
||||
color: series.color,
|
||||
value: nearest.point.value,
|
||||
seriesIndex,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const computeInteractiveSparklineHoverState = ({
|
||||
chartData,
|
||||
chartRect,
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ type SummarySeriesIdentity = {
|
|||
const normalizeSeriesId = (value: string | null | undefined): string => value?.trim() || '';
|
||||
|
||||
export function resolveSummaryActiveSeriesId(options: {
|
||||
chartHoveredSeriesId?: string | null;
|
||||
hoveredSeriesId?: string | null;
|
||||
focusedSeriesId?: string | null;
|
||||
}): string | null {
|
||||
const chartHoveredSeriesId = normalizeSeriesId(options.chartHoveredSeriesId);
|
||||
if (chartHoveredSeriesId) {
|
||||
return chartHoveredSeriesId;
|
||||
}
|
||||
|
||||
const hoveredSeriesId = normalizeSeriesId(options.hoveredSeriesId);
|
||||
if (hoveredSeriesId) {
|
||||
return hoveredSeriesId;
|
||||
|
|
@ -21,10 +27,12 @@ export function resolveSummaryActiveSeriesId(options: {
|
|||
|
||||
export function resolveSummaryCardInteractionState(options: {
|
||||
series: readonly SummarySeriesIdentity[];
|
||||
chartHoveredSeriesId?: string | null;
|
||||
hoveredSeriesId?: string | null;
|
||||
focusedSeriesId?: string | null;
|
||||
}): SummaryCardInteractionState {
|
||||
const activeSeriesId = resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: options.chartHoveredSeriesId,
|
||||
hoveredSeriesId: options.hoveredSeriesId,
|
||||
focusedSeriesId: options.focusedSeriesId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
buildDensityMapChartData,
|
||||
getDensityMapExternalSeriesIndex,
|
||||
buildDensityMapHoveredState,
|
||||
buildDensityMapSynchronizedHoveredState,
|
||||
formatDensityMapValue,
|
||||
getDensityMapCellOpacity,
|
||||
DENSITY_MAP_COLUMNS,
|
||||
|
|
@ -26,6 +27,22 @@ export function useDensityMapState(props: DensityMapProps) {
|
|||
const externalSeriesIndex = createMemo(() =>
|
||||
getDensityMapExternalSeriesIndex(chartData(), props.highlightSeriesId),
|
||||
);
|
||||
const synchronizedHoveredState = createMemo(() => {
|
||||
const hoverSync = props.hoverSync;
|
||||
if (!hoverSync) {
|
||||
return null;
|
||||
}
|
||||
if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) {
|
||||
return null;
|
||||
}
|
||||
return buildDensityMapSynchronizedHoveredState({
|
||||
data: chartData(),
|
||||
hoverSync,
|
||||
});
|
||||
});
|
||||
const activeHoveredState = createMemo<DensityMapHoveredState | null>(() => {
|
||||
return hoveredState() ?? synchronizedHoveredState();
|
||||
});
|
||||
|
||||
const formatValue = (value: number) => formatDensityMapValue(value, props.formatValue);
|
||||
|
||||
|
|
@ -55,7 +72,8 @@ export function useDensityMapState(props: DensityMapProps) {
|
|||
const cellWidth = width / DENSITY_MAP_COLUMNS;
|
||||
const cellHeight = height / rows;
|
||||
const interactionOpacity = props.interactionState === 'inactive' ? 0.35 : 1;
|
||||
const activeSeriesIndex = hoveredState()?.seriesIndex ?? externalSeriesIndex();
|
||||
const hover = activeHoveredState();
|
||||
const activeSeriesIndex = hover?.seriesIndex ?? externalSeriesIndex();
|
||||
const activeSeries =
|
||||
activeSeriesIndex !== null && activeSeriesIndex >= 0 && activeSeriesIndex < data.series.length
|
||||
? data.series[activeSeriesIndex]
|
||||
|
|
@ -109,6 +127,12 @@ export function useDensityMapState(props: DensityMapProps) {
|
|||
if (activeSeries !== null && activeSeriesIndex !== null) {
|
||||
const highlightY = activeSeriesIndex * cellHeight;
|
||||
context.save();
|
||||
if (hover) {
|
||||
const highlightX = hover.columnIndex * cellWidth;
|
||||
context.globalAlpha = 0.12 * interactionOpacity;
|
||||
context.fillStyle = activeSeries.color;
|
||||
context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height);
|
||||
}
|
||||
context.globalAlpha = 0.12 * interactionOpacity;
|
||||
context.fillStyle = activeSeries.color;
|
||||
context.fillRect(0, highlightY, width, cellHeight);
|
||||
|
|
@ -136,18 +160,33 @@ export function useDensityMapState(props: DensityMapProps) {
|
|||
const canvas = canvasRef();
|
||||
if (!canvas) return;
|
||||
|
||||
setHoveredState(
|
||||
buildDensityMapHoveredState({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
rect: canvas.getBoundingClientRect(),
|
||||
data: chartData(),
|
||||
}),
|
||||
);
|
||||
const computed = buildDensityMapHoveredState({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
rect: canvas.getBoundingClientRect(),
|
||||
data: chartData(),
|
||||
});
|
||||
setHoveredState(computed);
|
||||
if (!props.onHoverSyncChange || !props.hoverSourceKey || !computed) {
|
||||
props.onHoverSyncChange?.(null);
|
||||
return;
|
||||
}
|
||||
const hoveredSeries = chartData().series[computed.seriesIndex];
|
||||
const hoveredSeriesId = hoveredSeries?.id?.trim() || '';
|
||||
if (!hoveredSeriesId) {
|
||||
props.onHoverSyncChange(null);
|
||||
return;
|
||||
}
|
||||
props.onHoverSyncChange({
|
||||
sourceKey: props.hoverSourceKey,
|
||||
seriesId: hoveredSeriesId,
|
||||
timestamp: computed.timestamp,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredState(null);
|
||||
props.onHoverSyncChange?.(null);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -159,6 +198,7 @@ export function useDensityMapState(props: DensityMapProps) {
|
|||
});
|
||||
|
||||
return {
|
||||
activeHoveredState,
|
||||
chartData,
|
||||
externalSeriesIndex,
|
||||
formatValue,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
buildInteractiveSparklineChartData,
|
||||
buildInteractiveSparklineGridLineX,
|
||||
buildInteractiveSparklineGridLineY,
|
||||
buildInteractiveSparklineSynchronizedHoverState,
|
||||
buildInteractiveSparklineTopLabel,
|
||||
buildInteractiveSparklineXAxisTicks,
|
||||
clampInteractiveSparklineValue,
|
||||
|
|
@ -80,6 +81,29 @@ export function useInteractiveSparklineState(
|
|||
tooltipEstimatedWidth,
|
||||
});
|
||||
setHoveredState(computed);
|
||||
if (!props.onHoverSyncChange || !props.hoverSourceKey) {
|
||||
return;
|
||||
}
|
||||
if (!computed) {
|
||||
props.onHoverSyncChange(null);
|
||||
return;
|
||||
}
|
||||
const highlightedIndex = computed?.highlightedSeriesIndex;
|
||||
if (highlightedIndex === null || highlightedIndex === undefined) {
|
||||
props.onHoverSyncChange(null);
|
||||
return;
|
||||
}
|
||||
const highlightedSeries = chartData().validSeries[highlightedIndex];
|
||||
const highlightedSeriesId = highlightedSeries?.id?.trim() || '';
|
||||
if (!highlightedSeriesId) {
|
||||
props.onHoverSyncChange(null);
|
||||
return;
|
||||
}
|
||||
props.onHoverSyncChange({
|
||||
sourceKey: props.hoverSourceKey,
|
||||
seriesId: highlightedSeriesId,
|
||||
timestamp: computed.timestamp,
|
||||
});
|
||||
};
|
||||
|
||||
const flushHoverState = () => {
|
||||
|
|
@ -108,6 +132,7 @@ export function useInteractiveSparklineState(
|
|||
hoverRafId = null;
|
||||
}
|
||||
setHoveredState(null);
|
||||
props.onHoverSyncChange?.(null);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
|
|
@ -154,11 +179,30 @@ export function useInteractiveSparklineState(
|
|||
const externalSeriesIndex = createMemo(() =>
|
||||
getInteractiveSparklineExternalSeriesIndex(chartData(), props.highlightSeriesId),
|
||||
);
|
||||
const synchronizedHoverState = createMemo(() => {
|
||||
const hoverSync = props.hoverSync;
|
||||
if (!hoverSync) {
|
||||
return null;
|
||||
}
|
||||
if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) {
|
||||
return null;
|
||||
}
|
||||
return buildInteractiveSparklineSynchronizedHoverState({
|
||||
chartData: chartData(),
|
||||
hoverSync,
|
||||
vbW,
|
||||
vbH,
|
||||
yMode: yMode(),
|
||||
});
|
||||
});
|
||||
const activeHoverState = createMemo<InteractiveSparklineHoverState | null>(() => {
|
||||
return hoveredState() ?? synchronizedHoverState();
|
||||
});
|
||||
const activeEmphasisSeriesIndex = createMemo(() =>
|
||||
getInteractiveSparklineActiveEmphasisSeriesIndex({
|
||||
highlightNearestSeriesOnHover: props.highlightNearestSeriesOnHover === true,
|
||||
lockedSeriesIndex: lockedSeriesIndex(),
|
||||
hoveredState: hoveredState(),
|
||||
hoveredState: activeHoverState(),
|
||||
externalSeriesIndex: externalSeriesIndex(),
|
||||
}),
|
||||
);
|
||||
|
|
@ -341,7 +385,7 @@ export function useInteractiveSparklineState(
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
const hover = hoveredState();
|
||||
const hover = activeHoverState();
|
||||
if (!hover) return;
|
||||
ctx.save();
|
||||
const x = (hover.x / vbW) * width;
|
||||
|
|
@ -378,7 +422,7 @@ export function useInteractiveSparklineState(
|
|||
|
||||
void chartData();
|
||||
void activeEmphasisSeriesIndex();
|
||||
void hoveredState();
|
||||
void activeHoverState();
|
||||
queueCanvasDraw();
|
||||
});
|
||||
|
||||
|
|
@ -404,6 +448,7 @@ export function useInteractiveSparklineState(
|
|||
|
||||
return {
|
||||
activeEmphasisSeriesIndex,
|
||||
activeHoverState,
|
||||
activeSeriesDisplay,
|
||||
axisTicks,
|
||||
chartData,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,106 @@ async function expectSummaryHighlightCount(
|
|||
.toBe(expectedCount);
|
||||
}
|
||||
|
||||
async function readSummaryHighlightCount(
|
||||
summary: import("@playwright/test").Locator,
|
||||
resourceId: string,
|
||||
): Promise<number> {
|
||||
return summary
|
||||
.locator("[data-highlight-series-id]")
|
||||
.evaluateAll(
|
||||
(nodes, expectedId) =>
|
||||
nodes.filter(
|
||||
(node) =>
|
||||
node.getAttribute("data-highlight-series-id") === expectedId &&
|
||||
node.getAttribute("data-highlight-series-active") === "true",
|
||||
).length,
|
||||
resourceId,
|
||||
);
|
||||
}
|
||||
|
||||
async function hoverRowUntilSummaryHighlights(
|
||||
page: import("@playwright/test").Page,
|
||||
rows: import("@playwright/test").Locator,
|
||||
summary: import("@playwright/test").Locator,
|
||||
fallbackAttribute: string,
|
||||
expectedCount: number,
|
||||
): Promise<{ index: number; resourceId: string }> {
|
||||
const rowCount = await rows.count();
|
||||
for (let index = 0; index < rowCount; index += 1) {
|
||||
const row = rows.nth(index);
|
||||
if (!(await row.isVisible())) {
|
||||
continue;
|
||||
}
|
||||
const resourceId = await readSummarySeriesId(row, fallbackAttribute);
|
||||
if (!resourceId) {
|
||||
continue;
|
||||
}
|
||||
await row.hover();
|
||||
try {
|
||||
await expect
|
||||
.poll(() => readSummaryHighlightCount(summary, resourceId), {
|
||||
timeout: 1_500,
|
||||
})
|
||||
.toBe(expectedCount);
|
||||
return { index, resourceId };
|
||||
} catch {
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to find a row that maps to summary highlight state");
|
||||
}
|
||||
|
||||
async function hoverSummaryChartUntilActiveId(
|
||||
page: import("@playwright/test").Page,
|
||||
summary: import("@playwright/test").Locator,
|
||||
chartRoot: import("@playwright/test").Locator,
|
||||
): Promise<string> {
|
||||
const surface = chartRoot.locator("svg, canvas").first();
|
||||
await expect(surface).toBeVisible();
|
||||
const box = await surface.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) {
|
||||
throw new Error("Chart surface has no bounding box");
|
||||
}
|
||||
|
||||
const probePoints = [
|
||||
{ x: 0.88, y: 0.25 },
|
||||
{ x: 0.88, y: 0.5 },
|
||||
{ x: 0.72, y: 0.25 },
|
||||
{ x: 0.72, y: 0.5 },
|
||||
{ x: 0.56, y: 0.25 },
|
||||
{ x: 0.56, y: 0.5 },
|
||||
];
|
||||
|
||||
for (const point of probePoints) {
|
||||
await page.mouse.move(
|
||||
box.x + box.width * point.x,
|
||||
box.y + box.height * point.y,
|
||||
);
|
||||
const activeIds = await summary
|
||||
.locator("[data-highlight-series-id]")
|
||||
.evaluateAll((nodes) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node.getAttribute("data-highlight-series-active") === "true",
|
||||
)
|
||||
.map((node) => node.getAttribute("data-highlight-series-id") || "")
|
||||
.filter(Boolean),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (activeIds.length === 1) {
|
||||
return activeIds[0];
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to activate a summary chart hover state");
|
||||
}
|
||||
|
||||
async function expectActiveIsolatedLineCards(
|
||||
summary: import("@playwright/test").Locator,
|
||||
expectedCount: number,
|
||||
|
|
@ -152,19 +252,23 @@ test.describe.serial("Summary hover selection", () => {
|
|||
await page.goto("/infrastructure", { waitUntil: "domcontentloaded" });
|
||||
const infrastructureSummary = page.getByTestId("infrastructure-summary");
|
||||
await expect(infrastructureSummary).toBeVisible();
|
||||
const infrastructureRow = page
|
||||
.locator('[data-testid="infrastructure-page"] tr[data-row-id]')
|
||||
.first();
|
||||
await expect(infrastructureRow).toBeVisible();
|
||||
const infrastructureRowId =
|
||||
(await infrastructureRow.getAttribute("data-row-id")) ?? "";
|
||||
expect(infrastructureRowId).not.toBe("");
|
||||
await infrastructureRow.hover();
|
||||
await expectSummaryHighlightCount(
|
||||
const infrastructureRows = page.locator(
|
||||
'[data-testid="infrastructure-page"] tr[data-row-id]',
|
||||
);
|
||||
const firstInfrastructureRow = infrastructureRows.first();
|
||||
await expect(firstInfrastructureRow).toBeVisible();
|
||||
const {
|
||||
index: infrastructureRowIndex,
|
||||
resourceId: infrastructureRowId,
|
||||
} = await hoverRowUntilSummaryHighlights(
|
||||
page,
|
||||
infrastructureRows,
|
||||
infrastructureSummary,
|
||||
infrastructureRowId,
|
||||
"data-row-id",
|
||||
4,
|
||||
);
|
||||
const infrastructureRow = infrastructureRows.nth(infrastructureRowIndex);
|
||||
expect(infrastructureRowId).not.toBe("");
|
||||
await expectActiveIsolatedLineCards(infrastructureSummary, 2);
|
||||
await infrastructureRow.click();
|
||||
await infrastructureSummary.hover();
|
||||
|
|
@ -178,13 +282,17 @@ test.describe.serial("Summary hover selection", () => {
|
|||
await page.goto("/workloads", { waitUntil: "domcontentloaded" });
|
||||
const workloadsSummary = page.getByTestId("workloads-summary");
|
||||
await expect(workloadsSummary).toBeVisible();
|
||||
const workloadRow = page.locator("tr[data-guest-id]").first();
|
||||
await expect(workloadRow).toBeVisible();
|
||||
const workloadRowId =
|
||||
(await workloadRow.getAttribute("data-guest-id")) ?? "";
|
||||
const workloadRows = page.locator("tr[data-guest-id]");
|
||||
const firstWorkloadRow = workloadRows.first();
|
||||
await expect(firstWorkloadRow).toBeVisible();
|
||||
const { resourceId: workloadRowId } = await hoverRowUntilSummaryHighlights(
|
||||
page,
|
||||
workloadRows,
|
||||
workloadsSummary,
|
||||
"data-guest-id",
|
||||
4,
|
||||
);
|
||||
expect(workloadRowId).not.toBe("");
|
||||
await workloadRow.hover();
|
||||
await expectSummaryHighlightCount(workloadsSummary, workloadRowId, 4);
|
||||
await expectActiveIsolatedLineCards(workloadsSummary, 2);
|
||||
|
||||
await page.goto("/storage", { waitUntil: "domcontentloaded" });
|
||||
|
|
@ -198,27 +306,31 @@ test.describe.serial("Summary hover selection", () => {
|
|||
"false",
|
||||
);
|
||||
|
||||
const storagePoolRow = page.locator("tr[data-row-id]").first();
|
||||
await expect(storagePoolRow).toBeVisible();
|
||||
const storagePoolRowId = await readSummarySeriesId(
|
||||
storagePoolRow,
|
||||
const storagePoolRows = page.locator("tr[data-row-id]");
|
||||
const firstStoragePoolRow = storagePoolRows.first();
|
||||
await expect(firstStoragePoolRow).toBeVisible();
|
||||
const { resourceId: storagePoolRowId } = await hoverRowUntilSummaryHighlights(
|
||||
page,
|
||||
storagePoolRows,
|
||||
storageSummary,
|
||||
"data-row-id",
|
||||
3,
|
||||
);
|
||||
expect(storagePoolRowId).not.toBe("");
|
||||
await storagePoolRow.hover();
|
||||
await expectSummaryHighlightCount(storageSummary, storagePoolRowId, 3);
|
||||
await expectActiveIsolatedLineCards(storageSummary, 3);
|
||||
|
||||
await page.getByRole("tab", { name: "Physical Disks" }).click();
|
||||
const storageDiskRow = page.locator("tr[data-row-id]").first();
|
||||
await expect(storageDiskRow).toBeVisible();
|
||||
const storageDiskRowId = await readSummarySeriesId(
|
||||
storageDiskRow,
|
||||
const storageDiskRows = page.locator("tr[data-row-id]");
|
||||
const firstStorageDiskRow = storageDiskRows.first();
|
||||
await expect(firstStorageDiskRow).toBeVisible();
|
||||
const { resourceId: storageDiskRowId } = await hoverRowUntilSummaryHighlights(
|
||||
page,
|
||||
storageDiskRows,
|
||||
storageSummary,
|
||||
"data-row-id",
|
||||
1,
|
||||
);
|
||||
expect(storageDiskRowId).not.toBe("");
|
||||
await storageDiskRow.hover();
|
||||
await expectSummaryHighlightCount(storageSummary, storageDiskRowId, 1);
|
||||
await expectActiveIsolatedLineCards(storageSummary, 1);
|
||||
|
||||
await scrollPrimaryViewportToBottom(page);
|
||||
|
|
@ -227,4 +339,55 @@ test.describe.serial("Summary hover selection", () => {
|
|||
expect(stickyBox).not.toBeNull();
|
||||
expect(stickyBox?.y ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(24);
|
||||
});
|
||||
|
||||
test("synchronizes chart hover across summary cards on infrastructure, workloads, and storage", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("mobile-"),
|
||||
"Desktop runtime proof",
|
||||
);
|
||||
|
||||
await ensureMockModeEnabled(page);
|
||||
|
||||
await page.goto("/infrastructure", { waitUntil: "domcontentloaded" });
|
||||
const infrastructureSummary = page.getByTestId("infrastructure-summary");
|
||||
await expect(infrastructureSummary).toBeVisible();
|
||||
const infrastructureChartId = await hoverSummaryChartUntilActiveId(
|
||||
page,
|
||||
infrastructureSummary,
|
||||
infrastructureSummary.locator('[data-active-series-display="isolate"]').first(),
|
||||
);
|
||||
expect(infrastructureChartId).not.toBe("");
|
||||
await expectSummaryHighlightCount(
|
||||
infrastructureSummary,
|
||||
infrastructureChartId,
|
||||
4,
|
||||
);
|
||||
await expectActiveIsolatedLineCards(infrastructureSummary, 2);
|
||||
|
||||
await page.goto("/workloads", { waitUntil: "domcontentloaded" });
|
||||
const workloadsSummary = page.getByTestId("workloads-summary");
|
||||
await expect(workloadsSummary).toBeVisible();
|
||||
const workloadChartId = await hoverSummaryChartUntilActiveId(
|
||||
page,
|
||||
workloadsSummary,
|
||||
workloadsSummary.locator('[data-active-series-display="isolate"]').first(),
|
||||
);
|
||||
expect(workloadChartId).not.toBe("");
|
||||
await expectSummaryHighlightCount(workloadsSummary, workloadChartId, 4);
|
||||
await expectActiveIsolatedLineCards(workloadsSummary, 2);
|
||||
|
||||
await page.goto("/storage", { waitUntil: "domcontentloaded" });
|
||||
const storageSummary = page.getByTestId("storage-summary");
|
||||
await expect(storageSummary).toBeVisible();
|
||||
const storageChartId = await hoverSummaryChartUntilActiveId(
|
||||
page,
|
||||
storageSummary,
|
||||
storageSummary.locator('[data-active-series-display="isolate"]').first(),
|
||||
);
|
||||
expect(storageChartId).not.toBe("");
|
||||
await expectSummaryHighlightCount(storageSummary, storageChartId, 3);
|
||||
await expectActiveIsolatedLineCards(storageSummary, 3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue