From 6805d8898011508864f8eb5b8db938d1e368a247 Mon Sep 17 00:00:00 2001 From: Ousmane Samba Date: Mon, 6 Jul 2026 09:27:23 +0200 Subject: [PATCH] Standardize row height and cell alignment in EntityDataTable --- .../common/EntityDataTable/Constants.ts | 1 + .../DefaultColumnRenderers.tsx | 6 ++ .../EntityDataTable/EntityDataTable.test.tsx | 10 +++ .../EntityTableOverrideRow.tsx | 9 ++- .../common/EntityDataTable/Table.tsx | 65 +++++++++++++++---- .../hooks/useActionsColumnDefinition.tsx | 2 +- .../hooks/useAttributeColumnDefinitions.tsx | 48 ++++++++++---- .../common/EntityDataTable/types.ts | 5 +- .../events/events/ColumnRenderers.tsx | 1 + .../lookup-table-list/column-renderers.tsx | 2 + .../StreamsOverview/ColumnRenderers.tsx | 2 + .../StreamsOverview/cells/TitleCell.tsx | 37 ++++++++--- 12 files changed, 150 insertions(+), 38 deletions(-) diff --git a/graylog2-web-interface/src/components/common/EntityDataTable/Constants.ts b/graylog2-web-interface/src/components/common/EntityDataTable/Constants.ts index c7ab0bef7849..5c638aeba346 100644 --- a/graylog2-web-interface/src/components/common/EntityDataTable/Constants.ts +++ b/graylog2-web-interface/src/components/common/EntityDataTable/Constants.ts @@ -16,6 +16,7 @@ */ export const CELL_PADDING = 5; // px +export const ROW_MIN_HEIGHT = 38; // px, keeps sparse rows (e.g. bulk-select only) from collapsing export const DEFAULT_COL_MIN_WIDTH = 150; // px export const DEFAULT_COL_WIDTH = 1; // fraction, similar to CSS unit fr. export const MORE_ACTIONS_TITLE = 'More'; diff --git a/graylog2-web-interface/src/components/common/EntityDataTable/DefaultColumnRenderers.tsx b/graylog2-web-interface/src/components/common/EntityDataTable/DefaultColumnRenderers.tsx index 39253a9158b1..adfb59c7d11d 100644 --- a/graylog2-web-interface/src/components/common/EntityDataTable/DefaultColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/common/EntityDataTable/DefaultColumnRenderers.tsx @@ -31,6 +31,12 @@ const DefaultColumnRenderers = { }, }, attributes: { + title: { + textAlign: 'left' as const, + }, + name: { + textAlign: 'left' as const, + }, description: { width: 2, }, diff --git a/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.test.tsx b/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.test.tsx index de0624904e8b..b9064f1582f2 100644 --- a/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.test.tsx +++ b/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.test.tsx @@ -108,6 +108,16 @@ describe('', () => { await screen.findByText('Entity description'); }); + it('should center cell content, except for title cells', async () => { + render(); + + const statusCell = await screen.findByRole('cell', { name: 'enabled' }); + const titleCell = await screen.findByRole('cell', { name: 'Entity title' }); + + expect(statusCell).toHaveStyle({ textAlign: 'center' }); + expect(titleCell).toHaveStyle({ textAlign: 'left' }); + }); + it('should render custom cell and header renderer', async () => { render( css` table-layout: fixed; margin-bottom: 0; - height: 100%; // required to be able to use height: 100% in td + height: 100%; /* required to be able to use height: 100% in td */ tbody > tr.active { background-color: ${theme.colors.table.row.backgroundStriped} !important; @@ -52,34 +52,63 @@ const StyledTable = styled(BaseTable)( `, ); +const Tr = styled.tr` + height: ${ROW_MIN_HEIGHT}px; /* standardizes row height, acts as a minimum in table layout */ +`; + const Td = styled.td<{ $colId: string; $hidePadding: boolean; $pinningPosition: ColumnPinningPosition; + $showDivider: boolean; + $textAlign: 'left' | 'center' | 'right' | undefined; + $wrapContent: boolean; }>( - ({ $colId, $hidePadding, $pinningPosition }) => css` - word-break: break-word; + ({ $colId, $hidePadding, $pinningPosition, $showDivider, $textAlign, $wrapContent, theme }) => css` opacity: var(${columnOpacityVar($colId)}, 1); transform: var(${columnTransformVar($colId)}, none); transition: var(${columnTransition()}, none); - height: 100%; // required to be able to use height: 100% in child elements + height: 100%; /* required to be able to use height: 100% in child elements */ + text-align: ${$textAlign ?? 'center'}; + + && { + vertical-align: middle; /* center content vertically (horizontal alignment unchanged) */ + } + + ${$showDivider && + css` + border-right: 1px solid ${theme.colors.table.row.divider}; + `} + ${$pinningPosition ? css` position: sticky; ${$pinningPosition === 'left' ? 'left' : 'right'}: 0; ${ScrollShadow('left')} + &::before { display: var(${displayScrollRightIndicatorVar}, none); } ` : ''} - ${$hidePadding && - css` - && { - padding: 0; - } - `} + ${$hidePadding + ? css` + && { + padding: 0; + } + ` + : css` + ${$wrapContent + ? css` + overflow-wrap: break-word; + ` + : css` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `} + `} `, ); @@ -106,15 +135,23 @@ const Table = ({ {rows.map((row) => { const visibleCells = row.getVisibleCells(); const visibleCellCount = visibleCells.length; - const renderCell = (cell) => { + const renderCell = (cell, index?: number) => { const columnMeta = cell.column.columnDef.meta as ColumnMetaContext; + const nextCell = index === undefined ? undefined : visibleCells[index + 1]; + // Column dividers are only drawn between two attribute columns, so the bulk select + // and the (possibly empty) actions column don't produce stray lines. + const showDivider = + !!nextCell && !UTILITY_COLUMNS.has(cell.column.id) && !UTILITY_COLUMNS.has(nextCell.column.id); return ( + $hidePadding={columnMeta?.hideCellPadding} + $showDivider={showDivider} + $textAlign={columnMeta?.columnRenderer?.textAlign} + $wrapContent={columnMeta?.columnRenderer?.wrapContent}> {flexRender(cell.column.columnDef.cell, cell.getContext())} ); @@ -133,7 +170,7 @@ const Table = ({ /> ) : ( <> - {visibleCells.map(renderCell)} + {visibleCells.map(renderCell)} ( $isEvenRow ? theme.colors.table.row.background : theme.colors.table.row.backgroundStriped, ])}; height: 100%; - align-items: flex-start; + align-items: center; `, ); diff --git a/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useAttributeColumnDefinitions.tsx b/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useAttributeColumnDefinitions.tsx index f1906ba36b2b..3b2be35c8132 100644 --- a/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useAttributeColumnDefinitions.tsx +++ b/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useAttributeColumnDefinitions.tsx @@ -38,6 +38,26 @@ import ActiveSliceColContext from 'components/common/EntityDataTable/contexts/Ac import SortIcon from '../SortIcon'; +// Column drag-to-reorder and resize controls are hidden until the header is hovered or focused, +// reducing visual clutter. Uses opacity (not display) so the reserved width stays stable for +// useHeaderSectionObserver and the label doesn't shift when the control appears. Devices +// without hover (touch) keep the controls always visible, since they could not reveal them. +const HandleSlot = styled.div<{ $forceVisible?: boolean }>( + ({ $forceVisible }) => css` + opacity: ${$forceVisible ? 1 : 0}; + transition: opacity 150ms ease-in-out; + + th:hover &, + th:focus-within & { + opacity: 1; + } + + @media (hover: none) { + opacity: 1; + } + `, +); + export const ThInner = styled.div` display: flex; justify-content: space-between; @@ -119,13 +139,15 @@ const AttributeHeader = ({ {columnMeta?.enableColumnOrdering && ( - + + + )} ({ {ctx.header.column.getCanResize() && ( - + + + )} diff --git a/graylog2-web-interface/src/components/common/EntityDataTable/types.ts b/graylog2-web-interface/src/components/common/EntityDataTable/types.ts index e610aa8c01ab..e7536e57bad3 100644 --- a/graylog2-web-interface/src/components/common/EntityDataTable/types.ts +++ b/graylog2-web-interface/src/components/common/EntityDataTable/types.ts @@ -45,11 +45,14 @@ export type ColumnSchema = { export type ColumnRenderer = { renderCell?: (value: unknown, entity: Entity, meta: Meta, additionalInfo?: unknown) => React.ReactNode; renderHeader?: (title: string) => React.ReactNode; - textAlign?: string; + // Horizontal cell content alignment. Cells are centered by default; title and link cells should be left-aligned. + textAlign?: 'left' | 'center' | 'right'; minWidth?: number; // px width?: number; // fraction of unassigned table width, similar to CSS unit fr. // Uses the rendered title width as the fixed width; or provide a px value, if the title width is too small. staticWidth?: number | 'matchHeader'; + // Opt out of the default single-line truncation for cells whose content needs to wrap (e.g. multi-line renderers). + wrapContent?: boolean; }; export type ColumnRenderersByAttribute = { diff --git a/graylog2-web-interface/src/components/events/events/ColumnRenderers.tsx b/graylog2-web-interface/src/components/events/events/ColumnRenderers.tsx index 21afc7188c49..01307651fc42 100644 --- a/graylog2-web-interface/src/components/events/events/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/events/events/ColumnRenderers.tsx @@ -146,6 +146,7 @@ const CustomColumnRenderers: ColumnRenderers = { ...getGeneralEventAttributeRenderers(), event_definition_id: { width: 0.3, + textAlign: 'left', renderCell: (eventDefinitionId: string, _, meta: EventsAdditionalData) => ( ), diff --git a/graylog2-web-interface/src/components/lookup-tables/lookup-table-list/column-renderers.tsx b/graylog2-web-interface/src/components/lookup-tables/lookup-table-list/column-renderers.tsx index fa910cf304d3..4538c25ac685 100644 --- a/graylog2-web-interface/src/components/lookup-tables/lookup-table-list/column-renderers.tsx +++ b/graylog2-web-interface/src/components/lookup-tables/lookup-table-list/column-renderers.tsx @@ -119,10 +119,12 @@ const columnRenderers: ColumnRenderers , }, data_adapter_id: { width: 0.1, + textAlign: 'left', renderCell: (data_adapter_id: string, _c, meta) => ( ), diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx index 3222dd77e2ec..fd4e07b98050 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx @@ -53,10 +53,12 @@ const customColumnRenderers = ( title: { renderCell: (_title: string, stream) => , width: 0.5, + wrapContent: true, }, index_set_title: { renderCell: (_index_set_title: string, stream) => , width: 0.3, + textAlign: 'left', }, throughput: { renderCell: (_throughput: string, stream) => , diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/cells/TitleCell.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/cells/TitleCell.tsx index 983bd08684c1..22eaab925291 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/cells/TitleCell.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/cells/TitleCell.tsx @@ -30,22 +30,41 @@ const DefaultLabel = styled(Label)` vertical-align: inherit; `; +const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + min-height: 2lh; /* reserve two lines so rows with and without description have the same height */ +`; + +const TitleRow = styled.div` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + const StyledText = styled(Text)( ({ theme }) => css` color: ${theme.colors.text.secondary}; + line-height: inherit; /* match the title line, so two lines fill the container exactly */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; `, ); const TitleCell = ({ stream }: Props) => ( - <> - {stream.title} - {stream.is_default && ( - - Default - - )} - {stream.description} - + + + {stream.title} + {stream.is_default && ( + + Default + + )} + + {stream.description && {stream.description}} + ); export default TitleCell;