feat(ui): show filters top values (#2466)

* change(ui): click filter with selector options

* feat(ui): show top filter values
This commit is contained in:
Shekar Siri 2024-08-05 13:08:51 +02:00 committed by GitHub
parent a119e442db
commit 1dcb8e112c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 352 additions and 234 deletions

View file

@ -130,6 +130,7 @@ export default class APIClient {
async fetch(path: string, params?: any, method: string = 'GET', options: {
clean?: boolean
} = { clean: true }, headers?: Record<string, any>): Promise<Response> {
let _path = path;
let jwt = store.getState().getIn(['user', 'jwt']);
if (!path.includes('/refresh') && jwt && this.isTokenExpired(jwt)) {
jwt = await this.handleTokenRefresh();
@ -175,7 +176,11 @@ export default class APIClient {
delete init.credentials;
}
return fetch(edp + path, init).then((response) => {
if (path.includes('PROJECT_ID')) {
_path = _path.replace('PROJECT_ID', this.siteId + '');
}
return fetch(edp + _path, init).then((response) => {
if (response.ok) {
return response;
} else {
@ -229,4 +234,4 @@ export default class APIClient {
this.init.method = 'PATCH';
return this.fetch(path, params, 'PATCH');
}
}
}

View file

@ -3,267 +3,300 @@ import { Icon } from 'UI';
import APIClient from 'App/api_client';
import { debounce } from 'App/utils';
import stl from './FilterAutoComplete.module.css';
import { components, DropdownIndicatorProps } from 'react-select';
import colors from 'App/theme/colors';
import Select from 'react-select';
import cn from 'classnames';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
const dropdownStyles = {
option: (provided: any, state: any) => ({
...provided,
whiteSpace: 'nowrap',
width: '100%',
minWidth: 150,
transition: 'all 0.3s',
overflow: 'hidden',
textOverflow: 'ellipsis',
backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent',
color: state.isFocused ? colors.teal : 'black',
fontSize: '14px',
'&:hover': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue'],
},
'&:focus': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue'],
},
}),
control: (provided: any) => {
const obj = {
...provided,
border: 'solid thin transparent !important',
backgroundColor: 'transparent',
cursor: 'pointer',
height: '26px',
minHeight: '26px',
borderRadius: '.5rem',
boxShadow: 'none !important',
};
return obj;
option: (provided: any, state: any) => ({
...provided,
whiteSpace: 'nowrap',
width: '100%',
minWidth: 150,
transition: 'all 0.3s',
overflow: 'hidden',
textOverflow: 'ellipsis',
backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent',
color: state.isFocused ? colors.teal : 'black',
fontSize: '14px',
'&:hover': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue']
},
valueContainer: (provided: any) => ({
...provided,
// paddingRight: '0px',
width: 'fit-content',
alignItems: 'center',
height: '26px',
padding: '0 3px',
}),
indicatorsContainer: (provided: any) => ({
...provided,
padding: '0px',
height: '26px',
}),
menu: (provided: any, state: any) => ({
...provided,
top: 0,
borderRadius: '3px',
border: `1px solid ${colors['gray-light']}`,
backgroundColor: '#fff',
boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)',
position: 'absolute',
width: 'unset',
maxWidth: '300px',
overflow: 'hidden',
zIndex: 100,
}),
menuList: (provided: any, state: any) => ({
...provided,
padding: 0,
}),
noOptionsMessage: (provided: any) => ({
...provided,
whiteSpace: 'nowrap !important',
// minWidth: 'fit-content',
}),
container: (provided: any) => ({
...provided,
top: '18px',
position: 'absolute',
}),
input: (provided: any) => ({
...provided,
height: '22px',
'& input:focus': {
border: 'none !important',
},
}),
singleValue: (provided: any, state: { isDisabled: any }) => {
const opacity = state.isDisabled ? 0.5 : 1;
const transition = 'opacity 300ms';
'&:focus': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue']
}
}),
control: (provided: any) => {
const obj = {
...provided,
border: 'solid thin transparent !important',
backgroundColor: 'transparent',
cursor: 'pointer',
height: '26px',
minHeight: '26px',
borderRadius: '.5rem',
boxShadow: 'none !important'
};
return obj;
},
valueContainer: (provided: any) => ({
...provided,
// paddingRight: '0px',
width: 'fit-content',
alignItems: 'center',
height: '26px',
padding: '0 3px'
}),
indicatorsContainer: (provided: any) => ({
...provided,
padding: '0px',
height: '26px'
}),
menu: (provided: any, state: any) => ({
...provided,
top: 0,
borderRadius: '3px',
border: `1px solid ${colors['gray-light']}`,
backgroundColor: '#fff',
boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)',
position: 'absolute',
width: 'unset',
maxWidth: '300px',
overflow: 'hidden',
zIndex: 100
}),
menuList: (provided: any, state: any) => ({
...provided,
padding: 0
}),
noOptionsMessage: (provided: any) => ({
...provided,
whiteSpace: 'nowrap !important'
// minWidth: 'fit-content',
}),
container: (provided: any) => ({
...provided,
top: '18px',
position: 'absolute'
}),
input: (provided: any) => ({
...provided,
height: '22px',
'& input:focus': {
border: 'none !important'
}
}),
singleValue: (provided: any, state: { isDisabled: any }) => {
const opacity = state.isDisabled ? 0.5 : 1;
const transition = 'opacity 300ms';
return {
...provided,
opacity,
transition,
display: 'flex',
alignItems: 'center',
height: '20px',
};
},
return {
...provided,
opacity,
transition,
display: 'flex',
alignItems: 'center',
height: '20px'
};
}
};
type FilterParam = { [key: string]: any };
function processKey(input: FilterParam): FilterParam {
const result: FilterParam = {};
for (const key in input) {
if (input.type === "metadata" && typeof input[key] === 'string' && input[key].startsWith('_')) {
result[key] = input[key].substring(1);
} else {
result[key] = input[key];
}
const result: FilterParam = {};
for (const key in input) {
if (input.type === 'metadata' && typeof input[key] === 'string' && input[key].startsWith('_')) {
result[key] = input[key].substring(1);
} else {
result[key] = input[key];
}
return result;
}
return result;
}
interface Props {
showOrButton?: boolean;
showCloseButton?: boolean;
onRemoveValue?: () => void;
onAddValue?: () => void;
endpoint?: string;
method?: string;
params?: any;
headerText?: string;
placeholder?: string;
onSelect: (e: any, item: any) => void;
value: any;
icon?: string;
hideOrText?: boolean
showOrButton?: boolean;
showCloseButton?: boolean;
onRemoveValue?: () => void;
onAddValue?: () => void;
endpoint?: string;
method?: string;
params?: any;
headerText?: string;
placeholder?: string;
onSelect: (e: any, item: any) => void;
value: any;
icon?: string;
hideOrText?: boolean;
}
function FilterAutoComplete(props: Props) {
const {
showCloseButton = false,
placeholder = 'Type to search',
method = 'GET',
showOrButton = false,
onRemoveValue = () => null,
onAddValue = () => null,
endpoint = '',
params = {},
value = '',
hideOrText = false,
} = props;
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<any>([]);
const [query, setQuery] = useState(value);
const [menuIsOpen, setMenuIsOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState(false);
let selectRef: any = null;
let inputRef: any = null;
const {
showCloseButton = false,
placeholder = 'Type to search',
method = 'GET',
showOrButton = false,
onRemoveValue = () => null,
onAddValue = () => null,
endpoint = '',
params = {},
value = '',
hideOrText = false
} = props;
useEffect(() => {
setQuery(value);
}, [value])
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<any>([]);
const [query, setQuery] = useState(value);
const [menuIsOpen, setMenuIsOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState(false);
let selectRef: any = null;
let inputRef: any = null;
const { filterStore } = useStore();
const _params = processKey(params);
const [topValues, setTopValues] = useState<any>([]);
const [topValuesLoading, setTopValuesLoading] = useState(false);
const loadOptions = (inputValue: string, callback: (options: []) => void) => {
const _params = processKey(params);
useEffect(() => {
const fetchValues = async () => {
setTopValuesLoading(true);
const values = await filterStore.getTopValues(_params.type);
setTopValues(values);
new APIClient()
[method?.toLocaleLowerCase()](endpoint, { ..._params, q: inputValue })
.then((response: any) => {
return response.json();
})
.then(({ data }: any) => {
const _options = data.map((i: any) => ({ value: i.value, label: i.value })) || [];
setOptions(_options);
callback(_options);
setLoading(false);
})
.catch((e) => {
throw new Error(e);
})
setTopValuesLoading(false);
setLoading(false);
};
fetchValues().then(r => {
});
}, []);
const debouncedLoadOptions = React.useCallback(debounce(loadOptions, 1000), [params]);
useEffect(() => {
setQuery(value);
}, [value]);
const handleInputChange = (newValue: string) => {
// const inputValue = newValue.replace(/\W/g, '');
setLoading(true);
setInitialFocus(true);
setQuery(newValue);
debouncedLoadOptions(newValue, (opt: any) => {
selectRef?.focus();
});
};
const loadOptions = (inputValue: string, callback: (options: []) => void) => {
if (!inputValue.length) {
setOptions(topValues.map((i: any) => ({ value: i.value, label: i.value })));
callback(topValues.map((i: any) => ({ value: i.value, label: i.value })));
setLoading(false);
return;
}
const onChange = (item: any) => {
setMenuIsOpen(false);
setQuery(item);
props.onSelect(null, item);
// inputRef?.blur();
};
// @ts-ignore
new APIClient()
[method?.toLocaleLowerCase()](endpoint, { ..._params, q: inputValue })
.then((response: any) => {
return response.json();
})
.then(({ data }: any) => {
const _options = data.map((i: any) => ({ value: i.value, label: i.value })) || [];
setOptions(_options);
callback(_options);
setLoading(false);
})
.catch((e) => {
throw new Error(e);
});
};
const onFocus = () => {
setMenuIsOpen(true);
};
const debouncedLoadOptions = React.useCallback(debounce(loadOptions, 1000), [params, topValues]);
const onBlur = () => {
setMenuIsOpen(false);
props.onSelect(null, query);
};
const handleInputChange = (newValue: string) => {
setLoading(true);
setInitialFocus(true);
setQuery(newValue);
debouncedLoadOptions(newValue, (opt: any) => {
selectRef?.focus();
});
};
const selected = value ? options.find((i: any) => i.value === query) : null;
const onChange = (item: any) => {
setMenuIsOpen(false);
setQuery(item);
props.onSelect(null, item);
};
const uniqueOptions = options.filter((i: Record<string, string>) => i.value !== query)
const selectOptionsArr = query.length ? [{ value: query, label: query }, ...uniqueOptions] : options;
return (
<div className="relative flex items-center">
<div className={cn(stl.wrapper, 'relative')}>
<input
ref={(ref: any) => (inputRef = ref)}
className="w-full rounded px-2 no-focus"
value={query}
onChange={({ target: { value } }: any) => handleInputChange(value)}
onClick={onFocus}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
inputRef?.blur();
}
}}
/>
{loading && (
<div className="absolute top-0 right-0" style={{ marginTop: '5px', marginRight: !showCloseButton || (showCloseButton && !showOrButton) ? '34px' : '62px'}}>
<Icon name="spinner" className="animate-spin" size="14" />
</div>
)}
<Select
ref={(ref: any) => {
selectRef = ref;
}}
options={selectOptionsArr}
value={selected}
onChange={(e: any) => onChange(e.value)}
menuIsOpen={initialFocus && menuIsOpen && query !== ''}
menuPlacement="auto"
styles={dropdownStyles}
components={{
Control: ({ children, ...props }: any) => <></>,
}}
/>
<div className={stl.right}>
{showCloseButton && (
<div onClick={props.onRemoveValue}>
<Icon name="close" size="12" />
</div>
)}
{showOrButton && (
<div onClick={props.onAddValue} className="color-teal">
<span className="px-1">or</span>
</div>
)}
</div>
const onFocus = () => {
setInitialFocus(true);
if (!query.length) {
setLoading(topValuesLoading);
setMenuIsOpen(!topValuesLoading && topValues.length > 0);
setOptions(topValues.map((i: any) => ({ value: i.value, label: i.value })));
} else {
setMenuIsOpen(true);
}
};
const onBlur = () => {
setMenuIsOpen(false);
props.onSelect(null, query);
};
const selected = value ? options.find((i: any) => i.value === query) : null;
const uniqueOptions = options.filter((i: Record<string, string>) => i.value !== query);
const selectOptionsArr = query.length ? [{ value: query, label: query }, ...uniqueOptions] : options;
return (
<div className="relative flex items-center">
<div className={cn(stl.wrapper, 'relative')}>
<input
ref={(ref: any) => (inputRef = ref)}
className="w-full rounded px-2 no-focus"
value={query}
onChange={({ target: { value } }: any) => handleInputChange(value)}
onClick={onFocus}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
inputRef?.blur();
}
}}
/>
{loading && (
<div className="absolute top-0 right-0" style={{
marginTop: '5px',
marginRight: !showCloseButton || (showCloseButton && !showOrButton) ? '34px' : '62px'
}}>
<Icon name="spinner" className="animate-spin" size="14" />
</div>
)}
<Select
ref={(ref: any) => {
selectRef = ref;
}}
options={selectOptionsArr}
value={selected}
onChange={(e: any) => onChange(e.value)}
menuIsOpen={initialFocus && menuIsOpen}
menuPlacement="auto"
styles={dropdownStyles}
components={{
Control: ({ children, ...props }: any) => <></>
}}
/>
<div className={stl.right}>
{showCloseButton && (
<div onClick={props.onRemoveValue}>
<Icon name="close" size="12" />
</div>
{!showOrButton && !hideOrText && <div className="ml-3">or</div>}
)}
{showOrButton && (
<div onClick={props.onAddValue} className="color-teal">
<span className="px-1">or</span>
</div>
)}
</div>
);
</div>
{!showOrButton && !hideOrText && <div className="ml-3">or</div>}
</div>
);
}
export default FilterAutoComplete;
export default observer(FilterAutoComplete);

