diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts new file mode 100644 index 0000000000..0c5d33f9de --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts @@ -0,0 +1,122 @@ +import type { Page, Response } from '@playwright/test'; + +import { expect, test } from '../fixtures/e2e-test'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; + +test('stack sort survives reload and resets pagination and selection without sending an API sort parameter', async ({ e2eApi, e2eScenario, page }) => { + await ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario).submitRepresentativeEvent(); + const requests: URL[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (isStackRequest(url)) { + requests.push(url); + } + }); + + const initialResponse = waitForStackMode(page, 'stack_frequent'); + await page.goto(`/next/stack?project=${e2eScenario.projectId}&page=2`); + expect((await initialResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Sort by Events descending' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: /Sort by First/ })).toHaveCount(0); + + const recentResponse = waitForStackMode(page, 'stack_recent'); + await page.getByRole('button', { name: 'Sort by Last descending' }).click(); + expect((await recentResponse).ok()).toBe(true); + await expect(page).toHaveURL(/[?&]sort=stack_recent(?:&|$)/); + await expect(page).not.toHaveURL(/[?&]page=2(?:&|$)/); + + const reloadResponse = waitForStackMode(page, 'stack_recent'); + await page.reload(); + expect((await reloadResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Sort by Last descending' })).toHaveAttribute('aria-pressed', 'true'); + + await page.getByRole('checkbox', { name: 'Select row' }).first().click(); + await expect(page.getByRole('checkbox', { checked: true, name: 'Select row' })).toHaveCount(1); + await page.getByRole('button', { name: 'Sort by Events descending' }).click(); + await expect(page).not.toHaveURL(/[?&]sort=/); + await expect(page.getByRole('button', { name: 'Sort by Events descending' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('checkbox', { checked: true })).toHaveCount(0); + expect(requests.length).toBeGreaterThanOrEqual(3); + expect(requests.every((url) => !url.searchParams.has('sort'))).toBe(true); +}); + +for (const [sort, label, mode, explicitSort] of [ + ['-events', 'Events', 'stack_frequent', false], + ['-last', 'Last', 'stack_recent', false], + ['-events', 'Events', 'stack_frequent', true], + ['-last', 'Last', 'stack_recent', true] +] as const) { + test(`legacy ${sort} stack views ${explicitSort ? 'with an explicit URL sort ' : ''}preserve their sort through reset and save`, async ({ + e2eScenario, + page, + request + }) => { + const headers = { Authorization: `Bearer ${e2eScenario.userToken}` }; + const slug = `sort-${label.toLowerCase()}`; + const createResponse = await request.post(`/api/v2/organizations/${e2eScenario.organizationId}/saved-views`, { + data: { + filter: 'type:error', + name: `Sort ${label}`, + organization_id: e2eScenario.organizationId, + slug, + sort, + view_type: 'stacks' + }, + headers + }); + expect(createResponse.status()).toBe(201); + const savedView = (await createResponse.json()) as { id: string }; + + const initialResponse = waitForStackMode(page, mode); + await page.goto(`/next/stack/${slug}${explicitSort ? `?sort=${sort}` : ''}`); + expect((await initialResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: `Sort by ${label} descending` })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByLabel('Unsaved view changes')).toHaveCount(0); + + await page.getByRole('button', { name: /^View/ }).filter({ visible: true }).first().click(); + await page.getByRole('menuitemcheckbox', { exact: true, name: 'Chart' }).click(); + const displaySaveResponse = page.waitForResponse( + (response) => response.url().includes(`/saved-views/${savedView.id}`) && response.request().method() === 'PATCH' + ); + await page.getByRole('menuitem', { exact: true, name: 'Save' }).click(); + const displayResponse = await displaySaveResponse; + expect(displayResponse.ok()).toBe(true); + expect(displayResponse.request().postDataJSON().sort).toBe(sort); + await expect(page.getByLabel('Unsaved view changes')).toHaveCount(0); + + const otherLabel = label === 'Events' ? 'Last' : 'Events'; + const otherMode = mode === 'stack_frequent' ? 'stack_recent' : 'stack_frequent'; + await page.getByRole('button', { name: `Sort by ${otherLabel} descending` }).click(); + await expect(page.getByLabel('Unsaved view changes')).toBeVisible(); + await page.getByRole('button', { name: /^View/ }).filter({ visible: true }).first().click(); + await page.getByRole('menuitem', { name: 'Reset to Saved' }).click(); + await expect(page.getByRole('button', { name: `Sort by ${label} descending` })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByLabel('Unsaved view changes')).toHaveCount(0); + + await page.getByRole('button', { name: `Sort by ${otherLabel} descending` }).click(); + await page.getByRole('button', { name: /^View/ }).filter({ visible: true }).first().click(); + const saveResponse = page.waitForResponse( + (response) => response.url().includes(`/saved-views/${savedView.id}`) && response.request().method() === 'PATCH' + ); + await page.getByRole('menuitem', { exact: true, name: 'Save' }).click(); + const response = await saveResponse; + expect(response.ok()).toBe(true); + expect(response.request().postDataJSON().sort).toBe(otherMode); + await expect(page.getByLabel('Unsaved view changes')).toHaveCount(0); + + await page.goto(`/next/stack/${slug}`); + await expect(page.getByRole('button', { name: `Sort by ${otherLabel} descending` })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByLabel('Unsaved view changes')).toHaveCount(0); + }); +} + +function isStackRequest(url: URL): boolean { + return url.pathname.endsWith('/events') && url.searchParams.get('mode')?.startsWith('stack_') === true; +} + +function waitForStackMode(page: Page, mode: string): Promise { + return page.waitForResponse((response) => { + const url = new URL(response.url()); + return isStackRequest(url) && url.searchParams.get('mode') === mode; + }); +} 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 a323f1ca96..42634250c7 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 @@ -3,9 +3,38 @@ import { describe, expect, it } from 'vitest'; import type { EventSummaryModel, StackSummaryModel, SummaryTemplateKeys } from '../summary'; -import { defaultEventColumnVisibility, defaultStackColumnVisibility, getColumns } from './options.svelte'; +import { defaultEventColumnVisibility, defaultStackColumnVisibility, getColumns, getStackSortMode } from './options.svelte'; describe('event table columns', () => { + it('accepts only supported stack sort modes', () => { + expect(getStackSortMode('stack_frequent')).toBe('stack_frequent'); + expect(getStackSortMode('stack_recent')).toBe('stack_recent'); + expect(getStackSortMode('-events')).toBe('stack_frequent'); + expect(getStackSortMode('-last')).toBe('stack_recent'); + expect(getStackSortMode('stack_new')).toBeUndefined(); + expect(getStackSortMode('-last_occurrence')).toBeUndefined(); + expect(getStackSortMode(undefined)).toBeUndefined(); + }); + + it('uses dedicated stack-mode controls instead of API sort parameters', () => { + const result = getColumns>('stack_frequent'); + const columnsById = Object.fromEntries(result.map((column) => [column.id, column])); + + expect(columnsById.events?.enableSorting).toBe(false); + expect(columnsById.first?.enableSorting).toBe(false); + expect(columnsById.last?.enableSorting).toBe(false); + expect(columnsById.events?.header).toBeTypeOf('function'); + expect(columnsById.first?.header).toBe('First'); + expect(columnsById.last?.header).toBeTypeOf('function'); + }); + + it('keeps summary message column unsortable', () => { + const result = getColumns>('summary'); + const columnsById = Object.fromEntries(result.map((column) => [column.id, column])); + + expect(columnsById.message?.enableSorting).toBe(false); + }); + it('offers project and tags as hidden optional columns', () => { const columns = getColumns>(); const columnIds = columns.map((column) => column.id); 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 1775dde949..001c87d2d8 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 @@ -13,6 +13,7 @@ import LogLevel from '../log-level.svelte'; import Summary from '../summary/summary.svelte'; import EventTagsSummaryCell from './event-tags-summary-cell.svelte'; import EventsUserIdentitySummaryCell from './events-user-identity-summary-cell.svelte'; +import StackSortHeader from './stack-sort-header.svelte'; import StackStatusCell from './stack-status-cell.svelte'; import StackUsersSummaryCell from './stack-users-summary-cell.svelte'; @@ -33,9 +34,11 @@ export const defaultStackColumnVisibility: ColumnVisibilityState = { tags: false }; +export type StackSortMode = Extract; + export function getColumns>( mode: GetEventsMode = 'summary', - options?: { onTagClick?: (tag: string) => Promise | void; showType?: boolean } + options?: { onStackSort?: (mode: StackSortMode) => void; onTagClick?: (tag: string) => Promise | void; showType?: boolean } ): ColumnDef[] { const showType = options?.showType ?? true; const columns: ColumnDef[] = [ @@ -306,7 +309,12 @@ export function getColumns() }), enableSorting: false, - header: 'Events', + header: () => + renderComponent(StackSortHeader, { + active: mode === 'stack_frequent', + label: 'Events', + onclick: () => options?.onStackSort?.('stack_frequent') + }), id: 'events', maxSize: 320, meta: { @@ -338,7 +346,12 @@ export function getColumns() }), enableSorting: false, - header: 'Last', + header: () => + renderComponent(StackSortHeader, { + active: mode === 'stack_recent', + label: 'Last', + onclick: () => options?.onStackSort?.('stack_recent') + }), id: 'last', maxSize: 480, meta: { @@ -353,6 +366,18 @@ export function getColumns 0 ? value : '—'; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte new file mode 100644 index 0000000000..0c5413885b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte.test.ts new file mode 100644 index 0000000000..d86bd454e7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte.test.ts @@ -0,0 +1,24 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import StackSortHeader from './stack-sort-header.svelte'; + +describe('StackSortHeader', () => { + it('exposes the active descending sort and handles selection', async () => { + const onclick = vi.fn(); + render(StackSortHeader, { active: true, label: 'Events', onclick }); + + const button = screen.getByRole('button', { name: 'Sort by Events descending' }); + expect(button.getAttribute('aria-pressed')).toBe('true'); + + await fireEvent.click(button); + + expect(onclick).toHaveBeenCalledOnce(); + }); + + it('does not mark inactive sort modes as selected', () => { + render(StackSortHeader, { active: false, label: 'Last', onclick: vi.fn() }); + + expect(screen.getByRole('button', { name: 'Sort by Last descending' }).getAttribute('aria-pressed')).toBe('false'); + }); +}); 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 edd65c2b2a..5023872a7c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -44,7 +44,7 @@ } from '$features/events/components/filters/helpers.svelte'; import OrganizationDefaultsFacetedFilterBuilder from '$features/events/components/filters/organization-defaults-faceted-filter-builder.svelte'; import EventsDataTable from '$features/events/components/table/events-data-table.svelte'; - import { defaultStackColumnVisibility, getColumns } from '$features/events/components/table/options.svelte'; + import { defaultStackColumnVisibility, getColumns, getStackSortMode, type StackSortMode } from '$features/events/components/table/options.svelte'; import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { organization } from '$features/organizations/context.svelte'; import { premiumPage } from '$features/organizations/premium-page.svelte'; @@ -115,6 +115,7 @@ project: undefined as string | undefined, reference: undefined as string | undefined, session: undefined as string | undefined, + sort: undefined as string | undefined, stack: undefined as string | undefined, status: undefined as string | undefined, tag: undefined as string | undefined, @@ -132,6 +133,14 @@ return filter || null; } + function getEffectiveStackSort(): StackSortMode { + return getStackSortMode(getPersistedStackSort()) ?? 'stack_frequent'; + } + + function getPersistedStackSort(): string | undefined { + return queryParams.sort ?? savedViewsState.activeSavedView?.sort ?? undefined; + } + function getQueryFilters(params: ListFilterQueryParams = queryParams): FacetedFilter.IFilter[] | null { const filters: FacetedFilter.IFilter[] = []; @@ -208,6 +217,12 @@ return undefined; } + function setStackSort(mode: StackSortMode): void { + const savedViewSort = getStackSortMode(savedViewsState.activeSavedView?.sort) ?? 'stack_frequent'; + queryParams.sort = mode === savedViewSort ? null : mode; + table.setPageIndex(0); + } + function splitQueryParam(value: string): string[] { return value .split(',') @@ -229,6 +244,7 @@ project: 'string', reference: 'string', session: 'string', + sort: 'string', stack: 'string', status: 'string', tag: 'string', @@ -267,6 +283,7 @@ getFilterDefinitions: () => serializeFilters(filters ?? []), getShowChart: () => showChart, getShowStats: () => showStats, + getSort: getPersistedStackSort, getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), @@ -659,7 +676,9 @@ set limit(value) { setPageSize(value); }, - mode: 'stack_frequent', + get mode() { + return getEffectiveStackSort(); + }, offset: DEFAULT_OFFSET, get page() { return queryParams.page ?? undefined; @@ -700,6 +719,7 @@ columnPersistenceKey: 'stacks-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onStackSort: setStackSort, onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); @@ -719,6 +739,16 @@ }) ); + watch( + getEffectiveStackSort, + () => { + table.resetRowSelection(); + }, + { + lazy: true + } + ); + async function handleRefresh() { table.resetRowSelection(); await eventsQuery.refetch(); @@ -891,6 +921,7 @@ {showStats} setShowChart={(v) => (showChart = v)} setShowStats={(v) => (showStats = v)} + sort={getPersistedStackSort()} {table} time={getQueryTime() ?? undefined} view={VIEW}