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); }); 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..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 @@ -84,8 +84,13 @@ 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 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 { @@ -152,15 +157,94 @@ 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()) { + 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; + } + + 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 { + 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(' ') @@ -190,8 +274,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 ca3818522b..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 @@ -28,6 +28,67 @@ 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('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('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() }); 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..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'; @@ -13,11 +13,13 @@ interface Props { allColumnsSized?: boolean; + fullWidthSummary?: boolean; kind: 'event' | 'stack'; onRowClick: (row: TestSummary) => void; + sizedFullWidthSummary?: boolean; } - let { allColumnsSized = 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.', @@ -48,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', @@ -62,7 +73,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 }, @@ -73,7 +88,7 @@ } ], get defaultColumnSizing() { - return allColumnsSized ? { date: 130, summary: 140 } : undefined; + return getDefaultColumnSizing(); }, enableColumnResizing: true, paginationStrategy: 'memory',