View file

@ -61,6 +61,15 @@ export const stringOperatorsPerformance = options.filter(({ key }) => stringFilt
export const targetOperators = options.filter(({ key }) => targetFilterKeys.includes(key));
export const targetConditional = options.filter(({ key }) => ['on', 'notOn', 'startsWith', 'endsWith', 'contains'].includes(key));
export const stringConditional = options.filter(({ key }) => ['isAny', 'is', 'isNot', 'startsWith', 'endsWith', 'contains'].includes(key));
export const clickSelectorOperators = [
{ key: 'selectorIs', label: 'selector is', value: 'selectorIs' },
{ key: 'selectorIsAny', label: 'selector is any', value: 'selectorIsAny' },
{ key: 'selectorIsNot', label: 'selector is not', value: 'selectorIsNot' },
{ key: 'selectorContains', label: 'selector contains', value: 'selectorContains' },
{ key: 'selectorNotContains', label: 'selector not contains', value: 'selectorNotContains' },
{ key: 'selectorStartsWith', label: 'selector starts with', value: 'selectorStartsWith' },
{ key: 'selectorEndsWith', label: 'selector ends with', value: 'selectorEndsWith' }
]
export const booleanOperators = [
{ key: 'true', label: 'true', value: 'true' },

View file

@ -0,0 +1,42 @@
import { makeAutoObservable } from 'mobx';
import { filters } from 'Types/filter/newFilter';
import { filterService } from 'App/services';
interface TopValue {
rowCount: number;
rowPercentage: number;
value: string;
}
interface TopValues {
[key: string]: TopValue[];
}
export default class FilterStore {
topValues: TopValues = {};
constructor() {
makeAutoObservable(this);
filters.forEach((filter) => {
this.topValues[filter.key] = [];
});
}
setTopValues = (key: string, values: TopValue[]) => {
this.topValues[key] = values.filter((value) => value !== null && value.value !== '');
};
getTopValues = async (key: string) => {
if (!this.topValues[key] || this.topValues[key].length === 0) {
await this.fetchTopValues(key);
}
return Promise.resolve(this.topValues[key]);
};
fetchTopValues = async (key: string) => {
return filterService.fetchTopValues(key).then((response: TopValue[]) => {
this.setTopValues(key, response);
});
};
}

View file

@ -23,6 +23,7 @@ import AiSummaryStore from "./aiSummaryStore";
import AiFiltersStore from "./aiFiltersStore";
import SpotStore from "./spotStore";
import LoginStore from "./loginStore";
import FilterStore from "./filterStore";
export class RootStore {
dashboardStore: DashboardStore;
@ -47,6 +48,7 @@ export class RootStore {
aiFiltersStore: AiFiltersStore;
spotStore: SpotStore;
loginStore: LoginStore;
filterStore: FilterStore;
constructor() {
this.dashboardStore = new DashboardStore();
@ -71,6 +73,7 @@ export class RootStore {
this.aiFiltersStore = new AiFiltersStore();
this.spotStore = new SpotStore();
this.loginStore = new LoginStore();
this.filterStore = new FilterStore();
}
initClient() {

View file

@ -0,0 +1,18 @@
import APIClient from 'App/api_client';
export default class FilterService {
private client: APIClient;
constructor(client?: APIClient) {
this.client = client ? client : new APIClient();
}
initClient(client?: APIClient) {
this.client = client || new APIClient();
}
fetchTopValues = async (key: string) => {
const response = await this.client.get(`/PROJECT_ID/events/search?type=${key}`);
return await response.json();
};
}

View file

@ -19,6 +19,7 @@ import UxtestingService from './UxtestingService';
import WebhookService from './WebhookService';
import SpotService from './spotService';
import LoginService from "./loginService";
import FilterService from "./FilterService";
export const dashboardService = new DashboardService();
export const metricService = new MetricService();
@ -40,6 +41,7 @@ export const tagWatchService = new TagWatchService();
export const aiService = new AiService();
export const spotService = new SpotService();
export const loginService = new LoginService();
export const filterService = new FilterService();
export const services = [
dashboardService,
@ -62,4 +64,5 @@ export const services = [
aiService,
spotService,
loginService,
filterService,
];

View file

@ -1,4 +1,9 @@
import { stringConditional, tagElementOperators, targetConditional } from 'App/constants/filterOptions';
import {
clickSelectorOperators,
stringConditional,
tagElementOperators,
targetConditional
} from 'App/constants/filterOptions';
import { KEYS } from 'Types/filter/customFilter';
import Record from 'Types/Record';
import { FilterType, FilterKey, FilterCategory } from './filterType';
@ -91,7 +96,7 @@ export const filters = [
category: FilterCategory.INTERACTIONS,
label: 'Click',
operator: 'on',
operatorOptions: filterOptions.targetOperators,
operatorOptions: filterOptions.targetOperators.concat(clickSelectorOperators),
icon: 'filters/click',
isEvent: true
},