mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Filter on isRootSpan column if present (#1461)
Closes HDX-3009
# Summary
This PR updates the Root Spans Only filter to support a materialized `isRootSpan` column, which is expected to be present on some trace table schemas. If that column is present, then the `Root Spans Only` filter will add a filter like 'isRootSpan IN (TRUE)` to the query, instead of the default `ParentSpanId IN ('')`. The UI has also been updated to support displaying and pinning boolean filter values.
**Note:** We still only query string type filter values, so we won't show isRootSpan unless Root Spans Only has been toggled.
<details>
<summary>I confirmed that if `isRootSpan` is in the ordering key, then this new condition will utilize the key to prune granules:</summary>
<img width="1678" height="1065" alt="Screenshot 2025-12-11 at 11 54 35 AM" src="https://github.com/user-attachments/assets/e22ae689-25a9-4d6b-b0f6-cc8f8396c35b" />
</details>
## Demo
<details>
<summary>For a source with the isRootSpan filter</summary>
https://github.com/user-attachments/assets/ccc7a890-b16e-4de6-bbb9-295fb10aa214
</details>
<details>
<summary>For a source without the isRootSpan filter (no change)</summary>
https://github.com/user-attachments/assets/33d4dd0a-136a-4284-812c-ddd12e67246e
</details>
This commit is contained in:
parent
65bcc1e72e
commit
69d9a4186d
7 changed files with 185 additions and 76 deletions
5
.changeset/violet-paws-smash.md
Normal file
5
.changeset/violet-paws-smash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Filter on isRootSpan column if present
|
||||
|
|
@ -102,9 +102,10 @@ const DashboardFilters = ({
|
|||
filter={filter}
|
||||
dateRange={dateRange}
|
||||
onChange={value => onSetFilterValue(filter.expression, value)}
|
||||
value={
|
||||
filterValues[filter.expression]?.included.values().next().value
|
||||
}
|
||||
value={filterValues[filter.expression]?.included
|
||||
.values()
|
||||
.next()
|
||||
.value?.toString()}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,23 @@ describe('searchFilters', () => {
|
|||
{ type: 'sql', condition: "toString(json.key) NOT IN ('other value')" },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should should handle boolean filter values', () => {
|
||||
const filters = {
|
||||
isRootSpan: {
|
||||
included: new Set<string | boolean>([true]),
|
||||
excluded: new Set<string | boolean>([]),
|
||||
},
|
||||
another_column: {
|
||||
included: new Set<string | boolean>([]),
|
||||
excluded: new Set<string | boolean>([true, false]),
|
||||
},
|
||||
};
|
||||
expect(filtersToQuery(filters)).toEqual([
|
||||
{ type: 'sql', condition: 'isRootSpan IN (true)' },
|
||||
{ type: 'sql', condition: 'another_column NOT IN (true, false)' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseQuery', () => {
|
||||
|
|
@ -275,6 +292,29 @@ describe('searchFilters', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles boolean filter values', () => {
|
||||
const result = parseQuery([
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `isRootSpan IN (true)`,
|
||||
},
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `another_boolean NOT IN (TRUE, FALSE)`,
|
||||
},
|
||||
]);
|
||||
expect(result.filters).toEqual({
|
||||
isRootSpan: {
|
||||
included: new Set([true]),
|
||||
excluded: new Set(),
|
||||
},
|
||||
another_boolean: {
|
||||
included: new Set(),
|
||||
excluded: new Set([true, false]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('areFiltersEqual', () => {
|
||||
|
|
@ -323,6 +363,30 @@ describe('searchFilters', () => {
|
|||
};
|
||||
expect(areFiltersEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle boolean filters', () => {
|
||||
const a = {
|
||||
isRootSpan: {
|
||||
included: new Set<string | boolean>([true]),
|
||||
excluded: new Set<string | boolean>(),
|
||||
},
|
||||
another_column: {
|
||||
included: new Set<string | boolean>(),
|
||||
excluded: new Set<string | boolean>([true, false]),
|
||||
},
|
||||
};
|
||||
const b = {
|
||||
isRootSpan: {
|
||||
included: new Set<string | boolean>([true]),
|
||||
excluded: new Set<string | boolean>(),
|
||||
},
|
||||
another_column: {
|
||||
included: new Set<string | boolean>(),
|
||||
excluded: new Set<string | boolean>([false, true]),
|
||||
},
|
||||
};
|
||||
expect(areFiltersEqual(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSearchPageFilterState', () => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { IconSearch } from '@tabler/icons-react';
|
|||
|
||||
import {
|
||||
useAllFields,
|
||||
useColumns,
|
||||
useGetKeyValues,
|
||||
useGetValuesDistribution,
|
||||
useJsonColumns,
|
||||
|
|
@ -40,7 +41,11 @@ import {
|
|||
} from '@/hooks/useMetadata';
|
||||
import { useMetadataWithSettings } from '@/hooks/useMetadata';
|
||||
import useResizable from '@/hooks/useResizable';
|
||||
import { FilterStateHook, usePinnedFilters } from '@/searchFilters';
|
||||
import {
|
||||
FilterStateHook,
|
||||
IS_ROOT_SPAN_COLUMN_NAME,
|
||||
usePinnedFilters,
|
||||
} from '@/searchFilters';
|
||||
import { useSource } from '@/source';
|
||||
import { mergePath } from '@/utils';
|
||||
|
||||
|
|
@ -300,19 +305,19 @@ const FilterRangeDisplay = ({
|
|||
|
||||
export type FilterGroupProps = {
|
||||
name: string;
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string | boolean; label: string }[];
|
||||
optionsLoading?: boolean;
|
||||
selectedValues?: {
|
||||
included: Set<string>;
|
||||
excluded: Set<string>;
|
||||
included: Set<string | boolean>;
|
||||
excluded: Set<string | boolean>;
|
||||
range?: { min: number; max: number };
|
||||
};
|
||||
onChange: (value: string) => void;
|
||||
onChange: (value: string | boolean) => void;
|
||||
onClearClick: VoidFunction;
|
||||
onOnlyClick: (value: string) => void;
|
||||
onExcludeClick: (value: string) => void;
|
||||
onPinClick: (value: string) => void;
|
||||
isPinned: (value: string) => boolean;
|
||||
onOnlyClick: (value: string | boolean) => void;
|
||||
onExcludeClick: (value: string | boolean) => void;
|
||||
onPinClick: (value: string | boolean) => void;
|
||||
isPinned: (value: string | boolean) => boolean;
|
||||
onFieldPinClick?: VoidFunction;
|
||||
isFieldPinned?: boolean;
|
||||
onLoadMore: (key: string) => void;
|
||||
|
|
@ -355,7 +360,9 @@ export const FilterGroup = ({
|
|||
// Accordion expanded state
|
||||
const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
|
||||
// Track recently moved items for highlight animation
|
||||
const [recentlyMoved, setRecentlyMoved] = useState<Set<string>>(new Set());
|
||||
const [recentlyMoved, setRecentlyMoved] = useState<Set<string | boolean>>(
|
||||
new Set(),
|
||||
);
|
||||
// Show what percentage of the data has each value
|
||||
const [showDistributions, setShowDistributions] = useState(false);
|
||||
// For live searches, don't refresh percentages when date range changes
|
||||
|
|
@ -438,7 +445,7 @@ export const FilterGroup = ({
|
|||
return [
|
||||
...Array.from(selectedSet)
|
||||
.filter(value => !options.find(option => option.value === value))
|
||||
.map(value => ({ value, label: value })),
|
||||
.map(value => ({ value, label: value.toString() })),
|
||||
...options,
|
||||
];
|
||||
}, [options, selectedValues]);
|
||||
|
|
@ -455,11 +462,11 @@ export const FilterGroup = ({
|
|||
.filter(option => {
|
||||
return (
|
||||
option.value &&
|
||||
option.value.toLowerCase().includes(search.toLowerCase())
|
||||
option.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
})
|
||||
.toSorted((a, b) =>
|
||||
a.value.localeCompare(b.value, undefined, { numeric: true }),
|
||||
a.label.localeCompare(b.label, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -485,14 +492,14 @@ export const FilterGroup = ({
|
|||
if (!aExcluded && bExcluded) return 1;
|
||||
|
||||
// Then sort by estimated percentage of rows with this value, if available
|
||||
const aPercentage = distributionData?.get(a.value) ?? 0;
|
||||
const bPercentage = distributionData?.get(b.value) ?? 0;
|
||||
const aPercentage = distributionData?.get(a.value.toString()) ?? 0;
|
||||
const bPercentage = distributionData?.get(b.value.toString()) ?? 0;
|
||||
if (aPercentage !== bPercentage) {
|
||||
return bPercentage - aPercentage;
|
||||
}
|
||||
|
||||
// Finally sort alphabetically/numerically
|
||||
return a.value.localeCompare(b.value, undefined, { numeric: true });
|
||||
return a.label.localeCompare(b.label, undefined, { numeric: true });
|
||||
});
|
||||
}, [
|
||||
search,
|
||||
|
|
@ -512,7 +519,7 @@ export const FilterGroup = ({
|
|||
|
||||
// Simple highlight animation when checkbox is checked
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
(value: string | boolean) => {
|
||||
const wasIncluded = selectedValues.included.has(value);
|
||||
|
||||
// If checking (not unchecking), trigger highlight animation
|
||||
|
|
@ -662,7 +669,7 @@ export const FilterGroup = ({
|
|||
)}
|
||||
{displayedOptions.map(option => (
|
||||
<FilterCheckbox
|
||||
key={option.value}
|
||||
key={option.value.toString()}
|
||||
label={option.label}
|
||||
pinned={isPinned(option.value)}
|
||||
className={
|
||||
|
|
@ -684,7 +691,7 @@ export const FilterGroup = ({
|
|||
isPercentageLoading={isFetchingDistribution}
|
||||
percentage={
|
||||
showDistributions && distributionData
|
||||
? (distributionData.get(option.value) ?? 0)
|
||||
? (distributionData.get(option.value.toString()) ?? 0)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -794,7 +801,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
const setFilterValue = useCallback(
|
||||
(
|
||||
property: string,
|
||||
value: string,
|
||||
value: string | boolean,
|
||||
action?: 'only' | 'exclude' | 'include' | undefined,
|
||||
) => {
|
||||
return _setFilterValue(property, value, action);
|
||||
|
|
@ -821,6 +828,11 @@ const DBSearchPageFiltersComponent = ({
|
|||
tableName: chartConfig.from.tableName,
|
||||
connectionId: chartConfig.connection,
|
||||
});
|
||||
const { data: columns } = useColumns({
|
||||
databaseName: chartConfig.from.databaseName,
|
||||
tableName: chartConfig.from.tableName,
|
||||
connectionId: chartConfig.connection,
|
||||
});
|
||||
|
||||
const { data: source } = useSource({ id: sourceId });
|
||||
const { data: tableMetadata } = useTableMetadata(tcFromSource(source));
|
||||
|
|
@ -913,7 +925,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
return Array.from(mergedKeys).map(key => {
|
||||
const queriedValues = facetsMap.get(key);
|
||||
const pinnedValues = pinnedFilters[key];
|
||||
const mergedValues = new Set<string>([
|
||||
const mergedValues = new Set<string | boolean>([
|
||||
...(queriedValues ?? []),
|
||||
...(pinnedValues ?? []),
|
||||
]);
|
||||
|
|
@ -961,7 +973,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
);
|
||||
|
||||
const shownFacets = useMemo(() => {
|
||||
const _facets: { key: string; value: string[] }[] = [];
|
||||
const _facets: { key: string; value: (string | boolean)[] }[] = [];
|
||||
for (const _facet of facetsWithPinnedValues ?? []) {
|
||||
const facet = structuredClone(_facet);
|
||||
if (jsonColumns?.some(col => facet.key.startsWith(col))) {
|
||||
|
|
@ -996,7 +1008,10 @@ const DBSearchPageFiltersComponent = ({
|
|||
key => !_facets.some(facet => facet.key === key),
|
||||
);
|
||||
for (const key of remainingFilterState) {
|
||||
_facets.push({ key, value: Array.from(filterState[key].included) });
|
||||
_facets.push({
|
||||
key,
|
||||
value: Array.from(filterState[key].included).map(v => v.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
// prioritize facets that are primary keys
|
||||
|
|
@ -1052,12 +1067,17 @@ const DBSearchPageFiltersComponent = ({
|
|||
if (!source?.parentSpanIdExpression) return;
|
||||
|
||||
if (rootSpansOnly) {
|
||||
setFilterValue(source.parentSpanIdExpression, '', 'only');
|
||||
if (columns?.some(col => col.name === IS_ROOT_SPAN_COLUMN_NAME)) {
|
||||
setFilterValue(IS_ROOT_SPAN_COLUMN_NAME, true, 'only');
|
||||
} else {
|
||||
setFilterValue(source.parentSpanIdExpression, '', 'only');
|
||||
}
|
||||
} else {
|
||||
clearFilter(source.parentSpanIdExpression);
|
||||
clearFilter(IS_ROOT_SPAN_COLUMN_NAME);
|
||||
}
|
||||
},
|
||||
[setFilterValue, clearFilter, source],
|
||||
[setFilterValue, clearFilter, source, columns],
|
||||
);
|
||||
|
||||
const isRootSpansOnly = useMemo(() => {
|
||||
|
|
@ -1065,9 +1085,12 @@ const DBSearchPageFiltersComponent = ({
|
|||
return false;
|
||||
|
||||
const parentSpanIdFilter = filterState?.[source?.parentSpanIdExpression];
|
||||
const isRootSpanFilter = filterState?.[IS_ROOT_SPAN_COLUMN_NAME];
|
||||
return (
|
||||
parentSpanIdFilter?.included.size === 1 &&
|
||||
parentSpanIdFilter?.included.has('')
|
||||
(parentSpanIdFilter?.included.size === 1 &&
|
||||
parentSpanIdFilter?.included.has('')) ||
|
||||
(isRootSpanFilter?.included.size === 1 &&
|
||||
isRootSpanFilter?.included.has(true))
|
||||
);
|
||||
}, [filterState, source]);
|
||||
|
||||
|
|
@ -1214,7 +1237,10 @@ const DBSearchPageFiltersComponent = ({
|
|||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{ included: Set<string>; excluded: Set<string> }
|
||||
{
|
||||
included: Set<string | boolean>;
|
||||
excluded: Set<string | boolean>;
|
||||
}
|
||||
>,
|
||||
)}
|
||||
onChange={(key, value) => {
|
||||
|
|
@ -1269,7 +1295,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
name={cleanedFacetName(facet.key)}
|
||||
options={facet.value.map(value => ({
|
||||
value,
|
||||
label: value,
|
||||
label: value.toString(),
|
||||
}))}
|
||||
optionsLoading={isFacetsLoading}
|
||||
selectedValues={
|
||||
|
|
|
|||
|
|
@ -8,23 +8,26 @@ import {
|
|||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { FilterState } from '@/searchFilters';
|
||||
|
||||
import { FilterGroup } from '../DBSearchPageFilters';
|
||||
|
||||
import classes from '../../../styles/SearchPage.module.scss';
|
||||
|
||||
export type NestedFilterGroupProps = {
|
||||
name: string;
|
||||
childFilters: { key: string; value: string[]; propertyPath: string }[];
|
||||
selectedValues?: Record<
|
||||
string,
|
||||
{ included: Set<string>; excluded: Set<string> }
|
||||
>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
childFilters: {
|
||||
key: string;
|
||||
value: (string | boolean)[];
|
||||
propertyPath: string;
|
||||
}[];
|
||||
selectedValues?: FilterState;
|
||||
onChange: (key: string, value: string | boolean) => void;
|
||||
onClearClick: (key: string) => void;
|
||||
onOnlyClick: (key: string, value: string) => void;
|
||||
onExcludeClick: (key: string, value: string) => void;
|
||||
onPinClick: (key: string, value: string) => void;
|
||||
isPinned: (key: string, value: string) => boolean;
|
||||
onOnlyClick: (key: string, value: string | boolean) => void;
|
||||
onExcludeClick: (key: string, value: string | boolean) => void;
|
||||
onPinClick: (key: string, value: string | boolean) => void;
|
||||
isPinned: (key: string, value: string | boolean) => boolean;
|
||||
onFieldPinClick?: (key: string) => void;
|
||||
isFieldPinned?: (key: string) => boolean;
|
||||
onLoadMore: (key: string) => void;
|
||||
|
|
@ -137,8 +140,8 @@ export const NestedFilterGroup = ({
|
|||
name={child.propertyPath}
|
||||
distributionKey={child.key}
|
||||
options={child.value.map(value => ({
|
||||
value,
|
||||
label: value,
|
||||
value: value,
|
||||
label: value.toString(),
|
||||
}))}
|
||||
optionsLoading={false}
|
||||
selectedValues={childSelectedValues}
|
||||
|
|
|
|||
|
|
@ -46,17 +46,21 @@ export function parseMapFieldName(
|
|||
|
||||
// Group facets by their base names for map-like fields
|
||||
export function groupFacetsByBaseName(
|
||||
facets: { key: string; value: string[] }[],
|
||||
facets: { key: string; value: (string | boolean)[] }[],
|
||||
) {
|
||||
const grouped: Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value: string[];
|
||||
children: { key: string; value: string[]; propertyPath: string }[];
|
||||
value: (string | boolean)[];
|
||||
children: {
|
||||
key: string;
|
||||
value: (string | boolean)[];
|
||||
propertyPath: string;
|
||||
}[];
|
||||
}
|
||||
> = new Map();
|
||||
const nonGrouped: { key: string; value: string[] }[] = [];
|
||||
const nonGrouped: { key: string; value: (string | boolean)[] }[] = [];
|
||||
|
||||
for (const facet of facets) {
|
||||
const parsed = parseMapFieldName(facet.key);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import type { Filter } from '@hyperdx/common-utils/dist/types';
|
|||
|
||||
import { useLocalStorage } from './utils';
|
||||
|
||||
export const IS_ROOT_SPAN_COLUMN_NAME = 'isRootSpan';
|
||||
|
||||
export type FilterState = {
|
||||
[key: string]: {
|
||||
included: Set<string>;
|
||||
excluded: Set<string>;
|
||||
included: Set<string | boolean>;
|
||||
excluded: Set<string | boolean>;
|
||||
range?: { min: number; max: number }; // For BETWEEN conditions
|
||||
};
|
||||
};
|
||||
|
|
@ -31,7 +33,7 @@ export const filtersToQuery = (
|
|||
conditions.push({
|
||||
type: 'sql' as const,
|
||||
condition: `${actualKey} IN (${Array.from(values.included)
|
||||
.map(v => `'${v}'`)
|
||||
.map(v => (typeof v === 'string' ? `'${v}'` : v))
|
||||
.join(', ')})`,
|
||||
});
|
||||
}
|
||||
|
|
@ -39,7 +41,7 @@ export const filtersToQuery = (
|
|||
conditions.push({
|
||||
type: 'sql' as const,
|
||||
condition: `${actualKey} NOT IN (${Array.from(values.excluded)
|
||||
.map(v => `'${v}'`)
|
||||
.map(v => (typeof v === 'string' ? `'${v}'` : v))
|
||||
.join(', ')})`,
|
||||
});
|
||||
}
|
||||
|
|
@ -84,9 +86,24 @@ export const areFiltersEqual = (a: FilterState, b: FilterState) => {
|
|||
return true;
|
||||
};
|
||||
|
||||
// Helper function to split on commas while respecting quoted strings
|
||||
function splitValuesOnComma(valuesStr: string): string[] {
|
||||
const values: string[] = [];
|
||||
// Helper function to parse a string value as boolean if possible, or otherwise
|
||||
// return as string with surrounding quotes removed.
|
||||
const getBooleanOrUnquotedString = (value: string): string | boolean => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (['true', 'false'].includes(trimmed.toLowerCase())) {
|
||||
return trimmed.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
return trimmed.startsWith("'") && trimmed.endsWith("'")
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed;
|
||||
};
|
||||
|
||||
// Helper function to split on commas while respecting quoted strings and booleans
|
||||
function splitValuesOnComma(valuesStr: string): (string | boolean)[] {
|
||||
const values: (string | boolean)[] = [];
|
||||
let currentValue = '';
|
||||
let inString = false;
|
||||
|
||||
|
|
@ -101,13 +118,7 @@ function splitValuesOnComma(valuesStr: string): string[] {
|
|||
|
||||
if (!inString && char === ',') {
|
||||
if (currentValue.trim()) {
|
||||
// Remove surrounding quotes if present
|
||||
const trimmed = currentValue.trim();
|
||||
const unquoted =
|
||||
trimmed.startsWith("'") && trimmed.endsWith("'")
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed;
|
||||
values.push(unquoted);
|
||||
values.push(getBooleanOrUnquotedString(currentValue));
|
||||
}
|
||||
currentValue = '';
|
||||
continue;
|
||||
|
|
@ -118,12 +129,7 @@ function splitValuesOnComma(valuesStr: string): string[] {
|
|||
|
||||
// Add the last value
|
||||
if (currentValue.trim()) {
|
||||
const trimmed = currentValue.trim();
|
||||
const unquoted =
|
||||
trimmed.startsWith("'") && trimmed.endsWith("'")
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed;
|
||||
values.push(unquoted);
|
||||
values.push(getBooleanOrUnquotedString(currentValue));
|
||||
}
|
||||
|
||||
return values;
|
||||
|
|
@ -133,12 +139,12 @@ function splitValuesOnComma(valuesStr: string): string[] {
|
|||
// This handles both simple conditions and compound conditions with AND
|
||||
function extractInClauses(condition: string): Array<{
|
||||
key: string;
|
||||
values: string[];
|
||||
values: (string | boolean)[];
|
||||
isExclude: boolean;
|
||||
}> {
|
||||
const results: Array<{
|
||||
key: string;
|
||||
values: string[];
|
||||
values: (string | boolean)[];
|
||||
isExclude: boolean;
|
||||
}> = [];
|
||||
|
||||
|
|
@ -221,8 +227,8 @@ export const parseQuery = (
|
|||
const state = new Map<
|
||||
string,
|
||||
{
|
||||
included: Set<string>;
|
||||
excluded: Set<string>;
|
||||
included: Set<string | boolean>;
|
||||
excluded: Set<string | boolean>;
|
||||
range?: { min: number; max: number };
|
||||
}
|
||||
>();
|
||||
|
|
@ -311,7 +317,7 @@ export const useSearchPageFilterState = ({
|
|||
const setFilterValue = React.useCallback(
|
||||
(
|
||||
property: string,
|
||||
value: string,
|
||||
value: string | boolean,
|
||||
action?: 'only' | 'exclude' | 'include',
|
||||
) => {
|
||||
setFilters(prevFilters => {
|
||||
|
|
@ -400,7 +406,7 @@ export const useSearchPageFilterState = ({
|
|||
};
|
||||
|
||||
type PinnedFilters = {
|
||||
[key: string]: string[];
|
||||
[key: string]: (string | boolean)[];
|
||||
};
|
||||
|
||||
export type FilterStateHook = ReturnType<typeof useSearchPageFilterState>;
|
||||
|
|
@ -466,7 +472,7 @@ export function usePinnedFilters(sourceId: string | null) {
|
|||
usePinnedFilterBySource(sourceId);
|
||||
|
||||
const toggleFilterPin = React.useCallback(
|
||||
(property: string, value: string) => {
|
||||
(property: string, value: string | boolean) => {
|
||||
setPinnedFilters(prevFilters =>
|
||||
produce(prevFilters, draft => {
|
||||
if (!draft[property]) {
|
||||
|
|
@ -508,7 +514,7 @@ export function usePinnedFilters(sourceId: string | null) {
|
|||
);
|
||||
|
||||
const isFilterPinned = React.useCallback(
|
||||
(property: string, value: string): boolean => {
|
||||
(property: string, value: string | boolean): boolean => {
|
||||
return (
|
||||
pinnedFilters[property] &&
|
||||
pinnedFilters[property].some(v => v === value)
|
||||
|
|
|
|||
Loading…
Reference in a new issue