feat(ui) - assist filters

This commit is contained in:
Shekar Siri 2022-02-10 19:46:04 +01:00
parent 0584883a1b
commit 74944ed778
18 changed files with 317 additions and 49 deletions

View file

@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { fetchList } from 'Duck/sessions';
import { fetchLiveList } from 'Duck/sessions';
import { connect } from 'react-redux';
import { NoContent, Loader } from 'UI';
import { List, Map } from 'immutable';
@ -7,26 +7,56 @@ import SessionItem from 'Shared/SessionItem';
import withPermissions from 'HOCs/withPermissions'
import { KEYS } from 'Types/filter/customFilter';
import { applyFilter, addAttribute } from 'Duck/filters';
import Filter from 'Types/filter';
import { FilterCategory, FilterKey } from 'App/types/filter/filterType';
import { addFilterByKeyAndValue } from 'Duck/liveSearch';
const AUTOREFRESH_INTERVAL = .5 * 60 * 1000
interface Props {
loading: Boolean,
list?: List<any>,
fetchList: (params) => void,
list: List<any>,
fetchLiveList: () => Promise<void>,
applyFilter: () => void,
filters: Filter
filters: any,
addAttribute: (obj) => void,
addFilterByKeyAndValue: (key: FilterKey, value: string) => void,
}
function LiveSessionList(props: Props) {
const { loading, list, filters } = props;
const { loading, filters, list } = props;
var timeoutId;
const hasUserFilter = filters && filters.filters.map(i => i.key).includes(KEYS.USERID);
const hasUserFilter = filters.map(i => i.key).includes(KEYS.USERID);
const [sessions, setSessions] = React.useState(list);
useEffect(() => {
if (filters.size === 0) {
props.addFilterByKeyAndValue(FilterKey.USERID, '');
}
}, []);
useEffect(() => {
const filteredSessions = filters.size > 0 ? props.list.filter(session => {
let hasValidFilter = true;
filters.forEach(filter => {
if (!hasValidFilter) return;
const _values = filter.value.filter(i => i !== '' && i !== null && i !== undefined).map(i => i.toLowerCase());
if (filter.key === FilterKey.USERID) {
const _userId = session.userId ? session.userId.toLowerCase() : '';
hasValidFilter = _values.length > 0 ? (_values.includes(_userId) && hasValidFilter) || _values.some(i => _userId.includes(i)) : hasValidFilter;
}
if (filter.category === FilterCategory.METADATA) {
const _source = session.metadata[filter.key] ? session.metadata[filter.key].toLowerCase() : '';
hasValidFilter = _values.length > 0 ? (_values.includes(_source) && hasValidFilter) || _values.some(i => _source.includes(i)) : hasValidFilter;
}
})
return hasValidFilter;
}) : props.list;
setSessions(filteredSessions);
}, [filters, list]);
useEffect(() => {
props.fetchList(filters.toJS());
props.fetchLiveList();
timeout();
return () => {
clearTimeout(timeoutId)
@ -35,17 +65,15 @@ function LiveSessionList(props: Props) {
const onUserClick = (userId, userAnonymousId) => {
if (userId) {
props.addAttribute({ label: 'User Id', key: KEYS.USERID, type: KEYS.USERID, operator: 'is', value: userId })
props.addFilterByKeyAndValue(FilterKey.USERID, userId);
} else {
props.addAttribute({ label: 'Anonymous ID', key: 'USERANONYMOUSID', type: "USERANONYMOUSID", operator: 'is', value: userAnonymousId })
props.addFilterByKeyAndValue(FilterKey.USERANONYMOUSID, userAnonymousId);
}
props.applyFilter()
}
const timeout = () => {
timeoutId = setTimeout(() => {
props.fetchList(filters.toJS());
props.fetchLiveList();
timeout();
}, AUTOREFRESH_INTERVAL);
}
@ -59,11 +87,12 @@ function LiveSessionList(props: Props) {
See how to <a target="_blank" className="link" href="https://docs.openreplay.com/plugins/assist">{'enable Assist'}</a> if you haven't yet done so.
</span>
}
image={<img src="/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }}/>}
show={ !loading && list && list.size === 0}
image={<img src="/img/live-sessions.png"
style={{ width: '70%', marginBottom: '30px' }}/>}
show={ !loading && sessions && sessions.size === 0}
>
<Loader loading={ loading }>
{list && list.map(session => (
{sessions && sessions.map(session => (
<SessionItem
key={ session.sessionId }
session={ session }
@ -82,8 +111,7 @@ export default withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(connect(
(state) => ({
list: state.getIn(['sessions', 'liveSessions']),
loading: state.getIn([ 'sessions', 'loading' ]),
filters: state.getIn([ 'filters', 'appliedFilter' ]),
filters: state.getIn([ 'liveSearch', 'instance', 'filters' ]),
}),
{
fetchList, applyFilter, addAttribute }
{ fetchLiveList, applyFilter, addAttribute, addFilterByKeyAndValue }
)(LiveSessionList));

View file

@ -42,7 +42,6 @@ function FilterSeries(props: Props) {
}
const onChangeEventsOrder = (e, { name, value }) => {
props.editSeriesFilter(seriesIndex, { eventsOrder: value });
}

View file

@ -0,0 +1,81 @@
.wrapper {
border: solid thin $gray-light !important;
border-radius: 3px;
border-radius: 3px;
display: flex;
align-items: center;
background-color: white;
width: 100%;
& input {
height: 24px;
font-size: 13px !important;
padding: 0 5px !important;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
border: solid thin transparent !important;
width: 100%;
}
& .right {
height: 24px;
display: flex;
align-items: stretch;
padding: 0;
background-color: $gray-lightest;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
& div {
/* background-color: red; */
border-left: solid thin $gray-light !important;
width: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:last-child {
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
&:hover {
background-color: $gray-light;
}
}
}
}
.menu {
border-radius: 0 0 3px 3px;
border: solid thin $gray-light !important;
box-shadow: 0 2px 2px 0 $gray-light;
/* padding: 20px; */
background-color: white;
max-height: 350px;
overflow-y: auto;
position: absolute;
top: 28px;
left: 0;
width: 500px;
z-index: 99;
}
.filterItem {
display: flex;
align-items: center;
padding: 8px 10px;
cursor: pointer;
border-radius: 3px;
/* transition: all 0.4s; */
margin-bottom: 5px;
max-width: 100%;
& .label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
background-color: $gray-lightest;
/* transition: all 0.2s; */
}
}

View file

@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import { Icon, Loader } from 'UI';
import { debounce } from 'App/utils';
import stl from './FilterAutoCompleteLocal.css';
import cn from 'classnames';
interface Props {
showOrButton?: boolean;
showCloseButton?: boolean;
onRemoveValue?: () => void;
onAddValue?: () => void;
placeholder?: string;
onSelect: (e, item) => void;
value: any;
icon?: string;
}
function FilterAutoCompleteLocal(props: Props) {
const {
showCloseButton = false,
placeholder = 'Type to search',
showOrButton = false,
onRemoveValue = () => null,
onAddValue = () => null,
value = '',
icon = null,
} = props;
const [showModal, setShowModal] = useState(true)
const [query, setQuery] = useState(value);
const onInputChange = ({ target: { value } }) => {
setQuery(value);
}
useEffect(() => {
setQuery(value);
}, [value])
const onBlur = (e) => {
setTimeout(() => { setShowModal(false) }, 200)
props.onSelect(e, { value: query })
}
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
props.onSelect(e, { value: query })
}
}
return (
<div className="relative flex items-center">
<div className={stl.wrapper}>
<input
name="query"
onChange={ onInputChange }
onBlur={ onBlur }
onFocus={ () => setShowModal(true)}
value={ query }
autoFocus={ true }
type="text"
placeholder={ placeholder }
onKeyDown={handleKeyDown}
/>
<div
className={stl.right}
>
{ showCloseButton && <div onClick={onRemoveValue}><Icon name="close" size="12" /></div> }
{ showOrButton && <div onClick={onAddValue} className="color-teal"><span className="px-1">or</span></div> }
</div>
</div>
{ !showOrButton && <div className="ml-3">or</div> }
</div>
);
}
export default FilterAutoCompleteLocal;

View file

@ -0,0 +1 @@
export { default } from './FilterAutoCompleteLocal';

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import FilterAutoComplete from '../FilterAutoComplete';
import FilterAutoCompleteLocal from '../FilterAutoCompleteLocal';
import { FilterKey, FilterCategory, FilterType } from 'Types/filter/filterType';
import FilterValueDropdown from '../FilterValueDropdown';
import FilterDuration from '../FilterDuration';
@ -63,6 +64,18 @@ function FilterValue(props: Props) {
const renderValueFiled = (value, valueIndex) => {
const showOrButton = valueIndex === lastIndex;
switch(filter.type) {
case FilterType.STRING:
return (
<FilterAutoCompleteLocal
value={value}
showCloseButton={showCloseButton}
showOrButton={showOrButton}
onAddValue={onAddValue}
onRemoveValue={() => onRemoveValue(valueIndex)}
onSelect={(e, item) => onChange(e, item, valueIndex)}
icon={filter.icon}
/>
)
case FilterType.DROPDOWN:
return (
<FilterValueDropdown

View file

@ -3,8 +3,7 @@ import { Icon, Loader } from 'UI';
import { connect } from 'react-redux';
import cn from 'classnames';
import stl from './LiveFilterModal.css';
import { filtersMap, getMetaDataFilter } from 'Types/filter/newFilter';
import { FilterCategory, FilterKey } from 'App/types/filter/filterType';
import { filtersMap } from 'Types/filter/newFilter';
interface Props {
filters: any,
@ -69,11 +68,11 @@ function LiveFilterModal(props: Props) {
{ !hasSearchQuery && (
<div className="">
{filters && Object.keys(filters).filter(i => i === 'User' || i === 'Metadata').map((key) => (
{filters && Object.keys(filters).map((key) => (
<div className="mb-6" key={key}>
<div className="uppercase font-medium mb-1 color-gray-medium tracking-widest text-sm">{key}</div>
<div>
{filters[key].filter((i: any) => i.key === FilterKey.USERID || i.key === FilterKey.USERANONYMOUSID || i.category === FilterCategory.METADATA).map((filter: any) => (
{filters[key].map((filter: any) => (
<div key={filter.label} className={cn(stl.optionItem, "flex items-center py-2 cursor-pointer -mx-2 px-2")} onClick={() => onFilterClick(filter)}>
<Icon name={filter.icon} size="16"/>
<span className="ml-2">{filter.label}</span>
@ -89,7 +88,7 @@ function LiveFilterModal(props: Props) {
}
export default connect(state => ({
filters: state.getIn([ 'search', 'filterList' ]),
filters: state.getIn([ 'search', 'filterListLive' ]),
filterSearchList: state.getIn([ 'search', 'filterSearchList' ]),
metaOptions: state.getIn([ 'customFields', 'list' ]),
fetchingFilterSearchList: state.getIn([ 'search', 'fetchFilterSearch', 'loading' ]),

View file

@ -13,10 +13,10 @@ const LiveSearchBar = (props: Props) => {
const hasFilters = appliedFilter && appliedFilter.filters && appliedFilter.filters.size > 0;
return (
<div className="flex items-center">
<div style={{ width: "80%", marginRight: "10px"}}>
<div style={{ width: "60%", marginRight: "10px"}}>
<LiveSessionSearchField />
</div>
<div className="flex items-center" style={{ width: "20%"}}>
<div className="flex items-center" style={{ width: "40%"}}>
<Popup
trigger={
<Button

View file

@ -2,7 +2,8 @@ import React from 'react';
import FilterList from 'Shared/Filters/FilterList';
import { connect } from 'react-redux';
import { edit, addFilter } from 'Duck/liveSearch';
import LiveFilterModal from 'Shared/Filters/LiveFilterModal';
import FilterSelection from 'Shared/Filters/FilterSelection';
import { IconButton } from 'UI';
interface Props {
appliedFilter: any;
@ -14,6 +15,10 @@ function LiveSessionSearch(props: Props) {
const hasEvents = appliedFilter.filters.filter(i => i.isEvent).size > 0;
const hasFilters = appliedFilter.filters.filter(i => !i.isEvent).size > 0;
const onAddFilter = (filter) => {
props.addFilter(filter);
}
const onUpdateFilter = (filterIndex, filter) => {
const newFilters = appliedFilter.filters.map((_filter, i) => {
if (i === filterIndex) {
@ -55,6 +60,17 @@ function LiveSessionSearch(props: Props) {
onChangeEventsOrder={onChangeEventsOrder}
/>
</div>
<div className="border-t px-5 py-1 flex items-center -mx-2">
<div>
<FilterSelection
filter={undefined}
onFilterClick={onAddFilter}
>
<IconButton primaryText label="ADD FILTER" icon="plus" />
</FilterSelection>
</div>
</div>
</div>
) : <></>;
}

View file

@ -23,7 +23,6 @@ function LiveSessionSearchField(props: Props) {
}
const onAddFilter = (filter) => {
console.log('onAddFilter', filter)
props.addFilterByKeyAndValue(filter.key, filter.value)
}

View file

@ -33,7 +33,7 @@ function SavedSearch(props) {
className="flex items-center"
onClick={() => setShowMenu(true)}
>
<span className="mr-2">{`Search Saved (${list.size})`}</span>
<span className="mr-2">{`Saved Search (${list.size})`}</span>
<Icon name="ellipsis-v" color="teal" size="14" />
</Button>
{ savedSearch.exists() && (

View file

@ -6,7 +6,7 @@ import Filter from 'Types/filter';
import SavedFilter from 'Types/filter/savedFilter';
import { fetchList as fetchSessionList } from './sessions';
import { filtersMap } from 'Types/filter/newFilter';
import { filterMap, checkFilterValue } from './search';
import { filterMap, checkFilterValue, hasFilterApplied } from './search';
const name = "liveSearch";
const idKey = "searchId";
@ -73,8 +73,16 @@ export const clearSearch = () => (dispatch, getState) => {
export const addFilter = (filter) => (dispatch, getState) => {
filter.value = checkFilterValue(filter.value);
const instance = getState().getIn([ 'liveSearch', 'instance']);
const filters = instance.filters.push(filter);
return dispatch(edit(instance.set('filters', filters)));
if (hasFilterApplied(instance.filters, filter)) {
// const index = instance.filters.findIndex(f => f.key === filter.key);
// const oldFilter = instance.filters.get(index);
// oldFilter.value = oldFilter.value.concat(filter.value);
// return dispatch(edit(instance.setIn(['filters', index], oldFilter)));
} else {
const filters = instance.filters.push(filter);
return dispatch(edit(instance.set('filters', filters)));
}
}
export const addFilterByKeyAndValue = (key, value) => (dispatch, getState) => {

View file

@ -8,7 +8,7 @@ import { errors as errorsRoute, isRoute } from "App/routes";
import { fetchList as fetchSessionList } from './sessions';
import { fetchList as fetchErrorsList } from './errors';
import { FilterCategory, FilterKey } from '../types/filter/filterType';
import { filtersMap, generateFilterOptions } from 'Types/filter/newFilter';
import { filtersMap, generateFilterOptions, generateLiveFilterOptions } from 'Types/filter/newFilter';
const ERRORS_ROUTE = errorsRoute();
@ -43,6 +43,7 @@ const updateInstance = (state, instance) => state.getIn([ "savedSearch", savedSe
const initialState = Map({
filterList: generateFilterOptions(filtersMap),
filterListLive: generateLiveFilterOptions(filtersMap),
list: List(),
alertMetricId: null,
instance: new Filter({ filters: [] }),
@ -54,7 +55,8 @@ const initialState = Map({
function reducer(state = initialState, action = {}) {
switch (action.type) {
case REFRESH_FILTER_OPTIONS:
return state.set('filterList', generateFilterOptions(filtersMap));
return state.set('filterList', generateFilterOptions(filtersMap))
.set('filterListLive', generateLiveFilterOptions(filtersMap));
case EDIT:
return state.mergeIn(['instance'], action.instance);
case APPLY:
@ -212,11 +214,20 @@ export const clearSearch = () => (dispatch, getState) => {
});
}
export const hasFilterApplied = (filters, filter) => {
return !filter.isEvent && filters.some(f => f.key === filter.key);
}
export const addFilter = (filter) => (dispatch, getState) => {
filter.value = checkFilterValue(filter.value);
const instance = getState().getIn([ 'search', 'instance']);
const filters = instance.filters.push(filter);
return dispatch(edit(instance.set('filters', filters)));
if (hasFilterApplied(instance.filters, filter)) {
} else {
const filters = instance.filters.push(filter);
return dispatch(edit(instance.set('filters', filters)));
}
}
export const addFilterByKeyAndValue = (key, value) => (dispatch, getState) => {

View file

@ -74,7 +74,6 @@ const reducer = (state = initialState, action = {}) => {
case FETCH_ERROR_STACK.SUCCESS:
return state.set('errorStack', List(action.data.trace).map(ErrorStack)).set('sourcemapUploaded', action.data.sourcemapUploaded)
case FETCH_LIVE_LIST.SUCCESS:
// const { sessions, total } = action.data;
const liveList = List(action.data).map(s => new Session({...s, live: true}));
return state
.set('liveSessions', liveList)
@ -284,6 +283,13 @@ export const fetchList = (params = {}, clear = false, live = false) => (dispatch
})
}
// export const fetchLiveList = (id) => (dispatch, getState) => {
// return dispatch({
// types: FETCH_LIVE_LIST.toArray(),
// call: client => client.get('/assist/sessions'),
// })
// }
export function fetchErrorStackList(sessionId, errorId) {
return {
types: FETCH_ERROR_STACK.toArray(),

View file

@ -9,6 +9,7 @@ export enum FilterCategory {
};
export enum FilterType {
STRING = "STRING",
ISSUE = "ISSUE",
BOOLEAN = "BOOLEAN",
NUMBER = "NUMBER",
@ -17,6 +18,7 @@ export enum FilterType {
COUNTRY = "COUNTRY",
DROPDOWN = "DROPDOWN",
MULTIPLE_DROPDOWN = "MULTIPLE_DROPDOWN",
AUTOCOMPLETE_LOCAL = "AUTOCOMPLETE_LOCAL",
};
export enum FilterKey {

View file

@ -43,7 +43,7 @@ export const filtersMap = {
[FilterKey.DURATION]: { key: FilterKey.DURATION, type: FilterType.DURATION, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Duration', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is']), icon: 'filters/duration' },
[FilterKey.USER_COUNTRY]: { key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'User Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
// [FilterKey.CONSOLE]: { key: FilterKey.CONSOLE, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Console', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/console' },
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid' },
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid', isLive: true },
[FilterKey.USERANONYMOUSID]: { key: FilterKey.USERANONYMOUSID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User AnonymousId', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid' },
// PERFORMANCE
@ -56,6 +56,15 @@ export const filtersMap = {
[FilterKey.ISSUE]: { key: FilterKey.ISSUE, type: FilterType.ISSUE, category: FilterCategory.JAVASCRIPT, label: 'Issue', operator: 'is', operatorOptions: filterOptions.baseOperators, icon: 'filters/click', options: ISSUE_OPTIONS },
}
/**
* Add a new filter to the filter list
* @param {*} category
* @param {*} key
* @param {*} type
* @param {*} operator
* @param {*} operatorOptions
* @param {*} icon
*/
export const addElementToFiltersMap = (
category = FilterCategory.METADATA,
key,
@ -64,14 +73,7 @@ export const addElementToFiltersMap = (
operatorOptions = filterOptions.stringOperators,
icon = 'filters/metadata'
) => {
console.log('addElementToFiltersMap', category, key, type, operator, operatorOptions, icon)
filtersMap[key] = { key, type, category, label: capitalize(key), operator: operator, operatorOptions, icon }
}
export const getMetaDataFilter = (key) => {
const METADATA_FILTER = { key: key, type: FilterType.MULTIPLE, category: FilterCategory.METADATA, label: capitalize(key), operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/metadata' }
return METADATA_FILTER;
filtersMap[key] = { key, type, category, label: capitalize(key), operator: operator, operatorOptions, icon, isLive: true }
}
export default Record({
@ -113,11 +115,16 @@ export default Record({
..._filter,
key: _filter.key,
type: _filter.type, // camelCased(filter.type.toLowerCase()),
value: value.length === 0 ? [""] : value,
value: value.length === 0 ? [""] : value, // make sure there an empty value
}
},
})
/**
* Group filters by category
* @param {*} filtersMap
* @returns
*/
export const generateFilterOptions = (filtersMap) => {
const _options = {};
Object.keys(filtersMap).forEach(key => {
@ -129,4 +136,24 @@ export const generateFilterOptions = (filtersMap) => {
}
});
return _options;
}
export const generateLiveFilterOptions = (filtersMap) => {
const _options = {};
Object.keys(filtersMap).filter(i => filtersMap[i].isLive).forEach(key => {
const filter = filtersMap[key];
filter.operator = 'contains';
filter.type = FilterType.STRING;
// filter.type = FilterType.AUTOCOMPLETE_LOCAL;
// filter.options = countryOptions;
filter.operatorOptions = [
{ key: 'contains', text: 'contains', value: 'contains' },
]
if (_options.hasOwnProperty(filter.category)) {
_options[filter.category].push(filter);
} else {
_options[filter.category] = [filter];
}
});
return _options;
}

View file

@ -142,6 +142,7 @@ export default Record({
firstResourceTime,
issues: issuesList,
sessionId: sessionId || sessionID,
userId: session.userId || session.userID,
};
},
idKey: "sessionId",

View file

@ -1,6 +1,6 @@
import { promises as fs } from 'fs';
import replaceInFiles from 'replace-in-files';
import packageConfig from '../package.json';
import packageConfig from '../package.json' assert { type: 'json' };
async function main() {
const webworker = await fs.readFile('build/webworker.js', 'utf8');