Merge remote-tracking branch 'origin/custom-metrics-ui' into dev

This commit is contained in:
Taha Yassine Kraiem 2022-01-26 11:57:42 +01:00
commit 22795b2ede
117 changed files with 3268 additions and 103 deletions

View file

@ -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 = [

View file

@ -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)

View file

@ -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)

View file

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

View file

@ -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}
/>
}

View file

@ -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' &&

View file

@ -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 }>

View file

@ -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;
}
}

View file

@ -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 /> }

View file

@ -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

View file

@ -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 } />

View file

@ -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 })

View file

@ -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;
}

View file

@ -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} /> }

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -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);

View file

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

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -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);

View file

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

View file

@ -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);

View file

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

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -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;

View file

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

View file

@ -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);

View file

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

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

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

View file

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

View file

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

View file

@ -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()
}

View file

@ -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 }>

View file

@ -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;
}
}

View file

@ -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;

View file

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

View file

@ -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' },

View file

@ -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;
}
}

View file

@ -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;

View file

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

View file

@ -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;
}

View file

@ -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>
);
}
}

View file

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

View file

@ -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;

View file

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

View file

@ -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;

View file

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

View file

@ -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);

View file

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

View file

@ -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;
}
}

View file

@ -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;

View file

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

View file

@ -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;

View file

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

View file

@ -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;

View file

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

View file

@ -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;
}
}

View file

@ -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;

View file

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

View file

@ -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);

View file

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

View file

@ -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);

View file

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

View file

@ -0,0 +1,15 @@
@import 'mixins.css';
.modalHeader {
display: flex !important;
align-items: center;
justify-content: space-between;
}
.cancelButton {
@mixin plainButton;
}
.applyButton {
@mixin basicButton;
}

View 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);

View file

@ -0,0 +1,7 @@
.wrapper {
position: relative;
display: inline-block;
z-index: 999;
display: flex;
flex-direction: column;
}

View file

@ -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;

View file

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

View file

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

View file

@ -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' ]),

View file

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

View file

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

View file

@ -0,0 +1,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);

View file

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

View file

@ -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 => (

View file

@ -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;
}

View file

@ -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,

View file

@ -0,0 +1,5 @@
export default [
{ value: 'desktop', text: 'Desktop' },
{ value: 'mobile', text: 'Mobile' },
{ value: 'tablet', text: 'Tablet' },
]

View file

@ -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;

View 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,
};
}

View file

@ -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
}
}

View file

@ -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]

View file

@ -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
View 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,
};
}

View file

@ -106,4 +106,10 @@
opacity: .5;
}
.form-group {
margin-bottom: 25px;
& label {
display: inline-block;
margin-bottom: 5px;
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Some files were not shown because too many files have changed in this diff Show more