diff --git a/.changeset/remember-collapsed-pinned-filters.md b/.changeset/remember-collapsed-pinned-filters.md new file mode 100644 index 0000000000..2b7d8084b9 --- /dev/null +++ b/.changeset/remember-collapsed-pinned-filters.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +--- + +feat: A pinned filter collapsed in the search sidebar stays collapsed the next +time the source loads, instead of always opening diff --git a/packages/app/src/__tests__/pinnedFilters.test.ts b/packages/app/src/__tests__/pinnedFilters.test.ts index fa8a8d1ad6..ca56296ce7 100644 --- a/packages/app/src/__tests__/pinnedFilters.test.ts +++ b/packages/app/src/__tests__/pinnedFilters.test.ts @@ -4,7 +4,9 @@ * - localStorage migration logic */ -import { mergePinnedData } from '@/searchFilters'; +import { act, renderHook } from '@testing-library/react'; + +import { mergePinnedData, useCollapsedPinnedFields } from '@/searchFilters'; describe('mergePinnedData', () => { it('returns empty fields and filters when both are null', () => { @@ -167,3 +169,77 @@ describe('localStorage migration', () => { expect(result[sourceB]).toEqual({ level: ['error'] }); }); }); + +describe('useCollapsedPinnedFields', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('remembers a collapsed pinned field across remounts', () => { + const first = renderHook(() => useCollapsedPinnedFields('source1')); + expect(first.result.current.isPinnedFieldCollapsed('ServiceName')).toBe( + false, + ); + + act(() => + first.result.current.setPinnedFieldCollapsed('ServiceName', true), + ); + expect(first.result.current.isPinnedFieldCollapsed('ServiceName')).toBe( + true, + ); + first.unmount(); + + const second = renderHook(() => useCollapsedPinnedFields('source1')); + expect(second.result.current.isPinnedFieldCollapsed('ServiceName')).toBe( + true, + ); + }); + + it('forgets the field once it is expanded again', () => { + const { result } = renderHook(() => useCollapsedPinnedFields('source1')); + act(() => result.current.setPinnedFieldCollapsed('ServiceName', true)); + act(() => result.current.setPinnedFieldCollapsed('ServiceName', false)); + + expect(result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false); + expect( + JSON.parse(window.localStorage.getItem('hdx-collapsed-pinned-fields')!), + ).toEqual({ source1: [] }); + }); + + it('keeps the collapsed state per source', () => { + const a = renderHook(() => useCollapsedPinnedFields('sourceA')); + act(() => a.result.current.setPinnedFieldCollapsed('ServiceName', true)); + + const b = renderHook(() => useCollapsedPinnedFields('sourceB')); + expect(b.result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false); + }); + + it('does nothing without a source', () => { + const { result } = renderHook(() => useCollapsedPinnedFields(null)); + act(() => result.current.setPinnedFieldCollapsed('ServiceName', true)); + + expect(result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false); + expect(window.localStorage.getItem('hdx-collapsed-pinned-fields')).toBe( + null, + ); + }); +}); + +describe('useCollapsedPinnedFields pruning', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('keeps only the fields still pinned after a reset', () => { + const { result } = renderHook(() => useCollapsedPinnedFields('source1')); + act(() => result.current.setPinnedFieldCollapsed('ServiceName', true)); + act(() => result.current.setPinnedFieldCollapsed('SeverityText', true)); + + act(() => + result.current.pruneCollapsedPinnedFields(f => f === 'SeverityText'), + ); + + expect(result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false); + expect(result.current.isPinnedFieldCollapsed('SeverityText')).toBe(true); + }); +}); diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 2b75b5fcf5..e370cda45e 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -65,6 +65,7 @@ import { usePinnedFiltersApi } from '@/pinnedFilters'; import { FilterStateHook, IS_ROOT_SPAN_COLUMN_NAME, + useCollapsedPinnedFields, usePinnedFilters, } from '@/searchFilters'; import { useSource } from '@/source'; @@ -918,6 +919,31 @@ function FilterGroupActions({ const voidFunc = () => {}; +/** + * Whether a filter group opens on load. A pinned field the user collapsed + * stays collapsed unless it has an active filter. That persisted collapse + * intentionally takes precedence over a section-level `forceExpanded` + * (the Shared Filters section). + */ +export function getFilterGroupDefaultExpanded({ + hasSelection, + isPinned, + isCollapsed, + isPrimary, + forceExpanded, +}: { + hasSelection: boolean; + isPinned: boolean; + isCollapsed: boolean; + isPrimary: boolean; + forceExpanded?: boolean; +}): boolean { + if (isPinned && isCollapsed && !hasSelection) { + return false; + } + return forceExpanded ?? (isPrimary || isPinned || hasSelection); +} + export const FilterGroup = ({ name, options, @@ -1172,6 +1198,11 @@ const DBSearchPageFiltersComponent = ({ hasPersonalPins, hasSharedPins, } = usePinnedFilters(sourceId ?? null); + const { + isPinnedFieldCollapsed, + setPinnedFieldCollapsed, + pruneCollapsedPinnedFields, + } = useCollapsedPinnedFields(sourceId ?? null); const { data: pinnedFiltersApiData } = usePinnedFiltersApi(sourceId ?? null); const [isSharedFiltersVisible, setSharedFiltersVisible] = useLocalStorage( 'hdx-shared-filters-visible', @@ -1504,6 +1535,8 @@ const DBSearchPageFiltersComponent = ({ const { keyPrefix = '', isDefaultExpanded: forceExpanded } = options ?? {}; const { grouped, nonGrouped } = groupFacetsByBaseName(facets); + const isFieldPinnedAny = (key: string) => + isFieldPinned(key) || isSharedFieldPinned(key); const makeValuePins = (key: string): ValuePinHandlers => ({ onPinClick: (value: string | boolean) => toggleFilterPin(key, value), @@ -1515,9 +1548,21 @@ const DBSearchPageFiltersComponent = ({ }); const makeFieldPins = (key: string): FieldPinHandlers => ({ - onFieldPinClick: () => toggleFieldPin(key), + onFieldPinClick: () => { + // Unpinned for good: forget its collapsed state, so pinning it again + // opens it like any newly pinned field. + if (isFieldPinned(key) && !isSharedFieldPinned(key)) { + setPinnedFieldCollapsed(key, false); + } + toggleFieldPin(key); + }, isFieldPinned: isFieldPinned(key), - onToggleSharedFieldPin: () => toggleSharedFieldPin(key), + onToggleSharedFieldPin: () => { + if (isSharedFieldPinned(key) && !isFieldPinned(key)) { + setPinnedFieldCollapsed(key, false); + } + toggleSharedFieldPin(key); + }, isSharedFieldPinned: isSharedFieldPinned(key), }); @@ -1641,17 +1686,23 @@ const DBSearchPageFiltersComponent = ({ hasLoadedMore={extraFacetKeys.has(facet.key)} isDefaultExpanded={(() => { const entry = getFilterStateEntry(filterState, facet.key); - return ( - forceExpanded ?? - (isFieldPrimary(tableMetadata, facet.key) || - isFieldPinned(facet.key) || - isSharedFieldPinned(facet.key) || - (entry != null && - (entry.included.size > 0 || - entry.excluded.size > 0 || - entry.range != null))) - ); + return getFilterGroupDefaultExpanded({ + hasSelection: + entry != null && + (entry.included.size > 0 || + entry.excluded.size > 0 || + entry.range != null), + isPinned: isFieldPinnedAny(facet.key), + isCollapsed: isPinnedFieldCollapsed(facet.key), + isPrimary: !!isFieldPrimary(tableMetadata, facet.key), + forceExpanded, + }); })()} + onExpandedChange={expanded => { + if (isFieldPinnedAny(facet.key)) { + setPinnedFieldCollapsed(facet.key, !expanded); + } + }} chartConfig={chartConfig} isLive={isLive} onRangeChange={range => setFilterRange(facet.key, range)} @@ -1673,6 +1724,8 @@ const DBSearchPageFiltersComponent = ({ isFieldPinned, toggleSharedFieldPin, isSharedFieldPinned, + isPinnedFieldCollapsed, + setPinnedFieldCollapsed, onColumnToggle, displayedColumns, loadMoreFacetsForKey, @@ -1738,9 +1791,15 @@ const DBSearchPageFiltersComponent = ({ showFilterCounts={showFilterCounts} onShowFilterCountsChange={setShowFilterCounts} hasPersonalPins={hasPersonalPins} - onResetPersonalPins={resetPersonalPins} + onResetPersonalPins={() => { + pruneCollapsedPinnedFields(isSharedFieldPinned); + resetPersonalPins(); + }} hasSharedPins={hasSharedPins} - onResetSharedFilters={resetSharedFilters} + onResetSharedFilters={() => { + pruneCollapsedPinnedFields(isFieldPinned); + resetSharedFilters(); + }} showAllValues={showAllValues} onShowAllValuesChange={setShowAllValues} /> diff --git a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx index 524c94eca0..d018bae071 100644 --- a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx +++ b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx @@ -6,6 +6,7 @@ import { cleanedFacetName, FilterGroup, type FilterGroupProps, + getFilterGroupDefaultExpanded, } from '@/components/DBSearchPageFilters'; import { NestedFilterGroup } from '@/components/DBSearchPageFilters/NestedFilterGroup'; import { @@ -1149,3 +1150,60 @@ describe('NestedFilterGroup', () => { expect(screen.getByPlaceholderText('Search keys...')).toBeInTheDocument(); }); }); + +describe('getFilterGroupDefaultExpanded', () => { + const base = { + hasSelection: false, + isPinned: false, + isCollapsed: false, + isPrimary: false, + }; + + it('opens a pinned field by default', () => { + expect(getFilterGroupDefaultExpanded({ ...base, isPinned: true })).toBe( + true, + ); + }); + + it('keeps a pinned field the user collapsed closed', () => { + expect( + getFilterGroupDefaultExpanded({ + ...base, + isPinned: true, + isCollapsed: true, + }), + ).toBe(false); + }); + + it('still opens a collapsed pinned field that has an active filter', () => { + expect( + getFilterGroupDefaultExpanded({ + ...base, + isPinned: true, + isCollapsed: true, + hasSelection: true, + }), + ).toBe(true); + }); + + it('keeps it closed in the Shared Filters section, which forces groups open', () => { + expect( + getFilterGroupDefaultExpanded({ + ...base, + isPinned: true, + isCollapsed: true, + forceExpanded: true, + }), + ).toBe(false); + }); + + it('ignores a stale collapsed entry once the field is no longer pinned', () => { + expect( + getFilterGroupDefaultExpanded({ + ...base, + isCollapsed: true, + isPrimary: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/app/src/searchFilters.tsx b/packages/app/src/searchFilters.tsx index e9f1e50258..c84c1079fc 100644 --- a/packages/app/src/searchFilters.tsx +++ b/packages/app/src/searchFilters.tsx @@ -446,6 +446,55 @@ function usePersonalPinnedFilters(sourceId: string | null) { return { filters, fields, setFilters, setFields }; } +/** + * Pinned fields the user collapsed, per source, so they stay collapsed on the + * next load instead of always opening. Personal (localStorage), like the + * other expanded/collapsed states of the filters sidebar. + */ +export function useCollapsedPinnedFields(sourceId: string | null) { + const [collapsed, setCollapsed] = useLocalStorage<{ + [sourceId: string]: string[]; + }>('hdx-collapsed-pinned-fields', {}); + + const isPinnedFieldCollapsed = useCallback( + (field: string) => + sourceId != null && (collapsed[sourceId] ?? []).includes(field), + [collapsed, sourceId], + ); + + const setPinnedFieldCollapsed = useCallback( + (field: string, isCollapsed: boolean) => { + if (!sourceId) return; + setCollapsed(prev => { + const fields = (prev[sourceId] ?? []).filter(f => f !== field); + return { + ...prev, + [sourceId]: isCollapsed ? [...fields, field] : fields, + }; + }); + }, + [sourceId, setCollapsed], + ); + + /** Keep only the collapsed fields for which `keep` is true (e.g. after a pin reset). */ + const pruneCollapsedPinnedFields = useCallback( + (keep: (field: string) => boolean) => { + if (!sourceId) return; + setCollapsed(prev => ({ + ...prev, + [sourceId]: (prev[sourceId] ?? []).filter(keep), + })); + }, + [sourceId, setCollapsed], + ); + + return { + isPinnedFieldCollapsed, + setPinnedFieldCollapsed, + pruneCollapsedPinnedFields, + }; +} + export function usePinnedFilters(sourceId: string | null) { // Personal pins: localStorage (per-user, per-browser) const personal = usePersonalPinnedFilters(sourceId);