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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -69,11 +68,9 @@
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-auto p-0" side="bottom" onkeydown={handleKeyDown}>
<Popover.Content align="start" class="p-0" side="bottom" onkeydown={handleKeyDown}>
<div id={`${title}-help`} class="sr-only">Press Enter to apply filter, Escape to cancel</div>
<div class="flex flex-col">
<DateRangePicker bind:this={dateRangePickerRef} {quickRanges} value={filter.value} onselect={handleSelect} />
</div>
<DateRangePicker bind:this={dateRangePickerRef} value={filter.value} onselect={handleSelect} />
<FacetedFilter.Actions clear={onClearFilter} hidden={filter.hidden} remove={onRemoveFilter} showClear={filter.value !== undefined} {toggleHidden} />
</Popover.Content>
</Popover.Root>
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ import {
VersionFilter
} from './models.svelte';

let filterCacheVersion = $state(1);
export function filterCacheVersionNumber() {
return filterCacheVersion;
}

const filterCache = new SvelteMap<null | string, IFilter[]>();

interface SerializedFilter {
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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[] {
Expand Down
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -22,21 +19,20 @@

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'

Check warning on line 30 in src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/date-range-picker.stories.ts

View workflow job for this annotation

GitHub Actions / test-client

Named exports should not use the name annotation if it is redundant to the name that would be generated by the export name
};

export const WithSelectedRange: Story = {
args: {
value: '[now-1d TO now]'
value: '[now-7d TO now]'
}
};

Expand All @@ -47,22 +43,21 @@
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.'
}
}
}
};

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.'
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,82 +1,144 @@
<script lang="ts">
import type { CustomDateRange } from '$features/shared/models';

import * as Tabs from '$comp/ui/tabs';
import { extractRangeExpressions } from '$features/shared/utils/datemath';

import CustomRangeForm from './custom-range-form.svelte';
import QuickRangeSelector from './quick-range-selector.svelte';
import { quickRanges as defaultQuickRanges, type QuickRangeSection } from './quick-ranges';
import DateTime from '$comp/formatters/date-time.svelte';
import { Button } from '$comp/ui/button';
import { Input } from '$comp/ui/input';
import { extractRangeExpressions, validateAndResolveTime, validateDateMath } from '$features/shared/utils/datemath';
import Check from '@lucide/svelte/icons/check';
import ChevronRight from '@lucide/svelte/icons/chevron-right';

type Props = {
cancel?: () => void;
class?: string;
onselect?: (value: string) => void;
quickRanges?: QuickRangeSection[];
value?: Date | string;
};

let { cancel, class: className, onselect, quickRanges = defaultQuickRanges, value = $bindable() }: Props = $props();
let { cancel, class: className, onselect, value = $bindable() }: Props = $props();

let activeTab = $state<'custom' | 'quick'>('quick');
let customFormRef: CustomRangeForm | undefined = $state();
// Simplified quick ranges — just the most commonly used
const commonRanges = [
{ label: 'Last 15 minutes', value: '[now-15m TO now]' },
{ label: 'Last 1 hour', value: '[now-1h TO now]' },
{ label: 'Last 4 hours', value: '[now-4h TO now]' },
{ label: 'Last 24 hours', value: '[now-1d TO now]' },
{ label: 'Last 7 days', value: '[now-7d TO now]' },
{ label: 'Last 30 days', value: '[now-30d TO now]' },
{ label: 'Last 90 days', value: '[now-90d TO now]' }
];

const parsedCustomRange = $derived(() => {
if (!value) {
return null;
}
let showCustom = $state(false);
let startValue = $state('');
let endValue = $state('');

return extractRangeExpressions(value) as CustomDateRange | null;
});

