diff --git a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs index 550a4078bb..ae589961d3 100644 --- a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs +++ b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace Exceptionless.Core.Models; @@ -18,6 +19,10 @@ public sealed record SavedViewColumnSettings /// Whether the column fills the table's remaining width. Null or false means use fixed-width behavior. public bool? AutoFill { get; set; } + /// Whether cell content wraps onto multiple lines. Null or false means keep content on one line. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Wrap { get; set; } + /// Zero-based display position. Null means use the table default order. [Range(0, MaxPosition)] public int? Position { get; set; } diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts index 029251c335..c9aa5dc035 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts @@ -84,7 +84,7 @@ test('event list and detail effects stay bounded through paging and background c await expect(rowSelection).toBeChecked(); const response = page.waitForResponse((candidate) => isEventListResponse(candidate, e2eScenario.organizationId)); - await page.getByTitle('Return to the first page to refresh results').click(); + await page.getByTitle('Refresh results').click(); expect((await response).ok()).toBe(true); await expect(rowSelection).not.toBeChecked(); }); @@ -263,7 +263,7 @@ async function clickAndWaitForPage( await page.getByRole('button', { name: buttonName }).click(); await expect( page - .getByText(new RegExp(`^Page ${expectedPage} of`)) + .getByLabel(new RegExp(`^Page ${expectedPage} of`)) .filter({ visible: true }) .first() ).toBeVisible(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/rataplan-feedback.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/rataplan-feedback.e2e.ts new file mode 100644 index 0000000000..0c2d6dbde3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/rataplan-feedback.e2e.ts @@ -0,0 +1,314 @@ +import { createReferenceId, expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; +import { createRepresentativeEvent } from '../support/synthetic-event'; + +test('approved Rataplan UI feedback remains fixed', async ({ e2eApi, e2eScenario, page }) => { + const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + await journey.submitRepresentativeEvent(); + + const firstEventId = journey.eventId!; + const wrappedTagValues = ['Authentication', 'Background', 'Critical', 'Customer', 'Production', 'Server', 'TeamOne', 'Version2', 'Web']; + const secondEvent = await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: journey.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: createReferenceId(journey.run, '-second') + }); + expect(secondEvent.stack_id).toBe(journey.stackId); + + await page.route(`**/api/v2/events/${secondEvent.id}*`, async (route) => { + const response = await route.fetch(); + const event = (await response.json()) as { data?: Record }; + const data = { ...event.data }; + delete data['@request']; + await route.fulfill({ json: { ...event, data }, response }); + }); + + for (let index = 0; index < 5; index++) { + const referenceId = createReferenceId(journey.run, `-stack-${index}`); + const event = createRepresentativeEvent({ + appUrl: e2eApi.environment.appUrl, + message: `${journey.message} stack ${index}`, + referenceId, + runId: e2eApi.environment.runId + }); + if (index === 0) { + event.tags = wrappedTagValues; + } + + const data = event.data as Record; + const simpleError = data['@simple_error'] as Record; + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, { + ...event, + data: { + ...data, + '@simple_error': { + ...simpleError, + type: `PlaywrightRataplanException${index}` + } + } + }); + await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, referenceId); + } + + await test.step('event details use the page scroll and preserve the selected tab when the event changes', async () => { + await page.goto(`/next/stack/${journey.stackId}/event/${firstEventId}`); + const overviewTab = page.getByRole('tab', { name: 'Overview' }); + await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + + const stackTrace = page.getByRole('heading', { name: 'Stack Trace' }).locator('xpath=../following-sibling::div[1]'); + await expect(stackTrace).toBeVisible(); + await expect.poll(() => stackTrace.evaluate((element) => element.scrollHeight === element.clientHeight)).toBe(true); + + await page.getByRole('tab', { name: 'Exception' }).click(); + await expect(page.getByRole('tab', { name: 'Exception' })).toHaveAttribute('aria-selected', 'true'); + + const newerEventButton = page.getByRole('button', { name: 'Newer event' }); + const olderEventButton = page.getByRole('button', { name: 'Older event' }); + if (await newerEventButton.isEnabled()) { + await newerEventButton.click(); + } else { + await olderEventButton.click(); + } + + await expect(page).not.toHaveURL(new RegExp(`/event/${firstEventId}(?:[?#]|$)`)); + await expect(page.getByRole('tab', { name: 'Exception' })).toHaveAttribute('aria-selected', 'true'); + + await page.goto(`/next/stack/${journey.stackId}/event/${firstEventId}`); + await page.getByRole('tab', { name: 'Request' }).click(); + await expect(page.getByRole('tab', { name: 'Request' })).toHaveAttribute('aria-selected', 'true'); + + if (await newerEventButton.isEnabled()) { + await newerEventButton.click(); + } else { + await olderEventButton.click(); + } + + await expect(page).toHaveURL(new RegExp(`/event/${secondEvent.id}(?:[?#]|$)`)); + await expect(page.getByRole('tab', { name: 'Request' })).toBeHidden(); + await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + }); + + await test.step('command palette overflow is discoverable and shortcut chips have stronger emphasis', async () => { + await page.keyboard.press('/'); + const commandList = page.locator('[data-slot="command-list"]'); + await expect(commandList).toBeVisible(); + + const commandListState = await commandList.evaluate((element) => ({ + hasHiddenScrollbarClass: element.classList.contains('no-scrollbar'), + hasOverflow: element.scrollHeight > element.clientHeight, + overflowY: getComputedStyle(element).overflowY, + scrollbarWidth: getComputedStyle(element).scrollbarWidth + })); + expect(commandListState).toEqual({ + hasHiddenScrollbarClass: false, + hasOverflow: true, + overflowY: 'auto', + scrollbarWidth: 'thin' + }); + + const shortcut = commandList.locator('[data-slot="command-shortcut"]').first(); + await expect(shortcut).toBeVisible(); + const shortcutStyle = await shortcut.evaluate((element) => { + const style = getComputedStyle(element); + return { + borderTopWidth: style.borderTopWidth, + boxShadow: style.boxShadow, + fontWeight: style.fontWeight + }; + }); + expect(Number(shortcutStyle.fontWeight)).toBeGreaterThanOrEqual(600); + expect(Number.parseFloat(shortcutStyle.borderTopWidth)).toBeGreaterThan(0); + expect(shortcutStyle.boxShadow).not.toBe('none'); + + await commandList.hover(); + await commandList.evaluate((element) => (element.scrollTop = 40)); + await page.screenshot({ path: 'dogfood-output/rataplan-command-palette.png' }); + await page.keyboard.press('Escape'); + }); + + await test.step('manual refresh keeps the current page', async () => { + await page.goto(`/next/stack?project=${e2eScenario.projectId}&limit=5&time=all`); + const pager = page.getByRole('group', { name: 'Table pagination' }); + await expect(pager).toBeVisible(); + await expect(pager.getByRole('button', { name: 'Rows per page' })).toContainText('5 rows'); + await expect(pager.getByLabel('Page 1 of 2')).toBeVisible(); + await expect + .poll(async () => { + const pagerBounds = await pager.boundingBox(); + const footerBounds = await pager.locator('xpath=..').boundingBox(); + return pagerBounds && footerBounds ? footerBounds.x + footerBounds.width - (pagerBounds.x + pagerBounds.width) : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(1); + + const nextPageResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname.includes('/api/v2/organizations/') && url.pathname.endsWith('/events') && url.searchParams.get('page') === '2'; + }); + await page.getByRole('button', { name: 'Go to next page' }).click(); + expect((await nextPageResponse).ok()).toBe(true); + await expect(page).toHaveURL(/[?&]page=2(?:&|$)/); + await expect(pager.getByLabel('Page 2 of 2')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Go to previous page' })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Go to next page' })).toBeDisabled(); + + const refreshResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname.includes('/api/v2/organizations/') && url.pathname.endsWith('/events') && url.searchParams.get('page') === '2'; + }); + await page.getByTitle('Refresh results').click(); + expect((await refreshResponse).ok()).toBe(true); + await expect(page).toHaveURL(/[?&]page=2(?:&|$)/); + await page.screenshot({ path: 'dogfood-output/rataplan-compact-pager.png' }); + }); + + await test.step('row selection clears when navigating between stack views', async () => { + await page.goto(`/next/stack/all?project=${e2eScenario.projectId}&time=all`); + const selectedRow = page.getByRole('row').filter({ hasText: journey.message }).first(); + await expect(selectedRow).toBeVisible({ timeout: 30_000 }); + await selectedRow.getByRole('checkbox', { name: 'Select row' }).click(); + await expect(page.getByText('1 selected', { exact: true })).toBeVisible(); + + await page.getByRole('link', { exact: true, name: 'Most Frequent Errors' }).filter({ visible: true }).first().click(); + await expect(page).toHaveURL(/\/next\/stack\/most-frequent-errors/); + await expect(page.getByText(journey.message).filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('1 selected', { exact: true })).toHaveCount(0); + }); + + await test.step('the fixed-version field has space before the dialog footer', async () => { + await page.goto(`/next/stack?filter=project:${e2eScenario.projectId}&time=all`); + await page.getByRole('checkbox', { name: 'Select row' }).first().click(); + const bulkActionsButton = page.getByRole('button', { name: 'Bulk Actions' }); + const selectionCount = page.getByText('1 selected', { exact: true }); + await expect(selectionCount).toBeVisible(); + await expect + .poll(async () => { + const buttonBounds = await bulkActionsButton.boundingBox(); + const countBounds = await selectionCount.boundingBox(); + return buttonBounds && countBounds ? countBounds.x - (buttonBounds.x + buttonBounds.width) : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(16); + await bulkActionsButton.click(); + await expect(page.locator('[data-slot="dropdown-menu-group-heading"]', { hasText: 'Bulk Actions' })).toHaveCount(0); + await page.getByRole('menuitem', { name: 'Mark Fixed' }).click(); + + const versionField = page.getByRole('textbox', { name: 'Version' }).locator('xpath=..'); + await expect(versionField).toBeVisible(); + await expect.poll(() => versionField.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingBottom))).toBe(16); + await page.screenshot({ path: 'dogfood-output/rataplan-fixed-version-dialog.png' }); + await page.getByRole('button', { name: 'Cancel' }).click(); + }); + + await test.step('message is available as a wrappable event column', async () => { + await page.goto(`/next/stream?project=${e2eScenario.projectId}&time=all`); + await expect(page.getByText(journey.message).filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 }); + + await page.getByTitle('Manage View Settings').click(); + await page.getByRole('menuitem', { name: 'Manage Columns...' }).click(); + await page.getByRole('button', { name: 'Add Message column' }).click(); + await expect(page.getByRole('checkbox', { name: 'Message wrap text' })).toBeVisible(); + await page.getByRole('button', { name: 'Close' }).click(); + }); + + await test.step('renaming a project refreshes cached stack summaries', async () => { + const stackUrl = `/next/stack?filter=project:${e2eScenario.projectId}&time=all`; + await page.goto(stackUrl); + await expect(page.getByText(journey.message).filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 }); + + await page.getByTitle('Manage View Settings').click(); + await page.getByRole('menuitem', { name: 'Manage Columns...' }).click(); + + const summaryWrapCheckbox = page.getByRole('checkbox', { name: 'Summary wrap text' }); + await expect(summaryWrapCheckbox).not.toBeChecked(); + await summaryWrapCheckbox.click(); + await expect(summaryWrapCheckbox).toBeChecked(); + await expect(page.getByRole('checkbox', { name: 'Status wrap text' })).toHaveCount(0); + + await page.getByRole('button', { name: 'Add Tags column' }).click(); + const tagsWrapCheckbox = page.getByRole('checkbox', { name: 'Tags wrap text' }); + await expect(tagsWrapCheckbox).toBeVisible(); + await tagsWrapCheckbox.click(); + await expect(tagsWrapCheckbox).toBeChecked(); + await expect(page.getByRole('checkbox', { name: /wrap text$/ })).toHaveCount(2); + + const removeColumnButton = page.getByRole('button', { name: /^Remove .+ column$/ }).first(); + await expect(removeColumnButton).toBeVisible(); + await expect + .poll(async () => { + const buttonBounds = await removeColumnButton.boundingBox(); + const rowBounds = await removeColumnButton.locator('xpath=..').boundingBox(); + if (!buttonBounds || !rowBounds) { + return Number.POSITIVE_INFINITY; + } + + const buttonCenter = buttonBounds.y + buttonBounds.height / 2; + const rowCenter = rowBounds.y + rowBounds.height / 2; + return Math.abs(buttonCenter - rowCenter); + }) + .toBeLessThanOrEqual(1); + await page.screenshot({ path: 'dogfood-output/rataplan-column-remove-alignment.png' }); + + await page.getByRole('button', { name: 'Add Project column' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + const tagsResizeHandle = page.getByRole('button', { name: 'Resize tags column' }); + for (let index = 0; index < 12; index++) { + await tagsResizeHandle.press('ArrowRight'); + } + + const wrappedSummaryCell = page.locator('td[data-wrap="true"]').filter({ hasText: journey.message }).first(); + await expect(wrappedSummaryCell).toBeVisible(); + const wrappedSummaryStyle = await wrappedSummaryCell.evaluate((cell) => { + const summary = cell.querySelector('.line-clamp-2'); + return { + lineClamp: summary ? getComputedStyle(summary).webkitLineClamp : undefined, + whiteSpace: getComputedStyle(cell).whiteSpace + }; + }); + expect(wrappedSummaryStyle).toEqual({ lineClamp: 'none', whiteSpace: 'normal' }); + + const taggedRow = page + .getByRole('row') + .filter({ hasText: `${journey.message} stack 0` }) + .first(); + const wrappedTags = taggedRow.getByLabel(`Tags: ${wrappedTagValues.join(', ')}`); + await expect(wrappedTags).toBeVisible(); + const tagsCell = wrappedTags.locator('xpath=ancestor::td[1]'); + await expect + .poll(async () => { + const listBounds = await wrappedTags.boundingBox(); + const cellBounds = await tagsCell.boundingBox(); + return listBounds && cellBounds ? listBounds.width / cellBounds.width : 0; + }) + .toBeGreaterThan(0.9); + for (const tag of wrappedTagValues.slice(0, 6)) { + await expect(wrappedTags.getByRole('button', { exact: true, name: tag })).toBeVisible(); + } + await expect(wrappedTags.getByText('+3', { exact: true })).toBeVisible(); + await expect(wrappedTags.getByRole('button', { exact: true, name: wrappedTagValues[6] })).toHaveCount(0); + await page.screenshot({ path: 'dogfood-output/rataplan-wrapped-tags.png' }); + + await expect(page.getByRole('cell', { name: journey.projectName }).first()).toBeVisible(); + + await page.goto(`/next/project/${e2eScenario.projectId}/manage`); + const renamedProject = `${journey.projectName} Renamed`; + const updateResponse = page.waitForResponse( + (response) => response.url().includes(`/api/v2/projects/${e2eScenario.projectId}`) && response.request().method() === 'PATCH' + ); + await page.getByLabel('Project name').fill(renamedProject); + expect((await updateResponse).ok()).toBe(true); + + const refreshedStacks = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.includes('/api/v2/organizations/') && + url.pathname.endsWith('/events') && + url.searchParams.get('mode')?.startsWith('stack') === true + ); + }); + await page.goto(stackUrl); + expect((await refreshedStacks).ok()).toBe(true); + await expect(page.getByRole('cell', { name: renamedProject }).first()).toBeVisible(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts index 3f8a565737..f9683d1dce 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts @@ -122,7 +122,7 @@ test('stack effects stay bounded through background, paging, and navigation chao await expect(rowSelection).toBeChecked(); const response = page.waitForResponse((candidate) => isStackListResponse(candidate, e2eScenario.organizationId)); - await page.getByTitle('Return to the first page to refresh results').click(); + await page.getByTitle('Refresh results').click(); expect((await response).ok()).toBe(true); await expect(rowSelection).not.toBeChecked(); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-data-table.svelte index e8af5e58c0..70e689526c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-data-table.svelte @@ -33,11 +33,7 @@ {/if} - - - - - - + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-data-table.svelte index ff3cea9b9e..8c18129ce8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-data-table.svelte @@ -33,11 +33,7 @@ {/if} - - - - - - + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-data-table.svelte index 98a58fb58f..a4ec5442b6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-data-table.svelte @@ -33,11 +33,7 @@ {/if} - - - - - - + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview-tab-state.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview-tab-state.ts new file mode 100644 index 0000000000..7e65ea8223 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview-tab-state.ts @@ -0,0 +1,3 @@ +export function shouldResetActiveEventTab(eventLoaded: boolean, projectPending: boolean, tabs: readonly string[], activeTab: string): boolean { + return eventLoaded && !projectPending && !tabs.includes(activeTab); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 02fab21656..8bd8a218c6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -30,6 +30,7 @@ import type { PersistentEvent } from '../models/index'; import { getSessionId } from '../utils'; + import { shouldResetActiveEventTab } from './events-overview-tab-state'; import Environment from './views/environment.svelte'; import Error from './views/error.svelte'; import ExtendedData from './views/extended-data.svelte'; @@ -162,6 +163,12 @@ let notifiedEventId = $state(''); let showJsonDialog = $state(false); + $effect(() => { + if (shouldResetActiveEventTab(!!event, projectQuery.isPending, tabs, activeTab)) { + activeTab = 'Overview'; + } + }); + function isPromotedTab(tab: TabType): boolean { return !!projectQuery.data?.promoted_tabs?.includes(tab); } @@ -390,7 +397,7 @@ {#if event} - + {#if canScrollTabsLeft} { + it('waits for project metadata before deciding whether a promoted tab is unavailable', () => { + const activeTab = 'Customer Context'; + + expect(shouldResetActiveEventTab(true, true, ['Overview', 'Exception'], activeTab)).toBe(false); + expect(shouldResetActiveEventTab(true, false, ['Overview', 'Exception', activeTab], activeTab)).toBe(false); + expect(shouldResetActiveEventTab(true, false, ['Overview', 'Exception'], activeTab)).toBe(true); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte index 81711f8886..3009bffa67 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte @@ -9,4 +9,10 @@ let { onTagClick, tags }: Props = $props(); - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts index a9dfb65f42..4256df3d56 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts @@ -4,13 +4,16 @@ import { describe, expect, it } from 'vitest'; import EventTagsSummaryCell from './event-tags-summary-cell.svelte'; describe('EventTagsSummaryCell', () => { - it('shows two tags and summarizes the remaining tags', () => { + it('keeps compact mode to two tags and reveals more when wrapping', () => { render(EventTagsSummaryCell, { tags: ['api', 'production', 'critical', 'customer'] }); expect(screen.getByText('api')).toBeTruthy(); expect(screen.getByText('production')).toBeTruthy(); - expect(screen.getByText('+2')).toBeTruthy(); - expect(screen.queryByText('critical')).toBeNull(); + expect(screen.getByText('+2').closest('[data-slot="tooltip-trigger"]')?.classList).toContain('group-data-[wrap=true]/wrapped:hidden'); + + const thirdTagTrigger = screen.getByText('critical').closest('[data-slot="tooltip-trigger"]'); + expect(thirdTagTrigger?.classList).toContain('hidden'); + expect(thirdTagTrigger?.classList).toContain('group-data-[wrap=true]/wrapped:inline-flex'); expect(screen.getByLabelText('Tags: api, production, critical, customer').getAttribute('title')).toBeNull(); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte index 31dec91e56..fd33957158 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte @@ -54,7 +54,6 @@ - Bulk Actions (openRemoveEventDialog = true)} class="text-destructive" title="Delete event">Delete diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte.test.ts index 67e51e4f63..d2902b9510 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte.test.ts @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mutateAsync = vi.hoisted(() => vi.fn()); const deleteEvent = vi.hoisted(() => vi.fn(() => ({ mutateAsync }))); @@ -17,6 +17,25 @@ describe('EventsBulkActionsDropdownMenu', () => { toast.success.mockClear(); }); + afterEach(async () => { + cleanup(); + // Bits UI defers body-scroll restoration by 24 ms after an overlay unmounts. + await new Promise((resolve) => window.setTimeout(resolve, 30)); + }); + + it('does not repeat the trigger label inside the menu', async () => { + const table = { + getSelectedRowModel: () => ({ flatRows: [{ id: 'event-id' }] }), + resetRowSelection: vi.fn() + } as never; + render(EventsBulkActionsDropdownMenu, { props: { table } }); + + await fireEvent.click(screen.getByRole('button', { name: /Bulk Actions/ })); + + expect(document.querySelector('[data-slot="dropdown-menu-group-heading"]')).toBeNull(); + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeTruthy(); + }); + it('deletes the selected events and clears the selection', async () => { // Arrange const resetRowSelection = vi.fn(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-data-table.svelte index 03564d2ad5..ad05fb4a31 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-data-table.svelte @@ -18,6 +18,7 @@ rowHref?: (row: EventSummaryModel) => string; table: Table>; toolbarChildren?: Snippet; + wrappedColumnIds?: readonly string[]; } let { @@ -30,7 +31,8 @@ rowClick, rowHref, table, - toolbarChildren + toolbarChildren, + wrappedColumnIds = [] }: Props = $props(); @@ -40,7 +42,7 @@ {@render toolbarChildren()} {/if} - + {#if isLoading} @@ -56,20 +58,8 @@ {#if footerChildren} {@render footerChildren()} {:else} - - - - - - - - - - - - - - + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts index 569eb0b4a2..a323f1ca96 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts @@ -1,3 +1,4 @@ +import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { describe, expect, it } from 'vitest'; import type { EventSummaryModel, StackSummaryModel, SummaryTemplateKeys } from '../summary'; @@ -22,7 +23,9 @@ describe('event table columns', () => { const summary = columns.find((column) => column.id === 'summary'); expect(summary).toMatchObject({ enableResizing: true, maxSize: 1200, minSize: 240, size: 480 }); + expect(supportsColumnWrapping(summary?.meta)).toBe(true); expect(project).toMatchObject({ maxSize: 800, minSize: 160, size: 240 }); + expect(supportsColumnWrapping(project?.meta)).toBe(false); expect(select?.enableResizing).toBe(false); }); @@ -35,4 +38,18 @@ describe('event table columns', () => { expect(defaultStackColumnVisibility.project).toBe(false); expect(defaultStackColumnVisibility.tags).toBe(false); }); + + it('allows wrapping only for summary, tags, and message event columns', () => { + const columns = getColumns>(); + const wrappableColumnIds = columns.filter((column) => supportsColumnWrapping(column.meta)).map((column) => column.id); + + expect(wrappableColumnIds).toEqual(['summary', 'tags', 'message']); + }); + + it('allows wrapping only for summary and tags stack columns', () => { + const columns = getColumns>('stack_frequent'); + const wrappableColumnIds = columns.filter((column) => supportsColumnWrapping(column.meta)).map((column) => column.id); + + expect(wrappableColumnIds).toEqual(['summary', 'tags']); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index 93f628f242..1775dde949 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -85,7 +85,8 @@ export function getColumns - + {#if event.data?.['@error']} {:else if event.data?.['@simple_error']} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index e970cc018a..71fa94d9e7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -3,7 +3,9 @@ import type { StringValueFromBody, WorkInProgressResult } from '$features/shared import type { WebSocketMessageValue } from '$features/websockets/models'; import { accessToken } from '$features/auth/index.svelte'; +import { queryKeys as eventQueryKeys } from '$features/events/api.svelte'; import { fetchApiJson } from '$features/shared/api/api.svelte'; +import { queryKeys as stackQueryKeys } from '$features/stacks/api.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; @@ -30,6 +32,19 @@ export async function invalidateProjectQueries(queryClient: QueryClient, message queryKey: queryKeys.projects() }); } + + await invalidateProjectSummaryQueries(queryClient); +} + +export async function invalidateProjectSummaryQueries(queryClient: QueryClient): Promise { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: eventQueryKeys.type + }), + queryClient.invalidateQueries({ + queryKey: stackQueryKeys.type + }) + ]); } // TODO: Do we need to scope these all by organization? @@ -717,8 +732,17 @@ export function updateProject(request: UpdateProjectRequest) { queryKey: queryKeys.id(request.route.id) }); }, - onSuccess: (project: ViewProject) => { + onSuccess: async (project: ViewProject) => { queryClient.setQueryData(queryKeys.id(request.route.id), project); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.organization(project.organization_id) + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.projects() + }), + invalidateProjectSummaryQueries(queryClient) + ]); } })); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.test.ts new file mode 100644 index 0000000000..50257523e7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.test.ts @@ -0,0 +1,31 @@ +import { queryKeys as eventQueryKeys } from '$features/events/api.svelte'; +import { queryKeys as stackQueryKeys } from '$features/stacks/api.svelte'; +import { ChangeType } from '$features/websockets/models'; +import { QueryClient } from '@tanstack/svelte-query'; +import { describe, expect, it, vi } from 'vitest'; + +import { invalidateProjectQueries, queryKeys } from './api.svelte'; + +describe('invalidateProjectQueries', () => { + it('invalidates project caches and summaries that embed the project name', async () => { + // Arrange + const queryClient = new QueryClient(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {}); + + // Act + await invalidateProjectQueries(queryClient, { + change_type: ChangeType.Saved, + data: {}, + id: 'project-id', + organization_id: 'organization-id', + type: 'Project' + }); + + // Assert + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.id('project-id') }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.organization('organization-id') }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.projects() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: eventQueryKeys.type }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: stackQueryKeys.type }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-config-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-config-data-table.svelte index fca48ca792..42708d73be 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-config-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-config-data-table.svelte @@ -39,16 +39,12 @@ {@render bodyChildren()} {/if} - + {#if footerChildren} {@render footerChildren()} {:else} - - - - - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-data-table.svelte index 2f3fac6346..192af09bd3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/projects-data-table.svelte @@ -40,16 +40,12 @@ {@render bodyChildren()} {/if} - + {#if footerChildren} {@render footerChildren()} {:else} - - - - - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts index 7e161acb23..c1687bd639 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.test.ts @@ -9,8 +9,10 @@ import { getSavedColumnOrder, getSavedColumnSizing, getSavedColumnVisibility, + getSavedWrappedColumnIds, savedViewColumnOrderEqual, - savedViewColumnSizingEqual + savedViewColumnSizingEqual, + savedViewColumnWrappingEqual } from './column-settings'; describe('saved view column settings', () => { @@ -20,12 +22,14 @@ describe('saved view column settings', () => { ['select', 'project', 'summary'], { project: true, summary: true }, { project: 360 }, - 'summary' + 'summary', + undefined, + ['summary'] ); expect(result).toEqual({ project: { position: 0, visible: true, width: 360 }, - summary: { auto_fill: true, position: 1, visible: true } + summary: { auto_fill: true, position: 1, visible: true, wrap: true } }); }); @@ -58,11 +62,11 @@ describe('saved view column settings', () => { expect(getSavedAutoFillColumnSelection(legacyFixedDefault, 'summary')).toBeNull(); }); - it('reads order, visibility, and width from structured columns', () => { + it('reads order, visibility, width, and wrapping from structured columns', () => { const view = { columns: { project: { position: 0, visible: true, width: 360 }, - summary: { auto_fill: true, position: 1, visible: false } + summary: { auto_fill: true, position: 1, visible: false, wrap: true } } } as Pick; @@ -70,6 +74,7 @@ describe('saved view column settings', () => { expect(getSavedColumnVisibility(view)).toEqual({ project: true, summary: false }); expect(getSavedColumnSizing(view)).toEqual({ project: 360 }); expect(getSavedAutoFillColumnId(view)).toBe('summary'); + expect(getSavedWrappedColumnIds(view)).toEqual(['summary']); }); it('detects changed and reset column widths', () => { @@ -106,4 +111,29 @@ describe('saved view column settings', () => { expect(savedViewColumnOrderEqual(['select', 'date', 'project'], view)).toBe(true); }); + + it('treats missing wrap settings as the legacy single-line behavior', () => { + const view = { + columns: { + project: { visible: true }, + summary: { visible: true } + } + } as Pick; + + expect(getSavedWrappedColumnIds(view)).toEqual([]); + expect(savedViewColumnWrappingEqual([], view)).toBe(true); + expect(savedViewColumnWrappingEqual(['summary'], view)).toBe(false); + }); + + it('compares wrapped columns without depending on their order', () => { + const view = { + columns: { + project: { wrap: true }, + summary: { wrap: true } + } + } as Pick; + + expect(savedViewColumnWrappingEqual(['summary', 'project'], view)).toBe(true); + expect(savedViewColumnWrappingEqual(['summary'], view)).toBe(false); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts index e6cd83e8b3..70f4de50d9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts @@ -3,6 +3,7 @@ import type { ColumnOrderState, ColumnSizingState, ColumnVisibilityState } from import type { SavedView, SavedViewColumnSettings } from './models'; export type AutoFillColumnSelection = null | string; +export type WrappedColumnIds = string[]; type SavedColumnState = Pick; @@ -12,7 +13,8 @@ export function buildColumnSettings( columnVisibility: ColumnVisibilityState, columnSizing: ColumnSizingState, autoFillColumnId?: AutoFillColumnSelection, - defaultAutoFillColumnId?: string + defaultAutoFillColumnId?: string, + wrappedColumnIds: readonly string[] = [] ): Record { const availableColumnIds = columnIds.filter((columnId) => columnId !== 'select'); const availableColumnIdSet = new Set(availableColumnIds); @@ -31,6 +33,7 @@ export function buildColumnSettings( visible: columnVisibility[columnId] ?? true, ...(columnId === autoFillColumnId && columnVisibility[columnId] !== false && columnSizing[columnId] === undefined ? { auto_fill: true } : {}), ...(columnId === explicitNoneMarkerColumnId ? { auto_fill: false } : {}), + ...(wrappedColumnIds.includes(columnId) ? { wrap: true } : {}), ...(columnSizing[columnId] !== undefined ? { width: Math.round(columnSizing[columnId]) } : {}) } ]) @@ -85,6 +88,12 @@ export function getSavedColumnVisibility(view: SavedColumnState | undefined): Co ); } +export function getSavedWrappedColumnIds(view: SavedColumnState | undefined): WrappedColumnIds { + return Object.entries(view?.columns ?? {}) + .filter(([, settings]) => settings.wrap === true) + .map(([columnId]) => columnId); +} + export function savedViewColumnOrderEqual(current: ColumnOrderState | undefined, view: SavedColumnState): boolean { const savedOrder = getSavedColumnOrder(view); const savedColumnIds = new Set(savedOrder); @@ -100,3 +109,10 @@ export function savedViewColumnSizingEqual(current: ColumnSizingState | undefine return currentEntries.length === savedEntries.length && currentEntries.every(([columnId, width]) => saved[columnId] === Math.round(width)); } + +export function savedViewColumnWrappingEqual(current: readonly string[] | undefined, view: SavedColumnState): boolean { + const saved = getSavedWrappedColumnIds(view); + const currentIds = [...new Set(current ?? [])]; + + return currentIds.length === saved.length && currentIds.every((columnId) => saved.includes(columnId)); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte index cbf5bb8f25..3de5c3c413 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte @@ -9,11 +9,13 @@ import { Badge } from '$comp/ui/badge'; import { Button } from '$comp/ui/button'; + import { Checkbox } from '$comp/ui/checkbox'; import * as Dialog from '$comp/ui/dialog'; import * as InputGroup from '$comp/ui/input-group'; import { Label } from '$comp/ui/label'; import * as RadioGroup from '$comp/ui/radio-group'; import { Separator } from '$comp/ui/separator'; + import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import ChevronDown from '@lucide/svelte/icons/chevron-down'; import ChevronUp from '@lucide/svelte/icons/chevron-up'; import GripVertical from '@lucide/svelte/icons/grip-vertical'; @@ -22,17 +24,19 @@ import Search from '@lucide/svelte/icons/search'; import X from '@lucide/svelte/icons/x'; - import type { AutoFillColumnSelection } from '../column-settings'; + import type { AutoFillColumnSelection, WrappedColumnIds } from '../column-settings'; interface Props { autoFillColumnId: AutoFillColumnSelection; defaultAutoFillColumnId?: string; open: boolean; setAutoFillColumnId: (columnId: AutoFillColumnSelection) => void; + setWrappedColumnIds: (columnIds: WrappedColumnIds) => void; table: Table; + wrappedColumnIds: WrappedColumnIds; } - let { autoFillColumnId, defaultAutoFillColumnId, open = $bindable(), setAutoFillColumnId, table }: Props = $props(); + let { autoFillColumnId, defaultAutoFillColumnId, open = $bindable(), setAutoFillColumnId, setWrappedColumnIds, table, wrappedColumnIds }: Props = $props(); let draggedColumnId = $state(null); let search = $state(''); @@ -91,6 +95,17 @@ return column.getCanHide() && visibleColumns.length > 1; } + function setColumnWrapped(columnId: string, wrapped: boolean): void { + if (wrapped) { + if (!wrappedColumnIds.includes(columnId)) { + setWrappedColumnIds([...wrappedColumnIds, columnId]); + } + return; + } + + setWrappedColumnIds(wrappedColumnIds.filter((id) => id !== columnId)); + } + function moveColumnUp(columnId: string): void { const columnIds = visibleColumns.map((c) => c.id); const index = columnIds.indexOf(columnId); @@ -131,6 +146,7 @@ } else { setAutoFillColumnId(null); } + setWrappedColumnIds([]); search = ''; } @@ -177,7 +193,7 @@ > Column Picker - Select, reorder, and choose which column fills the available table width. + Select, reorder, wrap text, and choose which column fills the available table width. @@ -244,7 +260,7 @@ Selected Columns {visibleColumns.length} - Drag to reorder, or mark one column to auto fill the available width. + Drag to reorder, wrap text, or mark one column to auto fill the available width. @@ -278,7 +294,7 @@ removeColumn(column)} aria-label={`Remove ${getColumnLabel(column)} column`} @@ -286,6 +302,22 @@ {getColumnLabel(column)} + {#if supportsColumnWrapping(column.columnDef.meta)} + + setColumnWrapped(column.id, checked)} + /> + Wrap + + {:else} + + {/if} void; setShowChart?: (show: boolean) => void; setShowStats?: (show: boolean) => void; + setWrappedColumnIds: (columnIds: WrappedColumnIds) => void; showChart?: boolean; showStats?: boolean; sort?: string; table: Table; time?: string; view: string; + wrappedColumnIds: WrappedColumnIds; } let { @@ -84,12 +87,14 @@ setAutoFillColumnId, setShowChart, setShowStats, + setWrappedColumnIds, showChart = true, showStats = true, sort, table, time, - view + view, + wrappedColumnIds }: Props = $props(); let isSaveDialogOpen = $state(false); @@ -164,13 +169,15 @@ } function getSavedColumnSettings() { + const supportedWrappedColumnIds = wrappedColumnIds.filter((columnId) => supportsColumnWrapping(table.getColumn(columnId)?.columnDef.meta)); return buildColumnSettings( table.getAllLeafColumns().map((column) => column.id), columnOrder ?? [], columnVisibility ?? {}, columnSizing ?? {}, autoFillColumnId, - defaultAutoFillColumnId + defaultAutoFillColumnId, + supportedWrappedColumnIds ); } @@ -401,5 +408,13 @@ {/if} {#if isColumnDialogOpen} - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 59627e249f..51c2222596 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -5,7 +5,7 @@ import { goto } from '$app/navigation'; import { buildFilterCacheKey, deserializeFilters, serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { organization } from '$features/organizations/context.svelte'; -import type { AutoFillColumnSelection } from './column-settings'; +import type { AutoFillColumnSelection, WrappedColumnIds } from './column-settings'; import type { SavedView } from './models'; import { getSavedViewsByViewQuery } from './api.svelte'; @@ -14,8 +14,10 @@ import { getSavedColumnOrder, getSavedColumnSizing, getSavedColumnVisibility, + getSavedWrappedColumnIds, savedViewColumnOrderEqual, - savedViewColumnSizingEqual + savedViewColumnSizingEqual, + savedViewColumnWrappingEqual } from './column-settings'; import { savedViewHref, savedViewResolvedSlug } from './slugs'; @@ -67,6 +69,8 @@ export interface UseSavedViewsReturn { isModified: boolean; savedViews: SavedView[]; setAutoFillColumnId: (columnId: AutoFillColumnSelection) => void; + setWrappedColumnIds: (columnIds: WrappedColumnIds) => void; + wrappedColumnIds: WrappedColumnIds; } export function clearSavedViewQueryParams(queryParams: SavedViewQueryParams): void { @@ -173,6 +177,7 @@ export function supportsTimeQueryParam(queryParams: SavedViewQueryParams): query export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsReturn { const isEnabled = $derived(!!organization.current); let autoFillColumnId = $state(options.defaultAutoFillColumnId ?? null); + let wrappedColumnIds = $state([]); // Some routes, such as stream, do not declare every saved-view query parameter. const supportsSort = supportsSortQueryParam(options.queryParams); @@ -217,6 +222,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.setColumnSizing?.(getSavedColumnSizing(view)); autoFillColumnId = getSavedAutoFillColumnSelection(view, options.defaultAutoFillColumnId); + wrappedColumnIds = getSavedWrappedColumnIds(view); } function applyDisplayState(view: Pick | undefined): void { @@ -325,6 +331,10 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return true; } + if (!savedViewColumnWrappingEqual(wrappedColumnIds, view)) { + return true; + } + if (options.getShowStats && options.getShowStats() !== (view.show_stats ?? true)) { return true; } @@ -417,6 +427,12 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }, setAutoFillColumnId(columnId: AutoFillColumnSelection) { autoFillColumnId = columnId; + }, + setWrappedColumnIds(columnIds: WrappedColumnIds) { + wrappedColumnIds = columnIds.filter((columnId, index) => columnIds.indexOf(columnId) === index); + }, + get wrappedColumnIds() { + return wrappedColumnIds; } }; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/column-meta.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/column-meta.ts new file mode 100644 index 0000000000..f69522fdf5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/column-meta.ts @@ -0,0 +1,12 @@ +export interface DataTableColumnMeta { + class?: string; + enableWrapping?: boolean; +} + +export function getDataTableColumnMeta(meta: unknown): DataTableColumnMeta { + return (meta ?? {}) as DataTableColumnMeta; +} + +export function supportsColumnWrapping(meta: unknown): boolean { + return getDataTableColumnMeta(meta).enableWrapping === true; +} 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 2b23a9e60f..5b70d53ea2 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 @@ -9,6 +9,7 @@ import * as Table from '$comp/ui/table'; import { type Cell, FlexRender, type Header, type RowData, type StockFeatures, type Table as SvelteTable } from '@tanstack/svelte-table'; + import { getDataTableColumnMeta, supportsColumnWrapping } from './column-meta'; import DataTableColumnHeader from './data-table-column-header.svelte'; interface Props { @@ -18,9 +19,10 @@ rowClick?: (row: TData, event?: MouseEvent) => void; rowHref?: (row: TData) => string; table: SvelteTable; + wrappedColumnIds?: readonly string[]; } - let { autoFillColumnId, children, onAutoFillColumnResized, rowClick, rowHref, table }: Props = $props(); + let { autoFillColumnId, children, onAutoFillColumnResized, rowClick, rowHref, table, wrappedColumnIds = [] }: Props = $props(); const selectColumnClass = 'w-8 min-w-8 max-w-8'; const selectColumnWidth = 32; @@ -54,12 +56,19 @@ const isOnlyDataColumn = getVisibleDataColumnCount() === 1; const metaClass = isOnlyDataColumn ? removeWidthClasses(getMetaClass(cell.column.columnDef.meta)) : getMetaClass(cell.column.columnDef.meta); + const contentClass = isColumnWrapped(cell.column) + ? 'group/wrapped whitespace-normal break-words [&_.line-clamp-1]:line-clamp-none [&_.line-clamp-2]:line-clamp-none' + : 'truncate'; const classes = rowClick - ? ['cursor-pointer', 'truncate', !isOnlyDataColumn && 'max-w-sm', metaClass] - : ['truncate', !isOnlyDataColumn && 'max-w-sm', metaClass]; + ? ['cursor-pointer', contentClass, !isOnlyDataColumn && 'max-w-sm', metaClass] + : [contentClass, !isOnlyDataColumn && 'max-w-sm', metaClass]; return classes.filter(Boolean).join(' '); } + function isColumnWrapped(column: Cell['column']): boolean { + return supportsColumnWrapping(column.columnDef.meta) && wrappedColumnIds.includes(column.id); + } + function getHeaderContentClass(header: Header, headerClass: string): string { return header.column.getCanResize() ? removeWidthClasses(headerClass) : headerClass; } @@ -81,7 +90,7 @@ } function getMetaClass(meta: unknown): string { - return (meta as { class?: string })?.class ?? ''; + return getDataTableColumnMeta(meta).class ?? ''; } function getFlexibleDataColumnId(): string | undefined { @@ -327,12 +336,21 @@ {#if rowHref && cell.row.original} {@const href = rowHref(cell.row.original)} onCellClick(event, cell)} variant="ghost"> - + {:else} - onCellClick(event, cell)} style={getColumnStyle(cell.column)}> + onCellClick(event, cell)} + style={getColumnStyle(cell.column)} + > {/if} 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 6f67d026bf..0455ffdf11 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 @@ -72,6 +72,21 @@ describe('DataTableBody', () => { expect(dateHeader.style.cssText).toBe('width: 150px; min-width: 150px; max-width: 150px;'); }); + it('wraps only columns that opt in', () => { + render(DataTableBodyTestHarness, { kind: 'event', onRowClick: vi.fn(), wrappedColumnIds: ['summary', 'date'] }); + + const summaryCell = screen.getByText('Unexpected end of Stream, the content may have already been read by another component.').closest('td'); + const dateCell = screen.getByText('event-id').closest('td'); + + expect(summaryCell?.getAttribute('data-wrap')).toBe('true'); + expect(summaryCell?.classList.contains('whitespace-normal')).toBe(true); + expect(summaryCell?.classList.contains('break-words')).toBe(true); + expect(summaryCell?.classList.contains('truncate')).toBe(false); + expect(summaryCell?.className).toContain('[&_.line-clamp-2]:line-clamp-none'); + expect(dateCell?.hasAttribute('data-wrap')).toBe(false); + expect(dateCell?.classList.contains('truncate')).toBe(true); + }); + it('reports when the selected auto-fill column is resized', async () => { const onAutoFillColumnResized = vi.fn(); render(DataTableBodyTestHarness, { 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 4671cc8291..abc2a9f5ed 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 @@ -19,6 +19,7 @@ onAutoFillColumnResized?: (columnId: string) => void; onRowClick: (row: TestSummary) => void; sizedFullWidthSummary?: boolean; + wrappedColumnIds?: readonly string[]; } let { @@ -28,7 +29,8 @@ kind, onAutoFillColumnResized, onRowClick, - sizedFullWidthSummary = false + sizedFullWidthSummary = false, + wrappedColumnIds = [] }: Props = $props(); const summaryData = { @@ -100,7 +102,8 @@ meta: { get class() { return fullWidthSummary ? 'w-full' : 'w-60 min-w-60 max-w-60'; - } + }, + enableWrapping: true }, minSize: 120, size: 160 @@ -128,6 +131,7 @@ {autoFillColumnId} {onAutoFillColumnResized} rowClick={onRowClick} + {wrappedColumnIds} rowHref={(row: SummaryModel) => (kind === 'event' ? buildEventDetailsHref(row.id) : buildStackDetailsHref(row.id))} {table} /> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte index 7cd663ccb3..833033de77 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-size.svelte @@ -51,16 +51,15 @@ } - - Rows per page - - - {selected.label} - - + + + {selected.label} rows + + + {#each items as item (item.value)} - {item.label} + {item.label} rows {/each} - - - + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pager.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pager.svelte new file mode 100644 index 0000000000..eda5b6506e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pager.svelte @@ -0,0 +1,52 @@ + + + + + + + + / + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/index.ts index 8bb59b7207..f62835b4fc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/index.ts @@ -4,6 +4,7 @@ import Footer from './data-table-footer.svelte'; import Loading from './data-table-loading.svelte'; import PageCount from './data-table-page-count.svelte'; import PageSize from './data-table-page-size.svelte'; +import Pager from './data-table-pager.svelte'; import Pagination from './data-table-pagination.svelte'; import Refresh from './data-table-refresh.svelte'; import Selection from './data-table-selection.svelte'; @@ -18,6 +19,7 @@ export { Footer as DataTableFooter, Loading as DataTableLoading, PageCount as DataTablePageCount, + Pager as DataTablePager, PageSize as DataTablePageSize, Pagination as DataTablePagination, Refresh as DataTableRefresh, @@ -27,6 +29,7 @@ export { Footer, Loading, PageCount, + Pager, PageSize, Pagination, Refresh, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte index 9b7aa5d6b9..54045cac53 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte @@ -11,12 +11,16 @@ maxVisible?: number; onTagClick?: (tag: string) => Promise | void; tags?: null | string[]; + wrappedMaxVisible?: number; } - let { class: className, maxVisible = Number.POSITIVE_INFINITY, onTagClick, tags }: Props = $props(); + let { class: className, maxVisible = Number.POSITIVE_INFINITY, onTagClick, tags, wrappedMaxVisible }: Props = $props(); - const visibleTags = $derived(tags?.slice(0, maxVisible) ?? []); + const effectiveWrappedMaxVisible = $derived(Math.max(maxVisible, wrappedMaxVisible ?? maxVisible)); + const hasWrappedLimit = $derived(effectiveWrappedMaxVisible > maxVisible); + const visibleTags = $derived(tags?.slice(0, effectiveWrappedMaxVisible) ?? []); const hiddenTags = $derived(tags?.slice(maxVisible) ?? []); + const wrappedHiddenTags = $derived(tags?.slice(effectiveWrappedMaxVisible) ?? []); const tagList = $derived(tags?.join(', ') ?? ''); const truncatedTags = new SvelteSet(); @@ -74,10 +78,17 @@ variant="outline" class={[ 'border-border bg-muted text-muted-foreground group-hover/button:bg-accent group-hover/button:text-accent-foreground dark:border-muted-foreground/50 rounded-md text-xs', - showFullValue ? 'h-auto max-w-full py-0.5 whitespace-normal' : 'max-w-28' + showFullValue + ? 'h-auto max-w-full py-0.5 whitespace-normal' + : 'max-w-28 group-data-[wrap=true]/wrapped:h-auto group-data-[wrap=true]/wrapped:max-w-full group-data-[wrap=true]/wrapped:py-0.5' ]} > - + {tag} @@ -95,7 +106,7 @@ {/snippet} -{#snippet tag(tag: string)} +{#snippet tag(tag: string, visibilityClass?: string)} {#if onTagClick} @@ -105,7 +116,7 @@ type="button" size="sm" variant="ghost" - class="h-auto cursor-pointer p-0" + class={['h-auto cursor-pointer p-0', visibilityClass]} onclick={(event) => handleTagClick(event, tag)} > {@render tagBadge(tag)} @@ -127,7 +138,7 @@ {#snippet child({ props })} - + {@render tagBadge(tag)} {/snippet} @@ -154,40 +165,47 @@ {/if} {/snippet} +{#snippet overflow(hiddenValues: string[], visibilityClass?: string)} + + + {#snippet child({ props })} + + +{hiddenValues.length} + + {/snippet} + + + + {#each hiddenValues as value (value)} + {@render overflowTag(value)} + {/each} + + {#if onTagClick} + {@render tagActionHint()} + {/if} + + +{/snippet} + {#if visibleTags.length > 0} - {#each visibleTags as value (value)} - {@render tag(value)} + {#each visibleTags as value, index (value)} + {@render tag(value, hasWrappedLimit && index >= maxVisible ? 'hidden group-data-[wrap=true]/wrapped:inline-flex' : undefined)} {/each} {#if hiddenTags.length > 0} - - - {#snippet child({ props })} - - +{hiddenTags.length} - - {/snippet} - - - - {#each hiddenTags as value (value)} - {@render overflowTag(value)} - {/each} - - {#if onTagClick} - {@render tagActionHint()} - {/if} - - + {@render overflow(hiddenTags, hasWrappedLimit ? 'group-data-[wrap=true]/wrapped:hidden' : undefined)} + {/if} + {#if hasWrappedLimit && wrappedHiddenTags.length > 0} + {@render overflow(wrappedHiddenTags, 'hidden group-data-[wrap=true]/wrapped:inline-flex')} {/if} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts index c9f4d521ee..17eb411621 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts @@ -68,6 +68,27 @@ describe('TagList', () => { } }); + it('shows more tags before overflow when the surrounding column wraps', () => { + const { container } = render(TagList, { + maxVisible: 2, + tags: ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight'], + wrappedMaxVisible: 6 + }); + + const thirdTagTrigger = screen.getByText('three').closest('[data-slot="tooltip-trigger"]'); + expect(thirdTagTrigger?.classList).toContain('hidden'); + expect(thirdTagTrigger?.classList).toContain('group-data-[wrap=true]/wrapped:inline-flex'); + expect(screen.queryByText('seven')).toBeNull(); + + const compactOverflow = screen.getByText('+6').closest('[data-slot="tooltip-trigger"]'); + expect(compactOverflow?.classList).toContain('group-data-[wrap=true]/wrapped:hidden'); + + const wrappedOverflow = screen.getByText('+2').closest('[data-slot="tooltip-trigger"]'); + expect(wrappedOverflow?.classList).toContain('hidden'); + expect(wrappedOverflow?.classList).toContain('group-data-[wrap=true]/wrapped:inline-flex'); + expect(container.querySelectorAll('[data-slot="badge"]')).toHaveLength(6); + }); + it('shows full hidden tags and preserves filter actions in the overflow tooltip', async () => { const hiddenTag = 'customer-facing-api-that-is-unusually-long'; const onTagClick = vi.fn(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-list.svelte index f663bd3e5f..9d24782564 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-list.svelte @@ -9,9 +9,13 @@ }: CommandPrimitive.ListProps = $props(); + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-shortcut.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-shortcut.svelte index 38e8485c85..7d20a4c4bb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-shortcut.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-shortcut.svelte @@ -15,7 +15,7 @@ bind:this={ref} data-slot="command-shortcut" class={cn( - "border-border bg-muted text-muted-foreground group-data-selected/command-item:bg-background group-data-selected/command-item:text-foreground ml-auto h-5 w-fit min-w-5 gap-1 rounded-sm border px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none", + "border-border bg-muted text-foreground shadow-xs group-data-selected/command-item:bg-background group-data-selected/command-item:text-foreground ml-auto h-5 w-fit min-w-5 gap-1 rounded-sm border px-1 font-sans text-xs font-semibold [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none", className )} {...restProps} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/kbd/kbd.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/kbd/kbd.svelte index 034ef5a410..1c43db6225 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/kbd/kbd.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/kbd/kbd.svelte @@ -14,7 +14,7 @@ bind:this={ref} data-slot="kbd" class={cn( - "bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 h-5 w-fit min-w-5 gap-1 rounded-sm px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none", + "border-border bg-muted text-foreground shadow-xs in-data-[slot=tooltip-content]:border-background/20 in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 h-5 w-fit min-w-5 gap-1 rounded-sm border px-1 font-sans text-xs font-semibold [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none", className )} {...restProps} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts index 30f23a7e22..10269cc92e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts @@ -16,8 +16,8 @@ describe('formatKeyboardShortcutForPlatform', () => { }); it('should format single key shortcuts', () => { - expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.switchOrganization.keys, false)).toBe('O'); - expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.userMenu.keys, false)).toBe('U'); + expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.switchOrganization.keys, false)).toBe('o'); + expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.userMenu.keys, false)).toBe('u'); expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.keyboardShortcuts.keys, false)).toBe('?'); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts index b66a918f82..a2079b334f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts @@ -8,12 +8,12 @@ export type KeyboardShortcut = { export type ShortcutKey = 'Alt' | 'Mod' | 'Shift' | string; export const appKeyboardShortcuts = { - allEvents: { key: 'e', keys: ['E'] }, + allEvents: { key: 'e', keys: ['e'] }, commandPalette: { key: '/', keys: ['/'] }, keyboardShortcuts: { key: '?', keys: ['?'] }, - stacks: { key: 'i', keys: ['I'] }, - switchOrganization: { key: 'o', keys: ['O'] }, - userMenu: { key: 'u', keys: ['U'] } + stacks: { key: 'i', keys: ['i'] }, + switchOrganization: { key: 'o', keys: ['o'] }, + userMenu: { key: 'u', keys: ['u'] } } as const satisfies Record; export function formatKeyboardShortcut(keys: readonly ShortcutKey[]): string { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/mark-stack-fixed-in-version-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/mark-stack-fixed-in-version-dialog.svelte index 46ffd008ff..2bab02e1bc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/mark-stack-fixed-in-version-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/mark-stack-fixed-in-version-dialog.svelte @@ -122,7 +122,7 @@ {#snippet children(field)} - 0 ? true : undefined}> + 0 ? true : undefined}> Version - Bulk Actions - markOpen()}>Mark Open (openMarkStackFixedInVersionDialog = true)}>Mark Fixed diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte index 55b7ceda36..c9271053a3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte @@ -32,20 +32,8 @@ {#if footerChildren} {@render footerChildren()} {:else} - - - - - - - - - - - - - - + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/tokens-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/tokens-data-table.svelte index f9a6601380..a5269cfdec 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/tokens-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/tokens-data-table.svelte @@ -39,16 +39,12 @@ {@render bodyChildren()} {/if} - + {#if footerChildren} {@render footerChildren()} {:else} - - - - - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte index ba6a9c5d3f..a15795066e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte @@ -31,12 +31,8 @@ No applications have access to your account. {/if} - + - - - - - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/users-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/users-data-table.svelte index d5cee30a47..741acb8b56 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/users-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/users-data-table.svelte @@ -39,16 +39,12 @@ {@render bodyChildren()} {/if} - + {#if footerChildren} {@render footerChildren()} {:else} - - - - - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/webhooks-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/webhooks-data-table.svelte index 9516118bbf..50c39e9905 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/webhooks-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/webhooks-data-table.svelte @@ -39,16 +39,12 @@ {@render bodyChildren()} {/if} - + {#if footerChildren} {@render footerChildren()} {:else} - - - - - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 2411510e46..4aa44bc735 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -398,6 +398,8 @@ export interface SavedViewColumnSettings { visible?: null | boolean; /** Whether the column fills the table's remaining width. Null or false means use fixed-width behavior. */ auto_fill?: null | boolean; + /** Whether cell content wraps onto multiple lines. Null or false means keep content on one line. */ + wrap?: null | boolean; /** * Zero-based display position. Null means use the table default order. * @format int32 diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 6be8a910b2..5ff2c3aec1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -518,6 +518,7 @@ export type ResetPasswordModelFormData = Infer; export const SavedViewColumnSettingsSchema = object({ visible: boolean().nullable().optional(), auto_fill: boolean().nullable().optional(), + wrap: boolean().nullable().optional(), position: int32() .min(0, "Position must be at least 0") .max(49, "Position must be at most 49") diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 53b9549071..6d6faaba88 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -714,22 +714,13 @@ }) ); - const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.store.state.pagination.pageIndex === 0); - function reset() { table.resetRowSelection(); table.setPageIndex(0); } async function handleRefresh() { - const isFirstPage = table.store.state.pagination.pageIndex === 0; - if (!canRefresh) { - reset(); - if (!isFirstPage) { - return; - } - } - + table.resetRowSelection(); await eventsQuery.refetch(); } @@ -957,6 +948,7 @@ onResetToSaved={handleResetToSaved} savedViews={savedViewsState.savedViews} setAutoFillColumnId={savedViewsState.setAutoFillColumnId} + setWrappedColumnIds={savedViewsState.setWrappedColumnIds} {showChart} {showStats} setShowChart={(v) => (showChart = v)} @@ -965,14 +957,10 @@ {table} time={getQueryTime() ?? undefined} view={VIEW} + wrappedColumnIds={savedViewsState.wrappedColumnIds} /> {/if} - + @@ -1003,20 +991,17 @@ {rowClick} {rowHref} {table} + wrappedColumnIds={savedViewsState.wrappedColumnIds} > {#snippet footerChildren()} - + {#if table.getSelectedRowModel().flatRows.length} {/if} + - - - - - - + {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/+page.svelte index 810a854c19..abd537d3d1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/+page.svelte @@ -238,18 +238,13 @@ const table = createTable(getTableOptions(stacksQueryParameters, stacksQuery, handleTagClick)); - const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.store.state.pagination.pageIndex === 0); - function reset() { table.resetRowSelection(); table.setPageIndex(0); } async function handleRefresh() { - if (!canRefresh) { - reset(); - } - + table.resetRowSelection(); await stacksQuery.refetch(); } @@ -279,28 +274,19 @@ - + {#snippet footerChildren()} - + + - - - - - - + {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte index 7d1edb7770..793e57d485 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte @@ -204,18 +204,13 @@ }) ); - const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.store.state.pagination.pageIndex === 0); - function reset() { table.resetRowSelection(); table.setPageIndex(0); } async function handleRefresh() { - if (!canRefresh) { - reset(); - } - + table.resetRowSelection(); await loadData(); } @@ -368,12 +363,7 @@ View Active - + @@ -392,11 +382,7 @@ {#snippet footerChildren()} - - - - - + {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 00585957d1..04fd9c915f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -407,6 +407,10 @@ [() => page.url.pathname, () => getListFilterQueryParams(queryParams), () => savedViewsState.activeSavedView], ([pathname, currentQueryParams, activeSavedView], [previousPathname, , previousSavedView]) => { const savedViewChanged = pathname !== previousPathname || activeSavedView?.id !== previousSavedView?.id; + if (savedViewChanged) { + table.resetRowSelection(); + } + if (isInternalFilterUpdate && !savedViewChanged) { isInternalFilterUpdate = false; return; @@ -686,22 +690,13 @@ }) ); - const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.store.state.pagination.pageIndex === 0); - function reset() { table.resetRowSelection(); table.setPageIndex(0); } async function handleRefresh() { - const isFirstPage = table.store.state.pagination.pageIndex === 0; - if (!canRefresh) { - reset(); - if (!isFirstPage) { - return; - } - } - + table.resetRowSelection(); await eventsQuery.refetch(); } @@ -860,6 +855,7 @@ onResetToSaved={handleResetToSaved} savedViews={savedViewsState.savedViews} setAutoFillColumnId={savedViewsState.setAutoFillColumnId} + setWrappedColumnIds={savedViewsState.setWrappedColumnIds} {showChart} {showStats} setShowChart={(v) => (showChart = v)} @@ -867,14 +863,10 @@ {table} time={getQueryTime() ?? undefined} view={VIEW} + wrappedColumnIds={savedViewsState.wrappedColumnIds} /> {/if} - + @@ -905,18 +897,15 @@ {rowClick} {rowHref} {table} + wrappedColumnIds={savedViewsState.wrappedColumnIds} > {#snippet footerChildren()} - + + - - - - - - + {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index 27897587de..a24db93de9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -343,14 +343,22 @@ onResetToSaved={savedViewsState.handleResetToSaved} savedViews={savedViewsState.savedViews} setAutoFillColumnId={savedViewsState.setAutoFillColumnId} + setWrappedColumnIds={savedViewsState.setWrappedColumnIds} {table} view={VIEW} + wrappedColumnIds={savedViewsState.wrappedColumnIds} /> {/if} - + {#if clientStatus.isLoading} diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index ec1efe8815..232f5b6cec 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -12767,6 +12767,13 @@ ], "description": "Whether the column fills the table\u0027s remaining width. Null or false means use fixed-width behavior." }, + "wrap": { + "type": [ + "null", + "boolean" + ], + "description": "Whether cell content wraps onto multiple lines. Null or false means keep content on one line." + }, "position": { "maximum": 49, "minimum": 0, diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 891288aeb5..7391e33ee1 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -408,7 +408,7 @@ public async Task PostAsync_StructuredColumns_PersistsAllSettings() // Arrange var columnSettings = new Dictionary { - ["summary"] = new() { AutoFill = true, Position = 0, Visible = true }, + ["summary"] = new() { AutoFill = true, Position = 0, Visible = true, Wrap = true }, ["project"] = new() { Position = 1, Visible = true, Width = 240 } }; @@ -433,6 +433,7 @@ public async Task PostAsync_StructuredColumns_PersistsAllSettings() Assert.True(result.Columns?["project"].Visible); Assert.Equal(1, result.Columns?["project"].Position); Assert.True(result.Columns?["summary"].AutoFill); + Assert.True(result.Columns?["summary"].Wrap); var savedView = await _savedViewRepository.GetByIdAsync(result.Id); Assert.NotNull(savedView); @@ -440,6 +441,7 @@ public async Task PostAsync_StructuredColumns_PersistsAllSettings() Assert.True(savedView.Columns?["project"].Visible); Assert.Equal(1, savedView.Columns?["project"].Position); Assert.True(savedView.Columns?["summary"].AutoFill); + Assert.True(savedView.Columns?["summary"].Wrap); } [Fact] diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index f42d3bb6bf..9d85fa6956 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -104,6 +104,7 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem var savedViewColumnProperties = savedViewColumnSettings.GetProperty("properties"); Assert.Equal("boolean", savedViewColumnProperties.GetProperty("auto_fill").GetProperty("type")[1].GetString()); + Assert.Equal("boolean", savedViewColumnProperties.GetProperty("wrap").GetProperty("type")[1].GetString()); var position = savedViewColumnProperties.GetProperty("position"); Assert.Equal(0, position.GetProperty("minimum").GetInt32()); Assert.Equal(SavedViewColumnSettings.MaxPosition, position.GetProperty("maximum").GetInt32()); diff --git a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs index a1a840f23c..8aa4f1e11d 100644 --- a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs @@ -29,7 +29,7 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", Columns = new Dictionary { - ["summary"] = new() { AutoFill = true, Position = 0, Visible = true }, + ["summary"] = new() { AutoFill = true, Position = 0, Visible = true, Wrap = true }, ["status"] = new() { Position = 1, Visible = true, Width = 180 }, ["users"] = new() { Position = 2, Visible = false } } @@ -52,6 +52,7 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() Assert.Equal(1, result.Columns["status"].Position); Assert.Equal(180, result.Columns["status"].Width); Assert.True(result.Columns["summary"].AutoFill); + Assert.True(result.Columns["summary"].Wrap); } [Fact] @@ -113,7 +114,7 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\"]}]", Columns = new Dictionary { - ["summary"] = new() { AutoFill = true, Position = 0, Visible = true }, + ["summary"] = new() { AutoFill = true, Position = 0, Visible = true, Wrap = true }, ["status"] = new() { Position = 1, Visible = true, Width = 180 } }, Name = "My View", @@ -141,6 +142,7 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() Assert.Equal(1, result.Columns["status"].Position); Assert.Equal(180, result.Columns["status"].Width); Assert.True(result.Columns["summary"].AutoFill); + Assert.True(result.Columns["summary"].Wrap); Assert.Equal("My View", result.Name); Assert.Equal("[now-30d TO now]", result.Time); Assert.Equal("-last", result.Sort); diff --git a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs index ce5698aedd..34925d57da 100644 --- a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs +++ b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs @@ -112,4 +112,34 @@ public void GetContentHash_ColumnInsertionOrderDiffers_ReturnsSameHash() // Assert Assert.Equal(originalHash, reorderedHash); } + + [Fact] + public void GetContentHash_ColumnWrapSettingDiffers_ReturnsDifferentHash() + { + // Arrange + var singleLine = new SavedView + { + Name = "Errors", + Slug = "errors", + ViewType = "stacks", + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true } + } + }; + var wrapped = singleLine with + { + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true, Wrap = true } + } + }; + + // Act + string singleLineHash = PredefinedSavedViewContentHasher.GetContentHash(singleLine); + string wrappedHash = PredefinedSavedViewContentHasher.GetContentHash(wrapped); + + // Assert + Assert.NotEqual(singleLineHash, wrappedHash); + } } diff --git a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs index f9be6fc5cb..5ee5930fcf 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs @@ -29,7 +29,7 @@ public void RoundTrip_WithAllProperties_PreservesValues() FilterDefinitions = """[{"field":"status","operator":"in","values":["open","regressed"]}]""", Columns = new Dictionary { - ["title"] = new() { AutoFill = true, Position = 0, Visible = true }, + ["title"] = new() { AutoFill = true, Position = 0, Visible = true, Wrap = true }, ["date"] = new() { Position = 1, Visible = true }, ["status"] = new() { Position = 2, Visible = false, Width = 180 } }, @@ -62,6 +62,10 @@ public void RoundTrip_WithAllProperties_PreservesValues() Assert.True(result.Columns["title"].Visible); Assert.False(result.Columns["status"].Visible); Assert.True(result.Columns["title"].AutoFill); + Assert.True(result.Columns["title"].Wrap); + Assert.Null(result.Columns["date"].Wrap); + Assert.Contains("\"wrap\":true", json); + Assert.DoesNotContain("\"wrap\":null", json); } [Fact]
Drag to reorder, or mark one column to auto fill the available width.
Drag to reorder, wrap text, or mark one column to auto fill the available width.
Rows per page