From bbb49cc12084aea929b92c62c64c2343782e4111 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 12 Aug 2026 19:04:11 -0500 Subject: [PATCH 1/4] Fix Sessions grid column overlap --- .../components/session-table-columns.test.ts | 16 ++++++++++++++++ .../sessions/components/session-table-columns.ts | 6 +++++- .../components/data-table/data-table-body.svelte | 3 ++- .../data-table/data-table-body.svelte.test.ts | 10 ++++++++++ .../data-table-body.test-harness.svelte | 9 +++++++-- 5 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts new file mode 100644 index 0000000000..28d20657e2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { getSessionColumns } from './session-table-columns'; + +describe('getSessionColumns', () => { + it('marks Summary as the flexible full-width column', () => { + const summaryColumn = getSessionColumns().find((column) => column.id === 'summary'); + + expect(summaryColumn).toMatchObject({ + header: 'Summary', + meta: { + class: 'w-full' + } + }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts index cc3e1cd8d9..3b7d9ca69a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts @@ -36,7 +36,11 @@ export function getSessionColumns(): ColumnDef renderComponent(Summary, { showStatus: false, showType: false, summary: prop.row.original }), enableHiding: false, - header: 'Summary' + header: 'Summary', + id: 'summary', + meta: { + class: 'w-full' + } }, { cell: (prop) => renderComponent(SessionDurationCell, { summary: prop.row.original }), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 790ce234d7..255f89fd79 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -85,7 +85,8 @@ function getFlexibleDataColumnId(): string | undefined { const columnSizing = table.atoms.columnSizing?.get() ?? {}; const unsizedColumns = getVisibleDataColumns().filter((column) => columnSizing[column.id] === undefined); - return unsizedColumns.at(-1)?.id; + const fullWidthColumn = unsizedColumns.find((column) => getMetaClass(column.columnDef.meta).split(' ').includes('w-full')); + return fullWidthColumn?.id ?? unsizedColumns.at(-1)?.id; } function getVisibleDataColumnCount(): number { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts index ca3818522b..e2c8532542 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts @@ -28,6 +28,16 @@ describe('DataTableBody', () => { expect(dateHeader.style.cssText).toBe('width: 130px; min-width: 130px; max-width: 130px;'); }); + it('uses the full-width data column as the flexible column', () => { + render(DataTableBodyTestHarness, { fullWidthSummary: true, kind: 'event', onRowClick: vi.fn() }); + + const summaryHeader = screen.getByRole('columnheader', { name: 'Summary' }); + const dateHeader = screen.getByRole('columnheader', { name: 'Date' }); + + expect(summaryHeader.style.cssText).toBe('width: 100%;'); + expect(dateHeader.style.cssText).toBe('width: 150px; min-width: 150px; max-width: 150px;'); + }); + it('lets a resized header shrink below its metadata width', () => { render(DataTableBodyTestHarness, { kind: 'event', onRowClick: vi.fn() }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte index a9ebb795be..af8fd4620c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte @@ -13,11 +13,12 @@ interface Props { allColumnsSized?: boolean; + fullWidthSummary?: boolean; kind: 'event' | 'stack'; onRowClick: (row: TestSummary) => void; } - let { allColumnsSized = false, kind, onRowClick }: Props = $props(); + let { allColumnsSized = false, fullWidthSummary = false, kind, onRowClick }: Props = $props(); const summaryData = { Message: 'Unexpected end of Stream, the content may have already been read by another component.', @@ -62,7 +63,11 @@ cell: (props) => renderComponent(Summary, { showStatus: false, summary: props.row.original }), header: 'Summary', id: 'summary', - meta: { class: 'w-60 min-w-60 max-w-60' }, + meta: { + get class() { + return fullWidthSummary ? 'w-full' : 'w-60 min-w-60 max-w-60'; + } + }, minSize: 120, size: 160 }, From 5114bef1948dafbfb040952a653dc2e6efa3ca26 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 12 Aug 2026 23:29:48 -0500 Subject: [PATCH 2/4] Preserve flexible column resizing --- .../data-table/data-table-body.svelte | 38 ++++++++++++++++--- .../data-table/data-table-body.svelte.test.ts | 23 +++++++++++ .../data-table-body.test-harness.svelte | 16 ++++++-- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 255f89fd79..670d8d59b5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -84,9 +84,13 @@ function getFlexibleDataColumnId(): string | undefined { const columnSizing = table.atoms.columnSizing?.get() ?? {}; - const unsizedColumns = getVisibleDataColumns().filter((column) => columnSizing[column.id] === undefined); - const fullWidthColumn = unsizedColumns.find((column) => getMetaClass(column.columnDef.meta).split(' ').includes('w-full')); - return fullWidthColumn?.id ?? unsizedColumns.at(-1)?.id; + const visibleDataColumns = getVisibleDataColumns(); + const fullWidthColumns = visibleDataColumns.filter((column) => getMetaClass(column.columnDef.meta).split(' ').includes('w-full')); + if (fullWidthColumns.length > 0) { + return fullWidthColumns.find((column) => columnSizing[column.id] === undefined)?.id; + } + + return visibleDataColumns.filter((column) => columnSizing[column.id] === undefined).at(-1)?.id; } function getVisibleDataColumnCount(): number { @@ -153,15 +157,37 @@ event.preventDefault(); event.stopPropagation(); const delta = event.key === 'ArrowLeft' ? -16 : 16; + const currentSize = getResizeStartSize(event, header); table.setColumnSizing((current) => ({ ...current, [header.column.id]: Math.min( header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER, - Math.max(header.column.columnDef.minSize ?? 20, header.column.getSize() + delta) + Math.max(header.column.columnDef.minSize ?? 20, currentSize + delta) ) })); } + function onResizeStart(event: MouseEvent | TouchEvent, header: Header): void { + const currentSize = getResizeStartSize(event, header); + if (currentSize !== header.column.getSize()) { + table.setColumnSizing((current) => ({ + ...current, + [header.column.id]: currentSize + })); + } + + header.getResizeHandler()(event); + } + + function getResizeStartSize(event: KeyboardEvent | MouseEvent | TouchEvent, header: Header): number { + if (header.column.id !== getFlexibleDataColumnId()) { + return header.column.getSize(); + } + + const headerElement = (event.currentTarget as HTMLElement | null)?.closest('th'); + return headerElement?.getBoundingClientRect().width || header.column.getSize(); + } + function removeWidthClasses(className: string): string { return className .split(' ') @@ -191,8 +217,8 @@ ]} ondblclick={() => header.column.resetSize()} onkeydown={(event) => onResizeKeydown(event, header)} - onmousedown={header.getResizeHandler()} - ontouchstart={header.getResizeHandler()} + onmousedown={(event) => onResizeStart(event, header)} + ontouchstart={(event) => onResizeStart(event, header)} title={`Resize ${header.column.id} column`} type="button" > diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts index e2c8532542..4e3376f37b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts @@ -38,6 +38,29 @@ describe('DataTableBody', () => { expect(dateHeader.style.cssText).toBe('width: 150px; min-width: 150px; max-width: 150px;'); }); + it('does not transfer flexibility after the full-width column is sized', () => { + render(DataTableBodyTestHarness, { fullWidthSummary: true, kind: 'event', onRowClick: vi.fn(), sizedFullWidthSummary: true }); + + const table = screen.getByRole('table'); + const summaryHeader = screen.getByRole('columnheader', { name: 'Summary' }); + const dateHeader = screen.getByRole('columnheader', { name: 'Date' }); + + expect(table.style.cssText).toBe('width: 362px; min-width: 362px;'); + expect(summaryHeader.style.cssText).toBe('width: 180px; min-width: 180px; max-width: 180px;'); + expect(dateHeader.style.cssText).toBe('width: 150px; min-width: 150px; max-width: 150px;'); + }); + + it('resizes a flexible column from its rendered width', async () => { + render(DataTableBodyTestHarness, { fullWidthSummary: true, kind: 'event', onRowClick: vi.fn() }); + + const summaryHeader = screen.getByRole('columnheader', { name: 'Summary' }); + vi.spyOn(summaryHeader, 'getBoundingClientRect').mockReturnValue({ width: 300 } as DOMRect); + + await fireEvent.keyDown(screen.getByRole('button', { name: 'Resize summary column' }), { key: 'ArrowRight' }); + + expect(summaryHeader.style.cssText).toBe('width: 316px; min-width: 316px; max-width: 316px;'); + }); + it('lets a resized header shrink below its metadata width', () => { render(DataTableBodyTestHarness, { kind: 'event', onRowClick: vi.fn() }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte index af8fd4620c..691c4fbd01 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.test-harness.svelte @@ -5,7 +5,7 @@ import Summary from '$features/events/components/summary/summary.svelte'; import { getSharedTableOptions } from '$features/shared/table.svelte'; import { StackStatus } from '$features/stacks/models'; - import { createTable, renderComponent } from '@tanstack/svelte-table'; + import { type ColumnSizingState, createTable, renderComponent } from '@tanstack/svelte-table'; import DataTableBody from './data-table-body.svelte'; @@ -16,9 +16,10 @@ fullWidthSummary?: boolean; kind: 'event' | 'stack'; onRowClick: (row: TestSummary) => void; + sizedFullWidthSummary?: boolean; } - let { allColumnsSized = false, fullWidthSummary = false, kind, onRowClick }: Props = $props(); + let { allColumnsSized = false, fullWidthSummary = false, kind, onRowClick, sizedFullWidthSummary = false }: Props = $props(); const summaryData = { Message: 'Unexpected end of Stream, the content may have already been read by another component.', @@ -49,6 +50,15 @@ }; const summary: TestSummary = $derived(kind === 'event' ? eventSummary : stackSummary); const queryParameters = { limit: 20, page: 1 }; + + function getDefaultColumnSizing(): ColumnSizingState | undefined { + if (allColumnsSized) { + return { date: 130, summary: 140 }; + } + + return sizedFullWidthSummary ? { summary: 180 } : undefined; + } + const table = createTable( getSharedTableOptions({ columnPersistenceKey: 'row-navigation-test', @@ -78,7 +88,7 @@ } ], get defaultColumnSizing() { - return allColumnsSized ? { date: 130, summary: 140 } : undefined; + return getDefaultColumnSizing(); }, enableColumnResizing: true, paginationStrategy: 'memory', From 4112d2776813d3f7b36c23e18b873410a9e1b7b8 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 12 Aug 2026 23:42:24 -0500 Subject: [PATCH 3/4] Update flexible column E2E sizing --- .../e2e/tests/selection-column-width.e2e.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/selection-column-width.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/selection-column-width.e2e.ts index c602f109e7..1e53eb0df1 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/selection-column-width.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/selection-column-width.e2e.ts @@ -67,12 +67,21 @@ test('row selection column stays fixed at desktop and narrow widths', async ({ e expect(selectionWidths[0]).toBeCloseTo(selectionWidths[1]!, 1); await page.setViewportSize({ height: 900, width: 1440 }); - for (const columnId of ['summary', 'user', 'date']) { + const resizableColumnIds = ['summary', 'user', 'date']; + const columnWidthsBeforeResize = await Promise.all( + resizableColumnIds.map(async (columnId) => { + const resizeHandle = table.getByRole('button', { name: `Resize ${columnId} column` }); + return (await resizeHandle.locator('xpath=ancestor::th[1]').boundingBox())!.width; + }) + ); + + for (const columnId of resizableColumnIds) { await table.getByRole('button', { name: `Resize ${columnId} column` }).press('ArrowRight'); } const explicitlySizedTableBox = await table.boundingBox(); const explicitlySizedSelectionBox = await table.locator('thead th').first().boundingBox(); - expect(explicitlySizedTableBox!.width).toBeCloseTo(816, 1); + const expectedTableWidth = explicitlySizedSelectionBox!.width + columnWidthsBeforeResize.reduce((total, width) => total + width + 16, 0); + expect(explicitlySizedTableBox!.width).toBeCloseTo(expectedTableWidth, 1); expect(explicitlySizedSelectionBox!.width).toBeCloseTo(32, 1); }); From 78975bf49a9345be7699ac4954603ec781f2c550 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 12 Aug 2026 23:59:10 -0500 Subject: [PATCH 4/4] Preserve flexible width on resize clicks --- .../data-table/data-table-body.svelte | 61 ++++++++++++++++++- .../data-table/data-table-body.svelte.test.ts | 28 +++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 670d8d59b5..0a1a22435b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -169,14 +169,71 @@ function onResizeStart(event: MouseEvent | TouchEvent, header: Header): void { const currentSize = getResizeStartSize(event, header); - if (currentSize !== header.column.getSize()) { + if (currentSize === header.column.getSize()) { + header.getResizeHandler()(event); + return; + } + + const startPosition = getClientPosition(event); + const document = (event.currentTarget as HTMLElement | null)?.ownerDocument; + if (startPosition === undefined || !document) { + header.getResizeHandler()(event); + return; + } + + const startEvent = event; + const removePendingListeners = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseEnd); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + document.removeEventListener('touchcancel', onTouchEnd); + }; + + const startResize = (position: number) => { + if (position === startPosition) { + return; + } + + removePendingListeners(); table.setColumnSizing((current) => ({ ...current, [header.column.id]: currentSize })); + header.getResizeHandler()(startEvent); + setColumnSize(header, currentSize + position - startPosition); + }; + + const onMouseMove = (moveEvent: MouseEvent) => startResize(moveEvent.clientX); + const onMouseEnd = () => removePendingListeners(); + const onTouchMove = (moveEvent: TouchEvent) => { + const position = getClientPosition(moveEvent); + if (position !== undefined) { + startResize(position); + } + }; + + const onTouchEnd = () => removePendingListeners(); + + if (event instanceof TouchEvent) { + document.addEventListener('touchmove', onTouchMove); + document.addEventListener('touchend', onTouchEnd); + document.addEventListener('touchcancel', onTouchEnd); + } else { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseEnd); } + } + + function getClientPosition(event: MouseEvent | TouchEvent): number | undefined { + return event instanceof TouchEvent ? event.touches[0]?.clientX : event.clientX; + } - header.getResizeHandler()(event); + function setColumnSize(header: Header, size: number): void { + table.setColumnSizing((current) => ({ + ...current, + [header.column.id]: Math.min(header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER, Math.max(header.column.columnDef.minSize ?? 20, size)) + })); } function getResizeStartSize(event: KeyboardEvent | MouseEvent | TouchEvent, header: Header): number { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts index 4e3376f37b..34b86a4b3e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte.test.ts @@ -61,6 +61,34 @@ describe('DataTableBody', () => { expect(summaryHeader.style.cssText).toBe('width: 316px; min-width: 316px; max-width: 316px;'); }); + it('keeps a flexible column flexible when its resize handle is clicked without dragging', async () => { + render(DataTableBodyTestHarness, { fullWidthSummary: true, kind: 'event', onRowClick: vi.fn() }); + + const summaryHeader = screen.getByRole('columnheader', { name: 'Summary' }); + vi.spyOn(summaryHeader, 'getBoundingClientRect').mockReturnValue({ width: 300 } as DOMRect); + const resizeHandle = screen.getByRole('button', { name: 'Resize summary column' }); + + await fireEvent.mouseDown(resizeHandle, { clientX: 100 }); + await fireEvent.mouseUp(document, { clientX: 100 }); + + expect(summaryHeader.style.cssText).toBe('width: 100%;'); + }); + + it('starts dragging a flexible column from its rendered width', async () => { + render(DataTableBodyTestHarness, { fullWidthSummary: true, kind: 'event', onRowClick: vi.fn() }); + + const summaryHeader = screen.getByRole('columnheader', { name: 'Summary' }); + vi.spyOn(summaryHeader, 'getBoundingClientRect').mockReturnValue({ width: 300 } as DOMRect); + const resizeHandle = screen.getByRole('button', { name: 'Resize summary column' }); + + await fireEvent.mouseDown(resizeHandle, { clientX: 100 }); + await fireEvent.mouseMove(document, { clientX: 116 }); + + expect(summaryHeader.style.cssText).toBe('width: 316px; min-width: 316px; max-width: 316px;'); + + await fireEvent.mouseUp(document, { clientX: 116 }); + }); + it('lets a resized header shrink below its metadata width', () => { render(DataTableBodyTestHarness, { kind: 'event', onRowClick: vi.fn() });