From d64eff44270d2ec8680efdcae7103f65d06a4440 Mon Sep 17 00:00:00 2001 From: Amit Yathirajadasan Date: Wed, 9 Aug 2023 16:28:57 -0700 Subject: [PATCH] feat: filter bar component --- README.md | 3 + src/components/filter-bar/README.md | 93 ++++ .../components/conjunction/index.js | 70 +++ .../components/conjunction/styles.scss | 37 ++ src/components/filter-bar/components/index.js | 5 + .../filter-bar/components/label/index.js | 29 ++ .../filter-bar/components/label/styles.scss | 23 + .../filter-bar/components/value/index.js | 33 ++ .../filter-bar/components/value/styles.scss | 47 ++ src/components/filter-bar/icons/close.svg | 3 + src/components/filter-bar/icons/equal.svg | 4 + src/components/filter-bar/icons/filter.svg | 3 + src/components/filter-bar/icons/index.js | 7 + src/components/filter-bar/icons/not-equal.svg | 5 + src/components/filter-bar/icons/open.svg | 3 + src/components/filter-bar/icons/remove.svg | 3 + src/components/filter-bar/icons/search.svg | 3 + src/components/filter-bar/index.js | 488 ++++++++++++++++++ src/components/filter-bar/styles.scss | 136 +++++ src/components/index.js | 1 + 20 files changed, 996 insertions(+) create mode 100644 src/components/filter-bar/README.md create mode 100644 src/components/filter-bar/components/conjunction/index.js create mode 100644 src/components/filter-bar/components/conjunction/styles.scss create mode 100644 src/components/filter-bar/components/index.js create mode 100644 src/components/filter-bar/components/label/index.js create mode 100644 src/components/filter-bar/components/label/styles.scss create mode 100644 src/components/filter-bar/components/value/index.js create mode 100644 src/components/filter-bar/components/value/styles.scss create mode 100644 src/components/filter-bar/icons/close.svg create mode 100644 src/components/filter-bar/icons/equal.svg create mode 100644 src/components/filter-bar/icons/filter.svg create mode 100644 src/components/filter-bar/icons/index.js create mode 100644 src/components/filter-bar/icons/not-equal.svg create mode 100644 src/components/filter-bar/icons/open.svg create mode 100644 src/components/filter-bar/icons/remove.svg create mode 100644 src/components/filter-bar/icons/search.svg create mode 100644 src/components/filter-bar/index.js create mode 100644 src/components/filter-bar/styles.scss diff --git a/README.md b/README.md index 87298c8..827cf46 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ A layout component for `StatusIcon` ### [ProgressBar](src/components/progress-bar) A component that renders a progress bar. +### [FilterBar](src/components/filter-bar) +Component that allows a user to filter options. + ## Utilities ### [timeRangeToNrql](src/utils/time-range-to-nrql/) diff --git a/src/components/filter-bar/README.md b/src/components/filter-bar/README.md new file mode 100644 index 0000000..1b78f0c --- /dev/null +++ b/src/components/filter-bar/README.md @@ -0,0 +1,93 @@ +# FilterBar + +The FilterBar component allows a user to filter from a list of options. Based on the choices of the user, the component returns a NRQL query `WHERE` clause. + +## Usage + +To use the FilterBar component in your project, follow these steps: + +Import the component: + +```jsx +import { FilterBar } from '@newrelic/nr-labs-components'; +``` + +Use the component in your code: + +```jsx + fnToHandleChange()} + getValues={fnToGetAdditionalValues} +/> +``` + +### Props + +The FilterBar component accepts the following props: + +- `options` (array) - an array of option objects. See below for object properties. +- `onChange` (function) - a callback function that receives a string formatted as WHERE clauses for a NRQL query. +- `getValues` (function) - an aysnc function that fetches values that match user input. + +#### `options` object + +- `option` (string) - the title for the option +- `type` (string) - option type - either `string` or `numeric` +- `values` (array) - array of values for the option + +#### `getValues` + +The async function passed to `getvalues` is called in two scenarios. + +- When an empty array is passed for `values` for an option, and the user clicks on the option to expand the list of values for that option. The function is called with just one attribute - `option` and expects an array of values to be returned. +- When the user types out a value in the search field for the option. The attributes passed are the `option` and a `searchString` formatted as a NRQL WHERE clause. + +## Example + +Here's an example of how to use the FilterBar component: + +```jsx +import React from 'react'; +import { FilterBar } from '@newrelic/nr-labs-components'; + +const options = [ + { + option: 'responseCode', + type: 'numeric', + values: ['200', '404'], + }, + { + option: 'scheme', + type: 'string', + values: ['https', 'http'], + }, + // ... more filter options +]; + +const getValues = async (option, searchString) => { + console.log(`getValues was called for option ${option}`); + if (searchString) console.log(`SELECT attribute FROM event ${searchString}`); + // query for values and return values as an array + return []; +}; + +function App() { + const changeHandler = (whereClause) => { + console.log(`SELECT * FROM Transaction WHERE ${whereClause}`); + }; + + return ( +
+

