mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-14 08:20:11 +00:00
Split pulse data grid runtime owners
This commit is contained in:
parent
ca69dccaa9
commit
199463df4d
7 changed files with 197 additions and 106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' : ''}
|
||||
`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
80
frontend-modern/src/components/shared/pulseDataGridModel.ts
Normal file
80
frontend-modern/src/components/shared/pulseDataGridModel.ts
Normal 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]',
|
||||
),
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 => {',
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue