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 @@ -6,9 +6,8 @@
*/

import type {VisibilityState} from '@tanstack/react-table';
import {findOptionValueForInterval, type IntervalSelection} from 'components/interval-selector';
import type {IntervalSelection} from 'components/interval-selector';
import {createContext, type ReactNode, useCallback, useContext, useMemo, useState} from 'react';
import {intervalToNowFromDuration} from 'utils/date';
import type {
DashboardState,
FilterOption,
Expand Down Expand Up @@ -138,13 +137,6 @@ export function DashboardProvider({
// State management
const [searchQuery, setSearchQuery] = useState('');
const [selection, setSelection] = useState<IntervalSelection>(initialSelection);
const [intervalValue, setIntervalValue] = useState<string | undefined>(() => {
const interval =
initialSelection.type === 'interval'
? initialSelection.interval
: intervalToNowFromDuration(initialSelection.duration);
return findOptionValueForInterval(interval);
});
const [lastUpdated, setLastUpdated] = useState('13s ago');
const [columns, setColumns] = useState<ViewColumn[]>(initialColumns);
const [filters, setFilters] = useState<FilterOption[]>(initialFilters);
Expand All @@ -153,12 +145,6 @@ export function DashboardProvider({

const handleSelectionChange = useCallback((newSelection: IntervalSelection) => {
setSelection(newSelection);
const interval =
newSelection.type === 'interval'
? newSelection.interval
: intervalToNowFromDuration(newSelection.duration);
const value = findOptionValueForInterval(interval);
setIntervalValue(value);
}, []);

// Compute column visibility state
Expand All @@ -182,8 +168,6 @@ export function DashboardProvider({
setSearchQuery,
selection,
setSelection: handleSelectionChange,
intervalValue,
setIntervalValue,
lastUpdated,
setLastUpdated,
columns,
Expand All @@ -201,7 +185,6 @@ export function DashboardProvider({
searchQuery,
selection,
handleSelectionChange,
intervalValue,
lastUpdated,
columns,
columnVisibility,
Expand Down
2 changes: 0 additions & 2 deletions libs/react/ui/src/components/dashboard/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ export interface DashboardState {
// Time interval selection
selection: IntervalSelection;
setSelection: (selection: IntervalSelection) => void;
intervalValue: string | undefined;
setIntervalValue: (value: string | undefined) => void;

// Last updated timestamp
lastUpdated: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,7 @@ export function PageToolbar({
className,
...props
}: PageToolbarProps) {
const {selection, setSelection, intervalValue, setIntervalValue, lastUpdated} =
useDashboardContext();
const {selection, setSelection, lastUpdated} = useDashboardContext();
return (
<div
className={cn(
Expand Down Expand Up @@ -132,8 +131,6 @@ export function PageToolbar({
<IntervalSelector
selection={selection}
onSelectionChange={setSelection}
value={intervalValue}
onValueChange={setIntervalValue}
container={intervalSelectorContainer}
className="w-[75vw] md:w-350"
/>
Expand Down
4 changes: 4 additions & 0 deletions libs/react/ui/src/components/interval-selector/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './use-interval-selector';
export * from './use-interval-selector-input';
export * from './use-interval-selector-navigation';
export * from './use-interval-selector-state';
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {afterEach, beforeEach, describe, expect, it, vi} from '@shipfox/vitest/vi';
import {act, renderHook} from '@testing-library/react';
import type {RelativeSuggestion} from '../types';
import {useIntervalSelectorInput} from './use-interval-selector-input';

describe('useIntervalSelectorInput', () => {
const mockPastIntervals: RelativeSuggestion[] = [{type: 'relative', duration: {minutes: 5}}];

const mockProps = {
relativeSuggestions: mockPastIntervals,
inputValue: '',
setInputValue: vi.fn(),
setDetectedShortcut: vi.fn(),
setConfirmedShortcut: vi.fn(),
setIsInvalid: vi.fn(),
setSelectedLabel: vi.fn(),
setHighlightedIndex: vi.fn(),
selectedValueRef: {current: undefined as string | undefined},
triggerShakeAnimation: vi.fn(),
onSelect: vi.fn(),
};

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-19T17:45:00Z'));
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

describe('handleInputChange', () => {
it('should update input value and detect shortcut', () => {
const {result} = renderHook(() => useIntervalSelectorInput(mockProps));

act(() => {
result.current.handleInputChange({
target: {value: '5m'},
} as React.ChangeEvent<HTMLInputElement>);
});

expect(mockProps.setInputValue).toHaveBeenCalledWith('5m');
expect(mockProps.setIsInvalid).toHaveBeenCalledWith(false);
});

it('should set invalid state for non-empty input without shortcut', () => {
const {result} = renderHook(() => useIntervalSelectorInput(mockProps));

act(() => {
result.current.handleInputChange({
target: {value: 'invalid'},
} as React.ChangeEvent<HTMLInputElement>);
});

expect(mockProps.setIsInvalid).toHaveBeenCalledWith(true);
});

it('should clear selection when input changes', () => {
mockProps.selectedValueRef.current = '5m';
const {result} = renderHook(() => useIntervalSelectorInput(mockProps));

act(() => {
result.current.handleInputChange({
target: {value: 'new'},
} as React.ChangeEvent<HTMLInputElement>);
});

expect(mockProps.setSelectedLabel).toHaveBeenCalledWith(undefined);
expect(mockProps.selectedValueRef.current).toBeUndefined();
expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(-1);
});

it('should clear selectedLabel even when selectedValueRef.current is undefined', () => {
mockProps.selectedValueRef.current = undefined;
const {result} = renderHook(() => useIntervalSelectorInput(mockProps));

act(() => {
result.current.handleInputChange({
target: {value: 'new'},
} as React.ChangeEvent<HTMLInputElement>);
});

expect(mockProps.setSelectedLabel).toHaveBeenCalledWith(undefined);
expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(-1);
});
});

describe('handleConfirmInput', () => {
it('should handle relative time shortcut input', () => {
const {result} = renderHook(() =>
useIntervalSelectorInput({
...mockProps,
inputValue: '5m',
}),
);

act(() => {
result.current.handleConfirmInput();
});

expect(mockProps.onSelect).toHaveBeenCalledWith({
type: 'relative',
duration: {minutes: 5},
});
});

it('should handle parsed interval input', () => {
const {result} = renderHook(() =>
useIntervalSelectorInput({
...mockProps,
inputValue: 'Jan 1 2026 - Jan 15 2026',
}),
);

act(() => {
result.current.handleConfirmInput();
});

expect(mockProps.onSelect).toHaveBeenCalled();
});

it('should trigger shake animation for invalid input', () => {
const {result} = renderHook(() =>
useIntervalSelectorInput({
...mockProps,
inputValue: 'invalid input',
}),
);

act(() => {
result.current.handleConfirmInput();
});

expect(mockProps.triggerShakeAnimation).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {useCallback} from 'react';
import {parseTextDurationShortcut, parseTextInterval} from 'utils/date';
import type {IntervalSelection} from '../types';

interface UseIntervalSelectorInputProps {
inputValue: string;
setInputValue: (value: string) => void;
setIsInvalid: (invalid: boolean) => void;
setSelectedLabel: (label: string | undefined) => void;
setHighlightedIndex: (index: number) => void;
selectedValueRef: React.RefObject<string | undefined>;
triggerShakeAnimation: () => void;
onSelect: (selection: IntervalSelection) => void;
}

export function useIntervalSelectorInput({
inputValue,
setInputValue,
setIsInvalid,
setSelectedLabel,
setHighlightedIndex,
selectedValueRef,
triggerShakeAnimation,
onSelect,
}: UseIntervalSelectorInputProps) {
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);

const shortcut = parseTextDurationShortcut(newValue);
const interval = parseTextInterval(newValue);
const isValid = shortcut || interval || newValue.trim() === '';
setIsInvalid(!isValid);

if (selectedValueRef.current) selectedValueRef.current = undefined;

setSelectedLabel(undefined);
setHighlightedIndex(-1);
},
[setInputValue, setIsInvalid, setSelectedLabel, setHighlightedIndex, selectedValueRef],
);

const handleConfirmInput = useCallback(() => {
const shortcut = parseTextDurationShortcut(inputValue);
if (shortcut) return onSelect({type: 'relative', duration: shortcut});
const interval = parseTextInterval(inputValue);
if (interval) return onSelect({type: 'interval', interval});
triggerShakeAnimation();
}, [inputValue, onSelect, triggerShakeAnimation]);

return {
handleInputChange,
handleConfirmInput,
};
}
Loading
Loading