Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 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,120 @@
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);
});

test('legacy stack view aliases survive reset and save', async ({ e2eScenario, page, request }) => {
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) {
await test.step(`${sort} explicit URL: ${explicitSort}`, async () => {
const headers = { Authorization: `Bearer ${e2eScenario.userToken}` };
const slug = `sort-${label.toLowerCase()}-${explicitSort}`;
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 @@ -4,9 +4,38 @@ import { supportsColumnWrapping } from '#features/shared/components/data-table/c

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 @@ -15,6 +15,7 @@ import Summary from '../summary/summary.svelte';
import EventEnvironmentCell from './event-environment-cell.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 @@ -36,9 +37,16 @@ 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?: { onEnvironmentClick?: (environment: string) => Promise<void> | void; onTagClick?: (tag: string) => Promise<void> | void; showType?: boolean }
options?: {
onEnvironmentClick?: (environment: string) => Promise<void> | void;
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 @@ -322,7 +330,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 @@ -354,7 +367,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 @@ -369,6 +387,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,20 @@
<script lang="ts">
import ArrowDown from '@lucide/svelte/icons/arrow-down';

import { Button } from '#comp/ui/button/index.ts';

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 @@ -52,7 +52,7 @@
} from '#features/events/components/filters/index.ts';
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.ts';
import { defaultStackColumnVisibility, getColumns, getStackSortMode, type StackSortMode } from '#features/events/components/table/options.svelte.ts';
import { filterUsesPremiumFeatures } from '#features/events/premium-filter.ts';
import { organization } from '#features/organizations/context.svelte.ts';
import { premiumPage } from '#features/organizations/premium-page.svelte.ts';
Expand Down Expand Up @@ -120,6 +120,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 @@ -137,6 +138,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 @@ -217,6 +226,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 @@ -239,6 +254,7 @@
project: 'string',
reference: 'string',
session: 'string',
sort: 'string',
stack: 'string',
status: 'string',
tag: 'string',
Expand Down Expand Up @@ -277,6 +293,7 @@
getFilterDefinitions: () => serializeFilters(filters ?? []),
getShowChart: () => showChart,
getShowStats: () => showStats,
getSort: getPersistedStackSort,
getTime: getQueryTime,
queryParams,
setColumnOrder: (v) => table.setColumnOrder(v),
Expand Down Expand Up @@ -678,7 +695,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 @@ -719,6 +738,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 @@ -738,6 +758,16 @@
})
);

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

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