Skip to content

Commit bce000b

Browse files
authored
Support custom event tab reordering in the new UI (#2296)
* Add event tab reordering * Reset invalid selected organization * Fix saved view dirty state defaults * Clarify saved view default tests * Remove event tab drag handles * Handle empty app startup and stale stack links * Fix saved view dirty comparison * Fix stack detail sheet loading * Load stack summary in detail sheet * Seed event detail sheet from selected row * Remove event detail fallback regressions * Fix promoted tabs OpenAPI baseline * Fix cursor pagination page counts * Fix session event tab request churn * Remove duplicate event tab headings * Return bad request for mismatched stack events * Fix Svelte startup stability * Match signup page width to login * Match add setup page widths * Update project configuration notification copy * Hide org notifications on project configure * Improve project configure page title * Move project notification settings actions * Clarify project send events action * Shorten project notifications action label * Fix project configuration notification link text * Add clipboard copy fallback * Point organization action to events * Update organization projects action label * Use stable events route for organization action * Add events icon to configure action * Add event type column * Use intuitive event column ids * Fix client import lint
1 parent 0880563 commit bce000b

57 files changed

Lines changed: 713 additions & 105 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/Exceptionless.Core/Models/EventSummaryModel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
public record EventSummaryModel : SummaryData
44
{
55
public DateTimeOffset Date { get; set; }
6+
public string? Type { get; set; }
67
}

src/Exceptionless.Core/Models/Project.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public Project()
1212
{
1313
Configuration = new ClientConfiguration();
1414
NotificationSettings = new Dictionary<string, NotificationSettings>();
15-
PromotedTabs = new HashSet<string>();
15+
PromotedTabs = [];
1616
DeleteBotDataEnabled = false;
1717
Usage = new SortedSet<UsageInfo>(Comparer<UsageInfo>.Create((a, b) => a.Date.CompareTo(b.Date)));
1818
UsageHours = new SortedSet<UsageHourInfo>(Comparer<UsageHourInfo>.Create((a, b) => a.Date.CompareTo(b.Date)));
@@ -57,7 +57,7 @@ public Project()
5757
/// </summary>
5858
public DataDictionary? Data { get; set; }
5959

60-
public HashSet<string>? PromotedTabs { get; set; }
60+
public List<string>? PromotedTabs { get; set; }
6161

6262
public string CustomContent { get; set; } = null!;
6363

src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ export async function invalidatePersistentEventQueries(queryClient: QueryClient,
1616
}
1717

1818
if (stack_id) {
19-
await queryClient.invalidateQueries({ queryKey: queryKeys.stacks(stack_id) });
19+
await queryClient.invalidateQueries({ exact: true, queryKey: queryKeys.stacks(stack_id) });
2020
}
2121

2222
if (project_id) {
23-
await queryClient.invalidateQueries({ queryKey: queryKeys.projects(project_id) });
23+
await queryClient.invalidateQueries({ exact: true, queryKey: queryKeys.projects(project_id) });
2424
}
2525

2626
if (organization_id) {
27-
await queryClient.invalidateQueries({ queryKey: queryKeys.organizations(organization_id) });
27+
await queryClient.invalidateQueries({ exact: true, queryKey: queryKeys.organizations(organization_id) });
2828
}
2929

3030
if (!id && !stack_id) {
@@ -41,7 +41,8 @@ export const queryKeys = {
4141
organizationsCount: (id: string | undefined, params?: GetOrganizationCountRequest['params']) => [...queryKeys.organizations(id), 'count', params] as const,
4242
projects: (id: string | undefined) => [...queryKeys.type, 'projects', id] as const,
4343
projectsCount: (id: string | undefined, params?: GetProjectCountRequest['params']) => [...queryKeys.projects(id), 'count', params] as const,
44-
sessionEvents: (id: string | undefined, params?: GetSessionEventsRequest['params']) => [...queryKeys.type, 'sessions', 'session', id, params] as const,
44+
sessionEvents: (id: string | undefined, projectId?: string | undefined, params?: GetSessionEventsRequest['params']) =>
45+
[...queryKeys.type, 'sessions', 'session', id, projectId, params] as const,
4546
sessions: (id: string | undefined) => [...queryKeys.type, 'sessions', 'organizations', id] as const,
4647
sessionsCount: (id: string | undefined, params?: GetOrganizationSessionsCountRequest['params']) => [...queryKeys.sessions(id), 'count', params] as const,
4748
stackEvents: (id: string | undefined, params?: GetStackEventsRequest['params']) => [...queryKeys.stacks(id), 'events', params] as const,
@@ -68,6 +69,7 @@ export interface EventWithNavigation {
6869

6970
export interface GetEventRequest {
7071
params?: {
72+
expected_stack_id?: string;
7173
offset?: string;
7274
time?: string;
7375
};
@@ -156,6 +158,7 @@ export interface GetSessionEventsRequest {
156158
time?: string;
157159
};
158160
route: {
161+
projectId?: string | undefined;
159162
sessionId: string | undefined;
160163
};
161164
}
@@ -283,7 +286,7 @@ export function getEventWithNavigationQuery(request: GetEventRequest) {
283286
}
284287
};
285288
},
286-
queryKey: [...queryKeys.id(request.route.id), 'withNavigation']
289+
queryKey: [...queryKeys.id(request.route.id), 'withNavigation', request.params]
287290
}));
288291
}
289292

