From dabcd1bf723443d85d571f012d0176b94a3a3678 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 23:57:34 +0000 Subject: [PATCH 1/9] Initial plan From 854bb6381b5e7ac0770ab5b48188c65420ef552a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:25:36 +0000 Subject: [PATCH 2/9] Enable stack and session duration column sorting Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../components/table/options.svelte.test.ts | 15 +++++++++++++++ .../events/components/table/options.svelte.ts | 3 --- .../components/session-table-columns.test.ts | 6 ++++++ .../sessions/components/session-table-columns.ts | 1 - 4 files changed, 21 insertions(+), 4 deletions(-) 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..f606eaeced 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 @@ -6,6 +6,21 @@ import type { EventSummaryModel, StackSummaryModel, SummaryTemplateKeys } from ' import { defaultEventColumnVisibility, defaultStackColumnVisibility, getColumns } from './options.svelte'; describe('event table columns', () => { + it('keeps stack events, first, and last columns sortable', () => { + const result = getColumns>('stack_frequent'); + const columnsById = Object.fromEntries(result.map((column) => [column.id, column])); + + expect(columnsById.events?.enableSorting).toBeUndefined(); + expect(columnsById.first?.enableSorting).toBeUndefined(); + expect(columnsById.last?.enableSorting).toBeUndefined(); + }); + + 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..ac8ff6d546 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 @@ -305,7 +305,6 @@ export function getColumns() }), - enableSorting: false, header: 'Events', id: 'events', maxSize: 320, @@ -321,7 +320,6 @@ export function getColumns() }), - enableSorting: false, header: 'First', id: 'first', maxSize: 480, @@ -337,7 +335,6 @@ export function getColumns() }), - enableSorting: false, header: 'Last', id: 'last', maxSize: 480, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts index 04b1cb246c..5cf67afbda 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts @@ -3,6 +3,12 @@ import { describe, expect, it } from 'vitest'; import { getSessionColumns } from './session-table-columns'; describe('getSessionColumns', () => { + it('keeps duration column sortable', () => { + const result = getSessionColumns(); + const columnsById = Object.fromEntries(result.map((column) => [column.id, column])); + + expect(columnsById.duration?.enableSorting).toBeUndefined(); + }); it('marks Summary as the flexible full-width column', () => { const summaryColumn = getSessionColumns().find((column) => column.id === 'summary'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts index 8bc9cdbfae..7fcb26e430 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts @@ -51,7 +51,6 @@ export function getSessionColumns(): ColumnDef renderComponent(SessionDurationCell, { summary: prop.row.original }), - enableSorting: false, header: 'Duration', id: 'duration', maxSize: 320, From f498fdcc4bf40a68fc0ca10ecffd02fe0b29930e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 14:15:35 -0500 Subject: [PATCH 3/9] Fix stack sorting UI contract --- .../components/table/options.svelte.test.ts | 11 ++++--- .../events/components/table/options.svelte.ts | 29 ++++++++++++++++--- .../components/table/stack-sort-header.svelte | 19 ++++++++++++ .../table/stack-sort-header.svelte.test.ts | 24 +++++++++++++++ .../components/session-table-columns.test.ts | 6 ---- .../components/session-table-columns.ts | 1 + .../src/routes/(app)/stack/+page.svelte | 4 +++ 7 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-sort-header.svelte.test.ts 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 f606eaeced..a7340e4f75 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 @@ -6,13 +6,16 @@ import type { EventSummaryModel, StackSummaryModel, SummaryTemplateKeys } from ' import { defaultEventColumnVisibility, defaultStackColumnVisibility, getColumns } from './options.svelte'; describe('event table columns', () => { - it('keeps stack events, first, and last columns sortable', () => { + 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).toBeUndefined(); - expect(columnsById.first?.enableSorting).toBeUndefined(); - expect(columnsById.last?.enableSorting).toBeUndefined(); + 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).toBeTypeOf('function'); + expect(columnsById.last?.header).toBeTypeOf('function'); }); it('keeps summary message column unsortable', () => { 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 ac8ff6d546..24f8cca701 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[] = [ @@ -305,7 +308,13 @@ export function getColumns() }), - header: 'Events', + enableSorting: false, + header: () => + renderComponent(StackSortHeader, { + active: mode === 'stack_frequent', + label: 'Events', + onclick: () => options?.onStackSort?.('stack_frequent') + }), id: 'events', maxSize: 320, meta: { @@ -320,7 +329,13 @@ export function getColumns() }), - header: 'First', + enableSorting: false, + header: () => + renderComponent(StackSortHeader, { + active: mode === 'stack_new', + label: 'First', + onclick: () => options?.onStackSort?.('stack_new') + }), id: 'first', maxSize: 480, meta: { @@ -335,7 +350,13 @@ export function getColumns() }), - header: 'Last', + enableSorting: false, + header: () => + renderComponent(StackSortHeader, { + active: mode === 'stack_recent', + label: 'Last', + onclick: () => options?.onStackSort?.('stack_recent') + }), id: 'last', maxSize: 480, meta: { 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..56ab03aaa0 --- /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).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(button); + + expect(onclick).toHaveBeenCalledOnce(); + }); + + it('does not mark inactive sort modes as selected', () => { + render(StackSortHeader, { active: false, label: 'First', onclick: vi.fn() }); + + expect(screen.getByRole('button', { name: 'Sort by First descending' })).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts index 5cf67afbda..04b1cb246c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.test.ts @@ -3,12 +3,6 @@ import { describe, expect, it } from 'vitest'; import { getSessionColumns } from './session-table-columns'; describe('getSessionColumns', () => { - it('keeps duration column sortable', () => { - const result = getSessionColumns(); - const columnsById = Object.fromEntries(result.map((column) => [column.id, column])); - - expect(columnsById.duration?.enableSorting).toBeUndefined(); - }); it('marks Summary as the flexible full-width column', () => { const summaryColumn = getSessionColumns().find((column) => column.id === 'summary'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts index 7fcb26e430..8bc9cdbfae 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts @@ -51,6 +51,7 @@ export function getSessionColumns(): ColumnDef renderComponent(SessionDurationCell, { summary: prop.row.original }), + enableSorting: false, header: 'Duration', id: 'duration', maxSize: 320, 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..38f4a11967 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -700,6 +700,10 @@ columnPersistenceKey: 'stacks-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onStackSort: (mode) => { + eventsQueryParameters.mode = mode; + table.setPageIndex(0); + }, onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); From efef7d6505955cb8a4d7ac227be82cf0f2c8959f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 14:18:56 -0500 Subject: [PATCH 4/9] Fix stack sort test portability --- .../events/components/table/stack-sort-header.svelte.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 56ab03aaa0..0e29592915 100644 --- 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 @@ -9,7 +9,7 @@ describe('StackSortHeader', () => { render(StackSortHeader, { active: true, label: 'Events', onclick }); const button = screen.getByRole('button', { name: 'Sort by Events descending' }); - expect(button).toHaveAttribute('aria-pressed', 'true'); + expect(button.getAttribute('aria-pressed')).toBe('true'); await fireEvent.click(button); @@ -19,6 +19,6 @@ describe('StackSortHeader', () => { it('does not mark inactive sort modes as selected', () => { render(StackSortHeader, { active: false, label: 'First', onclick: vi.fn() }); - expect(screen.getByRole('button', { name: 'Sort by First descending' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Sort by First descending' }).getAttribute('aria-pressed')).toBe('false'); }); }); From fa4a078da2248415a0e3194cbd4d9a711c767a63 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 15 Jul 2026 21:50:10 -0500 Subject: [PATCH 5/9] Remove unsupported first occurrence stack sort --- .../events/components/table/options.svelte.test.ts | 2 +- .../features/events/components/table/options.svelte.ts | 9 ++------- .../components/table/stack-sort-header.svelte.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 10 deletions(-) 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 a7340e4f75..fef7f338bd 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 @@ -14,7 +14,7 @@ describe('event table columns', () => { expect(columnsById.first?.enableSorting).toBe(false); expect(columnsById.last?.enableSorting).toBe(false); expect(columnsById.events?.header).toBeTypeOf('function'); - expect(columnsById.first?.header).toBeTypeOf('function'); + expect(columnsById.first?.header).toBe('First'); expect(columnsById.last?.header).toBeTypeOf('function'); }); 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 24f8cca701..bcefc27dc9 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 @@ -34,7 +34,7 @@ export const defaultStackColumnVisibility: ColumnVisibilityState = { tags: false }; -export type StackSortMode = Extract; +export type StackSortMode = Extract; export function getColumns>( mode: GetEventsMode = 'summary', @@ -330,12 +330,7 @@ export function getColumns() }), enableSorting: false, - header: () => - renderComponent(StackSortHeader, { - active: mode === 'stack_new', - label: 'First', - onclick: () => options?.onStackSort?.('stack_new') - }), + header: 'First', id: 'first', maxSize: 480, meta: { 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 index 0e29592915..d86bd454e7 100644 --- 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 @@ -17,8 +17,8 @@ describe('StackSortHeader', () => { }); it('does not mark inactive sort modes as selected', () => { - render(StackSortHeader, { active: false, label: 'First', onclick: vi.fn() }); + render(StackSortHeader, { active: false, label: 'Last', onclick: vi.fn() }); - expect(screen.getByRole('button', { name: 'Sort by First descending' }).getAttribute('aria-pressed')).toBe('false'); + expect(screen.getByRole('button', { name: 'Sort by Last descending' }).getAttribute('aria-pressed')).toBe('false'); }); }); From 58d6587b4c3028239d2f5966c2227e94b734d753 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:36:20 -0500 Subject: [PATCH 6/9] Persist stack sort selection --- .../components/table/options.svelte.test.ts | 10 +++++- .../events/components/table/options.svelte.ts | 4 +++ .../src/routes/(app)/stack/+page.svelte | 33 +++++++++++++++---- 3 files changed, 40 insertions(+), 7 deletions(-) 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 fef7f338bd..890969cee0 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,17 @@ 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('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])); 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 bcefc27dc9..3204aaf576 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 @@ -366,6 +366,10 @@ export function getColumns 0 ? value : '—'; } 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 38f4a11967..a9bf7db747 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, @@ -215,6 +216,24 @@ .filter((item) => item); } + function getPersistedStackSort(): StackSortMode | undefined { + if (queryParams.sort != null) { + return getStackSortMode(queryParams.sort) ?? 'stack_frequent'; + } + + return getStackSortMode(savedViewsState.activeSavedView?.sort); + } + + function getEffectiveStackSort(): StackSortMode { + return getPersistedStackSort() ?? 'stack_frequent'; + } + + function setStackSort(mode: StackSortMode): void { + const savedViewSort = getStackSortMode(savedViewsState.activeSavedView?.sort) ?? 'stack_frequent'; + queryParams.sort = mode === savedViewSort ? null : mode; + table.setPageIndex(0); + } + updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS); const queryParams = createQueryParameters({ defaults: DEFAULT_PARAMS, @@ -229,6 +248,7 @@ project: 'string', reference: 'string', session: 'string', + sort: 'string', stack: 'string', status: 'string', tag: 'string', @@ -267,6 +287,7 @@ getFilterDefinitions: () => serializeFilters(filters ?? []), getShowChart: () => showChart, getShowStats: () => showStats, + getSort: getPersistedStackSort, getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), @@ -659,7 +680,9 @@ set limit(value) { setPageSize(value); }, - mode: 'stack_frequent', + get mode() { + return getEffectiveStackSort(); + }, offset: DEFAULT_OFFSET, get page() { return queryParams.page ?? undefined; @@ -700,10 +723,7 @@ columnPersistenceKey: 'stacks-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { - onStackSort: (mode) => { - eventsQueryParameters.mode = mode; - table.setPageIndex(0); - }, + onStackSort: setStackSort, onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); @@ -895,6 +915,7 @@ {showStats} setShowChart={(v) => (showChart = v)} setShowStats={(v) => (showStats = v)} + sort={getPersistedStackSort()} {table} time={getQueryTime() ?? undefined} view={VIEW} From c179f92851272e5105399655272a46cf9b3e7da6 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:56:04 -0500 Subject: [PATCH 7/9] Preserve legacy stack view sorting --- .../events/components/table/options.svelte.test.ts | 2 ++ .../features/events/components/table/options.svelte.ts | 10 +++++++++- .../ClientApp/src/routes/(app)/stack/+page.svelte | 8 ++++---- 3 files changed, 15 insertions(+), 5 deletions(-) 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 890969cee0..b13eb03339 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 @@ -9,6 +9,8 @@ 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(); 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 3204aaf576..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 @@ -367,7 +367,15 @@ export function getColumns item); } - function getPersistedStackSort(): StackSortMode | undefined { + function getPersistedStackSort(): string | undefined { if (queryParams.sort != null) { return getStackSortMode(queryParams.sort) ?? 'stack_frequent'; } - return getStackSortMode(savedViewsState.activeSavedView?.sort); + return savedViewsState.activeSavedView?.sort ?? undefined; } function getEffectiveStackSort(): StackSortMode { - return getPersistedStackSort() ?? 'stack_frequent'; + return getStackSortMode(getPersistedStackSort()) ?? 'stack_frequent'; } function setStackSort(mode: StackSortMode): void { @@ -915,7 +915,7 @@ {showStats} setShowChart={(v) => (showChart = v)} setShowStats={(v) => (showStats = v)} - sort={getPersistedStackSort()} + sort={getStackSortMode(getPersistedStackSort())} {table} time={getQueryTime() ?? undefined} view={VIEW} From 07f054e3f925ea12bab7e1dfa2f267d78738a089 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 15 Sep 2026 19:34:24 -0500 Subject: [PATCH 8/9] Preserve stack sort selection and legacy saved views --- .../ClientApp/e2e/tests/stack-sorting.e2e.ts | 116 ++++++++++++++++++ .../components/table/options.svelte.test.ts | 1 + .../src/routes/(app)/stack/+page.svelte | 48 +++++--- 3 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts 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..3a2a23eefa --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts @@ -0,0 +1,116 @@ +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] of [ + ['-events', 'Events', 'stack_frequent'], + ['-last', 'Last', 'stack_recent'] +] as const) { + test(`legacy ${sort} stack views 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}`); + 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 b13eb03339..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 @@ -34,6 +34,7 @@ describe('event table columns', () => { 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/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 9656dfa165..4c04f80077 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -133,6 +133,18 @@ return filter || null; } + function getEffectiveStackSort(): StackSortMode { + return getStackSortMode(getPersistedStackSort()) ?? 'stack_frequent'; + } + + function getPersistedStackSort(): string | undefined { + if (queryParams.sort != null) { + return getStackSortMode(queryParams.sort) ?? 'stack_frequent'; + } + + return savedViewsState.activeSavedView?.sort ?? undefined; + } + function getQueryFilters(params: ListFilterQueryParams = queryParams): FacetedFilter.IFilter[] | null { const filters: FacetedFilter.IFilter[] = []; @@ -209,6 +221,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(',') @@ -216,24 +234,6 @@ .filter((item) => item); } - function getPersistedStackSort(): string | undefined { - if (queryParams.sort != null) { - return getStackSortMode(queryParams.sort) ?? 'stack_frequent'; - } - - return savedViewsState.activeSavedView?.sort ?? undefined; - } - - function getEffectiveStackSort(): StackSortMode { - return getStackSortMode(getPersistedStackSort()) ?? 'stack_frequent'; - } - - function setStackSort(mode: StackSortMode): void { - const savedViewSort = getStackSortMode(savedViewsState.activeSavedView?.sort) ?? 'stack_frequent'; - queryParams.sort = mode === savedViewSort ? null : mode; - table.setPageIndex(0); - } - updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS); const queryParams = createQueryParameters({ defaults: DEFAULT_PARAMS, @@ -743,6 +743,16 @@ }) ); + watch( + getEffectiveStackSort, + () => { + table.resetRowSelection(); + }, + { + lazy: true + } + ); + async function handleRefresh() { table.resetRowSelection(); await eventsQuery.refetch(); @@ -915,7 +925,7 @@ {showStats} setShowChart={(v) => (showChart = v)} setShowStats={(v) => (showStats = v)} - sort={getStackSortMode(getPersistedStackSort())} + sort={getPersistedStackSort()} {table} time={getQueryTime() ?? undefined} view={VIEW} From 403498492ded3f343b17db98278470de8a5114a4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 21 Sep 2026 22:11:54 -0500 Subject: [PATCH 9/9] Preserve explicit legacy stack sort aliases --- .../ClientApp/e2e/tests/stack-sorting.e2e.ts | 16 +++++++++++----- .../src/routes/(app)/stack/+page.svelte | 6 +----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts index 3a2a23eefa..0c5d33f9de 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts @@ -40,11 +40,17 @@ test('stack sort survives reload and resets pagination and selection without sen expect(requests.every((url) => !url.searchParams.has('sort'))).toBe(true); }); -for (const [sort, label, mode] of [ - ['-events', 'Events', 'stack_frequent'], - ['-last', 'Last', 'stack_recent'] +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 preserve their sort through reset and save`, async ({ e2eScenario, page, request }) => { + 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`, { @@ -62,7 +68,7 @@ for (const [sort, label, mode] of [ const savedView = (await createResponse.json()) as { id: string }; const initialResponse = waitForStackMode(page, mode); - await page.goto(`/next/stack/${slug}`); + 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); 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 4c04f80077..5023872a7c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -138,11 +138,7 @@ } function getPersistedStackSort(): string | undefined { - if (queryParams.sort != null) { - return getStackSortMode(queryParams.sort) ?? 'stack_frequent'; - } - - return savedViewsState.activeSavedView?.sort ?? undefined; + return queryParams.sort ?? savedViewsState.activeSavedView?.sort ?? undefined; } function getQueryFilters(params: ListFilterQueryParams = queryParams): FacetedFilter.IFilter[] | null {