diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5c7df3636..a6d2d03c3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/src/components/shared/PulseDataGrid.tsx b/frontend-modern/src/components/shared/PulseDataGrid.tsx index 7e4b39a4e..d6eb03b5f 100644 --- a/frontend-modern/src/components/shared/PulseDataGrid.tsx +++ b/frontend-modern/src/components/shared/PulseDataGrid.tsx @@ -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 { - 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 { - /** The rows of data to display */ - data: T[]; - /** Definitions for each column */ - columns: TableColumn[]; - - /** - * 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(props: PulseDataGridProps) { 'class', ]); - const { isMobile } = useBreakpoint(); - type StableRow = { - __pulseKey: string | number; - value: T; - }; - const [stableRows, setStableRows] = createStore([]); - - 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 (
@@ -143,7 +51,7 @@ export function PulseDataGrid(props: PulseDataGridProps) { > - +
@@ -152,7 +60,7 @@ export function PulseDataGrid(props: PulseDataGridProps) { 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(props: PulseDataGridProps) { 0}> - + {(stableRow) => { const row = () => stableRow.value; const expanded = createMemo(() => local.isRowExpanded?.(row())); @@ -182,7 +90,7 @@ export function PulseDataGrid(props: PulseDataGridProps) { } `} onClick={(event) => { - if (isInteractiveTarget(event.target)) { + if (isPulseDataGridInteractiveTarget(event.target)) { return; } local.onRowClick?.(row()); @@ -194,7 +102,7 @@ export function PulseDataGrid(props: PulseDataGridProps) { 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' : ''} `} > diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index aae6dfaa7..2adb6c9e1 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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('); + }); }); diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx index fb0a444fe..a7fc32ae0 100644 --- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx @@ -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(); diff --git a/frontend-modern/src/components/shared/pulseDataGridModel.ts b/frontend-modern/src/components/shared/pulseDataGridModel.ts new file mode 100644 index 000000000..6fe49711e --- /dev/null +++ b/frontend-modern/src/components/shared/pulseDataGridModel.ts @@ -0,0 +1,80 @@ +import type { JSX } from 'solid-js'; + +export interface TableColumn { + 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 { + /** The rows of data to display */ + data: T[]; + /** Definitions for each column */ + columns: TableColumn[]; + + /** + * 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 = { + __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]', + ), + ); diff --git a/frontend-modern/src/components/shared/usePulseDataGridState.ts b/frontend-modern/src/components/shared/usePulseDataGridState.ts new file mode 100644 index 000000000..b7476a4b0 --- /dev/null +++ b/frontend-modern/src/components/shared/usePulseDataGridState.ts @@ -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 = Pick< + PulseDataGridProps, + 'data' | 'keyExtractor' | 'desktopMinWidth' | 'mobileMinWidth' +>; + +export function usePulseDataGridState(options: PulseDataGridStateOptions) { + const { isMobile } = useBreakpoint(); + const [stableRows, setStableRows] = createStore[]>([]); + + 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, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 34ffe0e85..d0ca63573 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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 => {', );