feat(ui) - assist filters

This commit is contained in:
Shekar Siri 2022-02-10 14:09:31 +01:00
parent 5eeea45d63
commit 0584883a1b
20 changed files with 430 additions and 58 deletions

View file

@ -27,6 +27,7 @@ import LiveSessionList from './LiveSessionList'
import SessionSearch from 'Shared/SessionSearch';
import MainSearchBar from 'Shared/MainSearchBar';
import LiveSearchBar from 'Shared/LiveSearchBar';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import { clearSearch } from 'Duck/search';
const weakEqual = (val1, val2) => {
@ -186,7 +187,7 @@ export default class BugFinder extends React.PureComponent {
<>
<div className="mb-5">
<LiveSearchBar />
<SessionSearch />
<LiveSessionSearch />
</div>
{ activeTab.type === 'live' && <LiveSessionList /> }
</>

View file

@ -1,10 +1,9 @@
import { connect } from 'react-redux';
import { Loader, NoContent, Button, LoadMoreButton } from 'UI';
import { applyFilter, addAttribute, addEvent } from 'Duck/filters';
import { fetchSessions } from 'Duck/search';
import { fetchSessions, addFilterByKeyAndValue } from 'Duck/search';
import SessionItem from 'Shared/SessionItem';
import SessionListHeader from './SessionListHeader';
import { addFilterByKeyAndValue } from 'Duck/search';
import { FilterKey } from 'Types/filter/filterType';
const ALL = 'all';

View file

@ -3,7 +3,7 @@ import { Icon, Loader } from 'UI';
import { connect } from 'react-redux';
import cn from 'classnames';
import stl from './FilterModal.css';
import { filtersMap, getMetaDataFilter } from 'Types/filter/newFilter';
import { filtersMap } from 'Types/filter/newFilter';
interface Props {
filters: any,
@ -26,17 +26,6 @@ function FilterModal(props: Props) {
} = props;
const hasSearchQuery = searchQuery && searchQuery.length > 0;
const showSearchList = isMainSearch && searchQuery.length > 0;
const allFilters = Object.assign({}, filters);
if (metaOptions.size > 0) {
allFilters['Metadata'] = [];
metaOptions.forEach((option) => {
if (option.key) {
const _metaFilter = getMetaDataFilter(option.key, option.value);
allFilters['Metadata'].push(_metaFilter);
}
});
}
const onFilterSearchClick = (filter) => {
const _filter = filtersMap[filter.type];
@ -79,11 +68,11 @@ function FilterModal(props: Props) {
{ !hasSearchQuery && (
<div className="" style={{ columns: "100px 2" }}>
{allFilters && Object.keys(allFilters).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>
{allFilters[key].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>
@ -99,7 +88,7 @@ function FilterModal(props: Props) {
}
export default connect(state => ({
filters: state.getIn([ 'filters', 'filterList' ]),
filters: state.getIn([ 'search', 'filterList' ]),
filterSearchList: state.getIn([ 'search', 'filterSearchList' ]),
metaOptions: state.getIn([ 'customFields', 'list' ]),
fetchingFilterSearchList: state.getIn([ 'search', 'fetchFilterSearch', 'loading' ]),

View file

@ -1,15 +1,18 @@
import React, { useState } from 'react';
import FilterModal from '../FilterModal';
import LiveFilterModal from '../LiveFilterModal';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import { Icon } from 'UI';
import { connect } from 'react-redux';
interface Props {
filter: any; // event/filter
onFilterClick: (filter) => void;
children?: any;
isLive?: boolean;
}
function FilterSelection(props: Props) {
const { filter, onFilterClick, children } = props;
const { filter, onFilterClick, children, isLive = true } = props;
const [showModal, setShowModal] = useState(false);
return (
@ -37,11 +40,13 @@ function FilterSelection(props: Props) {
</OutsideClickDetectingDiv>
{showModal && (
<div className="absolute left-0 top-20 border shadow rounded bg-white z-50">
<FilterModal onFilterClick={onFilterClick} />
{ isLive ? <LiveFilterModal onFilterClick={onFilterClick} /> : <FilterModal onFilterClick={onFilterClick} /> }
</div>
)}
</div>
);
}
export default FilterSelection;
export default connect(state => ({
isLive: state.getIn([ 'sessions', 'activeTab' ]).type === 'live',
}), { })(FilterSelection);

View file

@ -0,0 +1,37 @@
.wrapper {
border-radius: 3px;
border: solid thin $gray-light;
padding: 20px;
overflow: hidden;
overflow-y: auto;
box-shadow: 0 2px 2px 0 $gray-light;
}
.optionItem {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&:hover {
background-color: $active-blue;
color: $teal !important;
& svg {
fill: $teal !important;
}
}
}
.filterSearchItem {
&:hover {
background-color: $active-blue;
color: $teal;
& svg {
fill: $teal;
}
}
& div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

View file

@ -0,0 +1,96 @@
import React from 'react';
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';
interface Props {
filters: any,
onFilterClick?: (filter) => void,
filterSearchList: any,
metaOptions: any,
isMainSearch?: boolean,
fetchingFilterSearchList: boolean,
searchQuery?: string,
}
function LiveFilterModal(props: Props) {
const {
filters,
metaOptions,
onFilterClick = () => null,
filterSearchList,
isMainSearch = false,
fetchingFilterSearchList,
searchQuery = '',
} = props;
const hasSearchQuery = searchQuery && searchQuery.length > 0;
const showSearchList = isMainSearch && searchQuery.length > 0;
const onFilterSearchClick = (filter) => {
const _filter = filtersMap[filter.type];
_filter.value = [filter.value];
onFilterClick(_filter);
}
return (
<div className={stl.wrapper} style={{ width: '490px', maxHeight: '400px', overflowY: 'auto'}}>
{ showSearchList && (
<Loader size="small" loading={fetchingFilterSearchList}>
<div className="-mx-6 px-6">
{ filterSearchList && Object.keys(filterSearchList).map((key, index) => {
const filter = filterSearchList[key];
const option = filtersMap[key];
return (
<div
key={index}
className={cn('mb-3')}
>
<div className="font-medium uppercase color-gray-medium text-sm mb-2">{option.label}</div>
<div>
{filter.map((f, i) => (
<div
key={i}
className={cn(stl.filterSearchItem, "cursor-pointer px-3 py-1 text-sm flex items-center")}
onClick={() => onFilterSearchClick({ type: key, value: f.value })}
>
<Icon className="mr-2" name={option.icon} size="16" />
<div className="whitespace-nowrap text-ellipsis overflow-hidden">{f.value}</div>
</div>
))}
</div>
</div>
);
})}
</div>
</Loader>
)}
{ !hasSearchQuery && (
<div className="">
{filters && Object.keys(filters).filter(i => i === 'User' || i === 'Metadata').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) => (
<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>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
export default connect(state => ({
filters: state.getIn([ 'search', 'filterList' ]),
filterSearchList: state.getIn([ 'search', 'filterSearchList' ]),
metaOptions: state.getIn([ 'customFields', 'list' ]),
fetchingFilterSearchList: state.getIn([ 'search', 'fetchFilterSearch', 'loading' ]),
}))(LiveFilterModal);

View file

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

View file

@ -1,8 +1,7 @@
import React from 'react';
import SessionSearchField from 'Shared/SessionSearchField';
import SavedSearch from 'Shared/SavedSearch';
import LiveSessionSearchField from 'Shared/LiveSessionSearchField';
import { Button, Popup } from 'UI';
import { clearSearch } from 'Duck/search';
import { clearSearch } from 'Duck/liveSearch';
import { connect } from 'react-redux';
interface Props {
@ -15,7 +14,7 @@ const LiveSearchBar = (props: Props) => {
return (
<div className="flex items-center">
<div style={{ width: "80%", marginRight: "10px"}}>
<SessionSearchField />
<LiveSessionSearchField />
</div>
<div className="flex items-center" style={{ width: "20%"}}>
<Popup
@ -39,5 +38,5 @@ const LiveSearchBar = (props: Props) => {
)
}
export default connect(state => ({
appliedFilter: state.getIn(['search', 'instance']),
appliedFilter: state.getIn(['liveSearch', 'instance']),
}), { clearSearch })(LiveSearchBar);

View file

@ -0,0 +1,64 @@
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';
interface Props {
appliedFilter: any;
edit: typeof edit;
addFilter: typeof addFilter;
}
function LiveSessionSearch(props: Props) {
const { appliedFilter } = props;
const hasEvents = appliedFilter.filters.filter(i => i.isEvent).size > 0;
const hasFilters = appliedFilter.filters.filter(i => !i.isEvent).size > 0;
const onUpdateFilter = (filterIndex, filter) => {
const newFilters = appliedFilter.filters.map((_filter, i) => {
if (i === filterIndex) {
return filter;
} else {
return _filter;
}
});
props.edit({
...appliedFilter,
filters: newFilters,
});
}
const onRemoveFilter = (filterIndex) => {
const newFilters = appliedFilter.filters.filter((_filter, i) => {
return i !== filterIndex;
});
props.edit({
filters: newFilters,
});
}
const onChangeEventsOrder = (e, { name, value }) => {
props.edit({
eventsOrder: value,
});
}
return (hasEvents || hasFilters) ? (
<div className="border bg-white rounded mt-4">
<div className="p-5">
<FilterList
filter={appliedFilter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
/>
</div>
</div>
) : <></>;
}
export default connect(state => ({
appliedFilter: state.getIn([ 'liveSearch', 'instance' ]),
}), { edit, addFilter })(LiveSessionSearch);

View file

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

View file

@ -0,0 +1,10 @@
.searchField {
box-shadow: none !important;
& input {
box-shadow: none !important;
border-radius: 3 !important;
border: solid thin $gray-light !important;
height: 34px !important;
font-size: 16px;
}
}

View file

@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { connect } from 'react-redux';
import stl from './LiveSessionSearchField.css';
import { Input } from 'UI';
import LiveFilterModal from 'Shared/Filters/LiveFilterModal';
import { fetchFilterSearch } from 'Duck/search';
import { debounce } from 'App/utils';
import { edit as editFilter, addFilterByKeyAndValue } from 'Duck/liveSearch';
interface Props {
fetchFilterSearch: (query: any) => void;
editFilter: typeof editFilter;
addFilterByKeyAndValue: (key: string, value: string) => void;
}
function LiveSessionSearchField(props: Props) {
const debounceFetchFilterSearch = debounce(props.fetchFilterSearch, 1000)
const [showModal, setShowModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const onSearchChange = (e, { value }) => {
setSearchQuery(value)
debounceFetchFilterSearch({ q: value });
}
const onAddFilter = (filter) => {
console.log('onAddFilter', filter)
props.addFilterByKeyAndValue(filter.key, filter.value)
}
return (
<div className="relative">
<Input
// inputProps={ { "data-openreplay-label": "Search", "autocomplete": "off" } }
className={stl.searchField}
onFocus={ () => setShowModal(true) }
onBlur={ () => setTimeout(setShowModal, 200, false) }
onChange={ onSearchChange }
icon="search"
iconPosition="left"
placeholder={ 'Find live sessions by user or metadata.'}
fluid
id="search"
type="search"
autoComplete="off"
/>
{ showModal && (
<div className="absolute left-0 top-20 border shadow rounded bg-white z-50">
<LiveFilterModal
searchQuery={searchQuery}
isMainSearch={true}
onFilterClick={onAddFilter}
/>
</div>
)}
</div>
);
}
export default connect(null, { fetchFilterSearch, editFilter, addFilterByKeyAndValue })(LiveSessionSearchField);

View file

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

View file

@ -4,6 +4,9 @@ import { fetchListType, saveType, editType, initType, removeType } from './funcT
import { createItemInListUpdater, mergeReducers, success, array } from './funcTools/tools';
import { createEdit, createInit } from './funcTools/crud';
import { createRequestReducer } from './funcTools/request';
import { addElementToFiltersMap } from 'Types/filter/newFilter';
import { FilterCategory } from '../types/filter/filterType';
import { refreshFilterOptions } from './search'
const name = "integration/variable";
const idKey = 'index';
@ -25,7 +28,7 @@ const FETCH_SOURCES_SUCCESS = success(FETCH_SOURCES);
// const defaultMeta = [{key: 'user_id', index: 0}, {key: 'user_anonymous_id', index: 0}];
const initialState = Map({
list: List([{key: 'user_id'}, {key: 'user_anonymous_id'}]),
list: List(),
instance: CustomField(),
sources: List(),
});
@ -33,6 +36,10 @@ const initialState = Map({
const reducer = (state = initialState, action = {}) => {
switch(action.type) {
case FETCH_SUCCESS:
console.log('FETCH_SUCCESS', action.data);
action.data.forEach(item => {
addElementToFiltersMap(FilterCategory.METADATA, item.key);
});
return state.set('list', List(action.data).map(CustomField)) //.concat(defaultMeta))
case FETCH_SOURCES_SUCCESS:
return state.set('sources', List(action.data.map(({ value, ...item}) => ({label: value, key: value, ...item}))).map(CustomField))
@ -53,13 +60,14 @@ const reducer = (state = initialState, action = {}) => {
export const edit = createEdit(name);
export const init = createInit(name);
export const fetchList = (siteId) => {
return {
export const fetchList = (siteId) => (dispatch, getState) => {
return dispatch({
types: array(FETCH_LIST),
call: client => client.get(siteId ? `/${siteId}/metadata` : '/metadata'),
}
}).then(() => {
dispatch(refreshFilterOptions());
});
}
export const fetchSources = () => {
return {
types: array(FETCH_SOURCES),

View file

@ -12,17 +12,6 @@ import logger from 'App/logger';
import { newFiltersList } from 'Types/filter'
import NewFilter, { filtersMap } from 'Types/filter/newFilter';
const filterOptions = {}
Object.keys(filtersMap).forEach(key => {
const filter = filtersMap[key];
if (filterOptions.hasOwnProperty(filter.category)) {
filterOptions[filter.category].push(filter);
} else {
filterOptions[filter.category] = [filter];
}
})
// for (var i = 0; i < newFiltersList.length; i++) {
@ -58,8 +47,9 @@ const SET_ACTIVE_FLOW = 'filters/SET_ACTIVE_FLOW';
const UPDATE_VALUE = 'filters/UPDATE_VALUE';
const REFRESH_FILTER_OPTIONS = 'filters/REFRESH_FILTER_OPTIONS';
const initialState = Map({
filterList: filterOptions,
instance: Filter(),
activeFilter: null,
list: List(),

View file

@ -36,6 +36,7 @@ import config from './config';
import roles from './roles';
import customMetrics from './customMetrics';
import search from './search';
import liveSearch from './liveSearch';
export default combineReducers({
jwt,
@ -72,6 +73,7 @@ export default combineReducers({
roles,
customMetrics,
search,
liveSearch,
...integrations,
...sources,
});

View file

@ -0,0 +1,84 @@
import { List, Map } from 'immutable';
import { fetchType, editType } from './funcTools/crud';
import { createRequestReducer } from './funcTools/request';
import { mergeReducers } from './funcTools/tools';
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';
const name = "liveSearch";
const idKey = "searchId";
const FETCH = fetchType(name);
const EDIT = editType(name);
const CLEAR_SEARCH = `${name}/CLEAR_SEARCH`;
const APPLY = `${name}/APPLY`;
const initialState = Map({
list: List(),
instance: new Filter({ filters: [] }),
filterSearchList: {},
});
function reducer(state = initialState, action = {}) {
switch (action.type) {
case EDIT:
return state.mergeIn(['instance'], action.instance);
}
return state;
}
export default mergeReducers(
reducer,
createRequestReducer({
fetch: FETCH,
}),
);
const reduceThenFetchResource = actionCreator => (...args) => (dispatch, getState) => {
dispatch(actionCreator(...args));
const filter = getState().getIn([ 'search', 'instance']).toData();
filter.filters = filter.filters.map(filterMap);
return dispatch(fetchSessionList(filter));
};
export const edit = reduceThenFetchResource((instance) => ({
type: EDIT,
instance,
}));
export const applyFilter = reduceThenFetchResource((filter, fromUrl=false) => ({
type: APPLY,
filter,
fromUrl,
}));
export const fetchSessions = (filter) => (dispatch, getState) => {
const _filter = filter ? filter : getState().getIn([ 'search', 'instance']);
return dispatch(applyFilter(_filter));
};
export const clearSearch = () => (dispatch, getState) => {
// dispatch(applySavedSearch(new SavedFilter({})));
dispatch(edit(new Filter({ filters: [] })));
return dispatch({
type: CLEAR_SEARCH,
});
}
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)));
}
export const addFilterByKeyAndValue = (key, value) => (dispatch, getState) => {
let defaultFilter = filtersMap[key];
defaultFilter.value = value;
dispatch(addFilter(defaultFilter));
}

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 } from 'Types/filter/newFilter';
import { filtersMap, generateFilterOptions } from 'Types/filter/newFilter';
const ERRORS_ROUTE = errorsRoute();
@ -29,6 +29,8 @@ const UPDATE = `${name}/UPDATE`;
const APPLY = `${name}/APPLY`;
const SET_ALERT_METRIC_ID = `${name}/SET_ALERT_METRIC_ID`;
const REFRESH_FILTER_OPTIONS = 'filters/REFRESH_FILTER_OPTIONS';
function chartWrapper(chart = []) {
return chart.map(point => ({ ...point, count: Math.max(point.count, 0) }));
}
@ -40,6 +42,7 @@ const updateInstance = (state, instance) => state.getIn([ "savedSearch", savedSe
: state;
const initialState = Map({
filterList: generateFilterOptions(filtersMap),
list: List(),
alertMetricId: null,
instance: new Filter({ filters: [] }),
@ -50,6 +53,8 @@ const initialState = Map({
// Metric - Series - [] - filters
function reducer(state = initialState, action = {}) {
switch (action.type) {
case REFRESH_FILTER_OPTIONS:
return state.set('filterList', generateFilterOptions(filtersMap));
case EDIT:
return state.mergeIn(['instance'], action.instance);
case APPLY:
@ -100,8 +105,11 @@ const checkValues = (key, value) => {
return value.filter(i => i !== '' && i !== null);
}
export const checkFilterValue = (value) => {
return Array.isArray(value) ? (value.length === 0 ? [""] : value) : [value];
}
export const filterMap = ({category, value, key, operator, sourceOperator, source, custom, isEvent }) => ({
// value: value.filter(i => i !== '' && i !== null),
value: checkValues(key, value),
custom,
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
@ -204,10 +212,6 @@ export const clearSearch = () => (dispatch, getState) => {
});
}
const checkFilterValue = (value) => {
return Array.isArray(value) ? (value.length === 0 ? [""] : value) : [value];
}
export const addFilter = (filter) => (dispatch, getState) => {
filter.value = checkFilterValue(filter.value);
const instance = getState().getIn([ 'search', 'instance']);
@ -228,3 +232,8 @@ export const editSavedSearch = instance => {
}
};
export const refreshFilterOptions = () => {
return {
type: REFRESH_FILTER_OPTIONS
}
}

View file

@ -56,6 +56,18 @@ 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 },
}
export const addElementToFiltersMap = (
category = FilterCategory.METADATA,
key,
type = FilterType.MULTIPLE,
operator = 'is',
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' }
@ -106,11 +118,15 @@ export default Record({
},
})
// const getOperatorDefault = (type) => {
// if (type === MISSING_RESOURCE) return 'true';
// if (type === SLOW_SESSION) return 'true';
// if (type === CLICK_RAGE) return 'true';
// if (type === CLICK) return 'on';
// return 'is';
// }
export const generateFilterOptions = (filtersMap) => {
const _options = {};
Object.keys(filtersMap).forEach(key => {
const filter = filtersMap[key];
if (_options.hasOwnProperty(filter.category)) {
_options[filter.category].push(filter);
} else {
_options[filter.category] = [filter];
}
});
return _options;
}

View file

@ -138,7 +138,7 @@ export default Record({
startedAt,
duration,
userNumericHash: hashString(session.userId || session.userAnonymousId || session.userUuid || session.userID || session.userUUID || ""),
userDisplayName: session.userId || session.userAnonymousId || 'Anonymous User',
userDisplayName: session.userId || session.userAnonymousId || session.userID || 'Anonymous User',
firstResourceTime,
issues: issuesList,
sessionId: sessionId || sessionID,