diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts index 569eb0b4a2..d8a669549a 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 @@ -2,9 +2,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 93f628f242..2861593712 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,15 @@ 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[] = [ @@ -302,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: { @@ -334,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: { @@ -349,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 00585957d1..4785b7a8d7 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(): 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, @@ -229,6 +248,7 @@ project: 'string', reference: 'string', session: 'string', + sort: 'string', stack: 'string', status: 'string', tag: 'string', @@ -255,6 +275,7 @@ getFilterDefinitions: () => serializeFilters(filters ?? []), getShowChart: () => showChart, getShowStats: () => showStats, + getSort: getPersistedStackSort, getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), @@ -626,7 +647,9 @@ set limit(value) { setPageSize(value); }, - mode: 'stack_frequent', + get mode() { + return getEffectiveStackSort(); + }, offset: DEFAULT_OFFSET, get page() { return queryParams.page ?? undefined; @@ -667,6 +690,7 @@ columnPersistenceKey: 'stacks-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onStackSort: setStackSort, onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); @@ -864,6 +888,7 @@ {showStats} setShowChart={(v) => (showChart = v)} setShowStats={(v) => (showStats = v)} + sort={getStackSortMode(getPersistedStackSort())} {table} time={getQueryTime() ?? undefined} view={VIEW}