diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 84cc7096cd..54793aec4d 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -60,6 +60,12 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates [MaxLength(100)] public string Name { get; set; } = null!; + /// URL slug used to load this saved view. + [Required] + [MaxLength(100)] + [RegularExpression("^(?![a-f0-9]{24}$)[a-z0-9]+(?:-[a-z0-9]+)*$")] + public string Slug { get; set; } = null!; + /// Date-math time range, e.g. "[now-7d TO now]". Null if no time constraint. [MaxLength(100)] public string? Time { get; set; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte index 971710fd14..d19ca71401 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte @@ -19,7 +19,7 @@ let { detailsHref, eventId = $bindable(), filterChanged, onClose, onError }: Props = $props(); - const resolvedHref = $derived(detailsHref ?? (eventId ? resolve('/(app)/event/[eventId]', { eventId }) : '#')); + const resolvedHref = $derived(detailsHref ?? (eventId ? resolve('/(app)/events/[eventId=objectid]', { eventId }) : '#')); function handleOpenChange() { onClose(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte index 07c0802aa3..ce83f26d8a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte @@ -35,7 +35,7 @@ {#if normalizedLogLevel} - + {normalizedLogLevel} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-error-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-error-summary.svelte index 1a7ff86b4d..e18bf0e979 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-error-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-error-summary.svelte @@ -29,7 +29,7 @@ {/if} - + {source.data.Message} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-feature-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-feature-summary.svelte index e1c553b81a..221c84ac63 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-feature-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-feature-summary.svelte @@ -17,5 +17,5 @@ {#if showType} Feature:  {/if} - {source.data.Source} + {source.data.Source} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-log-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-log-summary.svelte index b9dcdf6c11..fbf1492e01 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-log-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-log-summary.svelte @@ -34,5 +34,5 @@ {/if} {#if showType || source.data.Source}: {/if} - {source.data.Message} + {source.data.Message} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-not-found-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-not-found-summary.svelte index 30e5c1e28a..585c5fd96b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-not-found-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-not-found-summary.svelte @@ -17,5 +17,5 @@ {#if showType} 404:  {/if} - {source.data.Source} + {source.data.Source} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-session-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-session-summary.svelte index f2526efe16..ccf43bcc60 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-session-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-session-summary.svelte @@ -26,7 +26,7 @@ :  {/if} - + {#if source.data.Name || source.data.Identity || source.data.SessionId} {source.data.Name || source.data.Identity || source.data.SessionId} {#if source.data.Name && source.data.Identity} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-simple-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-simple-summary.svelte index 56faa42dee..caf55d5a07 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-simple-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-simple-summary.svelte @@ -15,7 +15,7 @@
{source.data.Type}: - {source.data.Message} + {source.data.Message}
{#if source.data.Path} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-summary.svelte index 8094647ecd..e35cae8f2e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/event-summary.svelte @@ -26,5 +26,5 @@ {#if showType || source.data.Source} :  {/if} - {source.data.Message} + {source.data.Message} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte index d89813b081..8fd65946b6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte @@ -37,7 +37,7 @@ {/if} - + {source.title} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-feature-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-feature-summary.svelte index bf03b6d71b..d33a82790c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-feature-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-feature-summary.svelte @@ -27,7 +27,7 @@ Feature:  {/if} - + {source.title} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-log-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-log-summary.svelte index f22420e07c..d00a8ca8f9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-log-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-log-summary.svelte @@ -27,7 +27,7 @@ Log source:  {/if} - + {#if source.data?.Source} {source.data.SourceShortName} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-not-found-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-not-found-summary.svelte index 0723a40857..91a8c6ec56 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-not-found-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-not-found-summary.svelte @@ -26,7 +26,7 @@ {#if showType} 404:  {/if} - + {source.title} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-session-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-session-summary.svelte index 5d683637a4..bee537d038 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-session-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-session-summary.svelte @@ -27,7 +27,7 @@ Session:  {/if} - + {source.title} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-simple-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-simple-summary.svelte index d3f864c342..9a910e3f26 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-simple-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-simple-summary.svelte @@ -27,7 +27,7 @@ {source.data.Type}: - {source.title} + {source.title} {#if source.data.Path} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-summary.svelte index 3cad4a44a0..c34d604dee 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-summary.svelte @@ -39,7 +39,7 @@ :  {/if} - + {source.title} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte index b59cb11a94..a7ca86407c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte @@ -31,7 +31,7 @@ const sessionId = $derived(getSessionId(event)); const isSessionStart = $derived(event.type === 'session'); - const eventsPath = $derived(resolve('/(app)')); + const eventsPath = $derived(resolve('/(app)/events')); const sessionEventsHref = $derived.by(() => { const filter = getSessionFilter(); if (!filter) { @@ -64,7 +64,7 @@ }); function getEventHref(eventId: string): string { - return resolve('/(app)/event/[eventId]', { eventId }); + return resolve('/(app)/events/[eventId=objectid]', { eventId }); } function getSessionFilter(): SessionFilter | undefined { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte index 777f521c61..d4bedd5b10 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte @@ -17,7 +17,7 @@ let { name, userOrganizations, ...restProps }: Props = $props(); async function stopImpersonating(): Promise { - await goto(resolve('/(app)')); + await goto(resolve('/(app)/issues')); organization.current = userOrganizations[0]?.id; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts index 8af9e9ea8b..a41e8a207d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -55,6 +55,8 @@ export const queryKeys = { view: (organizationId: string | undefined, view: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'view', view] as const }; +let deletedSavedViewIds = $state([]); + export function deleteSavedView(request: { route: { organizationId: string | undefined } }) { const queryClient = useQueryClient(); @@ -66,6 +68,17 @@ export function deleteSavedView(request: { route: { organizationId: string | und expectedStatusCodes: [202] }); }, + onError: (_error: ProblemDetails, savedView: SavedView) => { + restoreDeletedSavedView(savedView); + syncSavedViewCaches(queryClient, savedView, request.route.organizationId); + }, + onMutate: (savedView: SavedView) => { + markSavedViewDeleted(savedView); + removeSavedViewFromCaches(queryClient, savedView, request.route.organizationId); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.type }); + }, onSuccess: (_data: void, savedView: SavedView) => { removeSavedViewFromCaches(queryClient, savedView, request.route.organizationId); } @@ -98,6 +111,16 @@ export function getSavedViewsQuery(request: { route: { organizationId: string | })); } +export function isSavedViewDeleted(savedView: SavedView): boolean { + return !!savedView.id && deletedSavedViewIds.includes(savedView.id); +} + +export function markSavedViewDeleted(savedView: SavedView): void { + if (savedView.id && !deletedSavedViewIds.includes(savedView.id)) { + deletedSavedViewIds = [...deletedSavedViewIds, savedView.id]; + } +} + export function patchSavedView(request: { route: { id: string | undefined } }) { const queryClient = useQueryClient(); @@ -134,6 +157,13 @@ export function removeSavedViewFromCaches(queryClient: QueryClient, savedView: S const evict = (cachedViews: SavedView[] | undefined) => cachedViews?.filter((v) => v.id !== savedView.id); queryClient.setQueryData(queryKeys.view(organizationId, savedView.view_type), evict); queryClient.setQueryData(queryKeys.organization(organizationId), evict); + queryClient.setQueriesData({ queryKey: queryKeys.type }, evict); +} + +export function restoreDeletedSavedView(savedView: SavedView): void { + if (savedView.id) { + deletedSavedViewIds = deletedSavedViewIds.filter((id) => id !== savedView.id); + } } export function syncSavedViewCaches(queryClient: QueryClient, savedView: SavedView, organizationId: string | undefined = savedView.organization_id) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/rename-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/rename-view-dialog.svelte index b9f666e43a..e7542fbb92 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/rename-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/rename-view-dialog.svelte @@ -4,30 +4,111 @@ import { Input } from '$comp/ui/input'; import { Label } from '$comp/ui/label'; + import type { SavedView } from '../models'; + + import { + findSavedViewByName, + findSavedViewBySlug, + isSavedViewSlugReserved, + isSavedViewSlugValid, + SAVED_VIEW_NAME_MAX_LENGTH, + SAVED_VIEW_SLUG_MAX_LENGTH, + savedViewSlug + } from '../slugs'; + interface Props { name: string; onClose: () => void; - onRename: (newName: string) => Promise; + onRename: (newName: string, slug: string) => Promise; open: boolean; + savedViews: SavedView[]; saving: boolean; + slug?: null | string; + viewId: string; } - let { name, onClose, onRename, open = $bindable(), saving }: Props = $props(); + let { name, onClose, onRename, open = $bindable(), savedViews, saving, slug, viewId }: Props = $props(); let renameName = $state(''); + let renameSlug = $state(''); + let isSlugDirty = $state(false); + let attemptedSubmit = $state(false); + + const trimmedName = $derived(renameName.trim()); + const normalizedSlug = $derived(savedViewSlug(renameSlug)); + const duplicateName = $derived(findSavedViewByName(savedViews, trimmedName, viewId)); + const duplicateSlug = $derived(findSavedViewBySlug(savedViews, normalizedSlug, viewId)); + const nameError = $derived.by(() => { + if (!trimmedName) { + return 'Name is required.'; + } + + if (trimmedName.length > SAVED_VIEW_NAME_MAX_LENGTH) { + return `Name cannot exceed ${SAVED_VIEW_NAME_MAX_LENGTH} characters.`; + } + + if (duplicateName) { + return `A saved view named "${duplicateName.name}" already exists.`; + } + + return undefined; + }); + const slugError = $derived.by(() => { + if (!normalizedSlug) { + return 'URL name is required. Use at least one letter or number.'; + } + + if (normalizedSlug.length > SAVED_VIEW_SLUG_MAX_LENGTH) { + return `URL name cannot exceed ${SAVED_VIEW_SLUG_MAX_LENGTH} characters.`; + } + + if (!isSavedViewSlugValid(normalizedSlug)) { + if (isSavedViewSlugReserved(normalizedSlug)) { + return 'URL name cannot look like an event or issue id.'; + } + + return 'URL name can only contain lowercase letters, numbers, and single dashes.'; + } + + if (duplicateSlug) { + return `A saved view with the URL name "${normalizedSlug}" already exists.`; + } + + return undefined; + }); + const visibleNameError = $derived(attemptedSubmit || renameName.length > 0 ? nameError : undefined); + const visibleSlugError = $derived(attemptedSubmit || renameName.length > 0 || renameSlug.length > 0 ? slugError : undefined); + const canRename = $derived(!nameError && !slugError && !saving); $effect(() => { if (open) { renameName = name; + renameSlug = savedViewSlug(slug || name); + isSlugDirty = false; + attemptedSubmit = false; + } + }); + + $effect(() => { + if (open && !isSlugDirty) { + renameSlug = savedViewSlug(renameName); + } + }); + + $effect(() => { + const normalizedSlug = savedViewSlug(renameSlug); + if (renameSlug !== normalizedSlug) { + renameSlug = normalizedSlug; } }); async function handleRename() { - if (!renameName.trim()) { + attemptedSubmit = true; + if (!canRename) { return; } - await onRename(renameName.trim()); + await onRename(trimmedName, normalizedSlug); } @@ -46,11 +127,41 @@ >
- + + {#if visibleNameError} +

{visibleNameError}

+ {/if} +
+
+ + { + isSlugDirty = true; + }} + /> + {#if visibleSlugError} +

{visibleSlugError}

+ {/if}
- diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index da69eb4645..ae5e8d638b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -8,33 +8,110 @@ import type { SavedView } from '../models'; + import { + findSavedViewByName, + findSavedViewBySlug, + isSavedViewSlugReserved, + isSavedViewSlugValid, + SAVED_VIEW_NAME_MAX_LENGTH, + SAVED_VIEW_SLUG_MAX_LENGTH, + savedViewSlug + } from '../slugs'; + interface Props { duplicateView?: SavedView; onClose: () => void; - onLoadView: (id: string) => void; - onSave: (name: string, isPrivate: boolean) => Promise; + onLoadView: (view: SavedView) => void; + onSave: (name: string, slug: string, isPrivate: boolean) => Promise; open: boolean; + savedViews: SavedView[]; saving: boolean; } - let { duplicateView, onClose, onLoadView, onSave, open = $bindable(), saving }: Props = $props(); + let { duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); let saveName = $state(''); + let saveSlug = $state(''); + let isSlugDirty = $state(false); let isPrivate = $state(false); + let attemptedSubmit = $state(false); + + const trimmedName = $derived(saveName.trim()); + const normalizedSlug = $derived(savedViewSlug(saveSlug)); + const duplicateName = $derived(findSavedViewByName(savedViews, trimmedName)); + const duplicateSlug = $derived(findSavedViewBySlug(savedViews, normalizedSlug)); + const nameError = $derived.by(() => { + if (!trimmedName) { + return 'Name is required.'; + } + + if (trimmedName.length > SAVED_VIEW_NAME_MAX_LENGTH) { + return `Name cannot exceed ${SAVED_VIEW_NAME_MAX_LENGTH} characters.`; + } + + if (duplicateName) { + return `A saved view named "${duplicateName.name}" already exists.`; + } + + return undefined; + }); + const slugError = $derived.by(() => { + if (!normalizedSlug) { + return 'URL name is required. Use at least one letter or number.'; + } + + if (normalizedSlug.length > SAVED_VIEW_SLUG_MAX_LENGTH) { + return `URL name cannot exceed ${SAVED_VIEW_SLUG_MAX_LENGTH} characters.`; + } + + if (!isSavedViewSlugValid(normalizedSlug)) { + if (isSavedViewSlugReserved(normalizedSlug)) { + return 'URL name cannot look like an event or issue id.'; + } + + return 'URL name can only contain lowercase letters, numbers, and single dashes.'; + } + + if (duplicateSlug) { + return `A saved view with the URL name "${normalizedSlug}" already exists.`; + } + + return undefined; + }); + const visibleNameError = $derived(attemptedSubmit || saveName.length > 0 ? nameError : undefined); + const visibleSlugError = $derived(attemptedSubmit || saveName.length > 0 || saveSlug.length > 0 ? slugError : undefined); + const canSave = $derived(!nameError && !slugError && !saving); $effect(() => { if (open) { saveName = ''; + saveSlug = ''; + isSlugDirty = false; isPrivate = false; + attemptedSubmit = false; + } + }); + + $effect(() => { + if (!isSlugDirty) { + saveSlug = savedViewSlug(saveName); + } + }); + + $effect(() => { + const normalizedSlug = savedViewSlug(saveSlug); + if (saveSlug !== normalizedSlug) { + saveSlug = normalizedSlug; } }); async function handleSave() { - if (!saveName.trim()) { + attemptedSubmit = true; + if (!canSave) { return; } - await onSave(saveName.trim(), isPrivate); + await onSave(trimmedName, normalizedSlug, isPrivate); } @@ -53,7 +130,7 @@ class="h-auto p-0 text-sm" onclick={() => { open = false; - onLoadView(duplicateView.id); + onLoadView(duplicateView); }}>load it instead, or save with a different name. @@ -68,7 +145,37 @@ >
- + + {#if visibleNameError} +

{visibleNameError}

+ {/if} +
+
+ + { + isSlugDirty = true; + }} + /> + {#if visibleSlugError} +

{visibleSlugError}

+ {/if}
@@ -79,7 +186,7 @@
- diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 9bf8cd3b7d..cec87d8ad9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -26,7 +26,7 @@ import type { NewSavedView, SavedView, UpdateSavedView } from '../models'; - import { deleteSavedView, patchSavedView, postSavedView } from '../api.svelte'; + import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; import RenameViewDialog from './rename-view-dialog.svelte'; import SaveViewDialog from './save-view-dialog.svelte'; @@ -48,7 +48,7 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => void; - onLoadView: (id: string) => void; + onLoadView: (view: SavedView) => void; onResetToSaved: () => void; savedViews: SavedView[]; setShowChart?: (show: boolean) => void; @@ -227,7 +227,7 @@ isDeleteDialogOpen = true; } - async function handleSave(name: string, isPrivate: boolean) { + async function handleSave(name: string, slug: string, isPrivate: boolean) { if (!organizationId) { return; } @@ -244,6 +244,7 @@ organization_id: organizationId, show_chart: showChart, show_stats: showStats, + slug, sort: sort || undefined, time: time || undefined, view_type: view @@ -252,20 +253,20 @@ try { const result = await createMutation.mutateAsync(body); isSaveDialogOpen = false; - onLoadView(result.id); + onLoadView(result); toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); } } - async function handleRename(name: string) { + async function handleRename(name: string, slug: string) { if (!activeView || !organizationId) { return; } try { - const result = await updateMutation.mutateAsync({ name }); + const result = await updateMutation.mutateAsync({ name, slug }); isRenameDialogOpen = false; toast.success(`View renamed to "${result.name}".`); } catch (error) { @@ -303,15 +304,22 @@ } const target = viewToDelete; + const wasActiveView = activeSavedView?.id === target.id; + markSavedViewDeleted(target); + if (wasActiveView) { + onClearSavedView(); + } + try { await removeMutation.mutateAsync(target); - if (activeSavedView?.id === target.id) { - onClearSavedView(); - } - toast.success(`View "${target.name}" deleted.`); } catch { + restoreDeletedSavedView(target); + if (wasActiveView) { + onLoadView(target); + } + toast.error('Failed to delete view. Please try again.'); } finally { isDeleteDialogOpen = false; @@ -323,9 +331,12 @@ {#snippet child({ props })} - {/snippet} @@ -437,11 +448,28 @@ {#if isSaveDialogOpen} - (isSaveDialogOpen = false)} {onLoadView} /> + (isSaveDialogOpen = false)} + {onLoadView} + /> {/if} {#if isRenameDialogOpen && activeView} - (isRenameDialogOpen = false)} /> + (isRenameDialogOpen = false)} + /> {/if} {#if isDeleteDialogOpen} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/slugs.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/slugs.ts new file mode 100644 index 0000000000..95d42318ab --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/slugs.ts @@ -0,0 +1,59 @@ +import { resolve } from '$app/paths'; + +import type { SavedView } from './models'; + +type SavedViewIdentity = Pick & { slug?: null | string }; +type SavedViewLink = Pick & { slug?: null | string }; + +export const SAVED_VIEW_NAME_MAX_LENGTH = 100; +export const SAVED_VIEW_SLUG_MAX_LENGTH = 100; +export const SAVED_VIEW_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +export const SAVED_VIEW_OBJECT_ID_PATTERN = /^[a-f0-9]{24}$/; + +export function findSavedViewByName(savedViews: SavedViewIdentity[], name: string, excludingId?: string): SavedViewIdentity | undefined { + const normalizedName = name.trim().toLowerCase(); + if (!normalizedName) { + return undefined; + } + + return savedViews.find((savedView) => savedView.id !== excludingId && savedView.name.trim().toLowerCase() === normalizedName); +} + +export function findSavedViewBySlug(savedViews: SavedViewIdentity[], slug: string, excludingId?: string): SavedViewIdentity | undefined { + const normalizedSlug = savedViewSlug(slug); + if (!normalizedSlug) { + return undefined; + } + + return savedViews.find((savedView) => savedView.id !== excludingId && savedViewResolvedSlug(savedView) === normalizedSlug); +} + +export function isSavedViewSlugReserved(slug: string): boolean { + return SAVED_VIEW_OBJECT_ID_PATTERN.test(slug); +} + +export function isSavedViewSlugValid(slug: string): boolean { + return SAVED_VIEW_SLUG_PATTERN.test(slug) && !isSavedViewSlugReserved(slug); +} + +export function savedViewHref(savedView: SavedViewLink): string { + if (savedView.view_type === 'stream') { + return `${resolve('/(app)/stream')}?saved=${savedView.id}`; + } + + const base = savedView.view_type === 'events' ? resolve('/(app)/events') : resolve('/(app)/issues'); + return `${base}/${savedViewResolvedSlug(savedView)}`; +} + +export function savedViewResolvedSlug(savedView: Pick): string { + return savedView.slug || savedViewSlug(savedView.name); +} + +export function savedViewSlug(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-+/g, '-'); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 367603fd5a..b29d5201c3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -1,34 +1,40 @@ import type { IFilter } from '$comp/faceted-filter'; import type { ColumnOrderState, ColumnVisibilityState } from '@tanstack/svelte-table'; +import { goto } from '$app/navigation'; import { deserializeFilters } from '$features/events/components/filters/helpers.svelte'; import { organization } from '$features/organizations/context.svelte'; -import { untrack } from 'svelte'; import type { SavedView } from './models'; import { getSavedViewsByViewQuery } from './api.svelte'; +import { savedViewHref, savedViewResolvedSlug } from './slugs'; export interface SavedViewQueryParams { - filter: null | string; - saved: null | string | undefined; + filter: null | string | undefined; + saved?: null | string | undefined; sort?: null | string; time?: null | string; } export interface UseSavedViewsOptions { + baseHref?: string; defaultColumnVisibility?: ColumnVisibilityState; filterCacheKey: (filter: null | string) => string; getColumnOrder?: () => ColumnOrderState; getColumnVisibility?: () => ColumnVisibilityState; + getFilter?: () => null | string; getFilterDefinitions?: () => string; getShowChart?: () => boolean; getShowStats?: () => boolean; + getSort?: () => null | string | undefined; + getTime?: () => null | string | undefined; queryParams: SavedViewQueryParams; setColumnOrder?: (order: ColumnOrderState) => void; setColumnVisibility?: (visibility: ColumnVisibilityState) => void; setShowChart?: (show: boolean) => void; setShowStats?: (show: boolean) => void; + slug?: string; updateFilterCache: (key: string, filters: IFilter[]) => void; view: string; } @@ -36,7 +42,7 @@ export interface UseSavedViewsOptions { export interface UseSavedViewsReturn { activeSavedView: SavedView | undefined; handleClearSavedView: () => void; - handleLoadView: (id: string) => void; + handleLoadView: (view: SavedView) => void; handleResetToSaved: () => void; isEnabled: boolean; isLoading: boolean; @@ -82,7 +88,22 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur } }); - const activeSavedView = $derived(savedViewsListQuery.data?.find((v) => v.id === options.queryParams.saved)); + const activeSavedView = $derived.by(() => { + const views = savedViewsListQuery.data; + if (!views) { + return undefined; + } + + if (options.slug) { + return views.find((view) => savedViewResolvedSlug(view) === options.slug); + } + + if (options.queryParams.saved) { + return views.find((view) => view.id === options.queryParams.saved); + } + + return undefined; + }); function applyColumnState(view: Pick | undefined): void { if (options.setColumnVisibility) { @@ -99,17 +120,18 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.setShowChart?.(view?.show_chart ?? true); } - // Hydrate filters/columns when a saved view loads, or clear params if the view is no longer found. + // Hydrate saved view state when a saved view loads. Query params remain URL overrides. // lastLoadedViewId prevents re-hydration on background refetches (which would stomp user edits). let lastLoadedViewId = ''; $effect(() => { - const savedId = options.queryParams.saved; + const savedViewKey = options.slug ?? options.queryParams.saved; + const view = activeSavedView; const isLoading = savedViewsListQuery.isLoading; const isFetching = savedViewsListQuery.isFetching; const views = savedViewsListQuery.data; - if (!savedId || isLoading || !views) { - if (!savedId) { + if (!savedViewKey || isLoading || !views) { + if (!savedViewKey) { if (lastLoadedViewId !== '') { applyColumnState(undefined); applyDisplayState(undefined); @@ -121,29 +143,21 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return; } - const view = views.find((v) => v.id === savedId); if (!view) { // Skip while refetching to avoid false-positive clears during cache invalidation if (isFetching) { return; } - // View not found after a definitive load — clear params and allow auto-restore to re-run - untrack(() => { - options.queryParams.saved = null; - }); - options.queryParams.filter = null; - setSortQueryParam(options.queryParams, null); - setTimeQueryParam(options.queryParams, null); return; } // Already loaded this view — skip to avoid stomping user edits on background refetch - if (savedId === lastLoadedViewId) { + if (view.id === lastLoadedViewId) { return; } - lastLoadedViewId = savedId; + lastLoadedViewId = view.id; if (view.filter_definitions) { try { @@ -154,9 +168,6 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur } } - options.queryParams.filter = view.filter ?? null; - setSortQueryParam(options.queryParams, view.sort ?? null); - setTimeQueryParam(options.queryParams, view.time ?? null); applyColumnState(view); applyDisplayState(view); }); @@ -164,19 +175,19 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur // Detect if current filters or columns differ from the active saved view const isModified = $derived.by(() => { const view = activeSavedView; - if (!view || !options.queryParams.saved) { + if (!view) { return false; } - if ((options.queryParams.filter ?? null) !== (view.filter ?? null)) { + if ((options.getFilter?.() ?? options.queryParams.filter ?? null) !== (view.filter ?? null)) { return true; } - if (supportsTime && (options.queryParams.time ?? null) !== (view.time ?? null)) { + if (supportsTime && (options.getTime?.() ?? options.queryParams.time ?? null) !== (view.time ?? null)) { return true; } - if (supportsSort && (options.queryParams.sort ?? null) !== (view.sort ?? null)) { + if (supportsSort && (options.getSort?.() ?? options.queryParams.sort ?? null) !== (view.sort ?? null)) { return true; } @@ -203,8 +214,13 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return false; }); - function handleLoadView(id: string) { - options.queryParams.saved = id; + function handleLoadView(view: SavedView) { + if (options.baseHref) { + goto(savedViewHref(view)); + return; + } + + options.queryParams.saved = view.id; } function handleResetToSaved() { @@ -222,20 +238,27 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur } } - options.queryParams.filter = view.filter ?? null; - setSortQueryParam(options.queryParams, view.sort ?? null); - setTimeQueryParam(options.queryParams, view.time ?? null); + options.queryParams.filter = null; + setSortQueryParam(options.queryParams, null); + setTimeQueryParam(options.queryParams, null); applyColumnState(view); applyDisplayState(view); } function handleClearSavedView() { - options.queryParams.saved = null; options.queryParams.filter = null; setSortQueryParam(options.queryParams, null); setTimeQueryParam(options.queryParams, null); applyColumnState(undefined); applyDisplayState(undefined); + + if (supportsSavedQueryParam(options.queryParams)) { + options.queryParams.saved = undefined; + } + + if (options.baseHref) { + goto(options.baseHref); + } } return { @@ -311,3 +334,7 @@ function normalizeFilterDefinitions(value: null | string | undefined): string { return value; } } + +function supportsSavedQueryParam(queryParams: SavedViewQueryParams): queryParams is SavedViewQueryParams & { saved: null | string | undefined } { + return Object.prototype.hasOwnProperty.call(queryParams, 'saved'); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts index 74c3a81d56..72bd3de7dc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SavedView } from './models'; -import { invalidateSavedViewQueries, queryKeys, SAVED_VIEW_REFRESH_DELAY_MS, syncSavedViewCaches } from './api.svelte'; +import { invalidateSavedViewQueries, queryKeys, removeSavedViewFromCaches, SAVED_VIEW_REFRESH_DELAY_MS, syncSavedViewCaches } from './api.svelte'; import { type SavedViewQueryParams, setSortQueryParam, setTimeQueryParam, supportsSortQueryParam, supportsTimeQueryParam } from './use-saved-views.svelte'; const TEST_ORG_ID = '507f1f77bcf86cd799439011'; @@ -15,6 +15,13 @@ afterEach(() => { }); function buildSavedView({ id, name, ...overrides }: Partial & Pick): SavedView { + const slug = + overrides.slug ?? + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + return { column_order: null, columns: {}, @@ -32,7 +39,8 @@ function buildSavedView({ id, name, ...overrides }: Partial & Pick { expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'issues'))).toEqual([updatedView, otherView]); expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([updatedView, otherView]); }); + + it('removes a deleted view from every saved-view list cache', () => { + // Arrange + const queryClient = new QueryClient(); + const deletedView = buildSavedView({ id: 'view-1', name: 'Deleted View' }); + const otherView = buildSavedView({ id: 'view-2', name: 'Other View' }); + + queryClient.setQueryData(queryKeys.organization(TEST_ORG_ID), [deletedView, otherView]); + queryClient.setQueryData(queryKeys.view(TEST_ORG_ID, 'issues'), [deletedView, otherView]); + queryClient.setQueryData(queryKeys.view(TEST_ORG_ID, 'events'), [deletedView, otherView]); + + // Act + removeSavedViewFromCaches(queryClient, deletedView, TEST_ORG_ID); + + // Assert + expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([otherView]); + expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'issues'))).toEqual([otherView]); + expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'events'))).toEqual([otherView]); + }); }); describe('rename cache update pattern', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-detail-sheet.svelte new file mode 100644 index 0000000000..438b73106a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-detail-sheet.svelte @@ -0,0 +1,55 @@ + + + + + + + Issue Details + + + +
+ {#if stackId} + + {/if} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-details.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-details.svelte new file mode 100644 index 0000000000..6282725c14 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-details.svelte @@ -0,0 +1,53 @@ + + +{#if stackEventsQuery.isSuccess && !eventId} + + This issue has no events to display. +{:else if eventId} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 9199c50736..89cbe977b8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -137,6 +137,7 @@ export interface NewSavedView { filter?: null | string; time?: null | string; sort?: null | string; + slug?: null | string; view_type: string; filter_definitions?: null | string; columns?: null | Record; @@ -375,6 +376,7 @@ export interface UpdateSavedView { filter?: null | string; time?: null | string; sort?: null | string; + slug?: null | string; filter_definitions?: null | string; columns?: null | Record; column_order?: string[] | null; @@ -575,6 +577,7 @@ export interface ViewSavedView { show_stats?: null | boolean; show_chart?: null | boolean; name: string; + slug: string; time?: null | string; sort?: null | string; /** @format int32 */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index b4f664af0d..e93216315a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -194,6 +194,12 @@ export const NewSavedViewSchema = object({ .max(100, "Sort must be at most 100 characters") .nullable() .optional(), + slug: string() + .min(1, "Slug is required") + .max(100, "Slug must be at most 100 characters") + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug has invalid format") + .nullable() + .optional(), view_type: string().min(1, "View type is required"), filter_definitions: string() .min(1, "Filter definitions is required") @@ -419,6 +425,12 @@ export const UpdateSavedViewSchema = object({ filter: string().min(1, "Filter is required").nullable().optional(), time: string().min(1, "Time is required").nullable().optional(), sort: string().min(1, "Sort is required").nullable().optional(), + slug: string() + .min(1, "Slug is required") + .max(100, "Slug must be at most 100 characters") + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug has invalid format") + .nullable() + .optional(), filter_definitions: string() .min(1, "Filter definitions is required") .nullable() @@ -619,6 +631,10 @@ export const ViewSavedViewSchema = object({ show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), name: string().min(1, "Name is required"), + slug: string() + .min(1, "Slug is required") + .max(100, "Slug must be at most 100 characters") + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug has invalid format"), time: string().min(1, "Time is required").nullable().optional(), sort: string().min(1, "Sort is required").nullable().optional(), version: int32(), diff --git a/src/Exceptionless.Web/ClientApp/src/params/objectid.ts b/src/Exceptionless.Web/ClientApp/src/params/objectid.ts new file mode 100644 index 0000000000..9db7a8b832 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/params/objectid.ts @@ -0,0 +1,3 @@ +export function match(param: string): boolean { + return /^[a-fA-F0-9]{24}$/.test(param); +} diff --git a/src/Exceptionless.Web/ClientApp/src/params/savedview.ts b/src/Exceptionless.Web/ClientApp/src/params/savedview.ts new file mode 100644 index 0000000000..80e19a1560 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/params/savedview.ts @@ -0,0 +1,3 @@ +export function match(param: string): boolean { + return /^(?![a-fA-F0-9]{24}$)[a-z0-9]+(?:-[a-z0-9]+)*$/.test(param); +} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index 2774abbe5b..ca2625356f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -25,7 +25,7 @@
- + {#if isMediumScreenQuery.current} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index 5162743c7b..a3b0be6783 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -72,12 +72,12 @@ } async function handleImpersonate(organization: ViewOrganization): Promise { - await goto(resolve('/(app)')); + await goto(resolve('/(app)/issues')); currentOrganizationId = organization.id; } async function stopImpersonating(): Promise { - await goto(resolve('/(app)')); + await goto(resolve('/(app)/issues')); currentOrganizationId = organizations[0]?.id; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte index 3a785bd17f..6523513bb6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte @@ -23,7 +23,11 @@ return isOnRoute && activeSavedParam === savedId; } - function isPathActive(href: string): boolean { + function isPathActive(href: string | undefined): boolean { + if (!href) { + return false; + } + return page.url.pathname === href || page.url.pathname.startsWith(href + '/'); } @@ -47,7 +51,7 @@ } function hasSavedViewChildren(route: NavigationItem): boolean { - return route.children?.some((childItem) => isSavedViewChild(childItem)) ?? false; + return !!route.view || (route.children?.some((childItem) => isSavedViewChild(childItem)) ?? false); } function isRouteActive(route: NavigationItem): boolean { @@ -272,7 +276,6 @@ {/if} {:else if route.children?.length} - {@const hasSavedViews = hasSavedViewChildren(route)} setRouteGroupOpen(route, open)} class="group/collapsible"> {#snippet child({ props: collapsibleProps })} @@ -280,30 +283,13 @@ {#snippet child({ props: triggerProps })} {#snippet child({ props: buttonProps })} - {#if hasSavedViews} - - {:else} - - - {route.title} - - - {/if} + {/snippet} {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte index 6979800f9c..2cb41c5a8e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte @@ -175,14 +175,14 @@ } function getEventHref(result: CommandSearchResult): string { - return resolve('/(app)/event/[eventId]', { eventId: result.id }); + return resolve('/(app)/events/[eventId=objectid]', { eventId: result.id }); } function getIssueHref(result: CommandSearchResult): string { - return resolve('/(app)/issues/[stackId]', { stackId: result.id }); + return resolve('/(app)/issues/[stackId=objectid]', { stackId: result.id }); } - const eventSearchHref = $derived(buildSearchHref(resolve('/(app)'), debouncedSearchText)); + const eventSearchHref = $derived(buildSearchHref(resolve('/(app)/events'), debouncedSearchText)); const issueSearchHref = $derived(buildSearchHref(resolve('/(app)/issues'), debouncedSearchText)); const commandRoutes = $derived( diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 9b6a0a6e4b..4f03195304 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -20,7 +20,8 @@ import { organization, showOrganizationNotifications } from '$features/organizations/context.svelte'; import { premiumPage } from '$features/organizations/premium-page.svelte'; import { invalidateProjectQueries } from '$features/projects/api.svelte'; - import { getSavedViewsQuery, invalidateSavedViewQueries } from '$features/saved-views/api.svelte'; + import { getSavedViewsQuery, invalidateSavedViewQueries, isSavedViewDeleted } from '$features/saved-views/api.svelte'; + import { savedViewHref } from '$features/saved-views/slugs'; import { appKeyboardShortcuts, isKeyboardShortcut } from '$features/shared/keyboard-shortcuts'; import { invalidateStackQueries } from '$features/stacks/api.svelte'; import { invalidateTokenQueries } from '$features/tokens/api.svelte'; @@ -223,7 +224,7 @@ if (isKeyboardShortcut(e, appKeyboardShortcuts.allEvents)) { e.preventDefault(); - void goto(resolve('/(app)')); + void goto(resolve('/(app)/events')); return; } @@ -354,27 +355,13 @@ }); const viewToHref: Record = { - events: resolve('/(app)'), + events: resolve('/(app)/events'), issues: resolve('/(app)/issues'), stream: resolve('/(app)/stream') }; - function buildSavedViewHref(baseHref: string, savedView: SavedView): string { - const queryEntries: [string, string][] = [['saved', savedView.id]]; - if (savedView.filter) { - queryEntries.push(['filter', savedView.filter]); - } - - if (savedView.time) { - queryEntries.push(['time', savedView.time]); - } - - if (savedView.sort && savedView.view_type !== 'issues') { - queryEntries.push(['sort', savedView.sort]); - } - - const queryParams = new URLSearchParams(queryEntries); - return `${baseHref}?${queryParams.toString()}`; + function buildSavedViewHref(savedView: SavedView): string { + return savedViewHref(savedView); } const filteredRoutes = $derived.by(() => { @@ -386,24 +373,30 @@ return route; } - if (route.href === resolve('/(app)') && page.params.eventId) { + if (route.href === resolve('/(app)/events') && page.params.eventId) { return { ...route, - children: [...(route.children ?? []), { href: resolve('/(app)/event/[eventId]', { eventId: page.params.eventId }), title: 'Details' }] + children: [ + ...(route.children ?? []), + { href: resolve('/(app)/events/[eventId=objectid]', { eventId: page.params.eventId }), title: 'Details' } + ] }; } if (route.href === resolve('/(app)/issues') && page.params.stackId) { return { ...route, - children: [...(route.children ?? []), { href: resolve('/(app)/issues/[stackId]', { stackId: page.params.stackId }), title: 'Details' }] + children: [ + ...(route.children ?? []), + { href: resolve('/(app)/issues/[stackId=objectid]', { stackId: page.params.stackId }), title: 'Details' } + ] }; } return route; }); - const savedViews = savedViewsQuery.data ?? []; + const savedViews = (savedViewsQuery.data ?? []).filter((savedView) => !isSavedViewDeleted(savedView)); if (savedViews.length === 0) { return allRoutes; } @@ -427,7 +420,7 @@ const children = [ ...sortedViews.map((savedView) => ({ - href: buildSavedViewHref(route.href, savedView), + href: buildSavedViewHref(savedView), title: savedView.name })), ...(route.children ?? []) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index f869cfe778..bda44fa9eb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -1,401 +1,8 @@ - -
-
-

{pageTitle}

-
- - - -
-
- {#if savedViewsState.isEnabled} - (showChart = v)} - setShowStats={(v) => (showStats = v)} - sort={queryParams.sort ?? undefined} - {table} - time={queryParams.time ?? undefined} - view={VIEW} - /> - {/if} - -
-
- -
- {#if showStats} - - {/if} - - {#if showChart} - - {/if} - - - {#snippet footerChildren()} -
- {#if table.getSelectedRowModel().flatRows.length} - - {/if} -
- - - -
- - -
- {/snippet} -
-
-
- - (selectedEventId = null)} onError={handleEventError} /> diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte new file mode 100644 index 0000000000..dd2ef6d42a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte @@ -0,0 +1,437 @@ + + +
+
+

{pageTitle}

+
+ + + +
+
+ {#if savedViewsState.isEnabled} + (showChart = v)} + setShowStats={(v) => (showStats = v)} + sort={getEffectiveSort() ?? undefined} + {table} + time={getQueryTime() ?? undefined} + view={VIEW} + /> + {/if} + +
+
+ +
+ {#if showStats} + + {/if} + + {#if showChart} + + {/if} + + + {#snippet footerChildren()} +
+ {#if table.getSelectedRowModel().flatRows.length} + + {/if} +
+ + + +
+ + +
+ {/snippet} +
+
+
+ + (selectedEventId = null)} onError={handleEventError} /> diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[eventId=objectid]/+page.svelte similarity index 94% rename from src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte rename to src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[eventId=objectid]/+page.svelte index 737bec4301..8b41c0240a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[eventId=objectid]/+page.svelte @@ -18,7 +18,7 @@ watch( () => organization.current, () => { - goto(resolve('/(app)')); + goto(resolve('/(app)/events')); }, { lazy: true } ); @@ -33,7 +33,7 @@ } toast.error(`The event "${page.params.eventId}" could not be found.`); - await goto(resolve('/(app)')); + await goto(resolve('/(app)/events')); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[slug=savedview]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[slug=savedview]/+page.svelte new file mode 100644 index 0000000000..27d94bd534 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[slug=savedview]/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/routes.svelte.ts similarity index 82% rename from src/Exceptionless.Web/ClientApp/src/routes/(app)/event/routes.svelte.ts rename to src/Exceptionless.Web/ClientApp/src/routes/(app)/events/routes.svelte.ts index 26e0392b59..127fa4c716 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/routes.svelte.ts @@ -12,7 +12,7 @@ export function routes(): NavigationItem[] { return [ { group: 'Event', - href: resolve('/(app)/event/[eventId]', { eventId: page.params.eventId }), + href: resolve('/(app)/events/[eventId=objectid]', { eventId: page.params.eventId }), icon: Events, show: () => false, title: 'Event Details' diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte index d1c17b8771..b534b61184 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -8,8 +8,7 @@ import RefreshButton from '$comp/refresh-button.svelte'; import { H3 } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; - import { type GetEventsParams, getOrganizationCountQuery, getStackEventsQuery } from '$features/events/api.svelte'; - import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; + import { type GetEventsParams, getOrganizationCountQuery } from '$features/events/api.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; import { DateFilter, ProjectFilter, StatusFilter, TypeFilter } from '$features/events/components/filters'; @@ -34,6 +33,7 @@ import { getSharedTableOptions, isTableEmpty, removeTableData, removeTableSelection } from '$features/shared/table.svelte'; import { fillDateSeries } from '$features/shared/utils/charts'; import { parseDateMathRange, toDateMathRange } from '$features/shared/utils/datemath'; + import IssueDetailSheet from '$features/stacks/components/issue-detail-sheet.svelte'; import TableStacksBulkActionsDropdownMenu from '$features/stacks/components/stacks-bulk-actions-dropdown-menu.svelte'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; @@ -48,6 +48,7 @@ // TODO: Update this page to use StackSummaryModel instead of EventSummaryModel. let selectedStackId = $state(); + const DEFAULT_FILTER = '(type:404 OR type:error) (status:open OR status:regressed)'; function handleStackError(problem: ProblemDetails) { showBillingDialogOnUpgradeProblem(problem, organization.current); @@ -58,22 +59,8 @@ selectedStackId = row.id; } - // Load the latest event for the stack and display it in the sidebar. - const eventsQuery = getStackEventsQuery({ - params: { - limit: 1 - }, - route: { - get stackId() { - return selectedStackId; - } - } - }); - const eventId = $derived(eventsQuery?.data?.[0]?.id); - function rowHref(row: EventSummaryModel): string { - const stackFilter = `stack:${row.id}`; - return `${resolve('/(app)')}?filter=${encodeURIComponent(stackFilter)}`; + return resolve('/(app)/issues/[stackId=objectid]', { stackId: row.id }); } const DEFAULT_TIME_RANGE = '[now-7d TO now]'; @@ -84,24 +71,38 @@ new StatusFilter([StackStatus.Open, StackStatus.Regressed]) ]; const DEFAULT_PARAMS = { - filter: '(type:404 OR type:error) (status:open OR status:regressed)', + filter: undefined as string | undefined, limit: DEFAULT_LIMIT, - saved: undefined as string | undefined, - time: DEFAULT_TIME_RANGE + time: undefined as string | undefined }; function filterCacheKey(filter: null | string): string { return buildFilterCacheKey(organization.current, page.url.pathname, filter); } - updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS); + function getQueryTime(): null | string { + if (page.url.searchParams.has('time')) { + return page.url.searchParams.get('time') === '' ? null : queryParams.time; + } + + return savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; + } + + function getEffectiveFilter(): null | string { + if (page.url.searchParams.has('filter')) { + return queryParams.filter ?? ''; + } + + return savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + } + + updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS); const queryParams = queryParamsState({ default: DEFAULT_PARAMS, pushHistory: true, schema: { filter: 'string', limit: 'number', - saved: 'string', time: 'string' } }); @@ -110,17 +111,23 @@ let showStats = $state(true); let showChart = $state(true); const savedViewsState = useSavedViews({ + baseHref: resolve('/(app)/issues'), filterCacheKey, - getColumnOrder: () => table.store.state.columnOrder, - getColumnVisibility: () => table.store.state.columnVisibility, + getColumnOrder: () => table.state.columnOrder, + getColumnVisibility: () => table.state.columnVisibility, + getFilter: getEffectiveFilter, getFilterDefinitions: () => serializeFilters(filters ?? []), getShowChart: () => showChart, getShowStats: () => showStats, + getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), setColumnVisibility: (v) => table.setColumnVisibility(v), setShowChart: (v) => (showChart = v), setShowStats: (v) => (showStats = v), + get slug() { + return page.params.slug; + }, updateFilterCache, view: VIEW }); @@ -129,7 +136,7 @@ watch( () => organization.current, () => { - updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS); + updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS); //params.$reset(); // Work around for https://github.com/beynar/kit-query-params/issues/7 Object.assign(queryParams, DEFAULT_PARAMS); reset(); @@ -137,11 +144,12 @@ { lazy: true } ); - let filters = $state(applyTimeFilter(getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter), queryParams.time)); + let filters = $state(applyTimeFilter(getFiltersFromCache(filterCacheKey(getEffectiveFilter()), getEffectiveFilter()), getQueryTime())); watch( - [() => queryParams.filter, () => queryParams.time, () => filterCacheVersionNumber()], - ([filter, time]) => { - filters = applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), time); + [() => page.url.search, () => savedViewsState.activeSavedView, () => filterCacheVersionNumber()], + () => { + const filter = getEffectiveFilter(); + filters = applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), getQueryTime()); }, { lazy: true } ); @@ -169,15 +177,18 @@ function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { const filter = toFilter(updatedFilters.filter((f) => f.type !== 'date')); + const time = ((updatedFilters.find((f) => f.type === 'date') as DateFilter | undefined)?.value as string | undefined) ?? null; + const baseFilter = savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + const baseTime = savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; updateFilterCache(filterCacheKey(filter), updatedFilters); - queryParams.time = ((updatedFilters.find((f) => f.type === 'date') as DateFilter | undefined)?.value as string | undefined) ?? null; - queryParams.filter = filter; + queryParams.time = time === baseTime ? null : (time ?? ''); + queryParams.filter = filter === baseFilter ? null : filter; } const eventsQueryParameters: GetEventsParams = $state({ get filter() { - return queryParams.filter!; + return getEffectiveFilter()!; }, set filter(value) { queryParams.filter = value; @@ -191,10 +202,11 @@ mode: 'stack_frequent', offset: DEFAULT_OFFSET, get time() { - return queryParams.time!; + return getQueryTime()!; }, set time(value) { - queryParams.time = value; + const baseTime = savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; + queryParams.time = value === baseTime ? null : (value ?? ''); } }); @@ -218,10 +230,15 @@ get queryParameters() { return eventsQueryParameters; } + }), + (state) => ({ + columnOrder: state.columnOrder, + columnVisibility: state.columnVisibility, + pagination: state.pagination }) ); - const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.store.state.pagination.pageIndex === 0); + const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.state.pagination.pageIndex === 0); function reset() { table.resetRowSelection(); @@ -290,7 +307,7 @@ }); const chartData = $derived(() => { - const timeRange = parseDateMathRange(queryParams.time); + const timeRange = parseDateMathRange(getQueryTime()); const buildZeroFilledSeries = () => { const series = fillDateSeries(timeRange.start, timeRange.end, (date: Date) => ({ @@ -319,7 +336,7 @@ const stats = $derived.by(() => { const aggregations = chartDataQuery.data?.aggregations; - const timeRange = parseDateMathRange(queryParams.time); + const timeRange = parseDateMathRange(getQueryTime()); const totalEvents = agg.sum(aggregations, 'sum_count')?.value ?? chartDataQuery.data?.total ?? 0; const totalIssues = agg.cardinality(aggregations, 'cardinality_stack')?.value ?? 0; const newIssues = agg.terms(aggregations, 'terms_first')?.buckets[0]?.total ?? 0; @@ -350,8 +367,8 @@ {#if savedViewsState.isEnabled} (showChart = v)} setShowStats={(v) => (showStats = v)} {table} - time={queryParams.time ?? undefined} + time={getQueryTime() ?? undefined} view={VIEW} /> {/if} @@ -408,4 +425,4 @@
- (selectedStackId = undefined)} onError={handleStackError} /> + (selectedStackId = undefined)} onError={handleStackError} /> diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[slug=savedview]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[slug=savedview]/+page.svelte new file mode 100644 index 0000000000..fdabd6f766 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[slug=savedview]/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId=objectid]/+page.svelte similarity index 56% rename from src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte rename to src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId=objectid]/+page.svelte index 6da88362e2..006be87c90 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId=objectid]/+page.svelte @@ -5,37 +5,16 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { page } from '$app/state'; - import { H3, Muted } from '$comp/typography'; + import { H3 } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing'; - import { getStackEventsQuery } from '$features/events/api.svelte'; - import EventsOverview from '$features/events/components/events-overview.svelte'; import { organization } from '$features/organizations/context.svelte'; - import StackCard from '$features/stacks/components/stack-card.svelte'; + import IssueDetails from '$features/stacks/components/issue-details.svelte'; import { watch } from 'runed'; import { toast } from 'svelte-sonner'; import { redirectToEventsWithFilter } from '../../redirect-to-events.svelte.js'; const stackId = $derived(page.params.stackId || ''); - let eventId = $state(null); - - const stackEventsQuery = getStackEventsQuery({ - params: { - limit: 1, - sort: '-date' - }, - route: { - get stackId() { - return stackId; - } - } - }); - - $effect(() => { - if (stackEventsQuery.isSuccess) { - eventId = stackEventsQuery.data?.[0]?.id ?? null; - } - }); watch( () => organization.current, @@ -60,10 +39,5 @@

Issue Details

- {#if stackEventsQuery.isSuccess && !eventId} - - This issue has no events to display. - {:else if eventId} - - {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/payment/[id]/+page@.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/payment/[id]/+page@.svelte index 5d785eb651..8ccde67e3d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/payment/[id]/+page@.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/payment/[id]/+page@.svelte @@ -26,11 +26,11 @@ $effect(() => { if (!accessToken.current || !organization.current || invoiceQuery.isError) { - goto(resolve('/(app)')); + goto(resolve('/(app)/issues')); } if (invoiceQuery.isSuccess && invoiceQuery.data?.organization_id !== organization.current) { - goto(resolve('/(app)')); + goto(resolve('/(app)/issues')); } }); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts index d594c20961..4d30451efe 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts @@ -10,8 +10,8 @@ import { buildFilterCacheKey, toFilter, updateFilterCache } from '$features/even */ export async function redirectToEventsWithFilter(organizationId: string | undefined, addedOrUpdated: IFilter): Promise { const filter = toFilter([addedOrUpdated]); - const filterCacheKey = buildFilterCacheKey(organizationId, resolve('/(app)'), filter); + const filterCacheKey = buildFilterCacheKey(organizationId, resolve('/(app)/events'), filter); updateFilterCache(filterCacheKey, [addedOrUpdated]); - await goto(`${resolve('/(app)')}?filter=${encodeURIComponent(filter)}`); + await goto(`${resolve('/(app)/events')}?filter=${encodeURIComponent(filter)}`); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index 629ab1c45d..161c9b6dd4 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -12,7 +12,7 @@ import Sessions from '@lucide/svelte/icons/timer'; import type { NavigationItem } from '../routes.svelte'; import { routes as accountRoutes } from './account/routes.svelte'; -import { routes as eventRoutes } from './event/routes.svelte'; +import { routes as eventRoutes } from './events/routes.svelte'; import { routes as organizationRoutes } from './organization/routes.svelte'; import { routes as projectRoutes } from './project/routes.svelte'; import { routes as systemRoutes } from './system/routes.svelte'; @@ -28,7 +28,7 @@ export function routes(): NavigationItem[] { }, { group: 'Dashboards', - href: resolve('/(app)'), + href: resolve('/(app)/events'), icon: Events, shortcut: appKeyboardShortcuts.allEvents.keys, title: 'Events' diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte index 72cde8e391..adaf4b1380 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte @@ -51,7 +51,7 @@ } function rowHref(row: EventSummaryModel): string { - return resolve('/(app)/event/[eventId]', { eventId: row.id }); + return resolve('/(app)/events/[eventId=objectid]', { eventId: row.id }); } // Register this page as requiring premium features (layout auto-resets on navigation) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index d91c3c6717..4b5fd6d1bd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -53,7 +53,7 @@ } function rowHref(row: EventSummaryModel): string { - return resolve('/(app)/event/[eventId]', { eventId: row.id }); + return resolve('/(app)/events/[eventId=objectid]', { eventId: row.id }); } const DEFAULT_FILTERS = [new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; @@ -82,8 +82,8 @@ const savedViewsState = useSavedViews({ defaultColumnVisibility: defaultEventColumnVisibility, filterCacheKey, - getColumnOrder: () => table.store.state.columnOrder, - getColumnVisibility: () => table.store.state.columnVisibility, + getColumnOrder: () => table.state.columnOrder, + getColumnVisibility: () => table.state.columnVisibility, getFilterDefinitions: () => serializeFilters(filters ?? []), queryParams, setColumnOrder: (v) => table.setColumnOrder(v), @@ -185,6 +185,10 @@ get queryParameters() { return eventsQueryParameters; } + }), + (state) => ({ + columnOrder: state.columnOrder, + columnVisibility: state.columnVisibility }) ); @@ -284,8 +288,8 @@ {#if savedViewsState.isEnabled} ({ diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs index f8b3c4c54c..227bded5dc 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Controllers/SavedViewController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -28,9 +29,23 @@ public SavedViewController( ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) { } - protected override SavedView MapToModel(NewSavedView newModel) => _mapper.MapToSavedView(newModel); - protected override ViewSavedView MapToViewModel(SavedView model) => _mapper.MapToViewSavedView(model); - protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewSavedViews(models); + protected override SavedView MapToModel(NewSavedView newModel) + { + var model = _mapper.MapToSavedView(newModel); + model.Slug = ToSlug(String.IsNullOrWhiteSpace(model.Slug) ? model.Name : model.Slug); + return model; + } + + protected override ViewSavedView MapToViewModel(SavedView model) + { + var viewModel = _mapper.MapToViewSavedView(model); + if (String.IsNullOrWhiteSpace(viewModel.Slug)) + viewModel.Slug = ToFallbackSlug(viewModel.Name, viewModel.Id); + + return viewModel; + } + + protected override List MapToViewModels(IEnumerable models) => models.Select(MapToViewModel).ToList(); /// /// Get by organization @@ -174,6 +189,21 @@ protected override async Task CanAddAsync(SavedView value) if (count >= MaxViewsPerOrganization) return PermissionResult.DenyWithMessage($"Organization is limited to {MaxViewsPerOrganization} saved views."); + if (String.IsNullOrWhiteSpace(value.Slug)) + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot be empty. Use at least one letter or number."); + + if (IsReservedSlug(value.Slug)) + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot look like an event or issue id."); + + if (!IsValidSlug(value.Slug)) + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name can only contain lowercase letters, numbers, and single dashes."); + + if (await NameExistsAsync(value.OrganizationId, value.ViewType, value.Name, null)) + return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view named '{value.Name.Trim()}' already exists."); + + if (await SlugExistsAsync(value.OrganizationId, value.ViewType, value.Slug, null)) + return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view with URL name '{value.Slug}' already exists."); + return await base.CanAddAsync(value); } @@ -192,7 +222,15 @@ protected override async Task CanUpdateAsync(SavedView origina return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "Name cannot be empty or whitespace."); } + if (changedNames.Contains(nameof(UpdateSavedView.Slug)) + && changes.TryGetPropertyValue(nameof(UpdateSavedView.Slug), out object? slugValue) + && (slugValue is not string slug || String.IsNullOrWhiteSpace(slug))) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot be empty. Use at least one letter or number."); + } + var lengthResult = ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Name), 100) + ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Slug), 100) ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Filter), 2000) ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Time), 100) ?? ValidateStringLength(changes, changedNames, nameof(UpdateSavedView.Sort), 100) @@ -200,6 +238,29 @@ protected override async Task CanUpdateAsync(SavedView origina if (lengthResult is not null) return lengthResult; + if (changedNames.Contains(nameof(UpdateSavedView.Name)) + && changes.TryGetPropertyValue(nameof(UpdateSavedView.Name), out nameValue) + && nameValue is string changedName + && await NameExistsAsync(original.OrganizationId, original.ViewType, changedName, original.Id)) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view named '{changedName.Trim()}' already exists."); + } + + if (changedNames.Contains(nameof(UpdateSavedView.Slug)) + && changes.TryGetPropertyValue(nameof(UpdateSavedView.Slug), out slugValue) + && slugValue is string changedSlug) + { + var normalizedSlug = ToSlug(changedSlug); + if (IsReservedSlug(normalizedSlug)) + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name cannot look like an event or issue id."); + + if (!IsValidSlug(normalizedSlug)) + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "URL name can only contain lowercase letters, numbers, and single dashes."); + + if (await SlugExistsAsync(original.OrganizationId, original.ViewType, normalizedSlug, original.Id)) + return PermissionResult.DenyWithStatus(StatusCodes.Status409Conflict, $"A saved view with URL name '{normalizedSlug}' already exists."); + } + if (changedNames.Contains(nameof(UpdateSavedView.FilterDefinitions)) && changes.TryGetPropertyValue(nameof(UpdateSavedView.FilterDefinitions), out object? filterDefsValue) && filterDefsValue is string filterDefs @@ -258,9 +319,18 @@ protected override Task AddModelAsync(SavedView value) protected override Task UpdateModelAsync(SavedView original, Delta changes) { + var changedNames = changes.GetChangedPropertyNames(); + changes.Patch(original); + + if (changedNames.Contains(nameof(UpdateSavedView.Slug))) + original.Slug = ToSlug(original.Slug); + + if (String.IsNullOrWhiteSpace(original.Slug)) + original.Slug = ToFallbackSlug(original.Name, original.Id); + original.UpdatedByUserId = CurrentUser.Id; - return base.UpdateModelAsync(original, changes); + return _repository.SaveAsync(original, o => o.Cache()); } protected override async Task CanDeleteAsync(SavedView value) @@ -270,4 +340,42 @@ protected override async Task CanDeleteAsync(SavedView value) return await base.CanDeleteAsync(value); } + + private async Task SlugExistsAsync(string organizationId, string viewType, string slug, string? excludingId) + { + var results = await _repository.GetByViewForUserAsync(organizationId, viewType, CurrentUser.Id, o => o.PageLimit(1000)); + return results.Documents.Any(view => view.Id != excludingId && String.Equals(ToFallbackSlug(String.IsNullOrWhiteSpace(view.Slug) ? view.Name : view.Slug, view.Id), slug, StringComparison.OrdinalIgnoreCase)); + } + + private async Task NameExistsAsync(string organizationId, string viewType, string name, string? excludingId) + { + var results = await _repository.GetByViewForUserAsync(organizationId, viewType, CurrentUser.Id, o => o.PageLimit(1000)); + return results.Documents.Any(view => view.Id != excludingId && String.Equals(view.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsValidSlug(string slug) + { + return Regex.IsMatch(slug, "^[a-z0-9]+(?:-[a-z0-9]+)*$") && !IsReservedSlug(slug); + } + + private static bool IsReservedSlug(string slug) + { + return Regex.IsMatch(slug, "^[a-f0-9]{24}$"); + } + + private static string ToSlug(string value) + { + var slug = Regex.Replace(value.Trim().ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-'); + slug = Regex.Replace(slug, "-+", "-"); + return slug; + } + + private static string ToFallbackSlug(string value, string id) + { + var slug = ToSlug(value); + if (!String.IsNullOrWhiteSpace(slug)) + return slug; + + return String.IsNullOrWhiteSpace(id) ? "saved-view" : $"saved-view-{id}"; + } } diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index f8e324768d..999335e0f3 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -42,6 +42,10 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject [MaxLength(100)] public string? Sort { get; set; } + [MaxLength(100)] + [RegularExpression("^[a-z0-9]+(?:-[a-z0-9]+)*$")] + public string? Slug { get; set; } + [Required] public string ViewType { get; set; } = null!; diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs index d094e85954..07503cc20f 100644 --- a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs @@ -13,6 +13,9 @@ public class UpdateSavedView : IValidatableObject public string? Time { get; set; } [MaxLength(100)] public string? Sort { get; set; } + [MaxLength(100)] + [RegularExpression("^[a-z0-9]+(?:-[a-z0-9]+)*$")] + public string? Slug { get; set; } [MaxLength(SavedView.MaxFilterDefinitionsLength)] public string? FilterDefinitions { get; set; } [MaxLength(50)] diff --git a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs index 326ff94c88..0a87f289e7 100644 --- a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs @@ -27,6 +27,7 @@ public record ViewSavedView : IIdentity, IHaveDates public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } public string Name { get; set; } = null!; + public string Slug { get; set; } = null!; public string? Time { get; set; } public string? Sort { get; set; } public int Version { get; set; } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index ace5b2d4cb..a036591468 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -7899,6 +7899,14 @@ "string" ] }, + "slug": { + "maxLength": 100, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": [ + "null", + "string" + ] + }, "view_type": { "type": "string" }, @@ -8579,6 +8587,12 @@ "string" ] }, + "slug": { + "type": [ + "null", + "string" + ] + }, "filter_definitions": { "type": [ "null", @@ -9216,6 +9230,7 @@ "organization_id", "created_by_user_id", "name", + "slug", "version", "view_type", "created_utc", @@ -9304,6 +9319,9 @@ "name": { "type": "string" }, + "slug": { + "type": "string" + }, "time": { "type": [ "null", diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index e447f21a66..a479dcd3f0 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; @@ -203,6 +204,90 @@ public Task PostAsync_WithEmptyName_ReturnsUnprocessableEntity() ); } + [Fact] + public Task PostAsync_WithNameThatCannotCreateUrlName_ReturnsUnprocessableEntity() + { + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "!!!", + Filter = "status:open", + ViewType = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_WithObjectIdUrlName_ReturnsUnprocessableEntity() + { + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Object Id URL Name", + Filter = "status:open", + Slug = "507f1f77bcf86cd799439011", + ViewType = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PostAsync_DuplicateName_ReturnsConflict() + { + var created = await CreateSavedViewAsync("Duplicate Saved View Name", "status:open", "events"); + Assert.NotNull(created); + + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "duplicate saved view name", + Filter = "type:error", + ViewType = "events" + }) + .ExpectedStatus(HttpStatusCode.Conflict) + ); + } + + [Fact] + public async Task PostAsync_DuplicateSlug_ReturnsConflict() + { + var created = await CreateSavedViewAsync("Shared URL Name", "status:open", "events", slug: "shared-url-name"); + Assert.NotNull(created); + + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Different Display Name", + Filter = "type:error", + Slug = "shared-url-name", + ViewType = "events" + }) + .ExpectedStatus(HttpStatusCode.Conflict) + ); + } + [Fact] public Task PostAsync_WithEmptyFilter_ReturnsCreated() { @@ -600,6 +685,7 @@ public async Task PostAsync_ExceedsPerOrgCap_ReturnsBadRequest() OrganizationId = SampleDataService.TEST_ORG_ID, Name = $"Cap Test {i}", Filter = "status:open", + Slug = $"cap-test-{i}", ViewType = "events", Version = 1, CreatedByUserId = "537650f3b77efe23a47914f0", @@ -641,6 +727,7 @@ public async Task RemoveUser_DeletesPrivateSavedViews_ButPreservesOrganizationWi OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Organization Wide", Filter = "status:open", + Slug = "organization-wide", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -651,6 +738,7 @@ public async Task RemoveUser_DeletesPrivateSavedViews_ButPreservesOrganizationWi UserId = testUser.Id, Name = "My Private View", Filter = "type:error", + Slug = "my-private-view", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -684,6 +772,7 @@ await _savedViewRepository.AddAsync(new SavedView OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Organization View", Filter = "status:open", + Slug = "organization-view", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -694,6 +783,7 @@ await _savedViewRepository.AddAsync(new SavedView UserId = testUser.Id, Name = "Private View", Filter = "type:error", + Slug = "private-view", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -727,6 +817,7 @@ public async Task RemoveUserSavedViews_WithMixedVisibility_OnlyDeletesPrivateVie OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Organization Wide", Filter = "status:open", + Slug = "organization-wide", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -737,6 +828,7 @@ public async Task RemoveUserSavedViews_WithMixedVisibility_OnlyDeletesPrivateVie UserId = testUser.Id, Name = "Private", Filter = "type:error", + Slug = "private", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -753,13 +845,14 @@ public async Task RemoveUserSavedViews_WithMixedVisibility_OnlyDeletesPrivateVie Assert.NotNull(await _savedViewRepository.GetByIdAsync(organizationWide.Id)); } - private async Task CreateSavedViewAsync(string name, string filter, string view, bool isPrivate = false) + private async Task CreateSavedViewAsync(string name, string filter, string view, bool isPrivate = false, string? slug = null) { var newView = new NewSavedView { OrganizationId = SampleDataService.TEST_ORG_ID, Name = name, Filter = filter, + Slug = slug, ViewType = view, IsPrivate = isPrivate }; @@ -823,6 +916,61 @@ public async Task PatchAsync_UpdateFilterDefinitions_PersistsJsonBlob() Assert.Equal(filterDefs, updated.FilterDefinitions); } + [Fact] + public async Task PatchAsync_DuplicateName_ReturnsConflict() + { + // Arrange + var existing = await CreateSavedViewAsync("Existing Patch Name", "status:open", "events"); + var created = await CreateSavedViewAsync("Editable Patch Name", "type:error", "events"); + Assert.NotNull(existing); + Assert.NotNull(created); + + // Act & Assert + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Name = "existing patch name" }) + .ExpectedStatus(HttpStatusCode.Conflict) + ); + } + + [Fact] + public async Task PatchAsync_DuplicateSlug_ReturnsConflict() + { + // Arrange + var existing = await CreateSavedViewAsync("Existing Patch URL", "status:open", "events", slug: "existing-patch-url"); + var created = await CreateSavedViewAsync("Editable Patch URL", "type:error", "events", slug: "editable-patch-url"); + Assert.NotNull(existing); + Assert.NotNull(created); + + // Act & Assert + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Slug = "existing-patch-url" }) + .ExpectedStatus(HttpStatusCode.Conflict) + ); + } + + [Fact] + public async Task PatchAsync_WithObjectIdUrlName_ReturnsUnprocessableEntity() + { + // Arrange + var created = await CreateSavedViewAsync("Editable Object Id URL", "type:error", "events", slug: "editable-object-id-url"); + Assert.NotNull(created); + + // Act & Assert + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Slug = "507f1f77bcf86cd799439011" }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + [Fact] public async Task PatchAsync_WithInvalidFilterDefinitions_ReturnsUnprocessableEntity() { @@ -1404,6 +1552,7 @@ public async Task GetByOrganizationForUserAsync_ReturnsPublicAndOwnPrivateViews( OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Public View", Filter = "status:open", + Slug = "public-view", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -1414,6 +1563,7 @@ public async Task GetByOrganizationForUserAsync_ReturnsPublicAndOwnPrivateViews( UserId = testUser.Id, Name = "Own Private", Filter = "type:error", + Slug = "own-private", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -1424,6 +1574,7 @@ public async Task GetByOrganizationForUserAsync_ReturnsPublicAndOwnPrivateViews( UserId = TestConstants.UserId2, Name = "Other Private", Filter = "type:log", + Slug = "other-private", ViewType = "events", CreatedByUserId = TestConstants.UserId2 }); @@ -1452,6 +1603,7 @@ public async Task GetByViewForUserAsync_FiltersOnViewType() OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Events View", Filter = "status:open", + Slug = "events-view", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -1461,6 +1613,7 @@ public async Task GetByViewForUserAsync_FiltersOnViewType() OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Issues View", Filter = "status:regressed", + Slug = "issues-view", ViewType = "issues", CreatedByUserId = testUser.Id }); @@ -1487,6 +1640,7 @@ await _savedViewRepository.AddAsync(new SavedView { OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Count Test 1", + Slug = "count-test-1", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -1495,6 +1649,7 @@ await _savedViewRepository.AddAsync(new SavedView { OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Count Test 2", + Slug = "count-test-2", ViewType = "issues", CreatedByUserId = testUser.Id }); @@ -1519,6 +1674,7 @@ public async Task GetByViewAsync_ReturnsAllViewsForViewType() { OrganizationId = SampleDataService.TEST_ORG_ID, Name = "View Type Test 1", + Slug = "view-type-test-1", ViewType = "stream", CreatedByUserId = testUser.Id }); @@ -1527,6 +1683,7 @@ public async Task GetByViewAsync_ReturnsAllViewsForViewType() { OrganizationId = SampleDataService.TEST_ORG_ID, Name = "View Type Test 2", + Slug = "view-type-test-2", ViewType = "stream", CreatedByUserId = testUser.Id }); @@ -1535,6 +1692,7 @@ public async Task GetByViewAsync_ReturnsAllViewsForViewType() { OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Wrong View Type", + Slug = "wrong-view-type", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -1561,6 +1719,7 @@ public async Task RemoveByUserIdAsync_OnlyRemovesPrivateViewsForUser() { OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Public Created By User", + Slug = "public-created-by-user", ViewType = "events", CreatedByUserId = testUser.Id }); @@ -1570,6 +1729,7 @@ public async Task RemoveByUserIdAsync_OnlyRemovesPrivateViewsForUser() OrganizationId = SampleDataService.TEST_ORG_ID, UserId = testUser.Id, Name = "User Private View", + Slug = "user-private-view", ViewType = "events", CreatedByUserId = testUser.Id });