Skip to content
122 changes: 122 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/stack-sorting.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
return page.waitForResponse((response) => {
const url = new URL(response.url());
return isStackRequest(url) && url.searchParams.get('mode') === mode;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<StackSummaryModel<SummaryTemplateKeys>>('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<EventSummaryModel<SummaryTemplateKeys>>('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<EventSummaryModel<SummaryTemplateKeys>>();
const columnIds = columns.map((column) => column.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -33,9 +34,11 @@ export const defaultStackColumnVisibility: ColumnVisibilityState = {
tags: false
};

export type StackSortMode = Extract<GetEventsMode, 'stack_frequent' | 'stack_recent'>;

export function getColumns<TSummaryModel extends SummaryModel<SummaryTemplateKeys>>(
mode: GetEventsMode = 'summary',
options?: { onTagClick?: (tag: string) => Promise<void> | void; showType?: boolean }
options?: { onStackSort?: (mode: StackSortMode) => void; onTagClick?: (tag: string) => Promise<void> | void; showType?: boolean }
): ColumnDef<StockFeatures, TSummaryModel, unknown>[] {
const showType = options?.showType ?? true;
const columns: ColumnDef<StockFeatures, TSummaryModel, unknown>[] = [
Expand Down Expand Up @@ -306,7 +309,12 @@ export function getColumns<TSummaryModel extends SummaryModel<SummaryTemplateKey
value: prop.getValue<number>()
}),
enableSorting: false,
header: 'Events',
header: () =>
renderComponent(StackSortHeader, {
active: mode === 'stack_frequent',
label: 'Events',
onclick: () => options?.onStackSort?.('stack_frequent')
}),
id: 'events',
maxSize: 320,
meta: {
Expand Down Expand Up @@ -338,7 +346,12 @@ export function getColumns<TSummaryModel extends SummaryModel<SummaryTemplateKey
value: prop.getValue<string>()
}),
enableSorting: false,
header: 'Last',
header: () =>
renderComponent(StackSortHeader, {
active: mode === 'stack_recent',
label: 'Last',
onclick: () => options?.onStackSort?.('stack_recent')
}),
id: 'last',
maxSize: 480,
meta: {
Expand All @@ -353,6 +366,18 @@ export function getColumns<TSummaryModel extends SummaryModel<SummaryTemplateKey
return columns;
}

export function getStackSortMode(value: null | string | undefined): StackSortMode | undefined {
if (value === 'stack_frequent' || value === '-events') {
return 'stack_frequent';
}

if (value === 'stack_recent' || value === '-last') {
return 'stack_recent';
}

return undefined;
}

function formatTextColumn(value: unknown): string {
return typeof value === 'string' && value.length > 0 ? value : '—';
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script lang="ts">
import { Button } from '$comp/ui/button';
import ArrowDown from '@lucide/svelte/icons/arrow-down';

interface Props {
active: boolean;
label: string;
onclick: () => void;
}

let { active, label, onclick }: Props = $props();
</script>

<Button aria-label={`Sort by ${label} descending`} aria-pressed={active} class="h-8" {onclick} variant="ghost">
{label}
{#if active}
<ArrowDown aria-hidden="true" />
{/if}
</Button>
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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[] = [];

Expand Down Expand Up @@ -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(',')
Expand All @@ -229,6 +244,7 @@
project: 'string',
reference: 'string',
session: 'string',
sort: 'string',
stack: 'string',
status: 'string',
tag: 'string',
Expand Down Expand Up @@ -267,6 +283,7 @@
getFilterDefinitions: () => serializeFilters(filters ?? []),
getShowChart: () => showChart,
getShowStats: () => showStats,
getSort: getPersistedStackSort,
getTime: getQueryTime,
queryParams,
setColumnOrder: (v) => table.setColumnOrder(v),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -700,6 +719,7 @@
columnPersistenceKey: 'stacks-column-visibility',
get columns() {
return getColumns<EventSummaryModel<SummaryTemplateKeys>>(eventsQueryParameters.mode, {
onStackSort: setStackSort,
onTagClick: (tag) => onFilterChanged(new TagFilter([tag])),
showType: !hasSingleTypeFilter(eventsQueryParameters.filter)
});
Expand All @@ -719,6 +739,16 @@
})
);

watch(
getEffectiveStackSort,
() => {
table.resetRowSelection();
},
{
lazy: true
}
);

async function handleRefresh() {
table.resetRowSelection();
await eventsQuery.refetch();
Expand Down Expand Up @@ -891,6 +921,7 @@
{showStats}
setShowChart={(v) => (showChart = v)}
setShowStats={(v) => (showStats = v)}
sort={getPersistedStackSort()}
{table}
time={getQueryTime() ?? undefined}
view={VIEW}
Expand Down
Loading