FilterBar

+ +
+ ); +} + +export default App; +``` diff --git a/src/components/filter-bar/components/conjunction/index.js b/src/components/filter-bar/components/conjunction/index.js new file mode 100644 index 0000000..dd6979c --- /dev/null +++ b/src/components/filter-bar/components/conjunction/index.js @@ -0,0 +1,70 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; + +import styles from './styles.scss'; + +const Conjunction = ({ operator, isHint, onChange }) => { + const [showPicker, setShowPicker] = useState(false); + const thisComponent = useRef(); + + useEffect(() => { + function handleClicksOutsideComponent(evt) { + if ( + showPicker && + thisComponent && + !thisComponent.current.contains(evt.target) + ) + setShowPicker(false); + } + document.addEventListener('mousedown', handleClicksOutsideComponent); + + return function cleanup() { + document.removeEventListener('mousedown', handleClicksOutsideComponent); + }; + }); + + const clickHandler = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + setShowPicker(!showPicker); + }; + + const changeHandler = (selection, evt) => { + evt.preventDefault(); + evt.stopPropagation(); + if (onChange && selection !== operator) onChange(selection); + }; + + const options = ['AND', 'OR']; + + return ( + + {operator} + {showPicker && ( + + {options.map((opt, i) => ( + changeHandler(opt, evt)} + > + {opt} + + ))} + + )} + + ); +}; + +Conjunction.propTypes = { + operator: PropTypes.string, + isHint: PropTypes.bool, + onChange: PropTypes.func, +}; + +export default Conjunction; diff --git a/src/components/filter-bar/components/conjunction/styles.scss b/src/components/filter-bar/components/conjunction/styles.scss new file mode 100644 index 0000000..f28fb48 --- /dev/null +++ b/src/components/filter-bar/components/conjunction/styles.scss @@ -0,0 +1,37 @@ +.conjunction { + padding: 1px 4px; + background: rgb(231, 233, 233); + border-radius: 3px; + margin-right: 8px; + color: rgb(83, 94, 101); + cursor: pointer; + position: relative; + display: inline-block; + + &.hint { + background: rgba(231, 233, 233, 0.5); + color: rgba(83, 94, 101, 0.5); + } + + .conjunction-picker { + position: absolute; + background: #ffffff; + box-shadow: 0px 4px 4px 4px rgba(0, 0, 0, 0.02), + 0px 8px 16px 8px rgba(2, 3, 3, 0.05); + border-radius: 4px; + display: flex; + gap: 3px; + padding: 5px; + z-index: 11; + + span { + padding: 1px 4px; + background: #ffffff; + border-radius: 3px; + + &.selected { + background: #e8e8e8; + } + } + } +} diff --git a/src/components/filter-bar/components/index.js b/src/components/filter-bar/components/index.js new file mode 100644 index 0000000..4e13ba3 --- /dev/null +++ b/src/components/filter-bar/components/index.js @@ -0,0 +1,5 @@ +import Conjunction from './conjunction'; +import Label from './label'; +import Value from './value'; + +export { Conjunction, Label, Value }; diff --git a/src/components/filter-bar/components/label/index.js b/src/components/filter-bar/components/label/index.js new file mode 100644 index 0000000..457dd89 --- /dev/null +++ b/src/components/filter-bar/components/label/index.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { RemoveIcon } from '../../icons'; + +import styles from './styles.scss'; + +const Label = ({ value, onRemove }) => { + const removeClickHandler = (evt) => { + evt.stopPropagation(); + if (onRemove) onRemove(evt); + }; + + return ( + + {value} + + remove + + + ); +}; + +Label.propTypes = { + value: PropTypes.string, + onRemove: PropTypes.func, +}; + +export default Label; diff --git a/src/components/filter-bar/components/label/styles.scss b/src/components/filter-bar/components/label/styles.scss new file mode 100644 index 0000000..45ebdf4 --- /dev/null +++ b/src/components/filter-bar/components/label/styles.scss @@ -0,0 +1,23 @@ +.label { + display: inline-flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 1px 4px; + background-color: #e1edff; + border-radius: 3px; + color: #0b6acb; + margin-right: 8px; + margin-bottom: 6px; + + .label-text { + flex: none; + order: 0; + flex-grow: 0; + } + + .label-remove { + margin-left: 4px; + cursor: pointer; + } +} diff --git a/src/components/filter-bar/components/value/index.js b/src/components/filter-bar/components/value/index.js new file mode 100644 index 0000000..9c31dfa --- /dev/null +++ b/src/components/filter-bar/components/value/index.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import styles from './styles.scss'; + +const Value = ({ value, width, optionIndex, valueIndex, onChange }) => { + const changeHandler = () => + onChange ? onChange(optionIndex, valueIndex) : null; + + return ( +
+ + +
+ ); +}; + +Value.propTypes = { + value: PropTypes.object, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + optionIndex: PropTypes.number, + valueIndex: PropTypes.number, + onChange: PropTypes.func, +}; + +export default Value; diff --git a/src/components/filter-bar/components/value/styles.scss b/src/components/filter-bar/components/value/styles.scss new file mode 100644 index 0000000..adbc4f4 --- /dev/null +++ b/src/components/filter-bar/components/value/styles.scss @@ -0,0 +1,47 @@ +.option-value { + align-items: center; + display: inline-flex; + position: relative; + + label { + padding-left: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .option-picker { + position: absolute; + background: #ffffff; + box-shadow: 0px 4px 4px 4px rgba(0, 0, 0, 0.02), + 0px 8px 16px 8px rgba(2, 3, 3, 0.05); + border-radius: 4px; + display: flex; + gap: 3px; + padding: 5px; + top: 100%; + z-index: 1; + + span { + padding: 1px 4px; + background: #ffffff; + border-radius: 3px; + width: 18px; + height: 18px; + background-position: center center; + background-repeat: no-repeat; + + &.equal { + background-image: url("data:image/svg+xml,%3Csvg width='8' height='5' viewBox='0 0 8 5' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cline x1='0.5' y1='1' x2='7.5' y2='1' stroke='%23293338' stroke-linecap='round'/%3E%3Cline x1='0.5' y1='4' x2='7.5' y2='4' stroke='%23293338' stroke-linecap='round'/%3E%3C/svg%3E"); + } + + &.not-equal { + background-image: url("data:image/svg+xml,%3Csvg width='8' height='9' viewBox='0 0 8 9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cline x1='0.5' y1='2.60288' x2='7.5' y2='2.60288' stroke='%23293338' stroke-linecap='round'/%3E%3Cline x1='0.5' y1='5.60288' x2='7.5' y2='5.60288' stroke='%23293338' stroke-linecap='round'/%3E%3Cline x1='6.18301' y1='0.785895' x2='2.18301' y2='7.7141' stroke='%23293338' stroke-linecap='round'/%3E%3C/svg%3E"); + } + + &.selected { + background-color: #e8e8e8; + } + } + } +} diff --git a/src/components/filter-bar/icons/close.svg b/src/components/filter-bar/icons/close.svg new file mode 100644 index 0000000..85f11e4 --- /dev/null +++ b/src/components/filter-bar/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/filter-bar/icons/equal.svg b/src/components/filter-bar/icons/equal.svg new file mode 100644 index 0000000..8ace7db --- /dev/null +++ b/src/components/filter-bar/icons/equal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/filter-bar/icons/filter.svg b/src/components/filter-bar/icons/filter.svg new file mode 100644 index 0000000..6638b13 --- /dev/null +++ b/src/components/filter-bar/icons/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/filter-bar/icons/index.js b/src/components/filter-bar/icons/index.js new file mode 100644 index 0000000..60d3f3f --- /dev/null +++ b/src/components/filter-bar/icons/index.js @@ -0,0 +1,7 @@ +import CloseIcon from './close.svg'; +import FilterByIcon from './filter.svg'; +import OpenIcon from './open.svg'; +import RemoveIcon from './remove.svg'; +import SearchIcon from './search.svg'; + +export { CloseIcon, FilterByIcon, OpenIcon, RemoveIcon, SearchIcon }; diff --git a/src/components/filter-bar/icons/not-equal.svg b/src/components/filter-bar/icons/not-equal.svg new file mode 100644 index 0000000..16183d1 --- /dev/null +++ b/src/components/filter-bar/icons/not-equal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/filter-bar/icons/open.svg b/src/components/filter-bar/icons/open.svg new file mode 100644 index 0000000..58ad673 --- /dev/null +++ b/src/components/filter-bar/icons/open.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/filter-bar/icons/remove.svg b/src/components/filter-bar/icons/remove.svg new file mode 100644 index 0000000..73c6e84 --- /dev/null +++ b/src/components/filter-bar/icons/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/filter-bar/icons/search.svg b/src/components/filter-bar/icons/search.svg new file mode 100644 index 0000000..45f8ffc --- /dev/null +++ b/src/components/filter-bar/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/filter-bar/index.js b/src/components/filter-bar/index.js new file mode 100644 index 0000000..2040f51 --- /dev/null +++ b/src/components/filter-bar/index.js @@ -0,0 +1,488 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; + +import { Spinner } from 'nr1'; + +import { CloseIcon, FilterByIcon, OpenIcon, SearchIcon } from './icons'; +import { Conjunction, Label, Value } from './components'; + +import styles from './styles.scss'; + +const FilterBar = ({ options, onChange, getValues }) => { + const thisComponent = useRef(); + const inputField = useRef(); + const [showItemsList, setShowItemsList] = useState(false); + const [filterItems, setFilterItems] = useState([]); + const [filterString, setFilterString] = useState(''); + const [searchTexts, setSearchTexts] = useState([]); + const [displayOptions, setDisplayOptions] = useState([]); + const [optionShouldMatch, setOptionShouldMatch] = useState([]); + const [optionFilterMatch, setOptionFilterMatch] = useState([]); + const [optionsLoading, setOptionsLoading] = useState([]); + const [optionsSearchText, setOptionsSearchText] = useState(''); + const [values, setValues] = useState([]); + const [shownValues, setShownValues] = useState([]); + const [conjunctions, setConjunctions] = useState([]); + const lastGroup = useRef(''); + const searchTimeout = useRef(); + + const MIN_ITEMS_SHOWN = 5; + const MAX_DROPDOWN_WIDTH = 360; + + useEffect(() => { + function handleClicksOutsideComponent(evt) { + if ( + showItemsList && + thisComponent && + !thisComponent.current.contains(evt.target) + ) + setShowItemsList(false); + } + document.addEventListener('mousedown', handleClicksOutsideComponent); + + return function cleanup() { + document.removeEventListener('mousedown', handleClicksOutsideComponent); + }; + }); + + useEffect(() => { + setDisplayOptions(options.map((_, i) => !i)); + setOptionShouldMatch(options.map(() => true)); + setOptionFilterMatch(options.map(() => true)); + setOptionsLoading(options.map(() => false)); + setValues( + options.map((o) => + (o.values || []).map((v) => ({ + value: v, + display: String(v), + id: String(v).replaceAll('^[^a-zA-Z_$]|[^\\w$]', '_'), + type: o.type, + attribute: o.option, + isIncluded: true, + isSelected: false, + shouldMatch: true, + })) + ) + ); + setShownValues( + options.map((o) => (o.values.length > 6 ? 5 : o.values.length)) + ); + }, [options]); + + useEffect(() => { + const fltrStr = updateFilterString(); + if (fltrStr !== filterString) { + setFilterString(fltrStr); + if (onChange) onChange(fltrStr); + } + }, [filterItems, conjunctions, optionShouldMatch]); + + const itemsListWidth = + inputField && inputField.current + ? inputField.current.clientWidth - 14 + : MAX_DROPDOWN_WIDTH; + const dropdownWidth = Math.min(itemsListWidth, MAX_DROPDOWN_WIDTH); + const checkboxWidth = (dropdownWidth - 32) / 2; + + const checkHandler = (optionIdx, valueIdx) => { + const vals = [...values]; + vals[optionIdx][valueIdx].isSelected = + !vals[optionIdx][valueIdx].isSelected; + setValues(vals); + const fltrItems = vals + .reduce( + (qry, opt, i) => { + opt.reduce((qry, val, j) => { + if (!val.isSelected) return qry; + const idx = +!val.shouldMatch; + if (!(val.attribute in qry[idx])) + qry[idx][val.attribute] = { + attribute: val.attribute, + optionIndex: i, + type: val.type, + matchType: val.shouldMatch, + valueIndexes: [], + }; + qry[idx][val.attribute].valueIndexes.push(j); + return qry; + }, qry); + return qry; + }, + [{}, {}] + ) + .reduce( + (fi, matches) => + Object.keys(matches).reduce((fi, opt) => [...fi, matches[opt]], fi), + [] + ); + + if (conjunctions.length < fltrItems.length) + setConjunctions([...conjunctions, 'AND']); + setFilterItems(fltrItems); + }; + + const updateOptionsSearchText = (evt) => { + const searchText = evt.target.value; + setOptionsSearchText(searchText); + const searchRE = new RegExp(searchText, 'i'); + setOptionFilterMatch(options.map((o) => searchRE.test(o.option))); + setShowItemsList(true); + }; + + const updateSearchText = (evt, option, idx) => { + const searchText = evt.target.value; + setSearchTexts(searchTexts.map((st, i) => (i === idx ? searchText : st))); + const searchRE = new RegExp(searchText, 'i'); + + clearTimeout(searchTimeout.current); + if (searchText.trim()) { + searchTimeout.current = setTimeout(async () => { + setOptionsLoading(optionsLoading.map((l, i) => (i === idx ? true : l))); + const updatedValues = await loadValuesLive( + option.option, + option.type, + idx, + searchText, + searchRE + ); + setValues( + options.map((_, i) => (i === idx ? updatedValues : values[i])) + ); + setShownValues( + shownValues.map((s, i) => + i === idx // eslint-disable-line no-nested-ternary + ? updatedValues.length > 6 + ? 5 + : updatedValues.length + : s + ) + ); + setOptionsLoading( + optionsLoading.map((l, i) => (i === idx ? false : l)) + ); + }, 500); + } else { + setValues( + values.map((val, i) => + i === idx ? val.map((v) => ({ ...v, isIncluded: true })) : val + ) + ); + setShownValues( + shownValues.map((show, i) => + i === idx ? shownCount(values[idx].length, show) : show + ) + ); + } + }; + + const includedValuesCount = (arr) => + arr.filter((val) => val.isIncluded).length; + + const shownCount = (count, show = MIN_ITEMS_SHOWN) => + count > Math.max(show, MIN_ITEMS_SHOWN) + ? Math.max(show, MIN_ITEMS_SHOWN) + : count; + + const optionClickHandler = async (option, idx) => { + const shouldLoad = !values[idx].length; + setDisplayOptions(displayOptions.map((d, i) => (i === idx ? !d : d))); + setOptionsLoading( + optionsLoading.map((l, i) => (i === idx && shouldLoad ? true : l)) + ); + if (shouldLoad) loadValues(option, idx); + }; + + const updateShownValues = (evt, idx) => { + evt.preventDefault(); + const shown = [...shownValues]; + shown[idx] = values[idx].filter((val) => val.isIncluded).length; + setShownValues(shown); + }; + + const shownAndIncluded = (vals, idx) => + [...vals].reduce( + (acc, cur) => + cur.isIncluded && acc.length < shownValues[idx] ? [...acc, cur] : acc, + [] + ); + + const loadValues = async (option, idx) => { + const vals = getValues ? await getValues(option.option) : []; + setValues( + options.map((o, i) => + i === idx + ? (vals || []).map((v) => ({ + value: v, + display: String(v), + id: String(v).replaceAll('^[^a-zA-Z_$]|[^\\w$]', '_'), + type: o.type, + attribute: o.option, + isIncluded: true, + isSelected: false, + shouldMatch: true, + })) + : values[i] + ) + ); + setShownValues( + shownValues.map( + (s, i) => (i === idx ? (vals.length > 6 ? 5 : vals.length) : s) // eslint-disable-line no-nested-ternary + ) + ); + setOptionsLoading(optionsLoading.map((l, i) => (i === idx ? false : l))); + }; + + const loadValuesLive = async (attr, type, idx, searchStr, searchRE) => { + let cond = ` WHERE `; + if (type === 'string') { + cond += ` ${attr} LIKE '%${searchStr}%' `; + } else { + const matches = [...searchStr.matchAll(/([><]+)\s{0,}([.-\d]{1,})/g)]; + if (matches.length) { + cond += matches + .map(([, op, num]) => + op && !isNaN(num) ? ` ${attr} ${op} ${Number(num)} ` : '' + ) + .join(' AND '); + } else { + const sanitizedSearchStr = searchStr.replace(/[^\w\s]/gi, ''); + cond += ` ${attr} = ${sanitizedSearchStr || 'false'} `; + } + } + const vals = getValues ? await getValues(attr, cond) : []; + const prevValues = values[idx].map((v) => ({ + ...v, + isIncluded: searchRE.test(v.display) || vals.includes(v.value), + })); + return vals.reduce((acc, val) => { + if (!acc.some((v) => v.value === val)) + acc.push({ + value: val, + display: String(val), + id: String(val).replaceAll('^[^a-zA-Z_$]|[^\\w$]', '_'), + type: type, + attribute: attr, + isIncluded: true, + isSelected: false, + shouldMatch: true, + }); + return acc; + }, prevValues); + }; + + const selectedValuesCounter = (idx) => { + const count = selectedValuesCount(idx); + if (count) + return {count}; + }; + + const selectedValuesCount = (idx) => + values[idx].reduce((acc, val) => (val.isSelected ? (acc += 1) : acc), 0); + + const filterItemStr = (item) => { + const attribValues = item.valueIndexes.map( + (valIdx) => values[item.optionIndex][valIdx].value + ); + const hasMany = attribValues.length > 1; + const surround = item.type === 'string' ? `'` : ''; + const joinStr = `${surround}, ${surround}`; + const operator = optionShouldMatch[item.optionIndex] // eslint-disable-line no-nested-ternary + ? hasMany + ? 'IN' + : '=' + : hasMany + ? 'NOT IN' + : '!='; + const valuesStr = `${hasMany ? '(' : ''}${surround}${attribValues.join( + joinStr + )}${surround}${hasMany ? ')' : ''}`; + return `${item.attribute} ${operator} ${valuesStr}`; + }; + + const removeFilterItem = (idx) => { + const fltrItems = [...filterItems]; + const cnjctns = [...conjunctions]; + const optIdx = fltrItems[idx].optionIndex; + const vals = values.map((opt, i) => + i === optIdx + ? opt.map((val) => ({ ...val, isSelected: false, shouldMatch: true })) + : opt + ); + fltrItems.splice(idx, 1); + cnjctns.splice(idx, 1); + setConjunctions(cnjctns); + setFilterItems(fltrItems); + setValues(vals); + }; + + const updateFilterString = () => + filterItems.length + ? filterItems + .map( + (item, i) => + `${filterItemStr(item)} ${ + i < filterItems.length - 1 ? conjunctions[i] : '' + }` + ) + .join(' ') + : ''; + + const changeConjunction = (idx, operator) => + setConjunctions( + conjunctions.map((conj, i) => (i === idx ? operator : conj)) + ); + + const changeMatchType = (idx, shouldMatch, evt) => { + evt.stopPropagation(); + setOptionShouldMatch( + optionShouldMatch.map((type, i) => (i === idx ? shouldMatch : type)) + ); + }; + + const groupBar = (group) => { + lastGroup.current = group; + return
{group}
; + }; + + return ( +
+
+
+ filter by +
+
setShowItemsList(!showItemsList)} + > + {filterItems.map((item, i) => ( + + + ))} + + + +
+
+ {showItemsList ? ( +
+ {options.map((option, i) => + optionFilterMatch[i] ? ( + <> + {option.group && option.group !== lastGroup.current + ? groupBar(option.group) + : null} +
+
optionClickHandler(option, i)} + > + show or hide options + {option.option} + {optionsLoading[i] ? ( + + ) : ( + selectedValuesCounter(i) + )} + {displayOptions[i] ? ( + + changeMatchType(i, true, evt)} + /> + changeMatchType(i, false, evt)} + /> + + ) : null} +
+ {displayOptions[i] ? ( + <> +
+ search options + updateSearchText(evt, option, i)} + /> +
+
+ {shownAndIncluded(values[i], i).map((value, j) => ( + + ))} + {includedValuesCount(values[i]) > shownValues[i] ? ( + + ) : null} +
+ + ) : null} +
+ + ) : null + )} +
+ ) : null} +
+ ); +}; + +FilterBar.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + option: PropTypes.string, + type: PropTypes.oneOf(['string', 'number', 'boolean']), + values: PropTypes.array, + group: PropTypes.string, + info: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + }) + ), + onChange: PropTypes.func, + getValues: PropTypes.func, +}; + +export default FilterBar; diff --git a/src/components/filter-bar/styles.scss b/src/components/filter-bar/styles.scss new file mode 100644 index 0000000..76d6637 --- /dev/null +++ b/src/components/filter-bar/styles.scss @@ -0,0 +1,136 @@ +.filter-bar { + width: 100%; + + .input-field { + background-color: #f3f4f4; + border: 1px solid #cdd3d5; + border-radius: 4px; + padding: 8px 8px 2px; + display: flex; + + .input-field-icon { + width: 16px; + height: 16px; + margin-right: 8px; + } + + .input-field-input { + flex: 1; + font-size: 12px; + line-height: 16px; + cursor: text; + + + + &.placeholder { + color: #6b757b; + margin-bottom: 6px; + padding: 1px 4px; + padding-left: 0; + } + } + + .input-field-search { + flex-grow: 1; + + input { + background-color: transparent; + border: 0; + } + } + } + + .list { + display: flex; + flex-direction: column; + padding: 4px 0; + background: #ffffff; + box-shadow: 0px 4px 4px 4px rgba(0, 0, 0, 0.02), + 0px 8px 16px 8px rgba(2, 3, 3, 0.05); + border-radius: 4px; + position: absolute; + max-height: 60vh; + overflow-y: scroll; + z-index: 10; + + .list-group, + .list-option, + .list-option-search { + display: flex; + align-items: center; + padding: 12px 16px; + gap: 8px; + border-bottom: 1px solid #f3f4f4; + } + + .list-group { + font-weight: 600; + font-size: 12px; + line-height: 16px; + text-transform: uppercase; + color: #293338; + } + + .list-option { + span:first-of-type { + cursor: pointer; + } + + .list-option-picker { + margin-left: auto; + background: #e7e9e9; + border-radius: 4px; + display: flex; + padding: 2px; + + &.lighten { + opacity: 0.5; + } + + span { + padding: 2px 4px; + border-radius: 3px; + width: 18px; + height: 18px; + background-position: center center; + background-repeat: no-repeat; + cursor: pointer; + + &.equal { + background-image: url("data:image/svg+xml,%3Csvg width='8' height='5' viewBox='0 0 8 5' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cline x1='0.5' y1='1' x2='7.5' y2='1' stroke='%23293338' stroke-linecap='round'/%3E%3Cline x1='0.5' y1='4' x2='7.5' y2='4' stroke='%23293338' stroke-linecap='round'/%3E%3C/svg%3E"); + } + + &.not-equal { + background-image: url("data:image/svg+xml,%3Csvg width='8' height='9' viewBox='0 0 8 9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cline x1='0.5' y1='2.60288' x2='7.5' y2='2.60288' stroke='%23293338' stroke-linecap='round'/%3E%3Cline x1='0.5' y1='5.60288' x2='7.5' y2='5.60288' stroke='%23293338' stroke-linecap='round'/%3E%3Cline x1='6.18301' y1='0.785895' x2='2.18301' y2='7.7141' stroke='%23293338' stroke-linecap='round'/%3E%3C/svg%3E"); + } + + &.selected { + background-color: #ffffff; + } + } + } + } + + .list-option-count { + background: #e7e9e9; + border-radius: 3px; + color: #535e65; + padding: 0 4px; + } + + .list-option-search { + padding: 8px 16px; + gap: 0; + } + + .list-option-values { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid #cdd3d5; + + + } + } +} diff --git a/src/components/index.js b/src/components/index.js index 8af25d7..00c1634 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -7,3 +7,4 @@ export { default as NrqlEditor } from './nrql-editor'; export { default as StatusIcon } from './status-icon'; export { default as StatusIconsLayout } from './status-icons-layout'; export { default as ProgressBar } from './progress-bar'; +export { default as FilterBar } from './filter-bar';