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}
-
+
{#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 @@ {getColumnLabel(column)} + {#if supportsColumnWrapping(column.columnDef.meta)} + + {: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 @@ - + @@ -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]