Merge remote-tracking branch 'origin/custom-metrics-ui' into dev
|
|
@ -16,13 +16,14 @@ const siteIdRequiredPaths = [
|
|||
'/integration/sources',
|
||||
'/issue_types',
|
||||
'/sample_rate',
|
||||
'/flows',
|
||||
'/saved_search',
|
||||
'/rehydrations',
|
||||
'/sourcemaps',
|
||||
'/errors',
|
||||
'/funnels',
|
||||
'/assist',
|
||||
'/heatmaps'
|
||||
'/heatmaps',
|
||||
'/custom_metrics',
|
||||
];
|
||||
|
||||
const noStoringFetchPathStarts = [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Button, Dropdown, Form, Input, SegmentSelection, Checkbox, Message, Link, Icon } from 'UI';
|
||||
import { alertMetrics as metrics } from 'App/constants';
|
||||
import { alertConditions as conditions } from 'App/constants';
|
||||
|
|
@ -8,6 +8,7 @@ import stl from './alertForm.css';
|
|||
import DropdownChips from './DropdownChips';
|
||||
import { validateEmail } from 'App/validate';
|
||||
import cn from 'classnames';
|
||||
import { fetchTriggerOptions } from 'Duck/alerts';
|
||||
|
||||
const thresholdOptions = [
|
||||
{ text: '15 minutes', value: 15 },
|
||||
|
|
@ -46,11 +47,15 @@ const Section = ({ index, title, description, content }) => (
|
|||
const integrationsRoute = client(CLIENT_TABS.INTEGRATIONS);
|
||||
|
||||
const AlertForm = props => {
|
||||
const { instance, slackChannels, webhooks, loading, onDelete, deleting } = props;
|
||||
const { instance, slackChannels, webhooks, loading, onDelete, deleting, triggerOptions } = props;
|
||||
const write = ({ target: { value, name } }) => props.edit({ [ name ]: value })
|
||||
const writeOption = (e, { name, value }) => props.edit({ [ name ]: value });
|
||||
const onChangeOption = (e, { checked, name }) => props.edit({ [ name ]: checked })
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchTriggerOptions();
|
||||
}, [])
|
||||
|
||||
const writeQueryOption = (e, { name, value }) => {
|
||||
const { query } = instance;
|
||||
props.edit({ query: { ...query, [name] : value } });
|
||||
|
|
@ -61,10 +66,12 @@ const AlertForm = props => {
|
|||
props.edit({ query: { ...query, [name] : value } });
|
||||
}
|
||||
|
||||
const metric = (instance && instance.query.left) ? metrics.find(i => i.value === instance.query.left) : null;
|
||||
const metric = (instance && instance.query.left) ? triggerOptions.find(i => i.value === instance.query.left) : null;
|
||||
const unit = metric ? metric.unit : '';
|
||||
const isThreshold = instance.detectionMethod === 'threshold';
|
||||
|
||||
console.log('triggerOptions', triggerOptions)
|
||||
|
||||
|
||||
return (
|
||||
<Form className={ cn("p-6", stl.wrapper)} style={{ width: '580px' }} onSubmit={() => props.onSubmit(instance)} id="alert-form">
|
||||
|
|
@ -135,7 +142,7 @@ const AlertForm = props => {
|
|||
placeholder="Select Metric"
|
||||
selection
|
||||
search
|
||||
options={ metrics }
|
||||
options={ triggerOptions }
|
||||
name="left"
|
||||
value={ instance.query.left }
|
||||
onChange={ writeQueryOption }
|
||||
|
|
@ -327,6 +334,7 @@ const AlertForm = props => {
|
|||
|
||||
export default connect(state => ({
|
||||
instance: state.getIn(['alerts', 'instance']),
|
||||
triggerOptions: state.getIn(['alerts', 'triggerOptions']),
|
||||
loading: state.getIn(['alerts', 'saveRequest', 'loading']),
|
||||
deleting: state.getIn(['alerts', 'removeRequest', 'loading'])
|
||||
}))(AlertForm)
|
||||
}), { fetchTriggerOptions })(AlertForm)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { SlideModal, IconButton } from 'UI';
|
||||
import { init, edit, save, remove } from 'Duck/alerts';
|
||||
import { fetchList as fetchWebhooks } from 'Duck/webhook';
|
||||
import AlertForm from '../AlertForm';
|
||||
import { connect } from 'react-redux';
|
||||
import { setShowAlerts } from 'Duck/dashboard';
|
||||
import { EMAIL, SLACK, WEBHOOK } from 'App/constants/schedule';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
|
||||
interface Props {
|
||||
showModal?: boolean;
|
||||
metricId?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
function AlertFormModal(props) {
|
||||
const { metricId = null, showModal = false, webhooks, setShowAlerts } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchWebhooks();
|
||||
}, [])
|
||||
|
||||
const slackChannels = webhooks.filter(hook => hook.type === SLACK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS();
|
||||
const hooks = webhooks.filter(hook => hook.type === WEBHOOK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS();
|
||||
|
||||
const saveAlert = instance => {
|
||||
const wasUpdating = instance.exists();
|
||||
props.save(instance).then(() => {
|
||||
if (!wasUpdating) {
|
||||
toggleForm(null, false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDelete = async (instance) => {
|
||||
if (await confirm({
|
||||
header: 'Confirm',
|
||||
confirmButton: 'Yes, Delete',
|
||||
confirmation: `Are you sure you want to permanently delete this alert?`
|
||||
})) {
|
||||
props.remove(instance.alertId).then(() => {
|
||||
toggleForm(null, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toggleForm = (instance, state) => {
|
||||
if (instance) {
|
||||
props.init(instance)
|
||||
}
|
||||
return setShowForm(state ? state : !showForm);
|
||||
}
|
||||
|
||||
return (
|
||||
<SlideModal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="mr-3">{ 'Create Alert' }</span>
|
||||
<IconButton
|
||||
circle
|
||||
size="small"
|
||||
icon="plus"
|
||||
outline
|
||||
id="add-button"
|
||||
onClick={ () => toggleForm({}, true) }
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
isDisplayed={ showModal }
|
||||
onClose={props.onClose}
|
||||
size="medium"
|
||||
content={ showModal &&
|
||||
<AlertForm
|
||||
metricId={ props.metricId }
|
||||
edit={props.edit}
|
||||
slackChannels={slackChannels}
|
||||
webhooks={hooks}
|
||||
onSubmit={saveAlert}
|
||||
onClose={props.onClose}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
webhooks: state.getIn(['webhooks', 'list']),
|
||||
instance: state.getIn(['alerts', 'instance']),
|
||||
}), { init, edit, save, remove, fetchWebhooks, setShowAlerts })(AlertFormModal)
|
||||
1
frontend/app/components/Alerts/AlertFormModal/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AlertFormModal';
|
||||
|
|
@ -27,9 +27,9 @@ class AttributeItem extends React.PureComponent {
|
|||
applyFilter = debounce(this.props.applyFilter, 1000)
|
||||
fetchFilterOptionsDebounce = debounce(this.props.fetchFilterOptions, 500)
|
||||
|
||||
onFilterChange = (e, { name, value }) => {
|
||||
onFilterChange = (name, value, valueIndex) => {
|
||||
const { index } = this.props;
|
||||
this.props.editAttribute(index, name, value);
|
||||
this.props.editAttribute(index, name, value, valueIndex);
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
|
|
@ -69,13 +69,14 @@ class AttributeItem extends React.PureComponent {
|
|||
/>
|
||||
}
|
||||
{
|
||||
!filter.hasNoValue &&
|
||||
// !filter.hasNoValue &&
|
||||
<AttributeValueField
|
||||
filter={ filter }
|
||||
options={ options }
|
||||
onChange={ this.onFilterChange }
|
||||
handleSearchChange={this.handleSearchChange}
|
||||
loading={loadingFilterOptions}
|
||||
index={index}
|
||||
/>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { LinkStyledInput, CircularLoader } from 'UI';
|
|||
import { KEYS } from 'Types/filter/customFilter';
|
||||
import Event, { TYPES } from 'Types/filter/event';
|
||||
import CustomFilter from 'Types/filter/customFilter';
|
||||
import { setActiveKey, addCustomFilter, removeCustomFilter, applyFilter } from 'Duck/filters';
|
||||
import { setActiveKey, addCustomFilter, removeCustomFilter, applyFilter, updateValue } from 'Duck/filters';
|
||||
import DurationFilter from '../DurationFilter/DurationFilter';
|
||||
import AutoComplete from '../AutoComplete';
|
||||
|
||||
|
|
@ -24,6 +24,7 @@ const getHeader = (type) => {
|
|||
addCustomFilter,
|
||||
removeCustomFilter,
|
||||
applyFilter,
|
||||
updateValue,
|
||||
})
|
||||
class AttributeValueField extends React.PureComponent {
|
||||
state = {
|
||||
|
|
@ -134,25 +135,46 @@ class AttributeValueField extends React.PureComponent {
|
|||
return params;
|
||||
}
|
||||
|
||||
onAddValue = () => {
|
||||
const { index, filter } = this.props;
|
||||
this.props.updateValue('filters', index, filter.value.concat(""));
|
||||
}
|
||||
|
||||
onRemoveValue = (valueIndex) => {
|
||||
const { index, filter } = this.props;
|
||||
this.props.updateValue('filters', index, filter.value.filter((_, i) => i !== valueIndex));
|
||||
}
|
||||
|
||||
onChange = (name, value, valueIndex) => {
|
||||
const { index, filter } = this.props;
|
||||
this.props.updateValue('filters', index, filter.value.map((item, i) => i === valueIndex ? value : item));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter, onChange, onTargetChange } = this.props;
|
||||
// const { filter, onChange } = this.props;
|
||||
const { filter } = this.props;
|
||||
const _showAutoComplete = this.isAutoComplete(filter.type);
|
||||
const _params = _showAutoComplete ? this.getParams(filter) : {};
|
||||
let _optionsEndpoint= '/events/search';
|
||||
let _optionsEndpoint= '/events/search';
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{ _showAutoComplete ?
|
||||
<AutoComplete
|
||||
{ _showAutoComplete ? filter.value.map((v, i) => (
|
||||
<AutoComplete
|
||||
name={ 'value' }
|
||||
endpoint={ _optionsEndpoint }
|
||||
value={ filter.value }
|
||||
value={ v }
|
||||
index={ i }
|
||||
params={ _params }
|
||||
optionMapping={this.optionMapping}
|
||||
onSelect={ onChange }
|
||||
onSelect={ (e, { name, value }) => onChange(name, value, i) }
|
||||
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
|
||||
fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value }
|
||||
onRemoveValue={() => this.onRemoveValue(i)}
|
||||
onAddValue={this.onAddValue}
|
||||
showCloseButton={i !== filter.value.length - 1}
|
||||
/>
|
||||
))
|
||||
: this.renderField()
|
||||
}
|
||||
{ filter.type === 'INPUT' &&
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import APIClient from 'App/api_client';
|
||||
import cn from 'classnames';
|
||||
import { Input } from 'UI';
|
||||
import { Input, Icon } from 'UI';
|
||||
import { debounce } from 'App/utils';
|
||||
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
||||
import EventSearchInput from 'Shared/EventSearchInput';
|
||||
import stl from './autoComplete.css';
|
||||
import FilterItem from '../CustomFilters/FilterItem';
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ class AutoComplete extends React.PureComponent {
|
|||
})
|
||||
|
||||
|
||||
onInputChange = (e, { name, value }) => {
|
||||
onInputChange = ({ target: { value } }) => {
|
||||
changed = true;
|
||||
this.setState({ query: value, updated: true })
|
||||
const _value = value.trim();
|
||||
|
|
@ -118,23 +119,53 @@ class AutoComplete extends React.PureComponent {
|
|||
valueToText = defaultValueToText,
|
||||
placeholder = 'Type to search...',
|
||||
headerText = '',
|
||||
fullWidth = false
|
||||
fullWidth = false,
|
||||
onRemoveValue = () => {},
|
||||
onAddValue = () => {},
|
||||
showCloseButton = false,
|
||||
} = this.props;
|
||||
|
||||
const options = optionMapping(values, valueToText)
|
||||
|
||||
return (
|
||||
<OutsideClickDetectingDiv
|
||||
className={ cn("relative", { "flex-1" : fullWidth }) }
|
||||
className={ cn("relative flex items-center", { "flex-1" : fullWidth }) }
|
||||
onClickOutside={this.onClickOutside}
|
||||
>
|
||||
<Input
|
||||
{/* <EventSearchInput /> */}
|
||||
<div className={stl.inputWrapper}>
|
||||
<input
|
||||
name="query"
|
||||
// className={cn(stl.input)}
|
||||
onFocus={ () => this.setState({ddOpen: true})}
|
||||
onChange={ this.onInputChange }
|
||||
onBlur={ this.onBlur }
|
||||
onFocus={ () => this.setState({ddOpen: true})}
|
||||
value={ query }
|
||||
autoFocus={ true }
|
||||
type="text"
|
||||
placeholder={ placeholder }
|
||||
onPaste={(e) => {
|
||||
const text = e.clipboardData.getData('Text');
|
||||
this.hiddenInput.value = text;
|
||||
pasted = true; // to use only the hidden input
|
||||
} }
|
||||
/>
|
||||
<div className={stl.right} onClick={showCloseButton ? onRemoveValue : onAddValue}>
|
||||
{ showCloseButton ? <Icon name="close" size="18" /> : <span className="px-1">or</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCloseButton && <div className='ml-2'>or</div>}
|
||||
{/* <Input
|
||||
className={ cn(stl.searchInput, { [ stl.fullWidth] : fullWidth }) }
|
||||
onChange={ this.onInputChange }
|
||||
onBlur={ this.onBlur }
|
||||
onFocus={ () => this.setState({ddOpen: true})}
|
||||
value={ query }
|
||||
icon="search"
|
||||
// icon="search"
|
||||
label={{ basic: true, content: <div>test</div> }}
|
||||
labelPosition='right'
|
||||
loading={ loading }
|
||||
autoFocus={ true }
|
||||
type="search"
|
||||
|
|
@ -144,7 +175,7 @@ class AutoComplete extends React.PureComponent {
|
|||
this.hiddenInput.value = text;
|
||||
pasted = true; // to use only the hidden input
|
||||
} }
|
||||
/>
|
||||
/> */}
|
||||
<textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea>
|
||||
{ ddOpen && options.length > 0 &&
|
||||
<div className={ stl.menu }>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@
|
|||
color: $gray-darkest !important;
|
||||
font-size: 14px !important;
|
||||
background-color: rgba(255, 255, 255, 0.8) !important;
|
||||
|
||||
& .label {
|
||||
padding: 0px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
height: 28px !important;
|
||||
width: 280px;
|
||||
|
|
@ -28,3 +35,30 @@
|
|||
.fullWidth {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
border: solid thin $gray-light !important;
|
||||
border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& input {
|
||||
height: 28px;
|
||||
font-size: 13px !important;
|
||||
padding: 0 5px !important;
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
& .right {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
background-color: $gray-lightest;
|
||||
border-left: solid thin $gray-light !important;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,9 +24,12 @@ import SessionFlowList from './SessionFlowList/SessionFlowList';
|
|||
import { LAST_7_DAYS } from 'Types/app/period';
|
||||
import { resetFunnel } from 'Duck/funnels';
|
||||
import { resetFunnelFilters } from 'Duck/funnelFilters'
|
||||
import NoSessionsMessage from '../shared/NoSessionsMessage';
|
||||
import TrackerUpdateMessage from '../shared/TrackerUpdateMessage';
|
||||
import NoSessionsMessage from 'Shared/NoSessionsMessage';
|
||||
import TrackerUpdateMessage from 'Shared/TrackerUpdateMessage';
|
||||
import SessionSearchField from 'Shared/SessionSearchField'
|
||||
import SavedSearch from 'Shared/SavedSearch'
|
||||
import LiveSessionList from './LiveSessionList'
|
||||
import SessionSearch from 'Shared/SessionSearch';
|
||||
|
||||
const weakEqual = (val1, val2) => {
|
||||
if (!!val1 === false && !!val2 === false) return true;
|
||||
|
|
@ -170,8 +173,13 @@ export default class BugFinder extends React.PureComponent {
|
|||
data-hidden={ activeTab === 'live' || activeTab === 'favorite' }
|
||||
className="mb-5"
|
||||
>
|
||||
<EventFilter />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div style={{ width: "70%", marginRight: "10px"}}><SessionSearchField /></div>
|
||||
<SavedSearch />
|
||||
</div>
|
||||
<SessionSearch />
|
||||
{/* <EventFilter /> */}
|
||||
</div>
|
||||
{ activeFlow && activeFlow.type === 'flows' && <FunnelList /> }
|
||||
{ activeTab.type !== 'live' && <SessionList onMenuItemClick={this.setActiveTab} /> }
|
||||
{ activeTab.type === 'live' && <LiveSessionList /> }
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { applyFilter } from 'Duck/filters';
|
||||
// import { applyFilter } from 'Duck/filters';
|
||||
import { applyFilter } from 'Duck/search';
|
||||
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
|
||||
import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
||||
|
||||
@connect(state => ({
|
||||
rangeValue: state.getIn([ 'filters', 'appliedFilter', 'rangeValue' ]),
|
||||
startDate: state.getIn([ 'filters', 'appliedFilter', 'startDate' ]),
|
||||
endDate: state.getIn([ 'filters', 'appliedFilter', 'endDate' ]),
|
||||
filter: state.getIn([ 'search', 'instance' ]),
|
||||
// rangeValue: state.getIn([ 'search', 'instance', 'rangeValue' ]),
|
||||
// startDate: state.getIn([ 'search', 'instance', 'startDate' ]),
|
||||
// endDate: state.getIn([ 'search', 'instance', 'endDate' ]),
|
||||
}), {
|
||||
applyFilter, fetchFunnelsList
|
||||
})
|
||||
export default class DateRange extends React.PureComponent {
|
||||
|
||||
onDateChange = (e) => {
|
||||
console.log('onDateChange', e);
|
||||
this.props.fetchFunnelsList(e.rangeValue)
|
||||
this.props.applyFilter(e)
|
||||
}
|
||||
render() {
|
||||
const { startDate, endDate, rangeValue, className } = this.props;
|
||||
const { filter: { rangeValue, startDate, endDate }, className } = this.props;
|
||||
// const { startDate, endDate, rangeValue, className } = this.props;
|
||||
|
||||
return (
|
||||
<DateRangeDropdown
|
||||
button
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
|||
import { Input } from 'semantic-ui-react';
|
||||
import { DNDContext } from 'Components/hocs/dnd';
|
||||
import {
|
||||
addEvent, applyFilter, moveEvent, clearEvents,
|
||||
addEvent, applyFilter, moveEvent, clearEvents, edit,
|
||||
addCustomFilter, addAttribute, setSearchQuery, setActiveFlow, setFilterOption
|
||||
} from 'Duck/filters';
|
||||
import { fetchList as fetchEventList } from 'Duck/events';
|
||||
|
|
@ -11,7 +11,7 @@ import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
|||
import EventEditor from './EventEditor';
|
||||
import ListHeader from '../ListHeader';
|
||||
import FilterModal from '../CustomFilters/FilterModal';
|
||||
import { IconButton } from 'UI';
|
||||
import { IconButton, SegmentSelection } from 'UI';
|
||||
import stl from './eventFilter.css';
|
||||
import Attributes from '../Attributes/Attributes';
|
||||
import RandomPlaceholder from './RandomPlaceholder';
|
||||
|
|
@ -19,6 +19,7 @@ import CustomFilters from '../CustomFilters';
|
|||
import ManageFilters from '../ManageFilters';
|
||||
import { blink as setBlink } from 'Duck/funnels';
|
||||
import cn from 'classnames';
|
||||
import SaveFilterButton from 'Shared/SaveFilterButton';
|
||||
|
||||
@connect(state => ({
|
||||
events: state.getIn([ 'filters', 'appliedFilter', 'events' ]),
|
||||
|
|
@ -41,7 +42,8 @@ import cn from 'classnames';
|
|||
setSearchQuery,
|
||||
setActiveFlow,
|
||||
setFilterOption,
|
||||
setBlink
|
||||
setBlink,
|
||||
edit,
|
||||
})
|
||||
@DNDContext
|
||||
export default class EventFilter extends React.PureComponent {
|
||||
|
|
@ -109,6 +111,10 @@ export default class EventFilter extends React.PureComponent {
|
|||
this.props.setActiveFlow(null)
|
||||
}
|
||||
|
||||
changeConditionTab = (e, { name, value }) => {
|
||||
this.props.edit({ [ 'condition' ]: value })
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
events,
|
||||
|
|
@ -124,34 +130,6 @@ export default class EventFilter extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<OutsideClickDetectingDiv className={ stl.wrapper } onClickOutside={ this.closeModal } >
|
||||
{ showPlacehoder && !hasFilters &&
|
||||
<div
|
||||
className={ stl.randomElement }
|
||||
onClick={ this.onPlaceholderClick }
|
||||
>
|
||||
{ !searchQuery &&
|
||||
<div className={ stl.placeholder }>Search for users, clicks, page visits, requests, errors and more</div>
|
||||
// <RandomPlaceholder onClick={ this.onPlaceholderItemClick } appliedFilterKeys={ appliedFilterKeys } />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<Input
|
||||
inputProps={ { "data-openreplay-label": "Search", "autocomplete": "off" } }
|
||||
className={stl.searchField}
|
||||
ref={ this.inputRef }
|
||||
onChange={ this.onSearchChange }
|
||||
onKeyUp={this.onKeyUp}
|
||||
value={searchQuery}
|
||||
icon="search"
|
||||
iconPosition="left"
|
||||
placeholder={ hasFilters ? 'Search sessions using any captured event (click, input, page, error...)' : ''}
|
||||
fluid
|
||||
onFocus={ this.onFocus }
|
||||
onBlur={ this.onBlur }
|
||||
id="search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<FilterModal
|
||||
close={ this.closeModal }
|
||||
displayed={ showFilterModal }
|
||||
|
|
@ -161,7 +139,24 @@ export default class EventFilter extends React.PureComponent {
|
|||
/>
|
||||
|
||||
{ hasFilters &&
|
||||
<div className={cn("bg-white rounded border-gray-light mt-2 relative", { 'blink-border' : blink })}>
|
||||
<div className={cn("bg-white rounded border-gray-light mt-2 relative", { 'blink-border' : blink })}>
|
||||
<div className="absolute right-0 top-0 m-3 z-10 flex items-center">
|
||||
<div className="mr-2">Operator</div>
|
||||
<SegmentSelection
|
||||
primary
|
||||
name="condition"
|
||||
extraSmall={true}
|
||||
// className="my-3"
|
||||
onSelect={ this.changeConditionTab }
|
||||
value={{ value: appliedFilter.condition }}
|
||||
list={ [
|
||||
{ name: 'AND', value: 'and' },
|
||||
{ name: 'OR', value: 'or' },
|
||||
{ name: 'THEN', value: 'then' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ events.size > 0 &&
|
||||
<>
|
||||
<div className="py-1"><ListHeader title="Events" /></div>
|
||||
|
|
@ -189,6 +184,7 @@ export default class EventFilter extends React.PureComponent {
|
|||
showFilters={ true }
|
||||
/>
|
||||
</div>
|
||||
<SaveFilterButton />
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<IconButton plain label="CLEAR STEPS" onClick={ this.clearEvents } />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
|||
import { Dropdown } from 'semantic-ui-react';
|
||||
import { Icon } from 'UI';
|
||||
import { sort } from 'Duck/sessions';
|
||||
import { applyFilter } from 'Duck/filters';
|
||||
import { applyFilter } from 'Duck/search';
|
||||
import stl from './sortDropdown.css';
|
||||
|
||||
@connect(null, { sort, applyFilter })
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
width: 150px;
|
||||
color: $gray-darkest;
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0.8) !important;
|
||||
background-color: rgba(0, 0, 0, 0.1) !important;
|
||||
&:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import withPermissions from 'HOCs/withPermissions'
|
|||
import { setPeriod, setPlatform, fetchMetadataOptions } from 'Duck/dashboard';
|
||||
import { NoContent } from 'UI';
|
||||
import { WIDGET_KEYS } from 'Types/dashboard';
|
||||
import CustomMetrics from 'Shared/CustomMetrics';
|
||||
|
||||
import {
|
||||
MissingResources,
|
||||
|
|
@ -38,6 +39,7 @@ import SideMenuSection from './SideMenu/SideMenuSection';
|
|||
import styles from './dashboard.css';
|
||||
import WidgetSection from 'Shared/WidgetSection/WidgetSection';
|
||||
import OverviewWidgets from './Widgets/OverviewWidgets/OverviewWidgets';
|
||||
import CustomMetricsWidgets from './Widgets/CustomMetricsWidgets/CustomMetricsWidgets';
|
||||
import WidgetHolder from './WidgetHolder/WidgetHolder';
|
||||
import MetricsFilters from 'Shared/MetricsFilters/MetricsFilters';
|
||||
import { withRouter } from 'react-router';
|
||||
|
|
@ -46,6 +48,7 @@ const OVERVIEW = 'overview';
|
|||
const PERFORMANCE = 'performance';
|
||||
const ERRORS_N_CRASHES = 'errors_n_crashes';
|
||||
const RESOURCES = 'resources';
|
||||
const CUSTOM_METRICS = 'custom_metrics';
|
||||
|
||||
const menuList = [
|
||||
{
|
||||
|
|
@ -184,6 +187,7 @@ export default class Dashboard extends React.PureComponent {
|
|||
<div>
|
||||
<div className={ cn(styles.header, "flex items-center w-full") }>
|
||||
<MetricsFilters />
|
||||
<CustomMetrics />
|
||||
</div>
|
||||
<div className="">
|
||||
<NoContent
|
||||
|
|
@ -199,6 +203,12 @@ export default class Dashboard extends React.PureComponent {
|
|||
</div>
|
||||
</WidgetSection>
|
||||
|
||||
<WidgetSection title="Custom Metrics" type="customMetrics" className="mb-4">
|
||||
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[CUSTOM_METRICS]}>
|
||||
<CustomMetricsWidgets onClickEdit={(e) => null}/>
|
||||
</div>
|
||||
</WidgetSection>
|
||||
|
||||
<WidgetSection title="Errors" className="mb-4" type="errors">
|
||||
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[ERRORS_N_CRASHES]}>
|
||||
{ dashboardAppearance.impactedSessionsByJsErrors && <WidgetHolder Component={SessionsAffectedByJSErrors} /> }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Loader, NoContent, Icon } from 'UI';
|
||||
import { widgetHOC, Styles } from '../../common';
|
||||
import { ResponsiveContainer, AreaChart, XAxis, YAxis, CartesianGrid, Area, Tooltip } from 'recharts';
|
||||
import { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period';
|
||||
import CustomMetricWidgetHoc from '../../common/CustomMetricWidgetHoc';
|
||||
import stl from './CustomMetricWidget.css';
|
||||
import { getChartFormatter } from 'Types/dashboard/helper';
|
||||
import { remove, setAlertMetricId } from 'Duck/customMetrics';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
import APIClient from 'App/api_client';
|
||||
import { setShowAlerts } from 'Duck/dashboard';
|
||||
|
||||
const customParams = rangeName => {
|
||||
const params = { density: 70 }
|
||||
|
||||
if (rangeName === LAST_24_HOURS) params.density = 70
|
||||
if (rangeName === LAST_30_MINUTES) params.density = 70
|
||||
if (rangeName === YESTERDAY) params.density = 70
|
||||
if (rangeName === LAST_7_DAYS) params.density = 70
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
interface Period {
|
||||
rangeName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
metric: any;
|
||||
// loading?: boolean;
|
||||
data?: any;
|
||||
showSync?: boolean;
|
||||
compare?: boolean;
|
||||
period?: Period;
|
||||
onClickEdit: (e) => void;
|
||||
remove: (id) => void;
|
||||
setShowAlerts: (showAlerts) => void;
|
||||
setAlertMetricId: (id) => void;
|
||||
onAlertClick: (e) => void;
|
||||
}
|
||||
function CustomMetricWidget(props: Props) {
|
||||
const { metric, showSync, compare, period = { rangeName: LAST_24_HOURS} } = props;
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<any>({ chart: [] })
|
||||
|
||||
const colors = compare ? Styles.compareColors : Styles.colors;
|
||||
const params = customParams(period.rangeName)
|
||||
const gradientDef = Styles.gradientDef();
|
||||
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart' }
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
// dataWrapper: (p, period) => SessionsImpactedBySlowRequests({ chart: p})
|
||||
// .update("chart", getChartFormatter(period))
|
||||
|
||||
new APIClient()['post']('/custom_metrics/chart', { ...metricParams, q: metric.name })
|
||||
.then(response => response.json())
|
||||
.then(({ errors, data }) => {
|
||||
if (errors) {
|
||||
console.log('err', errors)
|
||||
} else {
|
||||
// console.log('data', data);
|
||||
// const _data = data[0].map(CustomMetric).update("chart", getChartFormatter(period)).toJS();
|
||||
const _data = getChartFormatter(period)(data[0]);
|
||||
console.log('__data', _data)
|
||||
setData({ chart: _data });
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
}, [])
|
||||
|
||||
const deleteHandler = async () => {
|
||||
if (await confirm({
|
||||
header: 'Custom Metric',
|
||||
confirmButton: 'Delete',
|
||||
confirmation: `Are you sure you want to delete ${metric.name}`
|
||||
})) {
|
||||
props.remove(metric.metricId)
|
||||
}
|
||||
}
|
||||
|
||||
// const onAlertClick = () => {
|
||||
// props.setShowAlerts(true)
|
||||
// props.setAlertMetricId(metric.metricId)
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className={stl.wrapper}>
|
||||
<div className="flex items-center mb-10 p-2">
|
||||
<div className="font-medium">{metric.name + ' ' + metric.metricId}</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<div className="cursor-pointer mr-6" onClick={deleteHandler}>
|
||||
<Icon name="trash" size="14" />
|
||||
</div>
|
||||
<div className="cursor-pointer mr-6">
|
||||
<Icon name="pencil" size="14" />
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={props.onAlertClick}>
|
||||
<Icon name="bell-plus" size="14" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Loader loading={ loading } size="small">
|
||||
<NoContent
|
||||
size="small"
|
||||
show={ data.chart.length === 0 }
|
||||
>
|
||||
<ResponsiveContainer height={ 240 } width="100%">
|
||||
<AreaChart
|
||||
data={ data.chart }
|
||||
margin={Styles.chartMargins}
|
||||
syncId={ showSync ? "impactedSessionsBySlowPages" : undefined }
|
||||
>
|
||||
{gradientDef}
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
|
||||
<XAxis {...Styles.xaxis} dataKey="time" interval={params.density/7} />
|
||||
<YAxis
|
||||
{...Styles.yaxis}
|
||||
label={{ ...Styles.axisLabelLeft, value: "Number of Requests" }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip {...Styles.tooltip} />
|
||||
<Area
|
||||
name="Sessions"
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={colors[0]}
|
||||
fillOpacity={ 1 }
|
||||
strokeWidth={ 2 }
|
||||
strokeOpacity={ 0.8 }
|
||||
fill={compare ? 'url(#colorCountCompare)' : 'url(#colorCount)'}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(null, { remove, setShowAlerts, setAlertMetricId })(CustomMetricWidget);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricWidget';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Loader, NoContent, Icon } from 'UI';
|
||||
import { widgetHOC, Styles } from '../../common';
|
||||
import { ResponsiveContainer, AreaChart, XAxis, YAxis, CartesianGrid, Area, Tooltip } from 'recharts';
|
||||
import { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period';
|
||||
import stl from './CustomMetricWidgetPreview.css';
|
||||
import { getChartFormatter } from 'Types/dashboard/helper';
|
||||
import { remove } from 'Duck/customMetrics';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
|
||||
import APIClient from 'App/api_client';
|
||||
|
||||
const customParams = rangeName => {
|
||||
const params = { density: 70 }
|
||||
|
||||
if (rangeName === LAST_24_HOURS) params.density = 70
|
||||
if (rangeName === LAST_30_MINUTES) params.density = 70
|
||||
if (rangeName === YESTERDAY) params.density = 70
|
||||
if (rangeName === LAST_7_DAYS) params.density = 70
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
interface Period {
|
||||
rangeName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
metric: any;
|
||||
// loading?: boolean;
|
||||
data?: any;
|
||||
showSync?: boolean;
|
||||
compare?: boolean;
|
||||
period?: Period;
|
||||
onClickEdit?: (e) => void;
|
||||
remove: (id) => void;
|
||||
}
|
||||
function CustomMetricWidget(props: Props) {
|
||||
const { metric, showSync, compare, period = { rangeName: LAST_24_HOURS} } = props;
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<any>({ chart: [{}] })
|
||||
|
||||
const colors = compare ? Styles.compareColors : Styles.colors;
|
||||
const params = customParams(period.rangeName)
|
||||
const gradientDef = Styles.gradientDef();
|
||||
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart' }
|
||||
|
||||
useEffect(() => {
|
||||
new APIClient()['post']('/custom_metrics/try', { ...metricParams, ...metric.toSaveData() })
|
||||
.then(response => response.json())
|
||||
.then(({ errors, data }) => {
|
||||
if (errors) {
|
||||
console.log('err', errors)
|
||||
} else {
|
||||
const _data = getChartFormatter(period)(data[0]);
|
||||
console.log('__data', _data)
|
||||
setData({ chart: _data });
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
}, [metric])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className={stl.wrapper}>
|
||||
<div className="flex items-center mb-10 p-2">
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<Loader loading={ loading } size="small">
|
||||
<NoContent
|
||||
size="small"
|
||||
show={ data.chart.length === 0 }
|
||||
>
|
||||
<ResponsiveContainer height={ 240 } width="100%">
|
||||
<AreaChart
|
||||
data={ data.chart }
|
||||
margin={Styles.chartMargins}
|
||||
syncId={ showSync ? "impactedSessionsBySlowPages" : undefined }
|
||||
>
|
||||
{gradientDef}
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
|
||||
<XAxis {...Styles.xaxis} dataKey="time" interval={params.density/7} />
|
||||
<YAxis
|
||||
{...Styles.yaxis}
|
||||
label={{ ...Styles.axisLabelLeft, value: "Number of Requests" }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip {...Styles.tooltip} />
|
||||
<Area
|
||||
name="Sessions"
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={colors[0]}
|
||||
fillOpacity={ 1 }
|
||||
strokeWidth={ 2 }
|
||||
strokeOpacity={ 0.8 }
|
||||
fill={compare ? 'url(#colorCountCompare)' : 'url(#colorCount)'}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(null, { remove })(CustomMetricWidget);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricWidgetPreview';
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchList } from 'Duck/customMetrics';
|
||||
import { list } from 'App/components/BugFinder/CustomFilters/filterModal.css';
|
||||
import CustomMetricWidget from './CustomMetricWidget';
|
||||
import AlertFormModal from 'App/components/Alerts/AlertFormModal';
|
||||
|
||||
interface Props {
|
||||
fetchList: Function;
|
||||
list: any;
|
||||
onClickEdit: (e) => void;
|
||||
}
|
||||
function CustomMetricsWidgets(props: Props) {
|
||||
const { list } = props;
|
||||
const [activeMetricId, setActiveMetricId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchList()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{list.map((item: any) => (
|
||||
<CustomMetricWidget
|
||||
metric={item}
|
||||
onClickEdit={props.onClickEdit}
|
||||
onAlertClick={(e) => setActiveMetricId(item.metricId)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AlertFormModal
|
||||
showModal={!!activeMetricId}
|
||||
metricId={activeMetricId}
|
||||
onClose={() => setActiveMetricId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
list: state.getIn(['customMetrics', 'list']),
|
||||
}), { fetchList })(CustomMetricsWidgets);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricsWidgets';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import stl from './CustomMetricWidgetHoc.css';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
}
|
||||
const CustomMetricWidgetHoc = ({ ...rest }: Props) => BaseComponent => {
|
||||
|
||||
console.log('CustomMetricWidgetHoc', rest);
|
||||
return (
|
||||
<div className={stl.wrapper}>
|
||||
<div className="flex items-center mb-10 p-2">
|
||||
<div className="font-medium">Widget Name</div>
|
||||
<div className="ml-auto">
|
||||
<div className="cursor-pointer">
|
||||
<Icon name="bell-plus" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <BaseComponent {...rest} /> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomMetricWidgetHoc;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricWidgetHoc';
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import React from 'react';
|
||||
import { Form, SegmentSelection, Button, IconButton } from 'UI';
|
||||
import FilterSeries from '../FilterSeries';
|
||||
import { connect } from 'react-redux';
|
||||
import { edit as editMetric, save } from 'Duck/customMetrics';
|
||||
import CustomMetricWidgetPreview from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview';
|
||||
|
||||
interface Props {
|
||||
metric: any;
|
||||
editMetric: (metric) => void;
|
||||
save: (metric) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
function CustomMetricForm(props: Props) {
|
||||
const { metric, loading } = props;
|
||||
|
||||
const addSeries = () => {
|
||||
const newSeries = {
|
||||
name: `Series ${metric.series.size + 1}`,
|
||||
type: '',
|
||||
series: [],
|
||||
filter: {
|
||||
type: '',
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
props.editMetric({
|
||||
...metric,
|
||||
series: [...metric.series, newSeries],
|
||||
});
|
||||
}
|
||||
|
||||
const removeSeries = (index) => {
|
||||
const newSeries = metric.series.filter((_series, i) => {
|
||||
return i !== index;
|
||||
});
|
||||
props.editMetric({
|
||||
...metric,
|
||||
series: newSeries,
|
||||
});
|
||||
}
|
||||
|
||||
const write = ({ target: { value, name } }) => props.editMetric({ ...metric.toData(), [ name ]: value })
|
||||
|
||||
const changeConditionTab = (e, { name, value }) => {
|
||||
props.editMetric({ ...metric.toData(), [ 'type' ]: value })
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="relative"
|
||||
onSubmit={() => props.save(metric)}
|
||||
>
|
||||
<div className="p-5 pb-20" style={{ height: 'calc(100vh - 60px)', overflowY: 'auto' }}>
|
||||
<div className="form-group">
|
||||
<label className="font-medium">Metric Title</label>
|
||||
<input
|
||||
autoFocus={ true }
|
||||
className="text-lg"
|
||||
name="name"
|
||||
style={{ fontSize: '18px', padding: '10px', fontWeight: '600'}}
|
||||
// value={ instance && instance.name }
|
||||
onChange={ write }
|
||||
placeholder="Metric Title"
|
||||
id="name-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="font-medium">Metric Type</label>
|
||||
<div className="flex items-center">
|
||||
<span className="bg-white p-1 px-2 border rounded">Timeseries</span>
|
||||
<span className="mx-2 color-gray-medium">of</span>
|
||||
<div>
|
||||
<SegmentSelection
|
||||
primary
|
||||
name="condition"
|
||||
small={true}
|
||||
// className="my-3"
|
||||
onSelect={ changeConditionTab }
|
||||
value={{ value: metric.type }}
|
||||
list={ [
|
||||
{ name: 'Session Count', value: 'session_count' },
|
||||
{ name: 'Session Percentage', value: 'session_percentage' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="font-medium">Series</label>
|
||||
{metric.series && metric.series.size > 0 && metric.series.map((series: any, index: number) => (
|
||||
<div className="mb-2">
|
||||
<FilterSeries
|
||||
seriesIndex={index}
|
||||
series={series}
|
||||
onRemoveSeries={() => removeSeries(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<IconButton onClick={addSeries} primaryText label="SERIES" icon="plus" />
|
||||
</div>
|
||||
|
||||
<div className="my-4" />
|
||||
|
||||
<CustomMetricWidgetPreview metric={metric} />
|
||||
</div>
|
||||
|
||||
<div className="fixed border-t w-full bottom-0 px-5 py-2 bg-white">
|
||||
<Button loading={loading} primary>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
metric: state.getIn(['customMetrics', 'instance']),
|
||||
loading: state.getIn(['customMetrics', 'saveRequest', 'loading']),
|
||||
}), { editMetric, save })(CustomMetricForm);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricForm';
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import CustomMetricWidgetPreview from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview';
|
||||
import React, { useState } from 'react';
|
||||
import { IconButton, SlideModal } from 'UI'
|
||||
import CustomMetricForm from './CustomMetricForm';
|
||||
import { connect } from 'react-redux';
|
||||
import { edit } from 'Duck/customMetrics';
|
||||
|
||||
interface Props {
|
||||
metric: any;
|
||||
edit: (metric) => void;
|
||||
}
|
||||
function CustomMetrics(props: Props) {
|
||||
const { metric } = props;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const onClose = () => {
|
||||
setShowModal(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="self-start">
|
||||
<IconButton outline icon="plus" label="CREATE METRIC" onClick={() => {
|
||||
setShowModal(true);
|
||||
// props.edit({ name: 'New', series: [{ name: '', filter: {} }], type: '' });
|
||||
}} />
|
||||
|
||||
<SlideModal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="mr-3">{ 'Custom Metric' }</span>
|
||||
</div>
|
||||
}
|
||||
isDisplayed={ showModal }
|
||||
onClose={ () => setShowModal(false)}
|
||||
// size="medium"
|
||||
content={ (showModal || metric) && (
|
||||
<div style={{ backgroundColor: '#f6f6f6' }}>
|
||||
<CustomMetricForm metric={metric} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
metric: state.getIn(['customMetrics', 'instance']),
|
||||
alertInstance: state.getIn(['alerts', 'instance']),
|
||||
}), { edit })(CustomMetrics);
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import React, { useState } from 'react';
|
||||
import FilterList from 'Shared/Filters/FilterList';
|
||||
import { edit, updateSeries } from 'Duck/customMetrics';
|
||||
import { connect } from 'react-redux';
|
||||
import { IconButton, Button, Icon, SegmentSelection } from 'UI';
|
||||
import FilterSelection from '../../Filters/FilterSelection';
|
||||
import SeriesName from './SeriesName';
|
||||
|
||||
interface Props {
|
||||
seriesIndex: number;
|
||||
series: any;
|
||||
edit: typeof edit;
|
||||
updateSeries: typeof updateSeries;
|
||||
onRemoveSeries: (seriesIndex) => void;
|
||||
}
|
||||
|
||||
function FilterSeries(props: Props) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const { series, seriesIndex } = props;
|
||||
|
||||
const onAddFilter = (filter) => {
|
||||
filter.value = [""]
|
||||
const newFilters = series.filter.filters.concat(filter);
|
||||
props.updateSeries(seriesIndex, {
|
||||
...series,
|
||||
filter: {
|
||||
...series.filter,
|
||||
filters: newFilters,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onUpdateFilter = (filterIndex, filter) => {
|
||||
const newFilters = series.filter.filters.map((_filter, i) => {
|
||||
if (i === filterIndex) {
|
||||
return filter;
|
||||
} else {
|
||||
return _filter;
|
||||
}
|
||||
});
|
||||
|
||||
props.updateSeries(seriesIndex, {
|
||||
...series.toData(),
|
||||
filter: {
|
||||
...series.filter,
|
||||
filters: newFilters,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onChangeEventsOrder = (e, { name, value }) => {
|
||||
props.updateSeries(seriesIndex, {
|
||||
...series.toData(),
|
||||
filter: {
|
||||
...series.filter,
|
||||
eventsOrder: value,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onRemoveFilter = (filterIndex) => {
|
||||
const newFilters = series.filter.filters.filter((_filter, i) => {
|
||||
return i !== filterIndex;
|
||||
});
|
||||
|
||||
props.updateSeries(seriesIndex, {
|
||||
...series,
|
||||
filter: {
|
||||
...series.filter,
|
||||
filters: newFilters,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded bg-white">
|
||||
<div className="border-b px-5 h-12 flex items-center relative">
|
||||
{/* <div className="font-medium flex items-center">
|
||||
{ series.name }
|
||||
<div className="ml-3 cursor-pointer"><Icon name="pencil" size="14" /></div>
|
||||
</div> */}
|
||||
<div className="mr-auto">
|
||||
<SeriesName name={series.name} onUpdate={() => null } />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center cursor-pointer" >
|
||||
<div onClick={props.onRemoveSeries} className="ml-3">
|
||||
<Icon name="trash" size="16" />
|
||||
</div>
|
||||
|
||||
<div onClick={() => setExpanded(!expanded)} className="ml-3">
|
||||
<Icon name="chevron-down" size="16" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{ expanded && (
|
||||
<>
|
||||
<div className="p-5">
|
||||
{ series.filter.filters.size > 0 ? (
|
||||
<FilterList
|
||||
// filters={series.filter.filters.toJS()}
|
||||
filter={series.filter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
/>
|
||||
): (
|
||||
<div className="color-gray-medium">Add user event or filter to build the series.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 border-t h-12 flex items-center">
|
||||
<FilterSelection
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
>
|
||||
<IconButton primaryText label="ADD STEP" icon="plus" />
|
||||
</FilterSelection>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(null, { edit, updateSeries })(FilterSeries);
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { edit } from 'App/components/ui/ItemMenu/itemMenu.css';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onUpdate: (name) => void;
|
||||
}
|
||||
function SeriesName(props: Props) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(props.name)
|
||||
const ref = useRef<any>(null)
|
||||
|
||||
const write = ({ target: { value, name } }) => {
|
||||
setName(value)
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
setEditing(false)
|
||||
// props.onUpdate(name)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
ref.current.focus()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
// const { name } = props;
|
||||
return (
|
||||
<div className="font-medium flex items-center">
|
||||
<input
|
||||
ref={ ref }
|
||||
name="name"
|
||||
className="fluid border-0 -mx-2 px-2"
|
||||
value={name} readOnly={!editing}
|
||||
onChange={write}
|
||||
onBlur={onBlur}
|
||||
onFocus={() => setEditing(true)}
|
||||
/>
|
||||
<div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesName;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SeriesName';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterSeries'
|
||||
1
frontend/app/components/shared/CustomMetrics/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetrics';
|
||||
|
|
@ -137,12 +137,13 @@ class AttributeValueField extends React.PureComponent {
|
|||
const { filter, onChange } = this.props;
|
||||
const _showAutoComplete = this.isAutoComplete(filter.type);
|
||||
const _params = _showAutoComplete ? this.getParams(filter) : {};
|
||||
let _optionsEndpoint= '/events/search';
|
||||
let _optionsEndpoint= '/events/search';
|
||||
console.log('value', filter.value)
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{ _showAutoComplete ?
|
||||
<AutoComplete
|
||||
<AutoComplete
|
||||
name={ 'value' }
|
||||
endpoint={ _optionsEndpoint }
|
||||
value={ filter.value }
|
||||
|
|
@ -151,6 +152,7 @@ class AttributeValueField extends React.PureComponent {
|
|||
onSelect={ onChange }
|
||||
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
|
||||
fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value }
|
||||
// onAddOrRemove={}
|
||||
/>
|
||||
: this.renderField()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ class AutoComplete extends React.PureComponent {
|
|||
noResultsMessage: SOME_ERROR_MSG,
|
||||
})
|
||||
|
||||
onInputChange = (e, { name, value }) => {
|
||||
onInputChange = ({ target: { value } }) => {
|
||||
changed = true;
|
||||
this.setState({ query: value, updated: true })
|
||||
const _value = value.trim();
|
||||
|
|
@ -118,7 +118,8 @@ class AutoComplete extends React.PureComponent {
|
|||
valueToText = defaultValueToText,
|
||||
placeholder = 'Type to search...',
|
||||
headerText = '',
|
||||
fullWidth = false
|
||||
fullWidth = false,
|
||||
onAddOrRemove = () => null,
|
||||
} = this.props;
|
||||
|
||||
const options = optionMapping(values, valueToText)
|
||||
|
|
@ -128,7 +129,7 @@ class AutoComplete extends React.PureComponent {
|
|||
className={ cn("relative", { "flex-1" : fullWidth }) }
|
||||
onClickOutside={this.onClickOutside}
|
||||
>
|
||||
<Input
|
||||
{/* <Input
|
||||
className={ cn(stl.searchInput, { [ stl.fullWidth] : fullWidth }) }
|
||||
onChange={ this.onInputChange }
|
||||
onBlur={ this.onBlur }
|
||||
|
|
@ -144,7 +145,30 @@ class AutoComplete extends React.PureComponent {
|
|||
this.hiddenInput.value = text;
|
||||
pasted = true; // to use only the hidden input
|
||||
} }
|
||||
/>
|
||||
/> */}
|
||||
<div className={stl.inputWrapper}>
|
||||
<input
|
||||
name="query"
|
||||
// className={cn(stl.input)}
|
||||
onFocus={ () => this.setState({ddOpen: true})}
|
||||
onChange={ this.onInputChange }
|
||||
onBlur={ this.onBlur }
|
||||
onFocus={ () => this.setState({ddOpen: true})}
|
||||
value={ query }
|
||||
autoFocus={ true }
|
||||
type="text"
|
||||
placeholder={ placeholder }
|
||||
onPaste={(e) => {
|
||||
const text = e.clipboardData.getData('Text');
|
||||
this.hiddenInput.value = text;
|
||||
pasted = true; // to use only the hidden input
|
||||
} }
|
||||
/>
|
||||
<div className={cn(stl.right, 'cursor-pointer')} onLick={onAddOrRemove}>
|
||||
{/* <Icon name="close" size="18" /> */}
|
||||
<span className="px-1">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea>
|
||||
{ ddOpen && options.length > 0 &&
|
||||
<div className={ stl.menu }>
|
||||
|
|
|
|||
|
|
@ -28,3 +28,30 @@
|
|||
.fullWidth {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
border: solid thin $gray-light !important;
|
||||
border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& input {
|
||||
height: 28px;
|
||||
font-size: 13px !important;
|
||||
padding: 0 5px !important;
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
& .right {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
background-color: $gray-lightest;
|
||||
border-left: solid thin $gray-light !important;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
|
||||
}
|
||||
function EventSearchInput(props) {
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
className="border rounded p-1"
|
||||
type="text" placeholder="Search for an event"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventSearchInput;
|
||||
1
frontend/app/components/shared/EventSearchInput/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EventSearchInput';
|
||||
|
|
@ -27,7 +27,7 @@ const locationOptions = Object.keys(regionLabels).map(k => ({ key: LOCATION, tex
|
|||
const _filterKeys = [
|
||||
{ key: 'userId', name: 'User ID', icon: 'user-alt', placeholder: 'Search for User ID' },
|
||||
{ key: 'userAnonymousId', name: 'User Anonymous ID', icon: 'filters/userid', placeholder: 'Search for User Anonymous ID' },
|
||||
{ key: 'revId', name: 'Rev ID', icon: 'filters/border-outer', placeholder: 'Search for Rev ID' },
|
||||
{ key: 'revId', name: 'Rev ID', icon: 'filters/rev-id', placeholder: 'Search for Rev ID' },
|
||||
{ key: COUNTRY, name: 'Country', icon: 'map-marker-alt', placeholder: 'Search for Country' },
|
||||
{ key: 'device', name: 'Device', icon: 'device', placeholder: 'Search for Device' },
|
||||
{ key: 'os', name: 'OS', icon: 'os', placeholder: 'Search for OS' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
.wrapper {
|
||||
border: solid thin $gray-light !important;
|
||||
border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& input {
|
||||
height: 28px;
|
||||
font-size: 13px !important;
|
||||
padding: 0 5px !important;
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border: solid thin transparent !important;
|
||||
}
|
||||
|
||||
& .right {
|
||||
height: 28px;
|
||||
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 10px 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Icon, Loader } from 'UI';
|
||||
import APIClient from 'App/api_client';
|
||||
import { debounce } from 'App/utils';
|
||||
import stl from './FilterAutoComplete.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
const hiddenStyle = {
|
||||
whiteSpace: 'pre-wrap',
|
||||
opacity: 0, position: 'fixed', left: '-3000px'
|
||||
};
|
||||
|
||||
interface Props {
|
||||
showOrButton?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
onRemoveValue?: () => void;
|
||||
onAddValue?: () => void;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
params?: any;
|
||||
headerText?: string;
|
||||
placeholder?: string;
|
||||
onSelect: (e, item) => void;
|
||||
value: any;
|
||||
}
|
||||
|
||||
function FilterAutoComplete(props: Props) {
|
||||
const {
|
||||
showCloseButton = false,
|
||||
placeholder = 'Type to search',
|
||||
method = 'GET',
|
||||
showOrButton = false,
|
||||
onRemoveValue = () => null,
|
||||
onAddValue = () => null,
|
||||
endpoint = '',
|
||||
params = {},
|
||||
headerText = '',
|
||||
value = '',
|
||||
} = props;
|
||||
const [showModal, setShowModal] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [options, setOptions] = useState<any>([]);
|
||||
const [query, setQuery] = useState(value);
|
||||
|
||||
|
||||
const requestValues = (q) => {
|
||||
// const { params, method } = props;
|
||||
setLoading(true);
|
||||
|
||||
return new APIClient()[method?.toLowerCase()](endpoint, { ...params, q })
|
||||
.then(response => response.json())
|
||||
.then(({ errors, data }) => {
|
||||
if (errors) {
|
||||
// this.setError();
|
||||
} else {
|
||||
setOptions(data);
|
||||
// this.setState({
|
||||
// ddOpen: true,
|
||||
// values: data,
|
||||
// loading: false,
|
||||
// noResultsMessage: NO_RESULTS_MSG,
|
||||
// });
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
// .catch(this.setError);
|
||||
}
|
||||
|
||||
const debouncedRequestValues = debounce(requestValues, 1000)
|
||||
|
||||
const onInputChange = ({ target: { value } }) => {
|
||||
setQuery(value);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (query === '' || query === ' ') {
|
||||
return
|
||||
}
|
||||
|
||||
debouncedRequestValues(query)
|
||||
}, [query])
|
||||
|
||||
const onItemClick = (e, item) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
// const { onSelect, name } = this.props;
|
||||
|
||||
|
||||
if (query !== item.value) {
|
||||
setQuery(item.value);
|
||||
}
|
||||
// this.setState({ query: item.value, ddOpen: false})
|
||||
props.onSelect(e, item);
|
||||
// setTimeout(() => {
|
||||
// setShowModal(false)
|
||||
// }, 10)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div className={stl.wrapper}>
|
||||
<input
|
||||
name="query"
|
||||
onChange={ onInputChange }
|
||||
onBlur={ () => setTimeout(() => { setShowModal(false) }, 50) }
|
||||
onFocus={ () => setShowModal(true)}
|
||||
value={ query }
|
||||
autoFocus={ true }
|
||||
type="text"
|
||||
placeholder={ placeholder }
|
||||
// onPaste={(e) => {
|
||||
// const text = e.clipboardData.getData('Text');
|
||||
// // this.hiddenInput.value = text;
|
||||
// // pasted = true; // to use only the hidden input
|
||||
// } }
|
||||
/>
|
||||
<div
|
||||
className={stl.right}
|
||||
// onClick={showOrButton ? onRemoveValue : onAddValue}
|
||||
>
|
||||
{ showCloseButton && <div onClick={onRemoveValue}><Icon name="close" size="18" /></div> }
|
||||
{ showOrButton && <div onClick={onAddValue} className="color-teal"><span className="px-1">or</span></div> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ !showOrButton && <div className="ml-3">or</div> }
|
||||
|
||||
{/* <textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea> */}
|
||||
|
||||
{ showModal && (options.length > 0 || loading) &&
|
||||
<div className={ stl.menu }>
|
||||
{ headerText && headerText }
|
||||
<Loader loading={loading} size="small">
|
||||
{
|
||||
options.map(item => (
|
||||
<div
|
||||
className={ cn(stl.filterItem) }
|
||||
id="filter-item" onClick={ (e) => onItemClick(e, item) }
|
||||
>
|
||||
{ item.icon && <Icon name={ item.icon } size="16" marginRight="8" /> }
|
||||
<span className={ stl.label }>{ item.value }</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Loader>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterAutoComplete;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterAutoComplete';
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
& input {
|
||||
max-width: 85px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 400 !important;
|
||||
color: $gray-medium !important;
|
||||
}
|
||||
|
||||
& > div {
|
||||
&:first-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px !important;
|
||||
font-weight: 400 !important;
|
||||
color: $gray-medium !important;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Input, Label } from 'semantic-ui-react';
|
||||
import styles from './FilterDuration.css';
|
||||
|
||||
const fromMs = value => value ? `${ value / 1000 / 60 }` : ''
|
||||
const toMs = value => value !== '' ? value * 1000 * 60 : null
|
||||
|
||||
export default class FilterDuration extends React.PureComponent {
|
||||
state = { focused: false }
|
||||
onChange = (e, { name, value }) => {
|
||||
const { onChange } = this.props;
|
||||
if (typeof onChange === 'function') {
|
||||
onChange({
|
||||
[ name ]: toMs(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onKeyPress = e => {
|
||||
const { onEnterPress } = this.props;
|
||||
if (e.key === 'Enter' && typeof onEnterPress === 'function') {
|
||||
onEnterPress(e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
minDuration,
|
||||
maxDuration,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={ styles.wrapper }>
|
||||
<Input
|
||||
labelPosition="left"
|
||||
type="number"
|
||||
placeholder="0 min"
|
||||
name="minDuration"
|
||||
value={ fromMs(minDuration) }
|
||||
onChange={ this.onChange }
|
||||
className="customInput"
|
||||
onKeyPress={ this.onKeyPress }
|
||||
onFocus={() => this.setState({ focused: true })}
|
||||
onBlur={this.props.onBlur}
|
||||
>
|
||||
<Label basic className={ styles.label }>{ 'Min' }</Label>
|
||||
<input min="1" />
|
||||
</Input>
|
||||
<Input
|
||||
labelPosition="left"
|
||||
type="number"
|
||||
placeholder="∞ min"
|
||||
name="maxDuration"
|
||||
value={ fromMs(maxDuration) }
|
||||
onChange={ this.onChange }
|
||||
className="customInput"
|
||||
onKeyPress={ this.onKeyPress }
|
||||
onFocus={() => this.setState({ focused: true })}
|
||||
onBlur={this.props.onBlur}
|
||||
>
|
||||
<Label basic className={ styles.label }>{ 'Max' }</Label>
|
||||
<input min="1" />
|
||||
</Input>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterDuration';
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import FilterOperator from '../FilterOperator/FilterOperator';
|
||||
import FilterSelection from '../FilterSelection';
|
||||
import FilterValue from '../FilterValue';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
filterIndex: number;
|
||||
filter: any; // event/filter
|
||||
onUpdate: (filter) => void;
|
||||
onRemoveFilter: () => void;
|
||||
isFilter?: boolean;
|
||||
}
|
||||
function FitlerItem(props: Props) {
|
||||
const { isFilter = false, filterIndex, filter, onUpdate } = props;
|
||||
|
||||
const replaceFilter = (filter) => {
|
||||
onUpdate(filter);
|
||||
};
|
||||
|
||||
// const onAddValue = () => {
|
||||
// const newValues = filter.value.concat("")
|
||||
// onUpdate({ ...filter, value: newValues })
|
||||
// }
|
||||
|
||||
// const onRemoveValue = (valueIndex) => {
|
||||
// const newValues = filter.value.filter((_, _index) => _index !== valueIndex)
|
||||
// onUpdate({ ...filter, value: newValues })
|
||||
// }
|
||||
|
||||
// const onSelect = (e, item, valueIndex) => {
|
||||
// const newValues = filter.value.map((_, _index) => {
|
||||
// if (_index === valueIndex) {
|
||||
// return item.value;
|
||||
// }
|
||||
// return _;
|
||||
// })
|
||||
// onUpdate({ ...filter, value: newValues })
|
||||
// }
|
||||
|
||||
const onOperatorChange = (e, { name, value }) => {
|
||||
console.log('onOperatorChange', name, value)
|
||||
onUpdate({ ...filter, operator: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex items-start mr-auto">
|
||||
{ !isFilter && <div className="mt-1 w-6 h-6 text-xs flex justify-center rounded-full bg-gray-light-shade mr-2">{filterIndex+1}</div> }
|
||||
<FilterSelection filter={filter} onFilterClick={replaceFilter} />
|
||||
<FilterOperator filter={filter} onChange={onOperatorChange} className="mx-2 flex-shrink-0"/>
|
||||
<FilterValue filter={filter} onUpdate={onUpdate} />
|
||||
</div>
|
||||
<div className="flex self-start mt-2">
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
onClick={props.onRemoveFilter}
|
||||
>
|
||||
<Icon name="trash" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FitlerItem;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterItem';
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import React, { useState} from 'react';
|
||||
import FilterItem from '../FilterItem';
|
||||
import { SegmentSelection } from 'UI';
|
||||
|
||||
interface Props {
|
||||
// filters: any[]; // event/filter
|
||||
filter?: any; // event/filter
|
||||
onUpdateFilter: (filterIndex, filter) => void;
|
||||
onRemoveFilter: (filterIndex) => void;
|
||||
onChangeEventsOrder: (e, { name, value }) => void;
|
||||
}
|
||||
function FilterList(props: Props) {
|
||||
const { filter } = props;
|
||||
const filters = filter.filters;
|
||||
const hasEvents = filter.filters.filter(i => i.isEvent).size > 0;
|
||||
const hasFilters = filter.filters.filter(i => !i.isEvent).size > 0;
|
||||
|
||||
const onRemoveFilter = (filterIndex) => {
|
||||
const newFilters = filters.filter((_filter, i) => {
|
||||
return i !== filterIndex;
|
||||
});
|
||||
|
||||
props.onRemoveFilter(filterIndex);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{ hasEvents && (
|
||||
<>
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="mb-2 text-sm color-gray-medium mr-auto">EVENTS</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 color-gray-medium text-sm">Events Order</div>
|
||||
<SegmentSelection
|
||||
primary
|
||||
name="eventsOrder"
|
||||
extraSmall={true}
|
||||
// className="my-3"
|
||||
onSelect={props.onChangeEventsOrder}
|
||||
// onSelect={() => null }
|
||||
value={{ value: filter.eventsOrder }}
|
||||
// value={{ value: 'and' }}
|
||||
list={ [
|
||||
{ name: 'AND', value: 'and' },
|
||||
{ name: 'OR', value: 'or' },
|
||||
{ name: 'THEN', value: 'then' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filters.map((filter, filterIndex) => filter.isEvent ? (
|
||||
<FilterItem
|
||||
filterIndex={filterIndex}
|
||||
filter={filter}
|
||||
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
|
||||
onRemoveFilter={() => onRemoveFilter(filterIndex) }
|
||||
/>
|
||||
): null)}
|
||||
<div className='mb-2' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasFilters && (
|
||||
<>
|
||||
<div className='border-t -mx-5 mb-2' />
|
||||
<div className="mb-2 text-sm color-gray-medium mr-auto">FILTERS</div>
|
||||
{filters.map((filter, filterIndex) => !filter.isEvent ? (
|
||||
<FilterItem
|
||||
isFilter={true}
|
||||
filterIndex={filterIndex}
|
||||
filter={filter}
|
||||
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
|
||||
onRemoveFilter={() => onRemoveFilter(filterIndex) }
|
||||
/>
|
||||
): null)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterList;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterList';
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
interface Props {
|
||||
filters: any,
|
||||
onFilterClick?: (filter) => void
|
||||
}
|
||||
function FilterModal(props: Props) {
|
||||
const { filters, onFilterClick = () => null } = props;
|
||||
return (
|
||||
<div className="border p-3" style={{ width: '490px', height: '400px', overflowY: 'auto'}}>
|
||||
<div className="" style={{ columns: "100px 2" }}>
|
||||
{filters && Object.keys(filters).map((key) => (
|
||||
<div className="p-3 aspect-w-1">
|
||||
<div className="uppercase font-medium mb-1">{key}</div>
|
||||
<div>
|
||||
{filters[key].map((filter: any) => (
|
||||
<div className="flex items-center py-2 cursor-pointer hover:bg-gray-lightest -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([ 'filters', 'filterList' ])
|
||||
}))(FilterModal);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterModal';
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
.operatorDropdown {
|
||||
font-weight: 400;
|
||||
height: 30px;
|
||||
min-width: 60px;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px !important;
|
||||
font-size: 13px;
|
||||
/* background-color: rgba(255, 255, 255, 0.8) !important; */
|
||||
background-color: $gray-lightest !important;
|
||||
border: solid thin rgba(34, 36, 38, 0.15) !important;
|
||||
border-radius: 4px !important;
|
||||
color: $gray-darkest !important;
|
||||
font-size: 14px !important;
|
||||
&.ui.basic.button {
|
||||
box-shadow: 0 0 0 1px rgba(62, 170, 175,36,38,.35) inset, 0 0 0 0 rgba(62, 170, 175,.15) inset !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { Dropdown, Icon } from 'UI';
|
||||
import stl from './FilterOperator.css';
|
||||
|
||||
interface Props {
|
||||
filter: any; // event/filter
|
||||
onChange: (e, { name, value }) => void;
|
||||
className?: string;
|
||||
}
|
||||
function FilterOperator(props: Props) {
|
||||
const { filter, onChange, className = '' } = props;
|
||||
|
||||
console.log('FilterOperator', filter.operator);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className={ cn(stl.operatorDropdown, className) }
|
||||
options={ filter.operatorOptions }
|
||||
name="operator"
|
||||
value={ filter.operator }
|
||||
onChange={ onChange }
|
||||
placeholder="Select operator"
|
||||
icon={ <Icon className="ml-5" name="chevron-down" size="12" /> }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterOperator;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterOperator';
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState } from 'react';
|
||||
import FilterModal from '../FilterModal';
|
||||
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
filter: any; // event/filter
|
||||
onFilterClick: (filter) => void;
|
||||
children?: any;
|
||||
}
|
||||
function FilterSelection(props: Props) {
|
||||
const { filter, onFilterClick, children } = props;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0">
|
||||
<OutsideClickDetectingDiv
|
||||
className="relative"
|
||||
onClickOutside={ () => setTimeout(function() {
|
||||
setShowModal(false)
|
||||
}, 50)}
|
||||
>
|
||||
{ children ? React.cloneElement(children, { onClick: () => setShowModal(true)}) : (
|
||||
<div
|
||||
className="rounded py-1 px-3 flex items-center cursor-pointer bg-gray-lightest text-ellipsis"
|
||||
style={{ width: '140px', height: '30px', border: 'solid thin rgba(34, 36, 38, 0.15)'}}
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<span className="mr-auto truncate">{filter.label}</span>
|
||||
<Icon name="chevron-down" size="14" />
|
||||
</div>
|
||||
) }
|
||||
</OutsideClickDetectingDiv>
|
||||
{showModal && (
|
||||
<div className="absolute left-0 top-20 border shadow rounded bg-white z-50">
|
||||
<FilterModal onFilterClick={onFilterClick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterSelection;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterSelection';
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import React, { useState } from 'react';
|
||||
import FilterAutoComplete from '../FilterAutoComplete';
|
||||
import { FilterType } from 'Types/filter/filterType';
|
||||
import FilterValueDropdown from '../FilterValueDropdown';
|
||||
import FilterDuration from '../FilterDuration';
|
||||
|
||||
interface Props {
|
||||
filter: any;
|
||||
onUpdate: (filter) => void;
|
||||
}
|
||||
function FilterValue(props: Props) {
|
||||
const { filter } = props;
|
||||
const [durationValues, setDurationValues] = useState({ minDuration: 0, maxDuration: 0 });
|
||||
|
||||
const onAddValue = () => {
|
||||
const newValues = filter.value.concat("")
|
||||
props.onUpdate({ ...filter, value: newValues })
|
||||
}
|
||||
|
||||
const onRemoveValue = (valueIndex) => {
|
||||
const newValues = filter.value.filter((_, _index) => _index !== valueIndex)
|
||||
props.onUpdate({ ...filter, value: newValues })
|
||||
}
|
||||
|
||||
const onSelect = (e, item, valueIndex) => {
|
||||
const newValues = filter.value.map((_, _index) => {
|
||||
if (_index === valueIndex) {
|
||||
return item.value;
|
||||
}
|
||||
return _;
|
||||
})
|
||||
props.onUpdate({ ...filter, value: newValues })
|
||||
}
|
||||
|
||||
const onDurationChange = (newValues) => {
|
||||
console.log('durationValues', durationValues)
|
||||
// setDurationValues({ ...durationValues });
|
||||
setDurationValues({ ...durationValues, ...newValues });
|
||||
}
|
||||
|
||||
const handleBlur = (e) => {
|
||||
// const { filter, onChange } = props;
|
||||
if (filter.type === FilterType.DURATION) {
|
||||
const { maxDuration, minDuration, key } = filter;
|
||||
if (maxDuration || minDuration) return;
|
||||
if (maxDuration !== durationValues.maxDuration ||
|
||||
minDuration !== durationValues.minDuration) {
|
||||
// onChange(e, { name: 'value', value: [this.state.minDuration, this.state.maxDuration] });
|
||||
props.onUpdate({ ...filter, value: [durationValues.minDuration, durationValues.maxDuration] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderValueFiled = (value, valueIndex) => {
|
||||
switch(filter.type) {
|
||||
case FilterType.DROPDOWN:
|
||||
return (
|
||||
<FilterValueDropdown
|
||||
value={value}
|
||||
filter={filter}
|
||||
options={filter.options}
|
||||
onChange={(e, { name, value }) => onSelect(e, { value }, valueIndex)}
|
||||
/>
|
||||
)
|
||||
case FilterType.ISSUE:
|
||||
case FilterType.MULTIPLE_DROPDOWN:
|
||||
return (
|
||||
<FilterValueDropdown
|
||||
multiple={true}
|
||||
value={value}
|
||||
filter={filter}
|
||||
options={filter.options}
|
||||
onChange={(e, { name, value }) => onSelect(e, { value }, valueIndex)}
|
||||
/>
|
||||
)
|
||||
case FilterType.DURATION:
|
||||
return (
|
||||
<FilterDuration
|
||||
onChange={ onDurationChange }
|
||||
// onEnterPress={ this.handleClose }
|
||||
onBlur={handleBlur}
|
||||
minDuration={ filter.value[0] }
|
||||
maxDuration={ filter.value[1] }
|
||||
/>
|
||||
)
|
||||
case FilterType.NUMBER:
|
||||
return (
|
||||
<input
|
||||
className="w-full px-2 py-1 text-sm leading-tight text-gray-700 rounded-lg"
|
||||
type="number"
|
||||
name={`${filter.key}-${valueIndex}`}
|
||||
value={value}
|
||||
onChange={(e) => onSelect(e, { value: e.target.value }, valueIndex)}
|
||||
/>
|
||||
)
|
||||
case FilterType.MULTIPLE:
|
||||
return (
|
||||
<FilterAutoComplete
|
||||
value={value}
|
||||
showCloseButton={filter.value.length > 1}
|
||||
showOrButton={valueIndex === filter.value.length - 1}
|
||||
onAddValue={onAddValue}
|
||||
onRemoveValue={() => onRemoveValue(valueIndex)}
|
||||
method={'GET'}
|
||||
endpoint='/events/search'
|
||||
params={{ type: filter.key }}
|
||||
headerText={''}
|
||||
// placeholder={''}
|
||||
onSelect={(e, item) => onSelect(e, item, valueIndex)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{ filter.type === FilterType.DURATION ? (
|
||||
renderValueFiled(filter.value, 0)
|
||||
) : (
|
||||
filter.value && filter.value.map((value, valueIndex) => (
|
||||
renderValueFiled(value, valueIndex)
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterValue;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterValue';
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
.wrapper {
|
||||
border: solid thin $gray-light !important;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-lightest !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
|
||||
& .right {
|
||||
height: 28px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.operatorDropdown {
|
||||
font-weight: 400;
|
||||
height: 30px;
|
||||
min-width: 60px;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px !important;
|
||||
font-size: 13px;
|
||||
/* background-color: rgba(255, 255, 255, 0.8) !important; */
|
||||
/* background-color: $gray-lightest !important; */
|
||||
/* border: solid thin rgba(34, 36, 38, 0.15) !important; */
|
||||
/* border-radius: 4px !important; */
|
||||
color: $gray-darkest !important;
|
||||
font-size: 14px !important;
|
||||
&.ui.basic.button {
|
||||
box-shadow: 0 0 0 1px rgba(62, 170, 175,36,38,.35) inset, 0 0 0 0 rgba(62, 170, 175,.15) inset !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { Dropdown, Icon } from 'UI';
|
||||
import stl from './FilterValueDropdown.css';
|
||||
|
||||
interface Props {
|
||||
filter: any; // event/filter
|
||||
// options: any[];
|
||||
value: string;
|
||||
onChange: (e, { name, value }) => void;
|
||||
className?: string;
|
||||
options: any[];
|
||||
search?: boolean;
|
||||
multiple?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
showOrButton?: boolean;
|
||||
onRemoveValue?: () => void;
|
||||
onAddValue?: () => void;
|
||||
}
|
||||
function FilterValueDropdown(props: Props) {
|
||||
const { multiple = false, search = false, options, onChange, value, className = '', showCloseButton = true, showOrButton = true } = props;
|
||||
// const options = []
|
||||
|
||||
return (
|
||||
<div className={stl.wrapper}>
|
||||
<Dropdown
|
||||
search={search}
|
||||
className={ cn(stl.operatorDropdown, className) }
|
||||
options={ options }
|
||||
name="issue_type"
|
||||
value={ value }
|
||||
onChange={ onChange }
|
||||
placeholder="Select"
|
||||
icon={ <Icon className="ml-5" name="chevron-down" size="12" /> }
|
||||
/>
|
||||
<div
|
||||
className={stl.right}
|
||||
// onClick={showOrButton ? onRemoveValue : onAddValue}
|
||||
>
|
||||
{ showCloseButton && <div onClick={props.onRemoveValue}><Icon name="close" size="18" /></div> }
|
||||
{ showOrButton && <div onClick={props.onAddValue} className="color-teal"><span className="px-1">or</span></div> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterValueDropdown;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterValueDropdown';
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import React, { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { save } from 'Duck/filters';
|
||||
import { Button } from 'UI';
|
||||
import SaveSearchModal from 'Shared/SaveSearchModal'
|
||||
|
||||
interface Props {
|
||||
filter: any;
|
||||
}
|
||||
|
||||
function SaveFilterButton(props) {
|
||||
const [showModal, setshowModal] = useState(false)
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={() => setshowModal(true)}>SAVE FILTER</Button>
|
||||
<SaveSearchModal
|
||||
show={showModal}
|
||||
closeHandler={() => setshowModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
filter: state.getIn([ 'filters', 'appliedFilter' ]),
|
||||
}), { save })(SaveFilterButton);
|
||||
1
frontend/app/components/shared/SaveFilterButton/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SaveFilterButton'
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { edit, save } from 'Duck/search';
|
||||
import { Button, Modal, Form, Icon, Checkbox } from 'UI';
|
||||
import stl from './SaveSearchModal.css';
|
||||
|
||||
interface Props {
|
||||
filter: any;
|
||||
loading: boolean;
|
||||
edit: (filter: any) => void;
|
||||
save: (filter: any) => Promise<void>;
|
||||
show: boolean;
|
||||
closeHandler: () => void;
|
||||
}
|
||||
function SaveSearchModal(props: Props) {
|
||||
const { filter, loading, show, closeHandler } = props;
|
||||
|
||||
const onNameChange = ({ target: { value } }) => {
|
||||
props.edit({ name: value });
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const { filter, closeHandler } = props;
|
||||
if (filter.name.trim() === '') return;
|
||||
props.save(filter).then(function() {
|
||||
// this.props.fetchFunnelsList();
|
||||
closeHandler();
|
||||
});
|
||||
}
|
||||
console.log('filter', filter);
|
||||
return (
|
||||
<Modal size="tiny" open={ show }>
|
||||
<Modal.Header className={ stl.modalHeader }>
|
||||
<div>{ 'Save Search' }</div>
|
||||
<Icon
|
||||
role="button"
|
||||
tabIndex="-1"
|
||||
color="gray-dark"
|
||||
size="18"
|
||||
name="close"
|
||||
onClick={ closeHandler }
|
||||
/>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Content>
|
||||
<Form onSubmit={onSave}>
|
||||
<Form.Field>
|
||||
<label>{'Title:'}</label>
|
||||
<input
|
||||
autoFocus={ true }
|
||||
// className={ stl.name }
|
||||
name="name"
|
||||
value={ filter.name }
|
||||
onChange={ onNameChange }
|
||||
placeholder="Title"
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions className="">
|
||||
<Button
|
||||
primary
|
||||
onClick={ onSave }
|
||||
loading={ loading }
|
||||
>
|
||||
{ filter.exists() ? 'Modify' : 'Save' }
|
||||
</Button>
|
||||
<Button className={ stl.cancelButton } marginRight onClick={ closeHandler }>{ 'Cancel' }</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
filter: state.getIn(['search', 'instance']),
|
||||
loading: state.getIn([ 'filters', 'saveRequest', 'loading' ]) ||
|
||||
state.getIn([ 'filters', 'updateRequest', 'loading' ]),
|
||||
}), { edit, save })(SaveSearchModal);
|
||||
1
frontend/app/components/shared/SaveSearchModal/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SaveSearchModal'
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
@import 'mixins.css';
|
||||
|
||||
.modalHeader {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
@mixin plainButton;
|
||||
}
|
||||
|
||||
.applyButton {
|
||||
@mixin basicButton;
|
||||
}
|
||||
47
frontend/app/components/shared/SavedSearch/SavedSearch.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Icon } from 'UI';
|
||||
import SavedSearchDropdown from './components/SavedSearchDropdown';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchList as fetchListSavedSearch } from 'Duck/filters';
|
||||
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
||||
|
||||
interface Props {
|
||||
fetchListSavedSearch: () => void;
|
||||
list: any;
|
||||
}
|
||||
function SavedSearch(props) {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchListSavedSearch()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<OutsideClickDetectingDiv
|
||||
// className={ cn("relative", { "flex-1" : fullWidth }) }
|
||||
onClickOutside={() => setShowMenu(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Button prime outline size="small"
|
||||
className="flex items-center"
|
||||
onClick={() => setShowMenu(true)}
|
||||
>
|
||||
<span className="mr-2">Search Saved</span>
|
||||
<Icon name="ellipsis-v" color="teal" size="14" />
|
||||
</Button>
|
||||
{ showMenu && (
|
||||
<div
|
||||
className="absolute right-0 bg-white border rounded z-50"
|
||||
style={{ top: '33px', width: '200px' }}
|
||||
>
|
||||
<SavedSearchDropdown list={props.list}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OutsideClickDetectingDiv>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
list: state.getIn([ 'filters', 'list' ]),
|
||||
}), { fetchListSavedSearch })(SavedSearch);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import stl from './SavedSearchDropdown.css';
|
||||
|
||||
interface Props {
|
||||
list: Array<any>
|
||||
}
|
||||
|
||||
function Row ({ name }) {
|
||||
return (
|
||||
<div className="p-2 cursor-pointer hover:bg-gray-lightest">{name}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SavedSearchDropdown(props: Props) {
|
||||
return (
|
||||
<div className={stl.wrapper}>
|
||||
{props.list.map(item => (
|
||||
<Row key={item.searchId} name={item.name} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedSearchDropdown;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SavedSearchDropdown';
|
||||
1
frontend/app/components/shared/SavedSearch/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SavedSearch'
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react';
|
||||
import FilterList from 'Shared/Filters/FilterList';
|
||||
import FilterSelection from 'Shared/Filters/FilterSelection';
|
||||
import SaveFilterButton from 'Shared/SaveFilterButton';
|
||||
import { connect } from 'react-redux';
|
||||
import { IconButton, Button } from 'UI';
|
||||
import { edit } from 'Duck/search';
|
||||
|
||||
interface Props {
|
||||
appliedFilter: any;
|
||||
edit: typeof edit;
|
||||
}
|
||||
function SessionSearch(props) {
|
||||
const { appliedFilter } = props;
|
||||
|
||||
const onAddFilter = (filter) => {
|
||||
filter.value = [""]
|
||||
const newFilters = appliedFilter.filters.concat(filter);
|
||||
props.edit({
|
||||
...appliedFilter.filter,
|
||||
filters: newFilters,
|
||||
});
|
||||
}
|
||||
|
||||
const onUpdateFilter = (filterIndex, filter) => {
|
||||
const newFilters = appliedFilter.filters.map((_filter, i) => {
|
||||
if (i === filterIndex) {
|
||||
return filter;
|
||||
} else {
|
||||
return _filter;
|
||||
}
|
||||
});
|
||||
|
||||
props.edit({
|
||||
...appliedFilter.filter,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
props.edit({
|
||||
filters: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="border bg-white rounded mt-4">
|
||||
<div className="p-5">
|
||||
<FilterList
|
||||
// filters={appliedFilter.filter.filters.toJS()}
|
||||
filter={appliedFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
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 STEP" icon="plus" />
|
||||
</FilterSelection>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<SaveFilterButton />
|
||||
<Button onClick={clearSearch}>CLEAR STEPS</Button>
|
||||
<Button plain>SAVE FUNNEL</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
appliedFilter: state.getIn([ 'search', 'instance' ]),
|
||||
}), { edit })(SessionSearch);
|
||||
|
||||
// appliedFilter: state.getIn([ 'filters', 'appliedFilter' ]),
|
||||
1
frontend/app/components/shared/SessionSearch/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionSearch';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import stl from './SessionSearchField.css';
|
||||
import { Input } from 'UI';
|
||||
import FilterModal from 'Shared/EventFilter/FilterModal';
|
||||
import { fetchList as fetchEventList } from 'Duck/events';
|
||||
import { debounce } from 'App/utils';
|
||||
import {
|
||||
addEvent, applyFilter, moveEvent, clearEvents,
|
||||
addCustomFilter, addAttribute, setSearchQuery, setActiveFlow, setFilterOption
|
||||
} from 'Duck/filters';
|
||||
|
||||
interface Props {
|
||||
setSearchQuery: (query: string) => void;
|
||||
fetchEventList: (query: any) => void;
|
||||
searchQuery: string
|
||||
}
|
||||
function SessionSearchField(props: Props) {
|
||||
const debounceFetchEventList = debounce(props.fetchEventList, 1000)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const onSearchChange = (e, { value }) => {
|
||||
// props.setSearchQuery(value)
|
||||
debounceFetchEventList({ q: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
inputProps={ { "data-openreplay-label": "Search", "autocomplete": "off" } }
|
||||
className={stl.searchField}
|
||||
onFocus={ () => setShowModal(true) }
|
||||
onBlur={ () => setTimeout(setShowModal, 100, false) }
|
||||
// ref={ this.inputRef }
|
||||
onChange={ onSearchChange }
|
||||
// onKeyUp={this.onKeyUp}
|
||||
// value={props.searchQuery}
|
||||
icon="search"
|
||||
iconPosition="left"
|
||||
placeholder={ 'Search sessions using any captured event (click, input, page, error...)'}
|
||||
fluid
|
||||
id="search"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<FilterModal
|
||||
close={ () => setShowModal(false) }
|
||||
displayed={ showModal }
|
||||
// displayed={ true }
|
||||
// loading={ loading }
|
||||
// searchedEvents={ searchedEvents }
|
||||
searchQuery={ props.searchQuery }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
events: state.getIn([ 'filters', 'appliedFilter', 'events' ]),
|
||||
appliedFilter: state.getIn([ 'filters', 'appliedFilter' ]),
|
||||
searchQuery: state.getIn([ 'filters', 'searchQuery' ]),
|
||||
appliedFilterKeys: state.getIn([ 'filters', 'appliedFilter', 'filters' ])
|
||||
.map(({type}) => type).toJS(),
|
||||
searchedEvents: state.getIn([ 'events', 'list' ]),
|
||||
loading: state.getIn([ 'events', 'loading' ]),
|
||||
strict: state.getIn([ 'filters', 'appliedFilter', 'strict' ]),
|
||||
blink: state.getIn([ 'funnels', 'blink' ]),
|
||||
}), { setSearchQuery, fetchEventList })(SessionSearchField);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionSearchField';
|
||||
|
|
@ -9,12 +9,13 @@ class SegmentSelection extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { className, list, primary = false, size = "normal" } = this.props;
|
||||
const { className, list, small = false, extraSmall = false, primary = false, size = "normal" } = this.props;
|
||||
|
||||
return (
|
||||
<div className={ cn(styles.wrapper, {
|
||||
[styles.primary] : primary,
|
||||
[styles.small] : size === 'small'
|
||||
[styles.small] : size === 'small' || small,
|
||||
[styles.extraSmall] : extraSmall,
|
||||
}, className) }
|
||||
>
|
||||
{ list.map(item => (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
align-items: center;
|
||||
justify-content: space-around;
|
||||
border: solid thin $gray-light;
|
||||
border-radius: 5px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
& .item {
|
||||
|
|
@ -12,12 +12,13 @@
|
|||
padding: 10px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border-right: solid thin $gray-light;
|
||||
border-right: solid thin $teal;
|
||||
cursor: pointer;
|
||||
background-color: $gray-lightest;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
|
||||
& span svg {
|
||||
fill: $gray-medium;
|
||||
|
|
@ -61,4 +62,9 @@
|
|||
|
||||
.small .item {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.extraSmall .item {
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ export { default as alertConditions } from './alertConditions';
|
|||
export { default as alertMetrics } from './alertMetrics';
|
||||
export { default as regions } from './regions';
|
||||
export { default as links } from './links';
|
||||
export { default as platformOptions } from './platformOptions';
|
||||
export {
|
||||
DAYS as SCHEDULE_DAYS,
|
||||
HOURS as SCHEDULE_HOURS,
|
||||
|
|
|
|||
5
frontend/app/constants/platformOptions.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default [
|
||||
{ value: 'desktop', text: 'Desktop' },
|
||||
{ value: 'mobile', text: 'Mobile' },
|
||||
{ value: 'tablet', text: 'Tablet' },
|
||||
]
|
||||
|
|
@ -1,9 +1,38 @@
|
|||
import Alert from 'Types/alert';
|
||||
import { Map } from 'immutable';
|
||||
import crudDuckGenerator from './tools/crudDuck';
|
||||
import withRequestState, { RequestTypes } from 'Duck/requestStateCreator';
|
||||
import { reduceDucks } from 'Duck/tools';
|
||||
|
||||
const name = 'alert'
|
||||
const idKey = 'alertId';
|
||||
const crudDuck = crudDuckGenerator('alert', Alert, { idKey: idKey });
|
||||
const crudDuck = crudDuckGenerator(name, Alert, { idKey: idKey });
|
||||
export const { fetchList, init, edit, remove } = crudDuck.actions;
|
||||
const FETCH_TRIGGER_OPTIONS = new RequestTypes(`${name}/FETCH_TRIGGER_OPTIONS`);
|
||||
|
||||
const initialState = Map({
|
||||
definedPercent: 0,
|
||||
triggerOptions: [],
|
||||
});
|
||||
|
||||
const reducer = (state = initialState, action = {}) => {
|
||||
switch (action.type) {
|
||||
// case GENERATE_LINK.SUCCESS:
|
||||
// return state.update(
|
||||
// 'list',
|
||||
// list => list
|
||||
// .map(member => {
|
||||
// if(member.id === action.id) {
|
||||
// return Member({...member.toJS(), invitationLink: action.data.invitationLink })
|
||||
// }
|
||||
// return member
|
||||
// })
|
||||
// );
|
||||
case FETCH_TRIGGER_OPTIONS.SUCCESS:
|
||||
return state.set('triggerOptions', action.data);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export function save(instance) {
|
||||
return {
|
||||
|
|
@ -12,4 +41,12 @@ export function save(instance) {
|
|||
};
|
||||
}
|
||||
|
||||
export default crudDuck.reducer;
|
||||
export function fetchTriggerOptions() {
|
||||
return {
|
||||
types: FETCH_TRIGGER_OPTIONS.toArray(),
|
||||
call: client => client.get('/alerts/triggers'),
|
||||
};
|
||||
}
|
||||
|
||||
// export default crudDuck.reducer;
|
||||
export default reduceDucks(crudDuck, { initialState, reducer }).reducer;
|
||||
|
|
|
|||
116
frontend/app/duck/customMetrics.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { List, Map } from 'immutable';
|
||||
import { clean as cleanParams } from 'App/api_client';
|
||||
import ErrorInfo, { RESOLVED, UNRESOLVED, IGNORED } from 'Types/errorInfo';
|
||||
import CustomMetric, { FilterSeries } from 'Types/customMetric'
|
||||
import { createFetch, fetchListType, fetchType, saveType, removeType, editType, createRemove, createEdit } from './funcTools/crud';
|
||||
// import { createEdit, createInit } from './funcTools/crud';
|
||||
import { createRequestReducer, ROOT_KEY } from './funcTools/request';
|
||||
import { array, request, success, failure, createListUpdater, mergeReducers } from './funcTools/tools';
|
||||
import Filter from 'Types/filter';
|
||||
import NewFilter from 'Types/filter/newFilter';
|
||||
import Event from 'Types/filter/event';
|
||||
// import CustomFilter from 'Types/filter/customFilter';
|
||||
|
||||
const name = "custom_metric";
|
||||
const idKey = "metricId";
|
||||
|
||||
const FETCH_LIST = fetchListType(name);
|
||||
const FETCH = fetchType(name);
|
||||
const SAVE = saveType(name);
|
||||
const EDIT = editType(name);
|
||||
const REMOVE = removeType(name);
|
||||
const UPDATE_SERIES = `${name}/UPDATE_SERIES`;
|
||||
const SET_ALERT_METRIC_ID = `${name}/SET_ALERT_METRIC_ID`;
|
||||
|
||||
function chartWrapper(chart = []) {
|
||||
return chart.map(point => ({ ...point, count: Math.max(point.count, 0) }));
|
||||
}
|
||||
|
||||
// const updateItemInList = createListUpdater(idKey);
|
||||
// const updateInstance = (state, instance) => state.getIn([ "instance", idKey ]) === instance[ idKey ]
|
||||
// ? state.mergeIn([ "instance" ], instance)
|
||||
// : state;
|
||||
|
||||
const initialState = Map({
|
||||
list: List(),
|
||||
alertMetricId: null,
|
||||
// instance: null,
|
||||
instance: CustomMetric({
|
||||
name: 'New',
|
||||
series: List([
|
||||
{
|
||||
name: 'Session Count',
|
||||
filter: new Filter({ filters: [] }),
|
||||
},
|
||||
])
|
||||
}),
|
||||
});
|
||||
|
||||
// Metric - Series - [] - filters
|
||||
function reducer(state = initialState, action = {}) {
|
||||
switch (action.type) {
|
||||
case EDIT:
|
||||
console.log('EDIT', action);
|
||||
return state.mergeIn([ 'instance' ], CustomMetric(action.instance));
|
||||
case UPDATE_SERIES:
|
||||
console.log('update series', action.series);
|
||||
return state.setIn(['instance', 'series', action.index], FilterSeries(action.series));
|
||||
case success(SAVE):
|
||||
return state.set([ 'instance' ], CustomMetric(action.data));
|
||||
case success(REMOVE):
|
||||
console.log('action', action)
|
||||
return state.update('list', list => list.filter(item => item.metricId !== action.id));
|
||||
case success(FETCH):
|
||||
return state.set("instance", ErrorInfo(action.data));
|
||||
case success(FETCH_LIST):
|
||||
const { data } = action;
|
||||
return state.set("list", List(data.map(CustomMetric)));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export default mergeReducers(
|
||||
reducer,
|
||||
createRequestReducer({
|
||||
[ ROOT_KEY ]: FETCH_LIST,
|
||||
fetch: FETCH,
|
||||
}),
|
||||
);
|
||||
|
||||
export const edit = createEdit(name);
|
||||
export const remove = createRemove(name);
|
||||
|
||||
export const updateSeries = (index, series) => ({
|
||||
type: UPDATE_SERIES,
|
||||
index,
|
||||
series,
|
||||
});
|
||||
|
||||
export function fetch(id) {
|
||||
return {
|
||||
id,
|
||||
types: array(FETCH),
|
||||
call: c => c.get(`/errors/${id}`),
|
||||
}
|
||||
}
|
||||
|
||||
export function save(instance) {
|
||||
return {
|
||||
types: SAVE.array,
|
||||
call: client => client.post( `/${ name }s`, instance.toSaveData()),
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchList() {
|
||||
return {
|
||||
types: array(FETCH_LIST),
|
||||
call: client => client.get(`/${name}s`),
|
||||
};
|
||||
}
|
||||
|
||||
export function setAlertMetricId(id) {
|
||||
return {
|
||||
type: SET_ALERT_METRIC_ID,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
|
@ -7,8 +7,31 @@ import CustomFilter, { KEYS } from 'Types/filter/customFilter';
|
|||
import withRequestState, { RequestTypes } from './requestStateCreator';
|
||||
import { fetchList as fetchSessionList } from './sessions';
|
||||
import { fetchList as fetchErrorsList } from './errors';
|
||||
import { fetchListType, fetchType, saveType, editType, initType, removeType } from './funcTools/crud/types';
|
||||
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 = NewFilter(filtersMap[key]);
|
||||
const filter = filtersMap[key];
|
||||
if (filterOptions.hasOwnProperty(filter.category)) {
|
||||
filterOptions[filter.category].push(filter);
|
||||
} else {
|
||||
filterOptions[filter.category] = [filter];
|
||||
}
|
||||
})
|
||||
|
||||
console.log('filterOptions', filterOptions)
|
||||
|
||||
|
||||
// for (var i = 0; i < newFiltersList.length; i++) {
|
||||
// filterOptions[newFiltersList[i].category] = newFiltersList.filter(filter => filter.category === newFiltersList[i].category)
|
||||
// }
|
||||
|
||||
const ERRORS_ROUTE = errorsRoute();
|
||||
|
||||
const FETCH_LIST = new RequestTypes('filters/FETCH_LIST');
|
||||
|
|
@ -16,6 +39,7 @@ const FETCH_FILTER_OPTIONS = new RequestTypes('filters/FETCH_FILTER_OPTIONS');
|
|||
const SET_FILTER_OPTIONS = 'filters/SET_FILTER_OPTIONS';
|
||||
const SAVE = new RequestTypes('filters/SAVE');
|
||||
const REMOVE = new RequestTypes('filters/REMOVE');
|
||||
const EDIT = editType('funnel/EDIT');
|
||||
|
||||
const SET_SEARCH_QUERY = 'filters/SET_SEARCH_QUERY';
|
||||
const SET_ACTIVE = 'filters/SET_ACTIVE';
|
||||
|
|
@ -35,7 +59,11 @@ const EDIT_ATTRIBUTE = 'filters/EDIT_ATTRIBUTE';
|
|||
const REMOVE_ATTRIBUTE = 'filters/REMOVE_ATTRIBUTE';
|
||||
const SET_ACTIVE_FLOW = 'filters/SET_ACTIVE_FLOW';
|
||||
|
||||
const UPDATE_VALUE = 'filters/UPDATE_VALUE';
|
||||
|
||||
const initialState = Map({
|
||||
filterList: filterOptions,
|
||||
instance: Filter(),
|
||||
activeFilter: null,
|
||||
list: List(),
|
||||
appliedFilter: Filter(),
|
||||
|
|
@ -71,6 +99,8 @@ const updateList = (state, instance) => state.update('list', (list) => {
|
|||
const reducer = (state = initialState, action = {}) => {
|
||||
let optionsMap = null;
|
||||
switch (action.type) {
|
||||
case EDIT:
|
||||
return state.mergeIn([ 'appliedFilter' ], action.instance);
|
||||
case FETCH_FILTER_OPTIONS.SUCCESS:
|
||||
optionsMap = state.getIn(['filterOptions', action.key]).map(i => i.value).toJS();
|
||||
return state.mergeIn(['filterOptions', action.key], Set(action.data.filter(i => !optionsMap.includes(i.value))));
|
||||
|
|
@ -177,6 +207,8 @@ const reducer = (state = initialState, action = {}) => {
|
|||
return state.removeIn([ 'appliedFilter', 'filters', action.index ]);
|
||||
case SET_SEARCH_QUERY:
|
||||
return state.set('searchQuery', action.query);
|
||||
case UPDATE_VALUE:
|
||||
return state.setIn([ 'appliedFilter', action.filterType, action.index, 'value' ], action.value);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -234,7 +266,7 @@ export function removeAttribute(index) {
|
|||
export function fetchList(range) {
|
||||
return {
|
||||
types: FETCH_LIST.toArray(),
|
||||
call: client => client.get(`/flows${range ? '?range_value=' + range : ''}`),
|
||||
call: client => client.get(`/saved_search`),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +289,7 @@ export function setFilterOption(key, filterOption) {
|
|||
export function save(instance) {
|
||||
return {
|
||||
types: SAVE.toArray(),
|
||||
call: client => client.post('/filters', instance.toData()),
|
||||
call: client => client.post('/saved_search', instance.toData()),
|
||||
instance,
|
||||
};
|
||||
}
|
||||
|
|
@ -367,4 +399,21 @@ export function setSearchQuery(query) {
|
|||
type: SET_SEARCH_QUERY,
|
||||
query
|
||||
}
|
||||
}
|
||||
|
||||
export const edit = instance => {
|
||||
return {
|
||||
type: EDIT,
|
||||
instance,
|
||||
}
|
||||
};
|
||||
|
||||
// filterType: 'events' or 'filters'
|
||||
export const updateValue = (filterType, index, value) => {
|
||||
return {
|
||||
type: UPDATE_VALUE,
|
||||
filterType,
|
||||
index,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ const reducer = (state = initialState, action = {}) => {
|
|||
.set('issueTypesMap', tmpMap);
|
||||
case FETCH_INSIGHTS_SUCCESS:
|
||||
let stages = [];
|
||||
if (action.isRefresh) {
|
||||
if (action.isRefresh) {
|
||||
const activeStages = state.get('activeStages');
|
||||
const oldInsights = state.get('insights');
|
||||
const lastStage = action.data.stages[action.data.stages.length - 1]
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import errors from './errors';
|
|||
import funnels from './funnels';
|
||||
import config from './config';
|
||||
import roles from './roles';
|
||||
import customMetrics from './customMetrics';
|
||||
import search from './search';
|
||||
|
||||
export default combineReducers({
|
||||
jwt,
|
||||
|
|
@ -68,6 +70,8 @@ export default combineReducers({
|
|||
funnels,
|
||||
config,
|
||||
roles,
|
||||
customMetrics,
|
||||
search,
|
||||
...integrations,
|
||||
...sources,
|
||||
});
|
||||
|
|
|
|||
147
frontend/app/duck/search.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { List, Map } from 'immutable';
|
||||
import ErrorInfo, { RESOLVED, UNRESOLVED, IGNORED } from 'Types/errorInfo';
|
||||
import CustomMetric, { FilterSeries } from 'Types/customMetric'
|
||||
import { createFetch, fetchListType, fetchType, saveType, removeType, editType, createRemove, createEdit } from './funcTools/crud';
|
||||
import { createRequestReducer, ROOT_KEY } from './funcTools/request';
|
||||
import { array, request, success, failure, createListUpdater, mergeReducers } from './funcTools/tools';
|
||||
import Filter from 'Types/filter';
|
||||
import SavedFilter from 'Types/filter/savedFilter';
|
||||
import { errors as errorsRoute, isRoute } from "App/routes";
|
||||
import { fetchList as fetchSessionList } from './sessions';
|
||||
import { fetchList as fetchErrorsList } from './errors';
|
||||
|
||||
const ERRORS_ROUTE = errorsRoute();
|
||||
|
||||
const name = "custom_metric";
|
||||
const idKey = "metricId";
|
||||
|
||||
const FETCH_LIST = fetchListType(name);
|
||||
const FETCH = fetchType(name);
|
||||
const SAVE = saveType(name);
|
||||
const EDIT = editType(name);
|
||||
const REMOVE = removeType(name);
|
||||
const UPDATE = `${name}/UPDATE`;
|
||||
const APPLY = `${name}/APPLY`;
|
||||
const SET_ALERT_METRIC_ID = `${name}/SET_ALERT_METRIC_ID`;
|
||||
|
||||
function chartWrapper(chart = []) {
|
||||
return chart.map(point => ({ ...point, count: Math.max(point.count, 0) }));
|
||||
}
|
||||
|
||||
// const updateItemInList = createListUpdater(idKey);
|
||||
// const updateInstance = (state, instance) => state.getIn([ "instance", idKey ]) === instance[ idKey ]
|
||||
// ? state.mergeIn([ "instance" ], instance)
|
||||
// : state;
|
||||
|
||||
const initialState = Map({
|
||||
list: List(),
|
||||
alertMetricId: null,
|
||||
instance: new Filter({ filters: [] }),
|
||||
savedFilter: new SavedFilter({ filters: [] }),
|
||||
});
|
||||
|
||||
// Metric - Series - [] - filters
|
||||
function reducer(state = initialState, action = {}) {
|
||||
switch (action.type) {
|
||||
case EDIT:
|
||||
return state.mergeIn(['instance'], action.instance);
|
||||
case APPLY:
|
||||
return action.fromUrl
|
||||
? state.set('instance',
|
||||
Filter(action.filter)
|
||||
// .set('events', state.getIn([ 'instance', 'events' ]))
|
||||
)
|
||||
: state.mergeIn(['instance'], action.filter);
|
||||
case success(SAVE):
|
||||
return state.mergeIn([ 'instance' ], action.data);
|
||||
case success(REMOVE):
|
||||
return state.update('list', list => list.filter(item => item.metricId !== action.id));
|
||||
case success(FETCH):
|
||||
return state.set("instance", ErrorInfo(action.data));
|
||||
case success(FETCH_LIST):
|
||||
const { data } = action;
|
||||
return state.set("list", List(data.map(CustomMetric)));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export default mergeReducers(
|
||||
reducer,
|
||||
createRequestReducer({
|
||||
[ ROOT_KEY ]: FETCH_LIST,
|
||||
fetch: FETCH,
|
||||
}),
|
||||
);
|
||||
|
||||
const filterMap = ({value, type, key, operator, source, custom, isEvent }) => ({
|
||||
// value: Array.isArray(value) ? value: [value],
|
||||
value: value.filter(i => i !== '' && i !== null),
|
||||
custom,
|
||||
type: key,
|
||||
key, operator,
|
||||
source,
|
||||
isEvent
|
||||
});
|
||||
|
||||
const reduceThenFetchResource = actionCreator => (...args) => (dispatch, getState) => {
|
||||
dispatch(actionCreator(...args));
|
||||
const filter = getState().getIn([ 'search', 'instance']).toData();
|
||||
filter.filters = filter.filters.map(filterMap);
|
||||
filter.isNew = true // TODO remove this line
|
||||
|
||||
return isRoute(ERRORS_ROUTE, window.location.pathname)
|
||||
? dispatch(fetchErrorsList(filter))
|
||||
: dispatch(fetchSessionList(filter));
|
||||
};
|
||||
|
||||
export const edit = reduceThenFetchResource((instance) => ({
|
||||
type: EDIT,
|
||||
instance,
|
||||
}));
|
||||
|
||||
export const remove = createRemove(name);
|
||||
|
||||
export const applyFilter = reduceThenFetchResource((filter, fromUrl=false) => ({
|
||||
type: APPLY,
|
||||
filter,
|
||||
fromUrl,
|
||||
}));
|
||||
|
||||
export const updateSeries = (index, series) => ({
|
||||
type: UPDATE,
|
||||
index,
|
||||
series,
|
||||
});
|
||||
|
||||
export function fetch(id) {
|
||||
return {
|
||||
id,
|
||||
types: array(FETCH),
|
||||
call: c => c.get(`/errors/${id}`),
|
||||
}
|
||||
}
|
||||
|
||||
export function save(instance) {
|
||||
return {
|
||||
types: SAVE.array,
|
||||
call: client => client.post('/saved_search', {
|
||||
name: instance.name,
|
||||
filter: instance.filter.toSaveData(),
|
||||
}),
|
||||
instance,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchList() {
|
||||
return {
|
||||
types: array(FETCH_LIST),
|
||||
call: client => client.get(`/${name}s`),
|
||||
};
|
||||
}
|
||||
|
||||
export function setAlertMetricId(id) {
|
||||
return {
|
||||
type: SET_ALERT_METRIC_ID,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
|
@ -106,4 +106,10 @@
|
|||
opacity: .5;
|
||||
}
|
||||
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 25px;
|
||||
& label {
|
||||
display: inline-block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
3
frontend/app/svg/icons/filters/browser.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-globe" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
frontend/app/svg/icons/filters/clickrage.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-emoji-angry" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M4.285 12.433a.5.5 0 0 0 .683-.183A3.498 3.498 0 0 1 8 10.5c1.295 0 2.426.703 3.032 1.75a.5.5 0 0 0 .866-.5A4.498 4.498 0 0 0 8 9.5a4.5 4.5 0 0 0-3.898 2.25.5.5 0 0 0 .183.683zm6.991-8.38a.5.5 0 1 1 .448.894l-1.009.504c.176.27.285.64.285 1.049 0 .828-.448 1.5-1 1.5s-1-.672-1-1.5c0-.247.04-.48.11-.686a.502.502 0 0 1 .166-.761l2-1zm-6.552 0a.5.5 0 0 0-.448.894l1.009.504A1.94 1.94 0 0 0 5 6.5C5 7.328 5.448 8 6 8s1-.672 1-1.5c0-.247-.04-.48-.11-.686a.502.502 0 0 0-.166-.761l-2-1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 673 B |
1
frontend/app/svg/icons/filters/code.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M228.5 511.8l-25-7.1c-3.2-.9-5-4.2-4.1-7.4L340.1 4.4c.9-3.2 4.2-5 7.4-4.1l25 7.1c3.2.9 5 4.2 4.1 7.4L235.9 507.6c-.9 3.2-4.3 5.1-7.4 4.2zm-75.6-125.3l18.5-20.9c1.9-2.1 1.6-5.3-.5-7.1L49.9 256l121-102.5c2.1-1.8 2.4-5 .5-7.1l-18.5-20.9c-1.8-2.1-5-2.3-7.1-.4L1.7 252.3c-2.3 2-2.3 5.5 0 7.5L145.8 387c2.1 1.8 5.3 1.6 7.1-.5zm277.3.4l144.1-127.2c2.3-2 2.3-5.5 0-7.5L430.2 125.1c-2.1-1.8-5.2-1.6-7.1.4l-18.5 20.9c-1.9 2.1-1.6 5.3.5 7.1l121 102.5-121 102.5c-2.1 1.8-2.4 5-.5 7.1l18.5 20.9c1.8 2.1 5 2.3 7.1.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 582 B |
4
frontend/app/svg/icons/filters/country.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-geo-alt" viewBox="0 0 16 16">
|
||||
<path d="M12.166 8.94c-.524 1.062-1.234 2.12-1.96 3.07A31.493 31.493 0 0 1 8 14.58a31.481 31.481 0 0 1-2.206-2.57c-.726-.95-1.436-2.008-1.96-3.07C3.304 7.867 3 6.862 3 6a5 5 0 0 1 10 0c0 .862-.305 1.867-.834 2.94zM8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10z"/>
|
||||
<path d="M8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 1a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
1
frontend/app/svg/icons/filters/custom.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M228.5 511.8l-25-7.1c-3.2-.9-5-4.2-4.1-7.4L340.1 4.4c.9-3.2 4.2-5 7.4-4.1l25 7.1c3.2.9 5 4.2 4.1 7.4L235.9 507.6c-.9 3.2-4.3 5.1-7.4 4.2zm-75.6-125.3l18.5-20.9c1.9-2.1 1.6-5.3-.5-7.1L49.9 256l121-102.5c2.1-1.8 2.4-5 .5-7.1l-18.5-20.9c-1.8-2.1-5-2.3-7.1-.4L1.7 252.3c-2.3 2-2.3 5.5 0 7.5L145.8 387c2.1 1.8 5.3 1.6 7.1-.5zm277.3.4l144.1-127.2c2.3-2 2.3-5.5 0-7.5L430.2 125.1c-2.1-1.8-5.2-1.6-7.1.4l-18.5 20.9c-1.9 2.1-1.6 5.3.5 7.1l121 102.5-121 102.5c-2.1 1.8-2.4 5-.5 7.1l18.5 20.9c1.8 2.1 5 2.3 7.1.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 582 B |
7
frontend/app/svg/icons/filters/device.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
|
||||
<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
|
||||
<g><path d="M702.5,826.7H598V722.1h107.8v-68.6H78.6V157h816.7v277.7h55.5c3.3,0,9.8,0,13.1,3.3V134.1c0-26.1-19.6-45.7-45.7-45.7H55.7C29.6,88.4,10,108,10,134.1v542.3c0,26.1,19.6,45.7,45.7,45.7h323.4v104.5H255c-22.9,0-42.5,19.6-42.5,42.5c0,22.9,19.6,42.5,42.5,42.5h460.6c-6.5-9.8-9.8-19.6-9.8-29.4v-55.5H702.5z"/><path d="M960.6,457.5H767.9c-16.3,0-29.4,13.1-29.4,29.4v392c0,16.3,13.1,29.4,29.4,29.4h192.7c16.3,0,29.4-13.1,29.4-29.4v-392C990,473.9,976.9,457.5,960.6,457.5z M862.6,892c-6.5,0-13.1-6.5-13.1-13.1s6.5-13.1,13.1-13.1c6.5,0,13.1,6.5,13.1,13.1S869.1,892,862.6,892z M970.4,849.5H754.8V522.9h215.6V849.5z"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
frontend/app/svg/icons/filters/duration.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm216 248c0 118.7-96.1 216-216 216-118.7 0-216-96.1-216-216 0-118.7 96.1-216 216-216 118.7 0 216 96.1 216 216zm-148.9 88.3l-81.2-59c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h14c6.6 0 12 5.4 12 12v146.3l70.5 51.3c5.4 3.9 6.5 11.4 2.6 16.8l-8.2 11.3c-3.9 5.3-11.4 6.5-16.8 2.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 429 B |
4
frontend/app/svg/icons/filters/error.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-exclamation-circle" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 312 B |
3
frontend/app/svg/icons/filters/fetch.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-arrow-left-right" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
1
frontend/app/svg/icons/filters/graphql.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348.52 391.6"><title>graphql</title><path d="M356.7,250.2a35,35,0,0,0-9.2-3.67v-93A34.92,34.92,0,1,0,314,95.57l-80.54-46.5a34.9,34.9,0,1,0-68.35-10A35.24,35.24,0,0,0,166.53,49L86,95.53A35.4,35.4,0,0,0,78.1,89.3a34.92,34.92,0,1,0-25.6,64.19v93a34.93,34.93,0,1,0,33.5,58L166.52,351a34.9,34.9,0,1,0,66.61-1.12l80.05-46.22A34.92,34.92,0,1,0,356.7,250.2Zm-262,22a34.46,34.46,0,0,0-9.81-17L190.28,72.62a34.91,34.91,0,0,0,19.47,0l105.4,182.56a34.81,34.81,0,0,0-9.82,17ZM305.79,110a34.83,34.83,0,0,0,25.11,43.4v93.15l-1.35.36L224.13,64.32c.31-.3.63-.61.93-.92ZM174.9,63.36c.33.33.66.66,1,1L70.47,246.94l-1.37-.37V153.41A34.83,34.83,0,0,0,94.19,110Zm50.91,274a34.92,34.92,0,0,0-50.94-.71l-80.6-46.53c.12-.45.25-.9.36-1.35H305.36c.19.77.4,1.53.64,2.29Z" transform="translate(-25.74 -4.2)"/></svg>
|
||||
|
After Width: | Height: | Size: 872 B |
1
frontend/app/svg/icons/filters/i-cursor.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path d="M96 38.223C75.091 13.528 39.824 1.336 6.191.005 2.805-.129 0 2.617 0 6.006v20.013c0 3.191 2.498 5.847 5.686 5.989C46.519 33.825 80 55.127 80 80v160H38a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h42v160c0 24.873-33.481 46.175-74.314 47.992-3.188.141-5.686 2.797-5.686 5.989v20.013c0 3.389 2.806 6.135 6.192 6.002C40.03 510.658 75.193 498.351 96 473.777c20.909 24.695 56.176 36.887 89.809 38.218 3.386.134 6.191-2.612 6.191-6.001v-20.013c0-3.191-2.498-5.847-5.686-5.989C145.481 478.175 112 456.873 112 432V272h42a6 6 0 0 0 6-6v-20a6 6 0 0 0-6-6h-42V80c0-24.873 33.481-46.175 74.314-47.992 3.188-.142 5.686-2.798 5.686-5.989V6.006c0-3.389-2.806-6.135-6.192-6.002C151.97 1.342 116.807 13.648 96 38.223z"/></svg>
|
||||
|
After Width: | Height: | Size: 765 B |
4
frontend/app/svg/icons/filters/input.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-input-cursor-text" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M5 2a.5.5 0 0 1 .5-.5c.862 0 1.573.287 2.06.566.174.099.321.198.44.286.119-.088.266-.187.44-.286A4.165 4.165 0 0 1 10.5 1.5a.5.5 0 0 1 0 1c-.638 0-1.177.213-1.564.434a3.49 3.49 0 0 0-.436.294V7.5H9a.5.5 0 0 1 0 1h-.5v4.272c.1.08.248.187.436.294.387.221.926.434 1.564.434a.5.5 0 0 1 0 1 4.165 4.165 0 0 1-2.06-.566A4.561 4.561 0 0 1 8 13.65a4.561 4.561 0 0 1-.44.285 4.165 4.165 0 0 1-2.06.566.5.5 0 0 1 0-1c.638 0 1.177-.213 1.564-.434.188-.107.335-.214.436-.294V8.5H7a.5.5 0 0 1 0-1h.5V3.228a3.49 3.49 0 0 0-.436-.294A3.166 3.166 0 0 0 5.5 2.5.5.5 0 0 1 5 2z"/>
|
||||
<path d="M10 5h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4v1h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4v1zM6 5V4H2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4v-1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 867 B |
4
frontend/app/svg/icons/filters/link.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-link-45deg" viewBox="0 0 16 16">
|
||||
<path d="M4.715 6.542L3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.001 1.001 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 0 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 0 0-4.243-4.243L6.586 4.672z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |