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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Models/SavedView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates
[MaxLength(100)]
public string Name { get; set; } = null!;

/// <summary>URL slug used to load this saved view.</summary>
[Required]
[MaxLength(100)]
[RegularExpression("^(?![a-f0-9]{24}$)[a-z0-9]+(?:-[a-z0-9]+)*$")]
public string Slug { get; set; } = null!;

/// <summary>Date-math time range, e.g. "[now-7d TO now]". Null if no time constraint.</summary>
[MaxLength(100)]
public string? Time { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
</script>

{#if normalizedLogLevel}
<Badge {variant}>
<Badge class="w-14 justify-center px-0 text-center" {variant}>
{normalizedLogLevel}
</Badge>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
</strong>
{/if}

<A class="inline" href={resolve('/(app)/event/[eventId]', { eventId: source.id })}>
<A class="inline" href={resolve('/(app)/events/[eventId=objectid]', { eventId: source.id })}>
{source.data.Message}
</A>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
{#if showType}
<strong>Feature:&nbsp;</strong>
{/if}
<A class="inline" href={resolve('/(app)/event/[eventId]', { eventId: source.id })}>{source.data.Source}</A>
<A class="inline" href={resolve('/(app)/events/[eventId=objectid]', { eventId: source.id })}>{source.data.Source}</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@
</strong>
{/if}
{#if showType || source.data.Source}:&nbsp;{/if}
<A class="inline" href={resolve('/(app)/event/[eventId]', { eventId: source.id })}>{source.data.Message}</A>
<A class="inline" href={resolve('/(app)/events/[eventId=objectid]', { eventId: source.id })}>{source.data.Message}</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
{#if showType}
<strong>404</strong>:&nbsp;
{/if}
<A class="inline" href={resolve('/(app)/event/[eventId]', { eventId: source.id })}>{source.data.Source}</A>
<A class="inline" href={resolve('/(app)/events/[eventId=objectid]', { eventId: source.id })}>{source.data.Source}</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
</strong>:&nbsp;
{/if}

<A class="inline" href={resolve('/(app)/event/[eventId]', { eventId: source.id })}>
<A class="inline" href={resolve('/(app)/events/[eventId=objectid]', { eventId: source.id })}>
{#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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

<div class="line-clamp-2">
<strong><abbr title={source.data.TypeFullName}>{source.data.Type}</abbr>: </strong>
<A class="inline" href={resolve('/(app)/event/[eventId]', { eventId: source.id })}>{source.data.Message}</A>
<A class="inline" href={resolve('/(app)/events/[eventId=objectid]', { eventId: source.id })}>{source.data.Message}</A>
</div>

{#if source.data.Path}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@
{#if showType || source.data.Source}
:&nbsp;
{/if}
<A class="inline" href={resolve('/(app)/event/[eventId]', { eventId: source.id })}>{source.data.Message}</A>
<A class="inline" href={resolve('/(app)/events/[eventId=objectid]', { eventId: source.id })}>{source.data.Message}</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
</strong>
{/if}

<A class="inline" href={`${resolve('/(app)')}?filter=stack:${source.id}`}>
<A class="inline" href={`${resolve('/(app)/events')}?filter=stack:${source.id}`}>
{source.title}
</A>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<strong>Feature</strong>:&nbsp;
{/if}

<A class="inline" href={`${resolve('/(app)')}?filter=stack:${source.id}`}>
<A class="inline" href={`${resolve('/(app)/events')}?filter=stack:${source.id}`}>
{source.title}
</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<strong>Log source:</strong>&nbsp;
{/if}

<A class="inline" href={`${resolve('/(app)')}?filter=stack:${source.id}`}>
<A class="inline" href={`${resolve('/(app)/events')}?filter=stack:${source.id}`}>
{#if source.data?.Source}
<abbr title={source.data.Source}>{source.data.SourceShortName}</abbr>
{:else}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
{#if showType}
<strong>404</strong>:&nbsp;
{/if}
<A class="inline" href={`${resolve('/(app)')}?filter=stack:${source.id}`}>
<A class="inline" href={`${resolve('/(app)/events')}?filter=stack:${source.id}`}>
{source.title}
</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<strong>Session</strong>:&nbsp;
{/if}

<A class="inline" href={`${resolve('/(app)')}?filter=stack:${source.id}`}>
<A class="inline" href={`${resolve('/(app)/events')}?filter=stack:${source.id}`}>
{source.title}
</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<abbr title={source.data.TypeFullName}>{source.data.Type}</abbr>:
</strong>

<A class="inline" href={`${resolve('/(app)')}?filter=stack:${source.id}`}>{source.title}</A>
<A class="inline" href={`${resolve('/(app)/events')}?filter=stack:${source.id}`}>{source.title}</A>
</div>

{#if source.data.Path}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
:&nbsp;
{/if}

<A class="inline" href={`${resolve('/(app)')}?filter=stack:${source.id}`}>
<A class="inline" href={`${resolve('/(app)/events')}?filter=stack:${source.id}`}>
{source.title}
</A>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
let { name, userOrganizations, ...restProps }: Props = $props();
async function stopImpersonating(): Promise<void> {
await goto(resolve('/(app)'));
await goto(resolve('/(app)/issues'));
organization.current = userOrganizations[0]?.id;
}
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([]);

export function deleteSavedView(request: { route: { organizationId: string | undefined } }) {
const queryClient = useQueryClient();

Expand All @@ -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);
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<SavedView[]>({ 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
onRename: (newName: string, slug: string) => Promise<void>;
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);
}
</script>

Expand All @@ -46,11 +127,41 @@
>
<div class="flex flex-col gap-2">
<Label for="rename-view">Name</Label>
<Input id="rename-view" bind:value={renameName} placeholder="View name" required autofocus />
<Input
id="rename-view"
bind:value={renameName}
placeholder="View name"
maxlength={SAVED_VIEW_NAME_MAX_LENGTH}
aria-invalid={!!visibleNameError}
aria-describedby={visibleNameError ? 'rename-view-error' : undefined}
required
autofocus
/>
{#if visibleNameError}
<p id="rename-view-error" class="text-destructive text-sm">{visibleNameError}</p>
{/if}
</div>
<div class="flex flex-col gap-2">
<Label for="rename-view-slug">URL name</Label>
<Input
id="rename-view-slug"
bind:value={renameSlug}
placeholder="view-slug"
maxlength={SAVED_VIEW_SLUG_MAX_LENGTH}
aria-invalid={!!visibleSlugError}
aria-describedby={visibleSlugError ? 'rename-view-slug-error' : undefined}
required
oninput={() => {
isSlugDirty = true;
}}
/>
{#if visibleSlugError}
<p id="rename-view-slug-error" class="text-destructive text-sm">{visibleSlugError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={onClose}>Cancel</Button>
<Button type="submit" disabled={!renameName.trim() || saving}>
<Button type="submit" disabled={!canRename}>
{saving ? 'Saving...' : 'Rename'}
</Button>
</Dialog.Footer>
Expand Down
Loading