diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte index 47758b9ce1..876c05615b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte @@ -8,7 +8,6 @@ import { Button } from '$comp/ui/button'; import * as Popover from '$comp/ui/popover'; import Separator from '$comp/ui/separator/separator.svelte'; - import { quickRanges } from '$features/shared/components/date-range-picker/quick-ranges'; import { DateFilter } from './models.svelte'; @@ -69,11 +68,9 @@ {/snippet} - +
Press Enter to apply filter, Escape to cancel
-
- -
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts index 3e89d5c08c..dcad9673b4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts @@ -22,11 +22,6 @@ import { VersionFilter } from './models.svelte'; -let filterCacheVersion = $state(1); -export function filterCacheVersionNumber() { - return filterCacheVersion; -} - const filterCache = new SvelteMap(); interface SerializedFilter { @@ -56,7 +51,6 @@ export function buildFilterCacheKey(organization: string | undefined, scope: str export function clearFilterCache() { filterCache.clear(); - filterCacheVersion = 1; } export function deserializeFilters(json: string): IFilter[] { @@ -226,7 +220,6 @@ export function updateFilterCache(cacheKey: string, filters: IFilter[]) { filterCache.delete(cacheKey); filterCache.set(cacheKey, filters); - filterCacheVersion += 1; } function processFilterRules(filters: IFilter[]): IFilter[] { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.stories.ts index 422f0b60a8..6429b92782 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.stories.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.stories.ts @@ -1,12 +1,9 @@ import type { Meta, StoryObj } from '@storybook/sveltekit'; import DateRangePicker from './date-range-picker.svelte'; -import { quickRanges } from './quick-ranges'; const meta = { - args: { - quickRanges - }, + args: {}, argTypes: {}, component: DateRangePicker, parameters: { @@ -22,21 +19,20 @@ type Story = StoryObj; export const DefaultQuickRanges: Story = { args: { - value: 'last 24 hours' + value: '[now-1d TO now]' } }; -export const CustomQuickRanges: Story = { +export const CustomRange: Story = { args: { - quickRanges, - value: 'previous week' + value: '[2025-01-01T00:00:00 TO 2025-01-02T00:00:00]' }, name: 'Custom Range' }; export const WithSelectedRange: Story = { args: { - value: '[now-1d TO now]' + value: '[now-7d TO now]' } }; @@ -47,7 +43,7 @@ export const ShowingCustomForm: Story = { parameters: { docs: { description: { - story: 'This story demonstrates the calendar time picker with a selected quick range. Switch to the Custom tab to edit a custom range.' + story: 'This story demonstrates the date range picker with a selected quick range. Expand the Custom range section to edit a custom range.' } } } @@ -55,14 +51,13 @@ export const ShowingCustomForm: Story = { export const AutoSelectCustomTab: Story = { args: { - // A value unlikely to match a predefined quick range forces the custom tab active - value: '2025-01-01T00:00:00 TO 2025-01-02T00:00:00' + value: '[2025-01-01T00:00:00 TO 2025-01-02T00:00:00]' }, - name: 'Auto Selects Custom Tab (non quick value)', + name: 'Auto Expands Custom Section (non quick value)', parameters: { docs: { description: { - story: 'When the current value does not match any quick range option, the Custom tab is automatically selected.' + story: 'When the current value does not match any common range option, the Custom range section is automatically expanded.' } } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.svelte index a5ae5fd3c3..0ff5f89763 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.svelte @@ -1,82 +1,144 @@ -
- - - Quick Range - Custom - - - - - - - - +
+
+ {#each commonRanges as range (range.value)} + + {/each} + + + + {#if showCustom} +
+
+ + {#if startValue && startValidation.valid && startResolved} +

+ {:else if startValue && !startValidation.valid} +

{startValidation.error}

+ {/if} +
+
+ + {#if endValue && endValidation.valid && endResolved} +

+ {:else if endValue && !endValidation.valid} +

{endValidation.error}

+ {/if} +
+ +
+ {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte index 6ab6c1d13e..616baf08d7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte @@ -1,6 +1,10 @@ -
- +
{#if showClear} - + + + {#snippet child({ props })} + + {/snippet} + + Clear value + {/if} {#if toggleHidden} - + + + {#snippet child({ props })} + + {/snippet} + + {hidden ? 'Show filter' : 'Hide filter'} + {/if} - + + + {#snippet child({ props })} + + {/snippet} + + Remove filter +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte index bddf3a452a..3fc7897a48 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte @@ -36,6 +36,8 @@ } else if (selectedValue === 'no-value') { updatedValue = undefined; } + + changed(updatedValue); } function handleKeyDown(event: KeyboardEvent) { @@ -49,21 +51,18 @@ } function applyAndClose() { - if (updatedValue !== value) { - changed(updatedValue); - } - open = false; } function cancelAndClose() { updatedValue = value; + changed(updatedValue); open = false; } function onOpenChange(isOpen: boolean) { if (!isOpen) { - applyAndClose(); + open = false; } } @@ -91,7 +90,7 @@ {/snippet} - + e.preventDefault()}>
[] = $derived.by(() => { + let facets: FacetedFilter[] = $state([]); + + $effect.pre(() => { if (builderContext.size === 0) { - return []; + facets = []; + return; } - return filters + const newFacets = filters .map((filter) => { const builder = builderContext.get(filter.key); if (!builder) { @@ -44,6 +50,15 @@ } const f = builder.create(filter); + // Reuse existing facet to preserve open state and avoid component recreation + const existing = facets.find((facet) => facet.filter.id === f.id); + if (existing) { + existing.filter = f; + existing.component = builder.component; + existing.title = builder.title; + return existing; + } + return { component: builder.component, filter: f, @@ -52,6 +67,12 @@ }; }) .filter((f): f is FacetedFilter => !!f); + + // Only replace the array if the set of facets actually changed + const idsMatch = newFacets.length === facets.length && newFacets.every((f, i) => f.filter.id === facets[i]?.filter.id); + if (!idsMatch) { + facets = newFacets; + } }); const hiddenFilterCount = $derived(filters.filter((filter) => filter.hidden).length); @@ -82,10 +103,6 @@ } function filterChanged(filter: IFilter) { - if (lastOpenFilterId === filter.id) { - lastOpenFilterId = undefined; - } - changed(filter); } @@ -228,15 +245,34 @@ {/if} -
- +
{#if hiddenFilterCount > 0} - + + + {#snippet child({ props })} + + {/snippet} + + Toggle hidden filters + {/if} {#if filters.some((f) => f.type !== 'date')} - + + + {#snippet child({ props })} + + {/snippet} + + Clear all filters + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte index 0c09b5a08c..be4fecad20 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte @@ -46,22 +46,15 @@ updatedValue = value; }); - function applyAndClose() { - if (updatedValue !== value) { - changed(updatedValue); - } - - open = false; - } - function cancelAndClose() { updatedValue = value; + changed(updatedValue); open = false; } function onOpenChange(isOpen: boolean) { if (!isOpen) { - applyAndClose(); + open = false; } } @@ -76,10 +69,13 @@ } else { updatedValue = currentValue; } + + changed(updatedValue); } export function onClearFilter() { updatedValue = undefined; + changed(updatedValue); } function displayValue(value: string | undefined) { @@ -116,7 +112,7 @@ {/snippet} - + e.preventDefault()}> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-keyword.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-keyword.svelte index cca862faa1..4b875c97a7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-keyword.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-keyword.svelte @@ -77,8 +77,8 @@ {/snippet} - -
+ e.preventDefault()}> +
Type keywords. Enter applies, Escape cancels.
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte index 9cae79e333..83dacc93e3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte @@ -51,24 +51,15 @@ updatedValues = values; }); - const hasChanged = $derived(updatedValues.length !== values.length || updatedValues.some((value) => !values.includes(value))); - - function applyAndClose() { - if (hasChanged) { - changed(updatedValues); - } - - open = false; - } - function cancelAndClose() { updatedValues = values; + changed(updatedValues); open = false; } function onOpenChange(isOpen: boolean) { if (!isOpen) { - applyAndClose(); + open = false; } } @@ -79,10 +70,12 @@ export function onValueSelected(currentValue: string) { updatedValues = updatedValues.includes(currentValue) ? updatedValues.filter((v) => v !== currentValue) : [...updatedValues, currentValue]; + changed(updatedValues); } export function onClearFilter() { updatedValues = []; + changed(updatedValues); } function filter(value: string, search: string) { @@ -119,7 +112,7 @@ {/snippet} - + e.preventDefault()}> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-number.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-number.svelte index 1ccc54eb33..e249c879ff 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-number.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-number.svelte @@ -77,8 +77,8 @@ {/snippet} - -
+ e.preventDefault()}> +
{/snippet} - -
+ e.preventDefault()}> +
{@render children?.()} 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 6523513bb6..36affbaa02 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 @@ -220,7 +220,7 @@ {/if} - + {#each dashboardRoutes as route (route.href)} {@const Icon = route.icon} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 6e62935177..ea5021782d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -435,7 +435,6 @@ {#snippet header()} page.url.pathname, () => page.url.search, () => savedViewsState.activeSavedView, () => filterCacheVersionNumber()], + [() => page.url.pathname, () => page.url.search, () => savedViewsState.activeSavedView], () => { filters = getCurrentFilters(); }, @@ -179,12 +178,20 @@ }); function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter): void { - updateFilters(filterChanged(filters ?? [], addedOrUpdated)); + const isNew = !filters?.some((f) => f.id === addedOrUpdated.id); + const updatedFilters = filterChanged(filters ?? [], addedOrUpdated); + updateFilters(updatedFilters); + if (isNew) { + filters = updatedFilters; + } + selectedEventId = null; } function onFilterRemoved(removed?: FacetedFilter.IFilter): void { - updateFilters(filterRemoved(filters ?? [], removed)); + const updatedFilters = filterRemoved(filters ?? [], removed); + updateFilters(updatedFilters); + filters = updatedFilters; } function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { 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 103e4d9758..17929fe1ab 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -16,7 +16,6 @@ applyTimeFilter, buildFilterCacheKey, deserializeFilters, - filterCacheVersionNumber, filterChanged, filterRemoved, getFiltersFromCache, @@ -82,16 +81,16 @@ } function getQueryTime(): null | string { - if (page.url.searchParams.has('time')) { - return page.url.searchParams.get('time') === '' ? null : queryParams.time; + if (queryParams.time != null) { + return queryParams.time || null; } return savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; } function getEffectiveFilter(): null | string { - if (page.url.searchParams.has('filter')) { - return queryParams.filter ?? ''; + if (queryParams.filter != null) { + return queryParams.filter; } return savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; @@ -148,7 +147,7 @@ function getCurrentFilters(): FacetedFilter.IFilter[] { const filter = getEffectiveFilter(); const savedView = savedViewsState.activeSavedView; - if (!page.url.searchParams.has('filter') && savedView?.filter_definitions && filter === (savedView.filter ?? null)) { + if (queryParams.filter == null && savedView?.filter_definitions && filter === (savedView.filter ?? null)) { const hydrated = deserializeFilters(savedView.filter_definitions); return applyTimeFilter(hydrated, getQueryTime()); } @@ -158,7 +157,7 @@ let filters = $state(getCurrentFilters()); watch( - [() => page.url.pathname, () => page.url.search, () => savedViewsState.activeSavedView, () => filterCacheVersionNumber()], + [() => page.url.pathname, () => page.url.search, () => savedViewsState.activeSavedView], () => { filters = getCurrentFilters(); }, @@ -178,12 +177,22 @@ } // For all other filters, apply them to the current page - updateFilters(filterChanged(filters ?? [], addedOrUpdated)); + const isNew = !filters?.some((f) => f.id === addedOrUpdated.id); + const updatedFilters = filterChanged(filters ?? [], addedOrUpdated); + updateFilters(updatedFilters); + // Only reassign filters for newly added filters to avoid re-rendering open popovers. + // Existing filter values are already mutated in place via $state. + if (isNew) { + filters = updatedFilters; + } + selectedStackId = undefined; } function onFilterRemoved(removed?: FacetedFilter.IFilter): void { - updateFilters(filterRemoved(filters ?? [], removed)); + const updatedFilters = filterRemoved(filters ?? [], removed); + updateFilters(updatedFilters); + filters = updatedFilters; } function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte index 86ac0fd48b..cacff1be07 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte @@ -11,7 +11,6 @@ import { StatusFilter, StringFilter, TagFilter } from '$features/events/components/filters'; import { buildFilterCacheKey, - filterCacheVersionNumber, filterChanged, filterRemoved, getFiltersFromCache, @@ -115,7 +114,7 @@ let filters = $state(sanitizeStackFilters(getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter))); watch( - [() => queryParams.filter, () => filterCacheVersionNumber()], + [() => queryParams.filter], ([filter]) => { filters = sanitizeStackFilters(getFiltersFromCache(filterCacheKey(filter), filter), true); }, @@ -134,16 +133,24 @@ return; } - updateFilters(filterChanged(filters ?? [], addedOrUpdated)); + const isNew = !filters?.some((f) => f.id === addedOrUpdated.id); + const updatedFilters = filterChanged(filters ?? [], addedOrUpdated); + updateFilters(updatedFilters); + if (isNew) { + filters = sanitizeStackFilters(updatedFilters); + } } function onFilterRemoved(removed?: FacetedFilter.IFilter): void { if (!removed) { updateFilters([]); + filters = []; return; } - updateFilters(filterRemoved(filters ?? [], removed)); + const updatedFilters = filterRemoved(filters ?? [], removed); + updateFilters(updatedFilters); + filters = updatedFilters; } function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { 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 adaf4b1380..1e9ca8ffd0 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte @@ -18,7 +18,6 @@ import { applyTimeFilter, buildFilterCacheKey, - filterCacheVersionNumber, filterChanged, filterRemoved, getFiltersFromCache, @@ -107,7 +106,7 @@ let filters = $state(applyTimeFilter(getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter), queryParams.time)); watch( - [() => queryParams.filter, () => queryParams.time, () => filterCacheVersionNumber()], + [() => queryParams.filter, () => queryParams.time], ([filter, time]) => { filters = applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), time); }, @@ -119,12 +118,20 @@ }); function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter): void { - updateFilters(filterChanged(filters ?? [], addedOrUpdated)); + const isNew = !filters?.some((f) => f.id === addedOrUpdated.id); + const updatedFilters = filterChanged(filters ?? [], addedOrUpdated); + updateFilters(updatedFilters); + if (isNew) { + filters = updatedFilters; + } + selectedEventId = null; } function onFilterRemoved(removed?: FacetedFilter.IFilter): void { - updateFilters(filterRemoved(filters ?? [], removed)); + const updatedFilters = filterRemoved(filters ?? [], removed); + updateFilters(updatedFilters); + filters = updatedFilters; } function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { 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 4b5fd6d1bd..dd8b24bbfd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -15,7 +15,6 @@ import { ProjectFilter, StatusFilter } from '$features/events/components/filters'; import { buildFilterCacheKey, - filterCacheVersionNumber, filterChanged, filterRemoved, getFiltersFromCache, @@ -106,7 +105,7 @@ let filters = $state(getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter)); watch( - [() => queryParams.filter, () => filterCacheVersionNumber()], + [() => queryParams.filter], ([filter]) => { filters = getFiltersFromCache(filterCacheKey(filter), filter); }, @@ -122,14 +121,21 @@ // For all other filters (skipping date filters), apply them to the current page if (addedOrUpdated.type !== 'date') { - updateFilters(filterChanged(filters ?? [], addedOrUpdated)); + const isNew = !filters?.some((f) => f.id === addedOrUpdated.id); + const updatedFilters = filterChanged(filters ?? [], addedOrUpdated); + updateFilters(updatedFilters); + if (isNew) { + filters = updatedFilters; + } } selectedEventId = null; } function onFilterRemoved(removed?: FacetedFilter.IFilter): void { - updateFilters(filterRemoved(filters ?? [], removed)); + const updatedFilters = filterRemoved(filters ?? [], removed); + updateFilters(updatedFilters); + filters = updatedFilters; } function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void {