From 883e3525a4783438cb9d2f82ede09d716e785e56 Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov Date: Fri, 8 Nov 2024 11:17:09 +0000 Subject: [PATCH 1/3] POC for filter combobox collapse --- .../AdHocFiltersComboboxRenderer.tsx | 75 ++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx index a5ccf2a1e..66ecaab00 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx @@ -1,10 +1,11 @@ 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, useEffect, useRef, useState } from 'react'; import { AdHocFiltersVariable } from '../AdHocFiltersVariable'; import { AdHocFilterPill } from './AdHocFilterPill'; import { AdHocFiltersAlwaysWipCombobox } from './AdHocFiltersAlwaysWipCombobox'; +import { debounce } from 'lodash'; interface Props { model: AdHocFiltersVariable; @@ -13,6 +14,32 @@ interface Props { export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRenderer({ model }: Props) { const { filters, readOnly } = model.useState(); const styles = useStyles2(getStyles); + const [limitFiltersTo, setLimitFiltersTo] = useState(null); + const wrapperRef = useRef(null); + + const handleCollapseFilters = (shouldCollapse: boolean) => { + if (!shouldCollapse) { + setLimitFiltersTo(null); + return; + } + if (wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect(); + if (rect.height - 6 > 26) { + const componentLineSpan = (rect.height - 6) / 26; + const filterCutOff = Math.max(1, Math.floor(filters.length / (componentLineSpan + 1))); + setLimitFiltersTo(filterCutOff); + } else { + setLimitFiltersTo(null); + } + } + }; + + const debouncedSetActive = debounce(handleCollapseFilters, 100); + + useEffect(() => { + handleCollapseFilters(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // ref that focuses on the always wip filter input // defined in the combobox component via useImperativeHandle @@ -24,10 +51,17 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe onClick={() => { focusOnWipInputRef.current?.(); }} + ref={wrapperRef} + onFocusCapture={(e) => { + debouncedSetActive(false); + }} + onBlurCapture={(e) => { + debouncedSetActive(true); + }} > - {filters.map((filter, index) => ( + {(limitFiltersTo ? filters.slice(0, limitFiltersTo) : filters).map((filter, index) => ( ))} + {limitFiltersTo ? ( + + {filters.slice(limitFiltersTo).map((filter, i) => { + const keyLabel = filter.keyLabel ?? filter.key; + // TODO remove when we're on the latest version of @grafana/data + //@ts-expect-error + const valueLabel = filter.valueLabels?.join(', ') || filter.values?.join(', ') || filter.value; + return ( + + {keyLabel} {filter.operator} {valueLabel}
+
+ ); + })} + + } + > +
+{filters.length - limitFiltersTo} filters
+
+ ) : null} + {!readOnly ? : null} ); @@ -72,4 +128,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', + }), }); From 233ce9d3fadc2bc005708b8af9ff28fc64df4df6 Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov Date: Mon, 30 Dec 2024 09:38:42 +0000 Subject: [PATCH 2/3] finalise filter collapse functionality wip --- .../AdHocFiltersComboboxRenderer.tsx | 75 ++++++++++--------- .../adhoc/AdHocFiltersCombobox/utils.ts | 21 ++++++ .../variables/adhoc/AdHocFiltersVariable.tsx | 5 ++ 3 files changed, 64 insertions(+), 37 deletions(-) diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx index 66ecaab00..0462a03b8 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx @@ -1,11 +1,12 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; -import React, { Fragment, memo, useEffect, useRef, useState } from 'react'; +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; @@ -14,37 +15,42 @@ interface Props { export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRenderer({ model }: Props) { const { filters, readOnly } = model.useState(); const styles = useStyles2(getStyles); - const [limitFiltersTo, setLimitFiltersTo] = useState(null); const wrapperRef = useRef(null); - const handleCollapseFilters = (shouldCollapse: boolean) => { - if (!shouldCollapse) { - setLimitFiltersTo(null); - return; - } - if (wrapperRef.current) { - const rect = wrapperRef.current.getBoundingClientRect(); - if (rect.height - 6 > 26) { - const componentLineSpan = (rect.height - 6) / 26; - const filterCutOff = Math.max(1, Math.floor(filters.length / (componentLineSpan + 1))); - setLimitFiltersTo(filterCutOff); - } else { - setLimitFiltersTo(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(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 debouncedSetActive = debounce(handleCollapseFilters, 100); + const handleFilterCollapse = useCallback( + (shouldCollapse: boolean, filtersLength: number) => () => { + debouncedSetActive(shouldCollapse, filtersLength); + }, + [debouncedSetActive] + ); - useEffect(() => { - handleCollapseFilters(true); + useLayoutEffect(() => { + // updateCollapseThreshold(!!model.state.collapseFilters ? true : false, filters.length); + updateCollapseThreshold(true, filters.length); + // needs to run only on first render // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ref that focuses on the always wip filter input - // defined in the combobox component via useImperativeHandle - const focusOnWipInputRef = useRef<() => void>(); - return (
{ - debouncedSetActive(false); - }} - onBlurCapture={(e) => { - debouncedSetActive(true); - }} + onFocusCapture={handleFilterCollapse(false, filters.length)} + // onBlurCapture={handleFilterCollapse(!!model.state.collapseFilters ? true : false, filters.length} + onBlurCapture={handleFilterCollapse(true, filters.length)} > - {(limitFiltersTo ? filters.slice(0, limitFiltersTo) : filters).map((filter, index) => ( + {(collapseThreshold ? filters.slice(0, collapseThreshold) : filters).map((filter, index) => ( ))} - {limitFiltersTo ? ( + {collapseThreshold ? ( - {filters.slice(limitFiltersTo).map((filter, i) => { + {filters.slice(collapseThreshold).map((filter, i) => { const keyLabel = filter.keyLabel ?? filter.key; - // TODO remove when we're on the latest version of @grafana/data - //@ts-expect-error const valueLabel = filter.valueLabels?.join(', ') || filter.values?.join(', ') || filter.value; return ( @@ -89,7 +90,7 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe
} > -
+{filters.length - limitFiltersTo} filters
+
+{filters.length - collapseThreshold} filters
) : null} diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts index 78f0078d9..8f2f65e19 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts @@ -211,3 +211,24 @@ export const populateInputValueOnInputTypeSwitch = ({ setInputValue(''); } }; + +export const calculateCollapseThreshold = ( + shouldCollapse: boolean, + filtersLength: number, + wrapperRef: React.RefObject +) => { + if (!shouldCollapse || !wrapperRef.current) { + return null; + } + + const rect = wrapperRef.current.getBoundingClientRect(); + + if (rect.height - 6 > 26) { + const componentLineSpan = (rect.height - 6) / 26; + const filterCutOff = Math.max(1, Math.floor(filtersLength / (componentLineSpan + 1))); + + return filterCutOff; + } + + return null; +}; diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx index 2c4249c9d..a079ae476 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx @@ -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; } export type AdHocVariableExpressionBuilderFn = (filters: AdHocFilterWithLabels[]) => string; From de29b98beb78cf90949eb4a841bd06405530b13c Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov Date: Mon, 30 Dec 2024 10:01:57 +0000 Subject: [PATCH 3/3] clean up and add comments --- .../AdHocFiltersComboboxRenderer.tsx | 11 +++++++---- .../src/variables/adhoc/AdHocFiltersCombobox/utils.ts | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx index 0462a03b8..5c5aa709e 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx @@ -45,8 +45,10 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe ); useLayoutEffect(() => { - // updateCollapseThreshold(!!model.state.collapseFilters ? true : false, filters.length); - updateCollapseThreshold(true, filters.length); + 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 }, []); @@ -59,8 +61,9 @@ export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRe }} ref={wrapperRef} onFocusCapture={handleFilterCollapse(false, filters.length)} - // onBlurCapture={handleFilterCollapse(!!model.state.collapseFilters ? true : false, filters.length} - onBlurCapture={handleFilterCollapse(true, filters.length)} + // TODO: remove below before merging + // onBlurCapture={handleFilterCollapse(true, filters.length)} // for testing locally + onBlurCapture={handleFilterCollapse(!!model.state.collapseFilters ? true : false, filters.length)} > diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts index 8f2f65e19..327b5bb5a 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/utils.ts @@ -223,8 +223,12 @@ export const calculateCollapseThreshold = ( 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;