Split pulse data grid runtime owners

This commit is contained in:
rcourtman 2026-03-23 02:11:38 +00:00
parent ca69dccaa9
commit 199463df4d
7 changed files with 197 additions and 106 deletions

View file

@ -256,6 +256,14 @@ selection, and `frontend-modern/src/components/shared/commandPaletteModel.ts`
owns canonical command construction plus query normalization and filtering
policy. Future command-palette work should extend those owners instead of
pushing route construction or search policy back into the shared shell.
The shared pulse data grid now follows that same owner split.
`frontend-modern/src/components/shared/PulseDataGrid.tsx` stays the render
shell, `frontend-modern/src/components/shared/usePulseDataGridState.ts` owns
breakpoint-driven min-width selection and stable-row reconciliation, and
`frontend-modern/src/components/shared/pulseDataGridModel.ts` owns alignment
class policy plus interactive-target row-click protection. Future pulse-data-
grid work should extend those owners instead of pushing breakpoint lifecycle or
interaction policy back into the shared shell.
The audit log settings surface now follows that same owner split.
`frontend-modern/src/components/Settings/AuditLogPanel.tsx` stays the canonical

View file

@ -1,6 +1,4 @@
import { JSX, For, Show, createEffect, createMemo, splitProps } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { For, Show, createMemo, splitProps } from 'solid-js';
import {
Table,
TableHeader,
@ -9,60 +7,14 @@ import {
TableHead,
TableCell,
} from '@/components/shared/Table';
import {
getPulseDataGridAlignClass,
isPulseDataGridInteractiveTarget,
type PulseDataGridProps,
} from './pulseDataGridModel';
import { usePulseDataGridState } from './usePulseDataGridState';
export interface TableColumn<T> {
key: keyof T | string;
label: string;
/** Custom render function for the cell content. Useful for badges, links, or complex nested data */
render?: (row: T) => JSX.Element;
/** Optional alignment for the header and cell content */
align?: 'left' | 'center' | 'right';
/** Optional fixed width or width class */
width?: string;
/** Hidden on mobile via CSS class */
hiddenOnMobile?: boolean;
}
export interface PulseDataGridProps<T> {
/** The rows of data to display */
data: T[];
/** Definitions for each column */
columns: TableColumn<T>[];
/**
* A unique identifier function for each row to help SolidJS optimize rendering.
* Typically `(row) => row.id`
*/
keyExtractor: (row: T) => string | number;
/** Triggers when a row is clicked. */
onRowClick?: (row: T) => void;
/** What to display when the data array is empty */
emptyState?: JSX.Element;
/** Set to true to show a loading state */
isLoading?: boolean;
/** Determines if the current row should be expanded */
isRowExpanded?: (row: T) => boolean;
/** Render function for the expanded content of a row */
expandedRender?: (row: T) => JSX.Element;
/** Minimum width on desktop before horizontal scrolling kicks in */
desktopMinWidth?: string;
/**
* Minimum width on mobile.
* Defaults to '100%' so the table flexes into horizontal scroll natively
* without artificially breaking the screen width.
*/
mobileMinWidth?: string;
/** Custom classes applied to the root container */
class?: string;
}
export type { PulseDataGridProps, TableColumn } from './pulseDataGridModel';
/**
* A standardized, responsive datagrid component for Pulse.
@ -85,51 +37,7 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
'class',
]);
const { isMobile } = useBreakpoint();
type StableRow = {
__pulseKey: string | number;
value: T;
};
const [stableRows, setStableRows] = createStore<StableRow[]>([]);
const effectiveMinWidth = createMemo(() => {
if (isMobile()) {
return local.mobileMinWidth ?? '100%';
}
return local.desktopMinWidth ?? '800px';
});
const getAlignClass = (align?: 'left' | 'center' | 'right') => {
switch (align) {
case 'center':
return 'text-center justify-center';
case 'right':
return 'text-right justify-end';
case 'left':
default:
return 'text-left justify-start';
}
};
const isInteractiveTarget = (target: EventTarget | null) =>
target instanceof Element &&
Boolean(
target.closest(
'button, a, input, select, textarea, summary, [role="button"], [data-row-action]',
),
);
createEffect(() => {
setStableRows(
reconcile(
local.data.map((row) => ({
__pulseKey: local.keyExtractor(row),
value: row,
})),
{ key: '__pulseKey' },
),
);
});
const grid = usePulseDataGridState(local);
return (
<div class={`overflow-hidden rounded-md border border-border bg-surface ${local.class || ''}`}>
@ -143,7 +51,7 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
>
<style>{`.overflow-x-auto::-webkit-scrollbar { display: none; }`}</style>
<Table class="w-full border-collapse" style={{ 'min-width': effectiveMinWidth() }}>
<Table class="w-full border-collapse" style={{ 'min-width': grid.effectiveMinWidth() }}>
<TableHeader class="bg-surface-alt border-b border-border">
<TableRow>
<For each={local.columns}>
@ -152,7 +60,7 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
class={`
px-3 sm:px-4 py-2.5
text-[11px] sm:text-xs font-semibold uppercase tracking-wider whitespace-nowrap text-muted
${getAlignClass(col.align)}
${getPulseDataGridAlignClass(col.align)}
${col.hiddenOnMobile ? 'hidden sm:table-cell' : ''}
`}
style={col.width ? { width: col.width } : {}}
@ -165,7 +73,7 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
</TableHeader>
<TableBody class="divide-y divide-border transition-colors">
<Show when={!local.isLoading && local.data.length > 0}>
<For each={stableRows}>
<For each={grid.stableRows}>
{(stableRow) => {
const row = () => stableRow.value;
const expanded = createMemo(() => local.isRowExpanded?.(row()));
@ -182,7 +90,7 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
}
`}
onClick={(event) => {
if (isInteractiveTarget(event.target)) {
if (isPulseDataGridInteractiveTarget(event.target)) {
return;
}
local.onRowClick?.(row());
@ -194,7 +102,7 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
class={`
px-3 sm:px-4 py-2 sm:py-3.5
text-sm text-base-content align-middle
${getAlignClass(col.align)}
${getPulseDataGridAlignClass(col.align)}
${col.hiddenOnMobile ? 'hidden sm:table-cell' : ''}
`}
>

View file

@ -14,6 +14,8 @@ import historyChartModelSource from '@/components/shared/historyChartModel.ts?ra
import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw';
import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw';
import infrastructureSelectorSource from '@/components/shared/InfrastructureSelector.tsx?raw';
import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
@ -31,6 +33,7 @@ import helpIconStateSource from '@/components/shared/useHelpIconState.ts?raw';
import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw';
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw';
@ -328,4 +331,22 @@ describe('shared primitive guardrails', () => {
expect(mobileNavBarModelSource).toContain('getMobileNavAlertBadgeCounts');
expect(mobileNavBarModelSource).toContain('getMobileNavFadeState');
});
it('keeps pulse data grid on shell, runtime, and model owners', () => {
expect(pulseDataGridSource).toContain('usePulseDataGridState');
expect(pulseDataGridSource).toContain('getPulseDataGridAlignClass');
expect(pulseDataGridSource).toContain('isPulseDataGridInteractiveTarget');
expect(pulseDataGridSource).not.toContain('useBreakpoint');
expect(pulseDataGridSource).not.toContain('createStore');
expect(pulseDataGridSource).not.toContain('target.closest(');
expect(pulseDataGridStateSource).toContain('export function usePulseDataGridState');
expect(pulseDataGridStateSource).toContain('useBreakpoint');
expect(pulseDataGridStateSource).toContain('createStore');
expect(pulseDataGridStateSource).toContain('reconcile(');
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridAlignClass');
expect(pulseDataGridModelSource).toContain('export const isPulseDataGridInteractiveTarget');
expect(pulseDataGridModelSource).toContain('target.closest(');
});
});

View file

@ -1,6 +1,9 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
type TestRow = {
@ -13,6 +16,24 @@ describe('PulseDataGrid', () => {
cleanup();
});
it('keeps the shared pulse data grid on shell, runtime, and model owners', () => {
expect(pulseDataGridSource).toContain('usePulseDataGridState');
expect(pulseDataGridSource).toContain('getPulseDataGridAlignClass');
expect(pulseDataGridSource).toContain('isPulseDataGridInteractiveTarget');
expect(pulseDataGridSource).not.toContain('useBreakpoint');
expect(pulseDataGridSource).not.toContain('createStore');
expect(pulseDataGridSource).not.toContain('target.closest(');
expect(pulseDataGridStateSource).toContain('export function usePulseDataGridState');
expect(pulseDataGridStateSource).toContain('useBreakpoint');
expect(pulseDataGridStateSource).toContain('createStore');
expect(pulseDataGridStateSource).toContain('reconcile(');
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridAlignClass');
expect(pulseDataGridModelSource).toContain('export const isPulseDataGridInteractiveTarget');
expect(pulseDataGridModelSource).toContain('target.closest(');
});
it('triggers the row handler when a non-interactive cell is clicked', () => {
const onRowClick = vi.fn();

View file

@ -0,0 +1,80 @@
import type { JSX } from 'solid-js';
export interface TableColumn<T> {
key: keyof T | string;
label: string;
/** Custom render function for the cell content. Useful for badges, links, or complex nested data */
render?: (row: T) => JSX.Element;
/** Optional alignment for the header and cell content */
align?: 'left' | 'center' | 'right';
/** Optional fixed width or width class */
width?: string;
/** Hidden on mobile via CSS class */
hiddenOnMobile?: boolean;
}
export interface PulseDataGridProps<T> {
/** The rows of data to display */
data: T[];
/** Definitions for each column */
columns: TableColumn<T>[];
/**
* A unique identifier function for each row to help SolidJS optimize rendering.
* Typically `(row) => row.id`
*/
keyExtractor: (row: T) => string | number;
/** Triggers when a row is clicked. */
onRowClick?: (row: T) => void;
/** What to display when the data array is empty */
emptyState?: JSX.Element;
/** Set to true to show a loading state */
isLoading?: boolean;
/** Determines if the current row should be expanded */
isRowExpanded?: (row: T) => boolean;
/** Render function for the expanded content of a row */
expandedRender?: (row: T) => JSX.Element;
/** Minimum width on desktop before horizontal scrolling kicks in */
desktopMinWidth?: string;
/**
* Minimum width on mobile.
* Defaults to '100%' so the table flexes into horizontal scroll natively
* without artificially breaking the screen width.
*/
mobileMinWidth?: string;
/** Custom classes applied to the root container */
class?: string;
}
export type PulseDataGridStableRow<T> = {
__pulseKey: string | number;
value: T;
};
export const getPulseDataGridAlignClass = (align?: 'left' | 'center' | 'right') => {
switch (align) {
case 'center':
return 'text-center justify-center';
case 'right':
return 'text-right justify-end';
case 'left':
default:
return 'text-left justify-start';
}
};
export const isPulseDataGridInteractiveTarget = (target: EventTarget | null) =>
target instanceof Element &&
Boolean(
target.closest(
'button, a, input, select, textarea, summary, [role="button"], [data-row-action]',
),
);