const quickRangeMatch = $derived(() => {
if (!value) {
return null;
}

for (const section of quickRanges) {
for (const option of section.options) {
if (option.value === value) {
return option;
}
// Initialize custom fields from current value if it's not a common range
$effect(() => {
const isCommon = commonRanges.some((r) => r.value === value);
if (!isCommon && value && typeof value === 'string') {
const range = extractRangeExpressions(value) as CustomDateRange | null;
if (range) {
startValue = range.start ?? '';
endValue = range.end ?? '';
showCustom = true;
}
}

return null;
});

// Auto switch tab based on current value
$effect(() => {
if (quickRangeMatch()) {
activeTab = 'quick';
} else if (value) {
activeTab = 'custom';
const startValidation = $derived(validateDateMath(startValue));
const startResolved = $derived(startValidation.valid ? validateAndResolveTime(startValue) : null);
const endValidation = $derived(validateDateMath(endValue));
const endResolved = $derived(endValidation.valid ? validateAndResolveTime(endValue) : null);
const isCustomValid = $derived(startValidation.valid && endValidation.valid && !!startValue && !!endValue);

function selectRange(rangeValue: string) {
value = rangeValue;
onselect?.(rangeValue);
}

function applyCustom() {
if (isCustomValid) {
const customValue = `[${startValue} TO ${endValue}]`;
value = customValue;
onselect?.(customValue);
}
});
}

function handleCustomApply(range: CustomDateRange) {
if (range.start && range.end) {
value = `[${range.start} TO ${range.end}]`;
onselect?.(value);
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && isCustomValid) {
event.preventDefault();
applyCustom();
} else if (event.key === 'Escape') {
event.preventDefault();
cancel?.();
}
}

export function apply() {
customFormRef?.submitIfValid();
if (showCustom && isCustomValid) {
applyCustom();
}
}
</script>

<div class={['w-[420px] p-4', className]}>
<Tabs.Root value={activeTab}>
<Tabs.List>
<Tabs.Trigger value="quick">Quick Range</Tabs.Trigger>
<Tabs.Trigger value="custom">Custom</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="quick">
<QuickRangeSelector {quickRanges} bind:value {onselect} />
</Tabs.Content>
<Tabs.Content value="custom">
<CustomRangeForm bind:this={customFormRef} range={parsedCustomRange()} {cancel} apply={handleCustomApply} />
</Tabs.Content>
</Tabs.Root>
<div class={className}>
<div class="flex flex-col p-1">
{#each commonRanges as range (range.value)}
<button
type="button"
class="hover:bg-muted hover:text-foreground flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors select-none"
onclick={() => selectRange(range.value)}
>
<span class="size-4 shrink-0">
{#if range.value === value}
<Check class="text-primary size-4" />
{/if}
</span>
<span class={range.value === value ? 'font-medium' : ''}>{range.label}</span>
</button>
{/each}

<button
type="button"
class="hover:bg-muted hover:text-foreground flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors select-none"
onclick={() => (showCustom = !showCustom)}
>
<ChevronRight class={['text-muted-foreground size-4 shrink-0 transition-transform', showCustom && 'rotate-90']} />
<span class={showCustom ? 'font-medium' : ''}>Custom range</span>
</button>

{#if showCustom}
<div class="space-y-2 px-2 pt-2 pb-1">
<div>
<Input
placeholder="Start: now-1h, 2024-01-01"
class="h-7 font-mono text-xs"
bind:value={startValue}
aria-invalid={startValue ? !startValidation.valid : undefined}
onkeydown={handleKeyDown}
/>
{#if startValue && startValidation.valid && startResolved}
<p class="text-muted-foreground mt-0.5 text-[11px]"><DateTime value={startResolved} /></p>
{:else if startValue && !startValidation.valid}
<p class="text-destructive mt-0.5 text-[11px]">{startValidation.error}</p>
{/if}
</div>
<div>
<Input
placeholder="End: now, 2024-12-31"
class="h-7 font-mono text-xs"
bind:value={endValue}
aria-invalid={endValue ? !endValidation.valid : undefined}
onkeydown={handleKeyDown}
/>
{#if endValue && endValidation.valid && endResolved}
<p class="text-muted-foreground mt-0.5 text-[11px]"><DateTime value={endResolved} /></p>
{:else if endValue && !endValidation.valid}
<p class="text-destructive mt-0.5 text-[11px]">{endValidation.error}</p>
{/if}
</div>
<Button size="sm" class="w-full" disabled={!isCustomValid} onclick={applyCustom}>Apply</Button>
</div>
{/if}
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<script lang="ts">
import { Button } from '$comp/ui/button';
import Separator from '$comp/ui/separator/separator.svelte';
import * as Tooltip from '$comp/ui/tooltip';
import Eraser from '@lucide/svelte/icons/eraser';
import Eye from '@lucide/svelte/icons/eye';
import EyeOff from '@lucide/svelte/icons/eye-off';
import Trash2 from '@lucide/svelte/icons/trash-2';

interface Props {
clear: () => void;
Expand All @@ -13,13 +17,43 @@
let { clear, hidden = false, remove, showClear, toggleHidden }: Props = $props();
</script>

<div class="flex flex-col">
<Separator />
<div class="flex items-center justify-end gap-0.5 border-t px-2 py-1">
{#if showClear}
<Button variant="ghost" onclick={clear}>Clear Filter</Button>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-sm" onclick={clear}>
<Eraser class="text-muted-foreground size-3.5" />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Clear value</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if toggleHidden}
<Button variant="ghost" onclick={toggleHidden}>{hidden ? 'Show Filter' : 'Hide Filter'}</Button>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-sm" onclick={toggleHidden}>
{#if hidden}
<Eye class="text-muted-foreground size-3.5" />
{:else}
<EyeOff class="text-muted-foreground size-3.5" />
{/if}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>{hidden ? 'Show filter' : 'Hide filter'}</Tooltip.Content>
</Tooltip.Root>
{/if}
<Button variant="ghost" onclick={remove}>Remove Filter</Button>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-sm" onclick={remove}>
<Trash2 class="text-muted-foreground size-3.5" />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Remove filter</Tooltip.Content>
</Tooltip.Root>
</div>
Loading