Only visible to you
@@ -185,8 +196,8 @@
-
-
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts
new file mode 100644
index 0000000000..9b7330e40a
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts
@@ -0,0 +1,37 @@
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import { describe, expect, it, vi } from 'vitest';
+
+import SaveViewDialog from './save-view-dialog.svelte';
+
+describe('SaveViewDialog', () => {
+ it('preserves the draft when the guide default changes while open', async () => {
+ // Arrange
+ const { rerender } = render(SaveViewDialog, {
+ defaultPrivate: true,
+ onClose: vi.fn(),
+ onLoadView: vi.fn(),
+ onSave: vi.fn(),
+ open: true,
+ savedViews: [],
+ saving: false
+ });
+ await fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'My errors' } });
+
+ // Act
+ await rerender({ defaultPrivate: false });
+
+ // Assert
+ expect(screen.getByLabelText('Name')).toHaveValue('My errors');
+ expect(screen.getByLabelText('URL name')).toHaveValue('my-errors');
+ expect(screen.getByRole('switch', { name: 'Private' })).toHaveAttribute('aria-checked', 'true');
+
+ // Act
+ await rerender({ open: false });
+ await rerender({ open: true });
+
+ // Assert
+ expect(screen.getByLabelText('Name')).toHaveValue('');
+ expect(screen.getByRole('switch', { name: 'Private' })).toHaveAttribute('aria-checked', 'false');
+ });
+});
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 6d76559f95..883d2c3063 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
@@ -15,6 +15,7 @@
import { serializeFilters } from '$features/events/components/filters/helpers.svelte';
import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte';
import { organization } from '$features/organizations/context.svelte';
+ import SavedViewCreateTour from '$features/product-tours/components/saved-view-create-tour.svelte';
import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta';
import { getMeQuery } from '$features/users/api.svelte';
import Building2 from '@lucide/svelte/icons/building-2';
@@ -120,6 +121,7 @@
let isColumnDialogOpen = $state(false);
let isMenuOpen = $state(false);
let viewToDelete = $state
(null);
+ let savedViewCreateTour = $state();
const organizationId = $derived(organization.current);
const activeView = $derived(activeSavedView);
@@ -301,6 +303,7 @@
return;
}
+ const tour = savedViewCreateTour;
const filterDefinitions = serializeFilters(filters);
const body: NewSavedView = {
columns: getSavedColumnSettings(),
@@ -321,6 +324,9 @@
const result = await createMutation.mutateAsync(body);
isSaveDialogOpen = false;
onLoadView(result);
+ if (tour) {
+ await tour.created();
+ }
toast.success(`Saved view "${result.name}" created.`);
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to save view. Please try again.'));
@@ -355,6 +361,7 @@
async function openSaveDialog() {
await tick();
isSaveDialogOpen = true;
+ savedViewCreateTour?.openingSaveDialog();
}
async function toggleOrganizationDefault(): Promise {
@@ -393,7 +400,7 @@
{#snippet child({ props })}
-
+
View
{#if isModified}
@@ -402,7 +409,7 @@
{/snippet}
-
+
Saved View
{#if activeView}
@@ -411,7 +418,7 @@
Save
{/if}
-
+
Save As...
@@ -494,15 +501,24 @@
{#if isSaveDialogOpen}
(isSaveDialogOpen = false)}
+ onClose={() => savedViewCreateTour?.closed()}
{onLoadView}
/>
{/if}
+ (isMenuOpen = false)}
+ {isMenuOpen}
+ openMenu={() => (isMenuOpen = true)}
+ {openSaveDialog}
+/>
+
{#if isRenameDialogOpen && activeView}
facet.filter.id === f.id);
+ // Raw-filter drafts can contain multiple filters with the same key.
+ const sameKeyFacets = facets.filter((facet) => facet.filter.key === f.key);
+ const existing =
+ facets.find((facet) => facet.filter.id === f.id) ??
+ (sameKeyFacets.length === 1 && filters.filter((candidate) => candidate.key === f.key).length === 1 ? sameKeyFacets[0] : undefined);
if (existing) {
+ if (lastOpenFilterId === existing.filter.id) {
+ lastOpenFilterId = f.id;
+ }
existing.filter = f;
existing.component = builder.component;
existing.title = builder.title;
@@ -198,7 +205,7 @@
{@render children()}
{/if}
-{#each visibleFacets as facet (facet.filter.id)}
+{#each visibleFacets as facet (facet)}
{@const Facet = facet.component}
{
+ it('keeps duplicate raw filters distinct when another filter is added', async () => {
+ // Arrange
+ const local = new KeywordFilter('error.type:Local');
+ const remote = new KeywordFilter('error.type:Remote');
+ const view = render(Harness, { changed: vi.fn(), filters: [local], remove: vi.fn() });
+ const original = await screen.findByRole('button', { name: /^Raw Filter.*error\.type:Local/ });
+
+ // Act
+ await view.rerender({ filters: [local, remote] });
+
+ // Assert
+ expect(screen.getByRole('button', { name: /^Raw Filter.*error\.type:Local/ })).toBe(original);
+ expect(screen.getByRole('button', { name: /^Raw Filter.*error\.type:Remote/ })).not.toBe(original);
+
+ // Act
+ await view.rerender({ filters: [new KeywordFilter('error.type:Local'), new KeywordFilter('error.type:Remote')] });
+
+ // Assert
+ expect(screen.getAllByRole('button', { name: /^Raw Filter/ })).toHaveLength(2);
+ });
+
+ it('opens a newly added filter after the parent supplies it', async () => {
+ // Arrange
+ const changed = vi.fn<(filter: IFilter) => void>();
+ const view = render(Harness, { changed, filters: [], remove: vi.fn() });
+ await fireEvent.click(screen.getByRole('button', { name: 'Manage filters' }));
+
+ // Act
+ await fireEvent.click(await screen.findByRole('option', { name: 'Date' }));
+ expect(changed).toHaveBeenCalledOnce();
+ const added = changed.mock.calls[0]![0];
+ await view.rerender({ filters: [added] });
+
+ // Assert
+ expect(screen.getByRole('button', { name: /^Date/ }).getAttribute('aria-expanded')).toBe('true');
+ });
+
+ it('does not reopen a removed filter when it is added again', async () => {
+ // Arrange
+ const view = render(Harness, { changed: vi.fn(), filters: [new DateFilter('date', '[now-90d TO now]')], remove: vi.fn() });
+ await fireEvent.click(await screen.findByRole('button', { name: /^Date/ }));
+ await screen.findByRole('button', { name: 'Last 30 days' });
+
+ // Act
+ await view.rerender({ filters: [] });
+ await view.rerender({ filters: [new DateFilter('date', '[now-7d TO now]')] });
+
+ // Assert
+ expect(screen.getByRole('button', { name: /^Date/ }).getAttribute('aria-expanded')).toBe('false');
+ });
+
+ it('keeps the date picker open when hydration replaces a filter instance', async () => {
+ // Arrange
+ const changed = vi.fn();
+ const initial = new DateFilter('date', '[now-90d TO now]');
+ const hydrated = new DateFilter('date', '[now-90d TO now]');
+ const view = render(Harness, { changed, filters: [initial], remove: vi.fn() });
+ const trigger = await screen.findByRole('button', { name: /^Date/ });
+ await fireEvent.click(trigger);
+ await screen.findByRole('button', { name: 'Last 30 days' });
+
+ // Act
+ await view.rerender({ filters: [hydrated] });
+
+ // Assert
+ await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('true'));
+ expect(screen.getByRole('button', { name: /^Date/ })).toBe(trigger);
+
+ // Act
+ await fireEvent.click(screen.getByRole('button', { name: 'Last 30 days' }));
+
+ // Assert
+ expect(changed).toHaveBeenCalledWith(hydrated);
+ expect(hydrated.value).toBe('[now-30d TO now]');
+ });
+});
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte
new file mode 100644
index 0000000000..0649f5b3b7
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts
index 17eb411621..f62c49cc55 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts
@@ -8,16 +8,16 @@ const resizeObservers: ResizeObserverMock[] = [];
class ResizeObserverMock {
private observedElements = new Set();
- disconnect = vi.fn(() => {
+ public disconnect = vi.fn(() => {
this.observedElements.clear();
});
- observe = vi.fn((element: Element) => {
+ public observe = vi.fn((element: Element) => {
this.observedElements.add(element);
});
- takeRecords = vi.fn(() => []);
- unobserve = vi.fn((element: Element) => {
+ public takeRecords = vi.fn(() => []);
+ public unobserve = vi.fn((element: Element) => {
this.observedElements.delete(element);
});
private callback: ResizeObserverCallback;
@@ -26,7 +26,7 @@ class ResizeObserverMock {
resizeObservers.push(this);
}
- trigger(element: Element) {
+ public trigger(element: Element) {
if (this.observedElements.has(element)) {
this.callback([{ target: element } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte
index a4122d895a..ce02f11c3f 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte
@@ -40,6 +40,7 @@
>
{
/**
* Get the cached value without triggering PersistedState's deserialize
*/
- get current(): T {
+ public get current(): T {
return this.#cached;
}
/**
* Set the value, updating both the cache and PersistedState
*/
- set current(newValue: T) {
+ public set current(newValue: T) {
this.#cached = newValue;
this.#persisted.current = newValue;
}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte
index d28effe22b..b1680287e2 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte
@@ -184,7 +184,7 @@
-
+
@@ -192,7 +192,7 @@
-