View file

@ -0,0 +1,38 @@
import { createEffect, createMemo } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import type { PulseDataGridProps, PulseDataGridStableRow } from './pulseDataGridModel';
type PulseDataGridStateOptions<T> = Pick<
PulseDataGridProps<T>,
'data' | 'keyExtractor' | 'desktopMinWidth' | 'mobileMinWidth'
>;
export function usePulseDataGridState<T>(options: PulseDataGridStateOptions<T>) {
const { isMobile } = useBreakpoint();
const [stableRows, setStableRows] = createStore<PulseDataGridStableRow<T>[]>([]);
const effectiveMinWidth = createMemo(() => {
if (isMobile()) {
return options.mobileMinWidth ?? '100%';
}
return options.desktopMinWidth ?? '800px';
});
createEffect(() => {
setStableRows(
reconcile(
options.data.map((row) => ({
__pulseKey: options.keyExtractor(row),
value: row,
})),
{ key: '__pulseKey' },
),
);
});
return {
effectiveMinWidth,
stableRows,
};
}

View file

@ -21,6 +21,8 @@ import historyChartModelSource from '@/components/shared/historyChartModel.ts?ra
import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw';
import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw';
import infrastructureSelectorSource from '@/components/shared/InfrastructureSelector.tsx?raw';
import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
@ -33,6 +35,7 @@ import helpIconStateSource from '@/components/shared/useHelpIconState.ts?raw';
import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw';
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw';
import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw';
@ -2635,6 +2638,18 @@ describe('frontend resource type boundaries', () => {
expect(commandPaletteModelSource).toContain('buildCommandPaletteCommands');
expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery');
expect(commandPaletteModelSource).toContain('filterCommandPaletteCommands');
expect(pulseDataGridSource).toContain('usePulseDataGridState');
expect(pulseDataGridSource).toContain('getPulseDataGridAlignClass');
expect(pulseDataGridSource).toContain('isPulseDataGridInteractiveTarget');
expect(pulseDataGridSource).not.toContain('useBreakpoint');
expect(pulseDataGridSource).not.toContain('createStore');
expect(pulseDataGridSource).not.toContain('target.closest(');
expect(pulseDataGridStateSource).toContain('useBreakpoint');
expect(pulseDataGridStateSource).toContain('createStore');
expect(pulseDataGridStateSource).toContain('reconcile(');
expect(pulseDataGridModelSource).toContain('getPulseDataGridAlignClass');
expect(pulseDataGridModelSource).toContain('isPulseDataGridInteractiveTarget');
expect(pulseDataGridModelSource).toContain('target.closest(');
expect(infrastructureSummaryModelSource).not.toContain(
'const asTrimmedString = (value: unknown): string | null => {',
);