@@ -366,7 +369,10 @@ export function getSessionEventsQuery(request: GetSessionEventsRequest) {
366369
enabled: () => !!accessToken.current && !!request.route.sessionId,
367370
queryFn: async ({ signal }: { signal: AbortSignal }) => {
368371
const client = useFetchClient();
369-
const response = await client.getJSON<EventSummaryModel<SummaryTemplateKeys>[]>(`events/sessions/${request.route.sessionId}`, {
372+
const path = request.route.projectId
373+
? `projects/${request.route.projectId}/events/sessions/${request.route.sessionId}`
374+
: `events/sessions/${request.route.sessionId}`;
375+
const response = await client.getJSON<EventSummaryModel<SummaryTemplateKeys>[]>(path, {
370376
params: {
371377
...(DEFAULT_OFFSET ? { offset: DEFAULT_OFFSET } : {}),
372378
mode: 'summary',
@@ -377,7 +383,7 @@ export function getSessionEventsQuery(request: GetSessionEventsRequest) {
377383

378384
return response.data!;
379385
},
380-
queryKey: queryKeys.sessionEvents(request.route.sessionId, request.params)
386+
queryKey: queryKeys.sessionEvents(request.route.sessionId, request.route.projectId, request.params)
381387
}));
382388
}
383389

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ChangeType } from '$features/websockets/models';
2+
import { QueryClient } from '@tanstack/svelte-query';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
vi.mock('$features/auth/index.svelte', () => ({
6+
accessToken: { current: 'test-token' }
7+
}));
8+
9+
import { invalidatePersistentEventQueries, queryKeys } from './api.svelte';
10+
11+
describe('invalidatePersistentEventQueries', () => {
12+
it('does not invalidate nested count aggregation queries for event updates', async () => {
13+
// Arrange
14+
const queryClient = new QueryClient();
15+
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {});
16+
17+
// Act
18+
await invalidatePersistentEventQueries(queryClient, {
19+
change_type: ChangeType.Saved,
20+
data: {},
21+
id: 'event-id',
22+
organization_id: 'organization-id',
23+
project_id: 'project-id',
24+
stack_id: 'stack-id',
25+
type: 'PersistentEvent'
26+
});
27+
28+
// Assert
29+
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.id('event-id') });
30+
expect(invalidateSpy).toHaveBeenCalledWith({ exact: true, queryKey: queryKeys.stacks('stack-id') });
31+
expect(invalidateSpy).toHaveBeenCalledWith({ exact: true, queryKey: queryKeys.projects('project-id') });
32+
expect(invalidateSpy).toHaveBeenCalledWith({ exact: true, queryKey: queryKeys.organizations('organization-id') });
33+
expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: queryKeys.stacks('stack-id') });
34+
});
35+
});

src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte

