Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AdHocFiltersCombobox: Collapse filter combobox #961

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import React, { memo, useRef } from 'react';
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
import React, { Fragment, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { AdHocFiltersVariable } from '../AdHocFiltersVariable';
import { AdHocFilterPill } from './AdHocFilterPill';
import { AdHocFiltersAlwaysWipCombobox } from './AdHocFiltersAlwaysWipCombobox';
import { debounce } from 'lodash';
import { calculateCollapseThreshold } from './utils';

interface Props {
model: AdHocFiltersVariable;
Expand All @@ -13,21 +15,59 @@ interface Props {
export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRenderer({ model }: Props) {
const { filters, readOnly } = model.useState();
const styles = useStyles2(getStyles);
const wrapperRef = useRef<HTMLDivElement>(null);

// ref that focuses on the always wip filter input
// defined in the combobox component via useImperativeHandle
const focusOnWipInputRef = useRef<() => void>();

const [collapseThreshold, setCollapseThreshold] = useState<number | null>(null);

const updateCollapseThreshold = useCallback(
(shouldCollapse: boolean, filtersLength: number) => {
const filterCollapseThreshold = calculateCollapseThreshold(
!readOnly ? shouldCollapse : false,
filtersLength,
wrapperRef
);
setCollapseThreshold(filterCollapseThreshold);
},
[readOnly]
);

const debouncedSetActive = useMemo(() => debounce(updateCollapseThreshold, 100), [updateCollapseThreshold]);

const handleFilterCollapse = useCallback(
(shouldCollapse: boolean, filtersLength: number) => () => {
debouncedSetActive(shouldCollapse, filtersLength);
},
[debouncedSetActive]
);

useLayoutEffect(() => {
updateCollapseThreshold(!!model.state.collapseFilters ? true : false, filters.length);
// TODO: remove below before merging
// updateCollapseThreshold(true, filters.length); // for testing locally

// needs to run only on first render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div
className={cx(styles.comboboxWrapper, { [styles.comboboxFocusOutline]: !readOnly })}
onClick={() => {
focusOnWipInputRef.current?.();
}}
ref={wrapperRef}
onFocusCapture={handleFilterCollapse(false, filters.length)}
// TODO: remove below before merging
// onBlurCapture={handleFilterCollapse(true, filters.length)} // for testing locally
onBlurCapture={handleFilterCollapse(!!model.state.collapseFilters ? true : false, filters.length)}
>
<Icon name="filter" className={styles.filterIcon} size="lg" />

{filters.map((filter, index) => (
{(collapseThreshold ? filters.slice(0, collapseThreshold) : filters).map((filter, index) => (
<AdHocFilterPill
key={`${index}-${filter.key}`}
filter={filter}
Expand All @@ -37,6 +77,26 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe
/>
))}

{collapseThreshold ? (
<Tooltip
content={
<div>
{filters.slice(collapseThreshold).map((filter, i) => {
const keyLabel = filter.keyLabel ?? filter.key;
const valueLabel = filter.valueLabels?.join(', ') || filter.values?.join(', ') || filter.value;
return (
<Fragment key={`${keyLabel}-${i}`}>
{keyLabel} {filter.operator} {valueLabel} <br />
</Fragment>
);
})}
</div>
}
>
<div className={cx(styles.basePill)}>+{filters.length - collapseThreshold} filters </div>
</Tooltip>
) : null}

{!readOnly ? <AdHocFiltersAlwaysWipCombobox model={model} ref={focusOnWipInputRef} /> : null}
</div>
);
Expand Down Expand Up @@ -72,4 +132,17 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: theme.colors.text.secondary,
alignSelf: 'center',
}),
basePill: css({
display: 'flex',
alignItems: 'center',
background: theme.colors.action.selected,
border: `1px solid ${theme.colors.border.weak}`,
padding: theme.spacing(0.125, 1, 0.125, 1),
color: theme.colors.text.primary,
overflow: 'hidden',
whiteSpace: 'nowrap',
minHeight: theme.spacing(2.75),
...theme.typography.bodySmall,
cursor: 'pointer',
}),
});
25 changes: 25 additions & 0 deletions packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,28 @@ export const populateInputValueOnInputTypeSwitch = ({
setInputValue('');
}
};

export const calculateCollapseThreshold = (
shouldCollapse: boolean,
filtersLength: number,
wrapperRef: React.RefObject<HTMLDivElement>
) => {
if (!shouldCollapse || !wrapperRef.current) {
return null;
}

const rect = wrapperRef.current.getBoundingClientRect();

// without paddings variable wrapper height is 26px
// therefore trigger only when higher than that
if (rect.height - 6 > 26) {
const componentLineSpan = (rect.height - 6) / 26;
// magic number but dividing filters by line span +1 should yield
// number of filters that will fit in 1 line
const filterCutOff = Math.max(1, Math.floor(filtersLength / (componentLineSpan + 1)));

return filterCutOff;
}

return null;
};
5 changes: 5 additions & 0 deletions packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ export interface AdHocFiltersVariableState extends SceneVariableState {
* @internal state of the new filter being added
*/
_wip?: AdHocFilterWithLabels;

/**
* Flag that will collapse combobox filters when they become too long
*/
collapseFilters?: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first thing that comes to my mind is: Shouldn't this be something to control from the component rather than the variable declaration?

This is pure UI behavior. It is weird to create a SceneObject variable to represent the AdHoc filter and indicate that it is collapsible. Have you considered any other approach that allows you to configure this in the variables control, for example?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to go with this implementation because it does not involve any major design changes and offers persistence between reloads. Other approaches would struggle with persistence especially when this is opt in behaviour.

}

export type AdHocVariableExpressionBuilderFn = (filters: AdHocFilterWithLabels[]) => string;
Expand Down
Loading