diff --git a/.changeset/afraid-fireants-tie.md b/.changeset/afraid-fireants-tie.md
new file mode 100644
index 000000000..6c490f4cd
--- /dev/null
+++ b/.changeset/afraid-fireants-tie.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+Add accordion functionality to filter groups, changed how the system prioritizes which filters are open by default, added new sort logic for prioritizing certain filters.
diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx
index 0a8de1468..8cf571aec 100644
--- a/packages/app/src/components/DBSearchPageFilters.tsx
+++ b/packages/app/src/components/DBSearchPageFilters.tsx
@@ -1,9 +1,15 @@
-import { memo, useCallback, useEffect, useMemo, useState } from 'react';
+import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react';
+import {
+ TableMetadata,
+ tcFromSource,
+} from '@hyperdx/common-utils/dist/metadata';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import {
+ Accordion,
ActionIcon,
Box,
Button,
+ Center,
Checkbox,
Flex,
Group,
@@ -25,10 +31,12 @@ import {
useAllFields,
useGetKeyValues,
useJsonColumns,
+ useTableMetadata,
} from '@/hooks/useMetadata';
import useResizable from '@/hooks/useResizable';
import { getMetadata } from '@/metadata';
import { FilterStateHook, usePinnedFilters } from '@/searchFilters';
+import { useSource } from '@/source';
import { mergePath } from '@/utils';
import resizeStyles from '../../styles/ResizablePanel.module.scss';
@@ -153,6 +161,7 @@ export type FilterGroupProps = {
onLoadMore: (key: string) => void;
loadMoreLoading: boolean;
hasLoadedMore: boolean;
+ isDefaultExpanded?: boolean;
};
const MAX_FILTER_GROUP_ITEMS = 10;
@@ -173,9 +182,22 @@ export const FilterGroup = ({
onLoadMore,
loadMoreLoading,
hasLoadedMore,
+ isDefaultExpanded,
}: FilterGroupProps) => {
const [search, setSearch] = useState('');
- const [isExpanded, setExpanded] = useState(false);
+ // "Show More" button when there's lots of options
+ const [shouldShowMore, setShowMore] = useState(false);
+ // Accordion expanded state
+ const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
+
+ useEffect(() => {
+ if (isDefaultExpanded) {
+ setExpanded(true);
+ }
+ }, [isDefaultExpanded]);
+
+ const totalFiltersSize =
+ selectedValues.included.size + selectedValues.excluded.size;
const augmentedOptions = useMemo(() => {
const selectedSet = new Set([
@@ -200,10 +222,8 @@ export const FilterGroup = ({
});
}
- const sortBySelectionAndAlpha = (
- a: (typeof augmentedOptions)[0],
- b: (typeof augmentedOptions)[0],
- ) => {
+ // General Sorting of List
+ augmentedOptions.sort((a, b) => {
const aPinned = isPinned(a.value);
const aIncluded = selectedValues.included.has(a.value);
const aExcluded = selectedValues.excluded.has(a.value);
@@ -225,52 +245,92 @@ export const FilterGroup = ({
// Finally sort alphabetically/numerically
return a.value.localeCompare(b.value, undefined, { numeric: true });
- };
+ });
- // If expanded or small list, sort everything
- if (isExpanded || augmentedOptions.length <= MAX_FILTER_GROUP_ITEMS) {
- return augmentedOptions.sort(sortBySelectionAndAlpha);
+ // If expanded or small list, return everything
+ if (shouldShowMore || augmentedOptions.length <= MAX_FILTER_GROUP_ITEMS) {
+ return augmentedOptions;
}
-
- // Do not rearrange items if all selected values are visible without expanding
- return augmentedOptions
- .sort((a, b) => sortBySelectionAndAlpha(a, b))
- .slice(
- 0,
- Math.max(
- MAX_FILTER_GROUP_ITEMS,
- selectedValues.included.size + selectedValues.excluded.size,
- ),
- );
- }, [search, isExpanded, augmentedOptions, selectedValues]);
-
- const showExpandButton =
+ // Return the subset of items
+ const pageSize = Math.max(MAX_FILTER_GROUP_ITEMS, totalFiltersSize);
+ return augmentedOptions.slice(0, pageSize);
+ }, [
+ search,
+ shouldShowMore,
+ isPinned,
+ augmentedOptions,
+ selectedValues,
+ totalFiltersSize,
+ ]);
+
+ const showShowMoreButton =
!search &&
augmentedOptions.length > MAX_FILTER_GROUP_ITEMS &&
- selectedValues.included.size + selectedValues.excluded.size <
- augmentedOptions.length;
+ totalFiltersSize < augmentedOptions.length;
return (
-
- 26 ? 0 : 1500}
- label={name}
- position="top"
- withArrow
- fz="xxs"
- color="gray"
- >
- ) =>
- setSearch(event.currentTarget.value)
- }
- leftSectionWidth={27}
- leftSection={}
- rightSection={
-
+ {
+ setExpanded(v === name);
+ }}
+ >
+
+
+
+
+ 26 ? 0 : 1500}
+ label={name}
+ position="top"
+ withArrow
+ fz="xxs"
+ color="gray"
+ >
+ ) =>
+ setSearch(event.currentTarget.value)
+ }
+ onClick={e => {
+ // Prevent accordion from opening when clicking on the input, unless it's closed.
+ if (isExpanded) {
+ e.stopPropagation();
+ }
+ }}
+ styles={{ input: { transition: 'padding 0.2s' } }}
+ rightSectionWidth={isExpanded ? 20 : 2}
+ rightSection={
+
+ }
+ classNames={{
+ input: 'ps-0.5',
+ }}
+ />
+
+
+
{onFieldPinClick && (
)}
- {selectedValues.included.size + selectedValues.excluded.size >
- 0 && (
+ {totalFiltersSize > 0 && (
{
@@ -295,84 +354,100 @@ export const FilterGroup = ({
/>
)}
- }
- />
-
-
- {displayedOptions.map(option => (
- onChange(option.value)}
- onClickOnly={() => onOnlyClick(option.value)}
- onClickExclude={() => onExcludeClick(option.value)}
- onClickPin={() => onPinClick(option.value)}
- />
- ))}
- {optionsLoading ? (
-
-
-
- Loading...
-
-
- ) : displayedOptions.length === 0 ? (
-
-
- No options found
-
-
- ) : null}
- {showExpandButton && (
-
-
- Less
- >
- ) : (
- <>
- Show more
- >
- )
- }
- onClick={() => setExpanded(!isExpanded)}
- />
-
- )}
- {onLoadMore && (!showExpandButton || isExpanded) && (
-
- {loadMoreLoading ? (
-
-
-
- Loading more...
-
-
- ) : (
-
- Load more
- >
- }
- onClick={() => onLoadMore(name)}
- />
- )}
-
- )}
-
-
+
+
+
+ {displayedOptions.map(option => (
+ onChange(option.value)}
+ onClickOnly={() => onOnlyClick(option.value)}
+ onClickExclude={() => onExcludeClick(option.value)}
+ onClickPin={() => onPinClick(option.value)}
+ />
+ ))}
+ {optionsLoading ? (
+
+
+
+ Loading...
+
+
+ ) : displayedOptions.length === 0 ? (
+
+
+ No options found
+
+
+ ) : null}
+ {showShowMoreButton && (
+
+
+ Less
+ >
+ ) : (
+ <>
+ Show more
+ >
+ )
+ }
+ onClick={() => {
+ // When show more is clicked, immediately show all and also fetch more from server.
+ setShowMore(!shouldShowMore);
+ if (!shouldShowMore) {
+ onLoadMore?.(name);
+ }
+ }}
+ />
+
+ )}
+ {onLoadMore &&
+ !showShowMoreButton &&
+ !shouldShowMore &&
+ !hasLoadedMore && (
+
+ {loadMoreLoading ? (
+
+
+
+ Loading more...
+
+
+ ) : (
+
+ Load more
+ >
+ }
+ onClick={() => onLoadMore(name)}
+ />
+ )}
+
+ )}
+
+
+
+
+
);
};
@@ -421,6 +496,10 @@ const DBSearchPageFiltersComponent = ({
tableName: chartConfig.from.tableName,
connectionId: chartConfig.connection,
});
+
+ const { data: source } = useSource({ id: sourceId });
+ const { data: tableMetadata } = useTableMetadata(tcFromSource(source));
+
useEffect(() => {
if (error) {
notifications.show({
@@ -564,22 +643,29 @@ const DBSearchPageFiltersComponent = ({
_facets.push({ key, value: Array.from(filterState[key].included) });
}
- // Any other keys, let's add them in with empty values
- for (const key of keysToFetch) {
- if (!_facets.some(facet => facet.key === key)) {
- _facets.push({ key, value: [] });
- }
- }
+ // prioritize facets that are primary keys
+ _facets.sort((a, b) => {
+ const aIsPk = isFieldPrimary(tableMetadata, a.key);
+ const bIsPk = isFieldPrimary(tableMetadata, b.key);
+ return aIsPk && !bIsPk ? -1 : bIsPk && !aIsPk ? 1 : 0;
+ });
- // reorder facets to put pinned fields first
+ // prioritize facets that are pinned
_facets.sort((a, b) => {
const aPinned = isFieldPinned(a.key);
const bPinned = isFieldPinned(b.key);
return aPinned && !bPinned ? -1 : bPinned && !aPinned ? 1 : 0;
});
+ // prioritize facets that have checked items
+ _facets.sort((a, b) => {
+ const aChecked = filterState?.[a.key]?.included.size > 0;
+ const bChecked = filterState?.[b.key]?.included.size > 0;
+ return aChecked && !bChecked ? -1 : bChecked && !aChecked ? 1 : 0;
+ });
+
return _facets;
- }, [facets, filterState, extraFacets, keysToFetch, isFieldPinned]);
+ }, [facets, filterState, tableMetadata, extraFacets, isFieldPinned]);
const showClearAllButton = useMemo(
() =>
@@ -720,6 +806,14 @@ const DBSearchPageFiltersComponent = ({
onLoadMore={loadMoreFilterValuesForKey}
loadMoreLoading={loadMoreLoadingKeys.has(facet.key)}
hasLoadedMore={Boolean(extraFacets[facet.key])}
+ isDefaultExpanded={
+ // open by default if PK, or has selected values
+ isFieldPrimary(tableMetadata, facet.key) ||
+ isFieldPinned(facet.key) ||
+ (filterState[facet.key] &&
+ (filterState[facet.key].included.size > 0 ||
+ filterState[facet.key].excluded.size > 0))
+ }
/>
))}
@@ -735,10 +829,27 @@ const DBSearchPageFiltersComponent = ({
>
{showMoreFields ? 'Less filters' : 'More filters'}
+
+ {showMoreFields && (
+
+
+ Not seeing a filter?
+
+
+ {`Try searching instead (e.g. column:foo)`}
+
+
+ )}
);
};
+export function isFieldPrimary(
+ tableMetadata: TableMetadata | undefined,
+ key: string,
+) {
+ return tableMetadata?.primary_key?.includes(key);
+}
export const DBSearchPageFilters = memo(DBSearchPageFiltersComponent);
diff --git a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
index ae71d7ce9..516b54371 100644
--- a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
+++ b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
@@ -21,6 +21,7 @@ describe('FilterGroup', () => {
onLoadMore: jest.fn(),
loadMoreLoading: false,
hasLoadedMore: false,
+ isDefaultExpanded: true,
};
it('should sort options alphabetically by default', () => {
@@ -160,4 +161,28 @@ describe('FilterGroup', () => {
// Verify banana is not shown
expect(screen.queryByText('banana')).not.toBeInTheDocument();
});
+
+ it('Should allow opening the filter group', async () => {
+ renderWithMantine(
+ ,
+ );
+
+ // Verify the filter group is closed
+ expect(
+ (await screen.findByTestId('filter-group-panel')).getAttribute(
+ 'aria-hidden',
+ ),
+ ).toBe('true');
+
+ // Find and click the filter group header
+ const header = await screen.findByTestId('filter-group-control');
+ await userEvent.click(header);
+
+ // Verify the filter group is open
+ expect(
+ (await screen.findByTestId('filter-group-panel')).getAttribute(
+ 'aria-hidden',
+ ),
+ ).toBe('false');
+ });
});
diff --git a/packages/app/styles/SearchPage.module.scss b/packages/app/styles/SearchPage.module.scss
index 1b696813f..9854b17d8 100644
--- a/packages/app/styles/SearchPage.module.scss
+++ b/packages/app/styles/SearchPage.module.scss
@@ -122,3 +122,11 @@
overflow: hidden;
position: relative;
}
+
+.chevron {
+ transform: rotate(-90deg);
+
+ &[data-rotate] {
+ transform: rotate(0deg);
+ }
+}