Skip to content

Commit 467c167

Browse files
authored
feat(app): remember collapsed pinned filters (#3227) (#3605)
## Summary Closes #3227 We pin a few fields on our main logs source, and every time we open search they all come back expanded, so we collapse them again each time. This keeps a pinned field collapsed across reloads. - New `useCollapsedPinnedFields(sourceId)` hook in `searchFilters.tsx`: per-source list of collapsed pinned fields in localStorage (`hdx-collapsed-pinned-fields`), personal like the other sidebar expand states (`hdx-filters-expanded`, `hdx-shared-filters-expanded`). - In the filters sidebar, a pinned field (personal or shared) that the user collapses stays collapsed on the next load. It still opens when it has an active filter, so a selection is never hidden. Expanding it again removes it from the list. - Nested map groups (`LogAttributes.*`, ...) are unchanged: they keep their own expand logic. Happy to follow up if wanted. ### Screenshots or video Pinned `SeverityText`, collapsed, then page reloaded (`yarn app:dev:local`, demo ClickHouse, `otel.otel_logs`). | Before | After | | :----- | :---- | | ![before](https://raw.githubusercontent.com/bferreirq/ClickStack/pr-3227-screenshots/before.png) | ![after](https://raw.githubusercontent.com/bferreirq/ClickStack/pr-3227-screenshots/after.png) | ### How to reproduce **Starting point:** `yarn app:dev:local` connected to the demo server, Logs source on `/search`. **On `main`:** 1. In the Filters sidebar, pin the `SeverityText` field (pin icon on its row). 2. Collapse `SeverityText`. 3. Reload the page: `SeverityText` is expanded again. **On this branch:** 1. Same steps: after the reload `SeverityText` stays collapsed. 2. Select a value in it, reload: it opens (active filter). 3. Expand it again, reload: it stays expanded. ### How to test on Vercel preview **Preview routes:** /search **Steps:** 1. Open /search on the Logs source. 2. In the Filters sidebar, click the pin icon on the `SeverityText` field. 3. Click the `SeverityText` header to collapse it. 4. Reload the page. 5. Verify the `SeverityText` filter group is collapsed. ### References - Issue: #3227 - Tests: `packages/app/src/__tests__/pinnedFilters.test.ts` (state survives a remount, cleared on expand, kept per source, nothing stored without a source) - Related PRs:
1 parent 82b2104 commit 467c167

5 files changed

Lines changed: 263 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
feat: A pinned filter collapsed in the search sidebar stays collapsed the next
6+
time the source loads, instead of always opening

‎packages/app/src/__tests__/pinnedFilters.test.ts‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
* - localStorage migration logic
55
*/
66

7-
import { mergePinnedData } from '@/searchFilters';
7+
import { act, renderHook } from '@testing-library/react';
8+
9+
import { mergePinnedData, useCollapsedPinnedFields } from '@/searchFilters';
810

911
describe('mergePinnedData', () => {
1012
it('returns empty fields and filters when both are null', () => {
@@ -167,3 +169,77 @@ describe('localStorage migration', () => {
167169
expect(result[sourceB]).toEqual({ level: ['error'] });
168170
});
169171
});
172+
173+
describe('useCollapsedPinnedFields', () => {
174+
beforeEach(() => {
175+
window.localStorage.clear();
176+
});
177+
178+
it('remembers a collapsed pinned field across remounts', () => {
179+
const first = renderHook(() => useCollapsedPinnedFields('source1'));
180+
expect(first.result.current.isPinnedFieldCollapsed('ServiceName')).toBe(
181+
false,
182+
);
183+
184+
act(() =>
185+
first.result.current.setPinnedFieldCollapsed('ServiceName', true),
186+
);
187+
expect(first.result.current.isPinnedFieldCollapsed('ServiceName')).toBe(
188+
true,
189+
);
190+
first.unmount();
191+
192+
const second = renderHook(() => useCollapsedPinnedFields('source1'));
193+
expect(second.result.current.isPinnedFieldCollapsed('ServiceName')).toBe(
194+
true,
195+
);
196+
});
197+
198+
it('forgets the field once it is expanded again', () => {
199+
const { result } = renderHook(() => useCollapsedPinnedFields('source1'));
200+
act(() => result.current.setPinnedFieldCollapsed('ServiceName', true));
201+
act(() => result.current.setPinnedFieldCollapsed('ServiceName', false));
202+
203+
expect(result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false);
204+
expect(
205+
JSON.parse(window.localStorage.getItem('hdx-collapsed-pinned-fields')!),
206+
).toEqual({ source1: [] });
207+
});
208+
209+
it('keeps the collapsed state per source', () => {
210+
const a = renderHook(() => useCollapsedPinnedFields('sourceA'));
211+
act(() => a.result.current.setPinnedFieldCollapsed('ServiceName', true));
212+
213+
const b = renderHook(() => useCollapsedPinnedFields('sourceB'));
214+
expect(b.result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false);
215+
});
216+
217+
it('does nothing without a source', () => {
218+
const { result } = renderHook(() => useCollapsedPinnedFields(null));
219+
act(() => result.current.setPinnedFieldCollapsed('ServiceName', true));
220+
221+
expect(result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false);
222+
expect(window.localStorage.getItem('hdx-collapsed-pinned-fields')).toBe(
223+
null,
224+
);
225+
});
226+
});
227+
228+
describe('useCollapsedPinnedFields pruning', () => {
229+
beforeEach(() => {
230+
window.localStorage.clear();
231+
});
232+
233+
it('keeps only the fields still pinned after a reset', () => {
234+
const { result } = renderHook(() => useCollapsedPinnedFields('source1'));
235+
act(() => result.current.setPinnedFieldCollapsed('ServiceName', true));
236+
act(() => result.current.setPinnedFieldCollapsed('SeverityText', true));
237+
238+
act(() =>
239+
result.current.pruneCollapsedPinnedFields(f => f === 'SeverityText'),
240+
);
241+
242+
expect(result.current.isPinnedFieldCollapsed('ServiceName')).toBe(false);
243+
expect(result.current.isPinnedFieldCollapsed('SeverityText')).toBe(true);
244+
});
245+
});

‎packages/app/src/components/DBSearchPageFilters.tsx‎

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { usePinnedFiltersApi } from '@/pinnedFilters';
6565
import {
6666
FilterStateHook,
6767
IS_ROOT_SPAN_COLUMN_NAME,
68+
useCollapsedPinnedFields,
6869
usePinnedFilters,
6970
} from '@/searchFilters';
7071
import { useSource } from '@/source';
@@ -918,6 +919,31 @@ function FilterGroupActions({
918919

919920
const voidFunc = () => {};
920921

922+
/**
923+
* Whether a filter group opens on load. A pinned field the user collapsed
924+
* stays collapsed unless it has an active filter. That persisted collapse
925+
* intentionally takes precedence over a section-level `forceExpanded`
926+
* (the Shared Filters section).
927+
*/
928+
export function getFilterGroupDefaultExpanded({
929+
hasSelection,
930+
isPinned,
931+
isCollapsed,
932+
isPrimary,
933+
forceExpanded,
934+
}: {
935+
hasSelection: boolean;
936+
isPinned: boolean;
937+
isCollapsed: boolean;
938+
isPrimary: boolean;
939+
forceExpanded?: boolean;
940+
}): boolean {
941+
if (isPinned && isCollapsed && !hasSelection) {
942+
return false;
943+
}
944+
return forceExpanded ?? (isPrimary || isPinned || hasSelection);
945+
}
946+
921947
export const FilterGroup = ({
922948
name,
923949
options,
@@ -1172,6 +1198,11 @@ const DBSearchPageFiltersComponent = ({
11721198
hasPersonalPins,
11731199
hasSharedPins,
11741200
} = usePinnedFilters(sourceId ?? null);
1201+
const {
1202+
isPinnedFieldCollapsed,
1203+
setPinnedFieldCollapsed,
1204+
pruneCollapsedPinnedFields,
1205+
} = useCollapsedPinnedFields(sourceId ?? null);
11751206
const { data: pinnedFiltersApiData } = usePinnedFiltersApi(sourceId ?? null);
11761207
const [isSharedFiltersVisible, setSharedFiltersVisible] = useLocalStorage(
11771208
'hdx-shared-filters-visible',
@@ -1504,6 +1535,8 @@ const DBSearchPageFiltersComponent = ({
15041535
const { keyPrefix = '', isDefaultExpanded: forceExpanded } =
15051536
options ?? {};
15061537
const { grouped, nonGrouped } = groupFacetsByBaseName(facets);
1538+
const isFieldPinnedAny = (key: string) =>
1539+
isFieldPinned(key) || isSharedFieldPinned(key);
15071540

15081541
const makeValuePins = (key: string): ValuePinHandlers => ({
15091542
onPinClick: (value: string | boolean) => toggleFilterPin(key, value),
@@ -1515,9 +1548,21 @@ const DBSearchPageFiltersComponent = ({
15151548
});
15161549

15171550
const makeFieldPins = (key: string): FieldPinHandlers => ({
1518-
onFieldPinClick: () => toggleFieldPin(key),
1551+
onFieldPinClick: () => {
1552+
// Unpinned for good: forget its collapsed state, so pinning it again
1553+
// opens it like any newly pinned field.
1554+
if (isFieldPinned(key) && !isSharedFieldPinned(key)) {
1555+
setPinnedFieldCollapsed(key, false);
1556+
}
1557+
toggleFieldPin(key);
1558+
},
15191559
isFieldPinned: isFieldPinned(key),
1520-
onToggleSharedFieldPin: () => toggleSharedFieldPin(key),
1560+
onToggleSharedFieldPin: () => {
1561+
if (isSharedFieldPinned(key) && !isFieldPinned(key)) {
1562+
setPinnedFieldCollapsed(key, false);
1563+
}
1564+
toggleSharedFieldPin(key);
1565+
},
15211566
isSharedFieldPinned: isSharedFieldPinned(key),
15221567
});
15231568

@@ -1641,17 +1686,23 @@ const DBSearchPageFiltersComponent = ({
16411686
hasLoadedMore={extraFacetKeys.has(facet.key)}
16421687
isDefaultExpanded={(() => {
16431688
const entry = getFilterStateEntry(filterState, facet.key);
1644-
return (
1645-
forceExpanded ??
1646-
(isFieldPrimary(tableMetadata, facet.key) ||
1647-
isFieldPinned(facet.key) ||
1648-
isSharedFieldPinned(facet.key) ||
1649-
(entry != null &&
1650-
(entry.included.size > 0 ||
1651-
entry.excluded.size > 0 ||
1652-
entry.range != null)))
1653-
);
1689+
return getFilterGroupDefaultExpanded({
1690+
hasSelection:
1691+
entry != null &&
1692+
(entry.included.size > 0 ||
1693+
entry.excluded.size > 0 ||
1694+
entry.range != null),
1695+
isPinned: isFieldPinnedAny(facet.key),
1696+
isCollapsed: isPinnedFieldCollapsed(facet.key),
1697+
isPrimary: !!isFieldPrimary(tableMetadata, facet.key),
1698+
forceExpanded,
1699+
});
16541700
})()}
1701+
onExpandedChange={expanded => {
1702+
if (isFieldPinnedAny(facet.key)) {
1703+
setPinnedFieldCollapsed(facet.key, !expanded);
1704+
}
1705+
}}
16551706
chartConfig={chartConfig}
16561707
isLive={isLive}
16571708
onRangeChange={range => setFilterRange(facet.key, range)}
@@ -1673,6 +1724,8 @@ const DBSearchPageFiltersComponent = ({
16731724
isFieldPinned,
16741725
toggleSharedFieldPin,
16751726
isSharedFieldPinned,
1727+
isPinnedFieldCollapsed,
1728+
setPinnedFieldCollapsed,
16761729
onColumnToggle,
16771730
displayedColumns,
16781731
loadMoreFacetsForKey,
@@ -1738,9 +1791,15 @@ const DBSearchPageFiltersComponent = ({
17381791
showFilterCounts={showFilterCounts}
17391792
onShowFilterCountsChange={setShowFilterCounts}
17401793
hasPersonalPins={hasPersonalPins}
1741-
onResetPersonalPins={resetPersonalPins}
1794+
onResetPersonalPins={() => {
1795+
pruneCollapsedPinnedFields(isSharedFieldPinned);
1796+
resetPersonalPins();
1797+
}}
17421798
hasSharedPins={hasSharedPins}
1743-
onResetSharedFilters={resetSharedFilters}
1799+
onResetSharedFilters={() => {
1800+
pruneCollapsedPinnedFields(isFieldPinned);
1801+
resetSharedFilters();
1802+
}}
17441803
showAllValues={showAllValues}
17451804
onShowAllValuesChange={setShowAllValues}
17461805
/>

‎packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
cleanedFacetName,
77
FilterGroup,
88
type FilterGroupProps,
9+
getFilterGroupDefaultExpanded,
910
} from '@/components/DBSearchPageFilters';
1011
import { NestedFilterGroup } from '@/components/DBSearchPageFilters/NestedFilterGroup';
1112
import {
@@ -1149,3 +1150,60 @@ describe('NestedFilterGroup', () => {
11491150
expect(screen.getByPlaceholderText('Search keys...')).toBeInTheDocument();
11501151
});
11511152
});
1153+
1154+
describe('getFilterGroupDefaultExpanded', () => {
1155+
const base = {
1156+
hasSelection: false,
1157+
isPinned: false,
1158+
isCollapsed: false,
1159+
isPrimary: false,
1160+
};
1161+
1162+
it('opens a pinned field by default', () => {
1163+
expect(getFilterGroupDefaultExpanded({ ...base, isPinned: true })).toBe(
1164+
true,
1165+
);
1166+
});
1167+
1168+
it('keeps a pinned field the user collapsed closed', () => {
1169+
expect(
1170+
getFilterGroupDefaultExpanded({
1171+
...base,
1172+
isPinned: true,
1173+
isCollapsed: true,
1174+
}),
1175+
).toBe(false);
1176+
});
1177+
1178+
it('still opens a collapsed pinned field that has an active filter', () => {
1179+
expect(
1180+
getFilterGroupDefaultExpanded({
1181+
...base,
1182+
isPinned: true,
1183+
isCollapsed: true,
1184+
hasSelection: true,
1185+
}),
1186+
).toBe(true);
1187+
});
1188+
1189+
it('keeps it closed in the Shared Filters section, which forces groups open', () => {
1190+
expect(
1191+
getFilterGroupDefaultExpanded({
1192+
...base,
1193+
isPinned: true,
1194+
isCollapsed: true,
1195+
forceExpanded: true,
1196+
}),
1197+
).toBe(false);
1198+
});
1199+
1200+
it('ignores a stale collapsed entry once the field is no longer pinned', () => {
1201+
expect(
1202+
getFilterGroupDefaultExpanded({
1203+
...base,
1204+
isCollapsed: true,
1205+
isPrimary: true,
1206+
}),
1207+
).toBe(true);
1208+
});
1209+
});

‎packages/app/src/searchFilters.tsx‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,55 @@ function usePersonalPinnedFilters(sourceId: string | null) {
446446
return { filters, fields, setFilters, setFields };
447447
}
448448

449+
/**
450+
* Pinned fields the user collapsed, per source, so they stay collapsed on the
451+
* next load instead of always opening. Personal (localStorage), like the
452+
* other expanded/collapsed states of the filters sidebar.
453+
*/
454+
export function useCollapsedPinnedFields(sourceId: string | null) {
455+
const [collapsed, setCollapsed] = useLocalStorage<{
456+
[sourceId: string]: string[];
457+
}>('hdx-collapsed-pinned-fields', {});
458+
459+
const isPinnedFieldCollapsed = useCallback(
460+
(field: string) =>
461+
sourceId != null && (collapsed[sourceId] ?? []).includes(field),
462+
[collapsed, sourceId],
463+
);
464+
465+
const setPinnedFieldCollapsed = useCallback(
466+
(field: string, isCollapsed: boolean) => {
467+
if (!sourceId) return;
468+
setCollapsed(prev => {
469+
const fields = (prev[sourceId] ?? []).filter(f => f !== field);
470+
return {
471+
...prev,
472+
[sourceId]: isCollapsed ? [...fields, field] : fields,
473+
};
474+
});
475+
},
476+
[sourceId, setCollapsed],
477+
);
478+
479+
/** Keep only the collapsed fields for which `keep` is true (e.g. after a pin reset). */
480+
const pruneCollapsedPinnedFields = useCallback(
481+
(keep: (field: string) => boolean) => {
482+
if (!sourceId) return;
483+
setCollapsed(prev => ({
484+
...prev,
485+
[sourceId]: (prev[sourceId] ?? []).filter(keep),
486+
}));
487+
},
488+
[sourceId, setCollapsed],
489+
);
490+
491+
return {
492+
isPinnedFieldCollapsed,
493+
setPinnedFieldCollapsed,
494+
pruneCollapsedPinnedFields,
495+
};
496+
}
497+
449498
export function usePinnedFilters(sourceId: string | null) {
450499
// Personal pins: localStorage (per-user, per-browser)
451500
const personal = usePersonalPinnedFilters(sourceId);

0 commit comments

Comments
 (0)