Lines changed: 114 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import type { IFilter } from '$comp/faceted-filter';
3-
import type { ViewProject } from '$features/projects/models';
3+
import type { UpdateProject, ViewProject } from '$features/projects/models';
44
import type { ProblemDetails } from '@exceptionless/fetchclient';
55
66
import CopyToClipboardButton from '$comp/copy-to-clipboard-button.svelte';
@@ -15,13 +15,14 @@
1515
import * as EventsFacetedFilter from '$features/events/components/filters';
1616
import { getExtendedDataItems, hasErrorOrSimpleError } from '$features/events/persistent-event';
1717
import { getOrganizationQuery } from '$features/organizations/api.svelte';
18-
import { getProjectQuery } from '$features/projects/api.svelte';
18+
import { getProjectQuery, updateProject } from '$features/projects/api.svelte';
1919
import StackCard from '$features/stacks/components/stack-card.svelte';
2020
import Braces from '@lucide/svelte/icons/braces';
2121
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
2222
import ChevronRight from '@lucide/svelte/icons/chevron-right';
2323
import Funnel from '@lucide/svelte/icons/funnel';
2424
import { onMount, tick } from 'svelte';
25+
import { toast } from 'svelte-sonner';
2526
2627
import type { PersistentEvent } from '../models/index';
2728
@@ -36,14 +37,15 @@
3637
import TraceLog from './views/trace-log.svelte';
3738
3839
interface Props {
40+
expectedStackId?: string;
3941
filterChanged: (filter: IFilter) => void;
4042
handleError: (problem: ProblemDetails) => void;
4143
id: string;
4244
onEventLoaded?: (event: PersistentEvent) => void;
4345
onNavigate?: (eventId: string) => void;
4446
}
4547
46-
let { filterChanged, handleError, id, onEventLoaded, onNavigate }: Props = $props();
48+
let { expectedStackId, filterChanged, handleError, id, onEventLoaded, onNavigate }: Props = $props();
4749
4850
function getTabs(event?: null | PersistentEvent, project?: ViewProject): TabType[] {
4951
if (!event) {
@@ -94,6 +96,11 @@
9496
}
9597
9698
const eventQuery = getEventWithNavigationQuery({
99+
params: {
100+
get expected_stack_id() {
101+
return expectedStackId;
102+
}
103+
},
97104
route: {
98105
get id() {
99106
return id;
@@ -112,6 +119,14 @@
112119
}
113120
});
114121
122+
const updateProjectMutation = updateProject({
123+
route: {
124+
get id() {
125+
return event?.project_id ?? '';
126+
}
127+
}
128+
});
129+
115130
const organizationQuery = getOrganizationQuery({
116131
route: {
117132
get id() {
@@ -129,9 +144,14 @@
129144
let tabsListRef = $state<HTMLElement | null>(null);
130145
let canScrollTabsLeft = $state(false);
131146
let canScrollTabsRight = $state(false);
147+
let draggedPromotedTab = $state<null | string>(null);
132148
let notifiedEventId = $state('');
133149
let showJsonDialog = $state(false);
134150
151+
function isPromotedTab(tab: TabType): boolean {
152+
return !!projectQuery.data?.promoted_tabs?.includes(tab);
153+
}
154+
135155
function updateTabsOverflow(): void {
136156
if (!tabsListRef) {
137157
canScrollTabsLeft = false;
@@ -160,6 +180,70 @@
160180
activeTab = 'Extended Data';
161181
}
162182
183+
function movePromotedTab(source: string, target: string): null | string[] {
184+
const promotedTabs = [...(projectQuery.data?.promoted_tabs ?? [])];
185+
const fromIndex = promotedTabs.indexOf(source);
186+
const toIndex = promotedTabs.indexOf(target);
187+
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
188+
return null;
189+
}
190+
191+
const [moved] = promotedTabs.splice(fromIndex, 1);
192+
if (!moved) {
193+
return null;
194+
}
195+
196+
promotedTabs.splice(toIndex, 0, moved);
197+
return promotedTabs;
198+
}
199+
200+
function handlePromotedTabDragStart(event: DragEvent, tab: TabType): void {
201+
if (!isPromotedTab(tab)) {
202+
return;
203+
}
204+
205+
draggedPromotedTab = tab;
206+
if (event.dataTransfer) {
207+
event.dataTransfer.effectAllowed = 'move';
208+
event.dataTransfer.setData('text/plain', tab);
209+
}
210+
}
211+
212+
function handlePromotedTabDragOver(event: DragEvent, tab: TabType): void {
213+
if (!draggedPromotedTab || !isPromotedTab(tab) || draggedPromotedTab === tab) {
214+
return;
215+
}
216+
217+
event.preventDefault();
218+
if (event.dataTransfer) {
219+
event.dataTransfer.dropEffect = 'move';
220+
}
221+
}
222+
223+
async function handlePromotedTabDrop(event: DragEvent, tab: TabType): Promise<void> {
224+
event.preventDefault();
225+
const source = draggedPromotedTab;
226+
draggedPromotedTab = null;
227+
if (!source || !isPromotedTab(tab)) {
228+
return;
229+
}
230+
231+
const promotedTabs = movePromotedTab(source, tab);
232+
if (!promotedTabs) {
233+
return;
234+
}
235+
236+
try {
237+
await updateProjectMutation.mutateAsync({ promoted_tabs: promotedTabs } as UpdateProject);
238+
} catch {
239+
toast.error('An error occurred reordering tabs.');
240+
}
241+
}
242+
243+
function handlePromotedTabDragEnd(): void {
244+
draggedPromotedTab = null;
245+
}
246+
163247
function navigateToPrevious(): void {
164248
if (navigation?.previousId && onNavigate) {
165249
onNavigate(navigation.previousId);
@@ -270,19 +354,21 @@
270354
<Table.Cell class="w-4 pr-0"></Table.Cell>
271355
<Table.Cell class="flex items-center"><Skeleton class="h-6 w-full rounded-full" /></Table.Cell>{/if}
272356
</Table.Row>
273-
<Table.Row class="group">
274-
{#if projectQuery.isSuccess}
275-
<Table.Head class="w-40 font-semibold whitespace-nowrap">Project</Table.Head>
276-
<Table.Cell class="w-4 pr-0"
277-
><EventsFacetedFilter.ProjectTrigger changed={filterChanged} class="mr-0" value={[projectQuery.data.id!]} /></Table.Cell
278-
>
279-
<Table.Cell>{projectQuery.data.name}</Table.Cell>
280-
{:else}
281-
<Table.Head class="w-40 font-semibold whitespace-nowrap"><Skeleton class="h-6 w-full rounded-full" /></Table.Head>
282-
<Table.Cell class="w-4 pr-0"></Table.Cell>
283-
<Table.Cell class="flex items-center"><Skeleton class="h-6 w-full rounded-full" /></Table.Cell>
284-
{/if}
285-
</Table.Row>
357+
{#if projectQuery.isSuccess || event?.project_id}
358+
<Table.Row class="group">
359+
{#if projectQuery.isSuccess}
360+
<Table.Head class="w-40 font-semibold whitespace-nowrap">Project</Table.Head>
361+
<Table.Cell class="w-4 pr-0"
362+
><EventsFacetedFilter.ProjectTrigger changed={filterChanged} class="mr-0" value={[projectQuery.data.id!]} /></Table.Cell
363+
>
364+
<Table.Cell>{projectQuery.data.name}</Table.Cell>
365+
{:else}
366+
<Table.Head class="w-40 font-semibold whitespace-nowrap"><Skeleton class="h-6 w-full rounded-full" /></Table.Head>
367+
<Table.Cell class="w-4 pr-0"></Table.Cell>
368+
<Table.Cell class="flex items-center"><Skeleton class="h-6 w-full rounded-full" /></Table.Cell>
369+
{/if}
370+
</Table.Row>
371+
{/if}
286372
</Table.Body>
287373
</Table.Root>
288374

@@ -307,7 +393,18 @@
307393
>
308394
{#each tabs as tab (tab)}
309395
<Tabs.Trigger
310-
class="dark:data-[state=active]:bg-background flex-none shrink-0 px-3 py-1.5 data-[state=active]:shadow-xs dark:data-[state=active]:border-transparent"
396+
aria-label={isPromotedTab(tab) ? `${tab}. Drag to reorder custom tab.` : undefined}
397+
class={[
398+
'dark:data-[state=active]:bg-background flex-none shrink-0 px-3 py-1.5 data-[state=active]:shadow-xs dark:data-[state=active]:border-transparent',
399+
isPromotedTab(tab) && 'cursor-grab active:cursor-grabbing',
400+
draggedPromotedTab === tab && 'bg-accent/70'
401+
]}
402+
draggable={isPromotedTab(tab)}
403+
ondragstart={(event) => handlePromotedTabDragStart(event, tab)}
404+
ondragover={(event) => handlePromotedTabDragOver(event, tab)}
405+
ondrop={(event) => handlePromotedTabDrop(event, tab)}
406+
ondragend={handlePromotedTabDragEnd}
407+
title={isPromotedTab(tab) ? 'Drag to reorder custom tab' : undefined}
311408
value={tab}>{tab}</Tabs.Trigger
312409
>
313410
{/each}

src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
excludedKeys?: string[];
2121
isPromoted?: boolean;
2222
promote?: (title: string) => Promise<void>;
23+
showTitle?: boolean;
2324
title: string;
2425
}
2526
@@ -31,6 +32,7 @@
3132
excludedKeys = [],
3233
isPromoted = false,
3334
promote = async () => {},
35+
showTitle = true,
3436
title
3537
}: Props = $props();
3638
@@ -112,8 +114,10 @@
112114

113115
{#if hasData}
114116
<div class={['flex flex-col space-y-2', className]}>
115-
<div class="flex items-center justify-between">
116-
<H4>{title}</H4>
117+
<div class={['flex items-center', showTitle ? 'justify-between' : 'justify-end']}>
118+
{#if showTitle}
119+
<H4>{title}</H4>
120+
{/if}
117121
<DropdownMenu.Root>
118122
<DropdownMenu.Trigger>
119123
{#snippet child({ props })}

src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface EventSummaryData {
6161
export interface EventSummaryModel<T extends SummaryTemplateKeys> extends SummaryModel<T> {
6262
/** @format date-time */
6363
date: string;
64+
type?: string;
6465
}
6566

6667
export interface StackErrorSummaryData {

0 commit comments

Comments
 (0)