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); + } +}