Skip to content
Open
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(`/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]
Comment on lines +43 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Share setup across the saved-view alias cases

Each entry in this loop becomes a separate Playwright test, so e2eScenario provisions and deletes an organization and project four times; with one worker per browser shard, these variants serialize the same reset/save journey and add repeated service setup to the CI critical path. Keep one representative browser journey and move the alias matrix to Vitest, or exercise the variants with test.step under a single fixture.

AGENTS.md reference: src/Exceptionless.Web/ClientApp/AGENTS.md:L27-L29

Useful? React with 👍 / 👎.

] 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(`/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(`/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