diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index 02e575033..28aa2913b 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -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 = [ diff --git a/frontend/app/components/Alerts/AlertForm.js b/frontend/app/components/Alerts/AlertForm.js index deb0fa405..692191c96 100644 --- a/frontend/app/components/Alerts/AlertForm.js +++ b/frontend/app/components/Alerts/AlertForm.js @@ -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 (
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) diff --git a/frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx b/frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx new file mode 100644 index 000000000..5cf30c278 --- /dev/null +++ b/frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx @@ -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 ( + + { 'Create Alert' } + toggleForm({}, true) } + /> + + } + isDisplayed={ showModal } + onClose={props.onClose} + size="medium" + content={ showModal && + + } + /> + ); +} + +export default connect(state => ({ + webhooks: state.getIn(['webhooks', 'list']), + instance: state.getIn(['alerts', 'instance']), +}), { init, edit, save, remove, fetchWebhooks, setShowAlerts })(AlertFormModal) \ No newline at end of file diff --git a/frontend/app/components/Alerts/AlertFormModal/index.ts b/frontend/app/components/Alerts/AlertFormModal/index.ts new file mode 100644 index 000000000..6eb4de1f2 --- /dev/null +++ b/frontend/app/components/Alerts/AlertFormModal/index.ts @@ -0,0 +1 @@ +export { default } from './AlertFormModal'; \ No newline at end of file diff --git a/frontend/app/components/BugFinder/Attributes/AttributeItem.js b/frontend/app/components/BugFinder/Attributes/AttributeItem.js index bc4e45d41..ad27649dd 100644 --- a/frontend/app/components/BugFinder/Attributes/AttributeItem.js +++ b/frontend/app/components/BugFinder/Attributes/AttributeItem.js @@ -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 && } diff --git a/frontend/app/components/BugFinder/Attributes/AttributeValueField.js b/frontend/app/components/BugFinder/Attributes/AttributeValueField.js index 440eadec0..9e6b7eb78 100644 --- a/frontend/app/components/BugFinder/Attributes/AttributeValueField.js +++ b/frontend/app/components/BugFinder/Attributes/AttributeValueField.js @@ -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 ( - { _showAutoComplete ? - ( + onChange(name, value, i) } headerText={
{ getHeader(filter.type) }
} 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' && diff --git a/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js b/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js index 7c22584a9..f902235ed 100644 --- a/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js +++ b/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js @@ -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 ( - */} +
+ 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 + } } + /> +
+ { showCloseButton ? : or} +
+
+ + {showCloseButton &&
or
} + {/* this.setState({ddOpen: true})} value={ query } - icon="search" + // icon="search" + label={{ basic: true, content:
test
}} + 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 } } - /> + /> */} { ddOpen && options.length > 0 &&
diff --git a/frontend/app/components/BugFinder/AutoComplete/autoComplete.css b/frontend/app/components/BugFinder/AutoComplete/autoComplete.css index 79439f51c..09a9a6571 100644 --- a/frontend/app/components/BugFinder/AutoComplete/autoComplete.css +++ b/frontend/app/components/BugFinder/AutoComplete/autoComplete.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/app/components/BugFinder/BugFinder.js b/frontend/app/components/BugFinder/BugFinder.js index a71c54cf9..93ad66347 100644 --- a/frontend/app/components/BugFinder/BugFinder.js +++ b/frontend/app/components/BugFinder/BugFinder.js @@ -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" > - -
+
+
+ +
+ + {/* */} + { activeFlow && activeFlow.type === 'flows' && } { activeTab.type !== 'live' && } { activeTab.type === 'live' && } diff --git a/frontend/app/components/BugFinder/DateRange.js b/frontend/app/components/BugFinder/DateRange.js index 60e98ffa1..b9bbd745a 100644 --- a/frontend/app/components/BugFinder/DateRange.js +++ b/frontend/app/components/BugFinder/DateRange.js @@ -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 ( ({ 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 ( - { showPlacehoder && !hasFilters && -
- { !searchQuery && -
Search for users, clicks, page visits, requests, errors and more
- // - } -
- } - - { hasFilters && -
+
+
+
Operator
+ +
+ { events.size > 0 && <>
@@ -189,6 +184,7 @@ export default class EventFilter extends React.PureComponent { showFilters={ true } />
+
diff --git a/frontend/app/components/BugFinder/Filters/SortDropdown.js b/frontend/app/components/BugFinder/Filters/SortDropdown.js index 80f88a0d7..cdcc2e468 100644 --- a/frontend/app/components/BugFinder/Filters/SortDropdown.js +++ b/frontend/app/components/BugFinder/Filters/SortDropdown.js @@ -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 }) diff --git a/frontend/app/components/BugFinder/filterSelectionButton.css b/frontend/app/components/BugFinder/filterSelectionButton.css index 1741e7d83..4c51cf3a4 100644 --- a/frontend/app/components/BugFinder/filterSelectionButton.css +++ b/frontend/app/components/BugFinder/filterSelectionButton.css @@ -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; } diff --git a/frontend/app/components/Dashboard/Dashboard.js b/frontend/app/components/Dashboard/Dashboard.js index ff91e10a6..7d2a2f37b 100644 --- a/frontend/app/components/Dashboard/Dashboard.js +++ b/frontend/app/components/Dashboard/Dashboard.js @@ -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 {
+
+ +
+ null}/> +
+
+
{ dashboardAppearance.impactedSessionsByJsErrors && } diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.css b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.css new file mode 100644 index 000000000..1d1ef3ee4 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.css @@ -0,0 +1,6 @@ +.wrapper { + background-color: white; + /* border: solid thin $gray-medium; */ + border-radius: 3px; + padding: 10px; +} \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.tsx new file mode 100644 index 000000000..21b04b809 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.tsx @@ -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({ 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 ( +
+
+
{metric.name + ' ' + metric.metricId}
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + + {gradientDef} + + + + + + + + + +
+
+ ); +} + +export default connect(null, { remove, setShowAlerts, setAlertMetricId })(CustomMetricWidget); \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/index.ts b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/index.ts new file mode 100644 index 000000000..4a6d9b653 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/index.ts @@ -0,0 +1 @@ +export { default } from './CustomMetricWidget'; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/CustomMetricWidgetPreview.css b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/CustomMetricWidgetPreview.css new file mode 100644 index 000000000..1d1ef3ee4 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/CustomMetricWidgetPreview.css @@ -0,0 +1,6 @@ +.wrapper { + background-color: white; + /* border: solid thin $gray-medium; */ + border-radius: 3px; + padding: 10px; +} \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/CustomMetricWidgetPreview.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/CustomMetricWidgetPreview.tsx new file mode 100644 index 000000000..8c09ed389 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/CustomMetricWidgetPreview.tsx @@ -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({ 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 ( +
+
+ +
+
+ + + + + {gradientDef} + + + + + + + + + +
+
+ ); +} + +export default connect(null, { remove })(CustomMetricWidget); \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/index.ts b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/index.ts new file mode 100644 index 000000000..9595513c4 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview/index.ts @@ -0,0 +1 @@ +export { default } from './CustomMetricWidgetPreview'; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricsWidgets.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricsWidgets.tsx new file mode 100644 index 000000000..632c7dc22 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricsWidgets.tsx @@ -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) => ( + setActiveMetricId(item.metricId)} + /> + ))} + + setActiveMetricId(null)} + /> + + ); +} + +export default connect(state => ({ + list: state.getIn(['customMetrics', 'list']), +}), { fetchList })(CustomMetricsWidgets); \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/index.ts b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/index.ts new file mode 100644 index 000000000..54d9a4192 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/index.ts @@ -0,0 +1 @@ +export { default } from './CustomMetricsWidgets'; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/CustomMetricWidgetHoc.css b/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/CustomMetricWidgetHoc.css new file mode 100644 index 000000000..1d1ef3ee4 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/CustomMetricWidgetHoc.css @@ -0,0 +1,6 @@ +.wrapper { + background-color: white; + /* border: solid thin $gray-medium; */ + border-radius: 3px; + padding: 10px; +} \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/CustomMetricWidgetHoc.tsx b/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/CustomMetricWidgetHoc.tsx new file mode 100644 index 000000000..ba4a2726a --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/CustomMetricWidgetHoc.tsx @@ -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 ( +
+
+
Widget Name
+
+
+ +
+
+
+ {/* */} +
+ ); +} + +export default CustomMetricWidgetHoc; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/index.ts b/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/index.ts new file mode 100644 index 000000000..0be8a5be5 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/common/CustomMetricWidgetHoc/index.ts @@ -0,0 +1 @@ +export { default } from './CustomMetricWidgetHoc'; \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/CustomMetricForm/CustomMetricForm.tsx b/frontend/app/components/shared/CustomMetrics/CustomMetricForm/CustomMetricForm.tsx new file mode 100644 index 000000000..60bcce7a9 --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/CustomMetricForm/CustomMetricForm.tsx @@ -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 ( + props.save(metric)} + > +
+
+ + +
+ +
+ +
+ Timeseries + of +
+ +
+
+
+ +
+ + {metric.series && metric.series.size > 0 && metric.series.map((series: any, index: number) => ( +
+ removeSeries(index)} + /> +
+ ))} +
+ +
+ +
+ +
+ + +
+ +
+ +
+ + ); +} + +export default connect(state => ({ + metric: state.getIn(['customMetrics', 'instance']), + loading: state.getIn(['customMetrics', 'saveRequest', 'loading']), +}), { editMetric, save })(CustomMetricForm); \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/CustomMetricForm/index.ts b/frontend/app/components/shared/CustomMetrics/CustomMetricForm/index.ts new file mode 100644 index 000000000..e6ffb605b --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/CustomMetricForm/index.ts @@ -0,0 +1 @@ +export { default } from './CustomMetricForm'; \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/CustomMetrics.tsx b/frontend/app/components/shared/CustomMetrics/CustomMetrics.tsx new file mode 100644 index 000000000..158ef73c5 --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/CustomMetrics.tsx @@ -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 ( +
+ { + setShowModal(true); + // props.edit({ name: 'New', series: [{ name: '', filter: {} }], type: '' }); + }} /> + + + { 'Custom Metric' } +
+ } + isDisplayed={ showModal } + onClose={ () => setShowModal(false)} + // size="medium" + content={ (showModal || metric) && ( +
+ +
+ )} + /> +
+ ); +} + +export default connect(state => ({ + metric: state.getIn(['customMetrics', 'instance']), + alertInstance: state.getIn(['alerts', 'instance']), +}), { edit })(CustomMetrics); \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/FilterSeries/FilterSeries.tsx b/frontend/app/components/shared/CustomMetrics/FilterSeries/FilterSeries.tsx new file mode 100644 index 000000000..40214855c --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/FilterSeries/FilterSeries.tsx @@ -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 ( +
+
+ {/*
+ { series.name } +
+
*/} +
+ null } /> +
+ +
+
+ +
+ +
setExpanded(!expanded)} className="ml-3"> + +
+ +
+
+ { expanded && ( + <> +
+ { series.filter.filters.size > 0 ? ( + + ): ( +
Add user event or filter to build the series.
+ )} +
+
+ + + +
+ + )} +
+ ); +} + +export default connect(null, { edit, updateSeries })(FilterSeries); \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx new file mode 100644 index 000000000..7be696fd5 --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx @@ -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(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 ( +
+ setEditing(true)} + /> +
setEditing(true)}>
+
+ ); +} + +export default SeriesName; \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/index.ts b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/index.ts new file mode 100644 index 000000000..90e63cdb6 --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/index.ts @@ -0,0 +1 @@ +export { default } from './SeriesName'; \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/FilterSeries/index.ts b/frontend/app/components/shared/CustomMetrics/FilterSeries/index.ts new file mode 100644 index 000000000..5882e382a --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/FilterSeries/index.ts @@ -0,0 +1 @@ +export { default } from './FilterSeries' \ No newline at end of file diff --git a/frontend/app/components/shared/CustomMetrics/index.ts b/frontend/app/components/shared/CustomMetrics/index.ts new file mode 100644 index 000000000..ebbb8203c --- /dev/null +++ b/frontend/app/components/shared/CustomMetrics/index.ts @@ -0,0 +1 @@ +export { default } from './CustomMetrics'; \ No newline at end of file diff --git a/frontend/app/components/shared/EventFilter/Attributes/AttributeValueField.js b/frontend/app/components/shared/EventFilter/Attributes/AttributeValueField.js index 176ae2c4a..daee15cc3 100644 --- a/frontend/app/components/shared/EventFilter/Attributes/AttributeValueField.js +++ b/frontend/app/components/shared/EventFilter/Attributes/AttributeValueField.js @@ -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 ( { _showAutoComplete ? - { getHeader(filter.type) } } fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value } + // onAddOrRemove={} /> : this.renderField() } diff --git a/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js b/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js index 922b7f650..e075b17bd 100644 --- a/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js +++ b/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js @@ -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} > - + /> */} +
+ 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 + } } + /> +
+ {/* */} + or +
+
{ ddOpen && options.length > 0 &&
diff --git a/frontend/app/components/shared/EventFilter/AutoComplete/autoComplete.css b/frontend/app/components/shared/EventFilter/AutoComplete/autoComplete.css index c2c827bfe..b72653c42 100644 --- a/frontend/app/components/shared/EventFilter/AutoComplete/autoComplete.css +++ b/frontend/app/components/shared/EventFilter/AutoComplete/autoComplete.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/app/components/shared/EventSearchInput/EventSearchInput.tsx b/frontend/app/components/shared/EventSearchInput/EventSearchInput.tsx new file mode 100644 index 000000000..0a7752ccd --- /dev/null +++ b/frontend/app/components/shared/EventSearchInput/EventSearchInput.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface Props { + +} +function EventSearchInput(props) { + return ( +
+ +
+ ); +} + +export default EventSearchInput; \ No newline at end of file diff --git a/frontend/app/components/shared/EventSearchInput/index.ts b/frontend/app/components/shared/EventSearchInput/index.ts new file mode 100644 index 000000000..2e4e57078 --- /dev/null +++ b/frontend/app/components/shared/EventSearchInput/index.ts @@ -0,0 +1 @@ +export { default } from './EventSearchInput'; \ No newline at end of file diff --git a/frontend/app/components/shared/FilterDropdown/FilterDropdown.js b/frontend/app/components/shared/FilterDropdown/FilterDropdown.js index 8bf6e5faa..66da1f586 100644 --- a/frontend/app/components/shared/FilterDropdown/FilterDropdown.js +++ b/frontend/app/components/shared/FilterDropdown/FilterDropdown.js @@ -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' }, diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.css b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.css new file mode 100644 index 000000000..e2ce40ac0 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx new file mode 100644 index 000000000..c313563ec --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx @@ -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([]); + 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 ( +
+
+ 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 + // } } + /> +
+ { showCloseButton &&
} + { showOrButton &&
or
} +
+
+ + { !showOrButton &&
or
} + + {/* */} + + { showModal && (options.length > 0 || loading) && +
+ { headerText && headerText } + + { + options.map(item => ( +
onItemClick(e, item) } + > + { item.icon && } + { item.value } +
+ )) + } +
+
+ } +
+ ); +} + +export default FilterAutoComplete; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/index.ts b/frontend/app/components/shared/Filters/FilterAutoComplete/index.ts new file mode 100644 index 000000000..8540e6f40 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/index.ts @@ -0,0 +1 @@ +export { default } from './FilterAutoComplete'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.css b/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.css new file mode 100644 index 000000000..c7a272458 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.css @@ -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; +} + diff --git a/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.js b/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.js new file mode 100644 index 000000000..8069bf3a8 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.js @@ -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 ( +
+ this.setState({ focused: true })} + onBlur={this.props.onBlur} + > + + + + this.setState({ focused: true })} + onBlur={this.props.onBlur} + > + + + +
+ ); + } +} diff --git a/frontend/app/components/shared/Filters/FilterDuration/index.js b/frontend/app/components/shared/Filters/FilterDuration/index.js new file mode 100644 index 000000000..cbf9296f3 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterDuration/index.js @@ -0,0 +1 @@ +export { default } from './FilterDuration'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx new file mode 100644 index 000000000..97e09d190 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx @@ -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 ( +
+
+ { !isFilter &&
{filterIndex+1}
} + + + +
+
+
+ +
+
+
+ ); +} + +export default FitlerItem; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterItem/index.ts b/frontend/app/components/shared/Filters/FilterItem/index.ts new file mode 100644 index 000000000..b09a3e2f1 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterItem/index.ts @@ -0,0 +1 @@ +export { default } from './FilterItem'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterList/FilterList.tsx b/frontend/app/components/shared/Filters/FilterList/FilterList.tsx new file mode 100644 index 000000000..c2f71fd5d --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterList/FilterList.tsx @@ -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 ( +
+ { hasEvents && ( + <> +
+
EVENTS
+
+
Events Order
+ null } + value={{ value: filter.eventsOrder }} + // value={{ value: 'and' }} + list={ [ + { name: 'AND', value: 'and' }, + { name: 'OR', value: 'or' }, + { name: 'THEN', value: 'then' }, + ]} + /> +
+
+ {filters.map((filter, filterIndex) => filter.isEvent ? ( + props.onUpdateFilter(filterIndex, filter)} + onRemoveFilter={() => onRemoveFilter(filterIndex) } + /> + ): null)} +
+ + )} + + {hasFilters && ( + <> +
+
FILTERS
+ {filters.map((filter, filterIndex) => !filter.isEvent ? ( + props.onUpdateFilter(filterIndex, filter)} + onRemoveFilter={() => onRemoveFilter(filterIndex) } + /> + ): null)} + + )} +
+ ); +} + +export default FilterList; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterList/index.ts b/frontend/app/components/shared/Filters/FilterList/index.ts new file mode 100644 index 000000000..ecf0adf70 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterList/index.ts @@ -0,0 +1 @@ +export { default } from './FilterList'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx new file mode 100644 index 000000000..e282ab78f --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx @@ -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 ( +
+
+ {filters && Object.keys(filters).map((key) => ( +
+
{key}
+
+ {filters[key].map((filter: any) => ( +
onFilterClick(filter)}> + + {filter.label} +
+ ))} +
+
+ ))} +
+
+ ); +} + +export default connect(state => ({ + filters: state.getIn([ 'filters', 'filterList' ]) +}))(FilterModal); \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterModal/index.ts b/frontend/app/components/shared/Filters/FilterModal/index.ts new file mode 100644 index 000000000..a8ab8d552 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterModal/index.ts @@ -0,0 +1 @@ +export { default } from './FilterModal'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.css b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.css new file mode 100644 index 000000000..8a2329dbd --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx new file mode 100644 index 000000000..7399d8e5b --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx @@ -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 ( + } + /> + ); +} + +export default FilterOperator; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterOperator/index.ts b/frontend/app/components/shared/Filters/FilterOperator/index.ts new file mode 100644 index 000000000..9345f24f8 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterOperator/index.ts @@ -0,0 +1 @@ +export { default } from './FilterOperator'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx new file mode 100644 index 000000000..17cc7d288 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx @@ -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 ( +
+ setTimeout(function() { + setShowModal(false) + }, 50)} + > + { children ? React.cloneElement(children, { onClick: () => setShowModal(true)}) : ( +
setShowModal(true)} + > + {filter.label} + +
+ ) } +
+ {showModal && ( +
+ +
+ )} +
+ ); +} + +export default FilterSelection; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterSelection/index.ts b/frontend/app/components/shared/Filters/FilterSelection/index.ts new file mode 100644 index 000000000..8c9764781 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterSelection/index.ts @@ -0,0 +1 @@ +export { default } from './FilterSelection'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx b/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx new file mode 100644 index 000000000..ef327eb7f --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx @@ -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 ( + onSelect(e, { value }, valueIndex)} + /> + ) + case FilterType.ISSUE: + case FilterType.MULTIPLE_DROPDOWN: + return ( + onSelect(e, { value }, valueIndex)} + /> + ) + case FilterType.DURATION: + return ( + + ) + case FilterType.NUMBER: + return ( + onSelect(e, { value: e.target.value }, valueIndex)} + /> + ) + case FilterType.MULTIPLE: + return ( + 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 ( +
+ { filter.type === FilterType.DURATION ? ( + renderValueFiled(filter.value, 0) + ) : ( + filter.value && filter.value.map((value, valueIndex) => ( + renderValueFiled(value, valueIndex) + )) + )} +
+ ); +} + +export default FilterValue; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterValue/index.ts b/frontend/app/components/shared/Filters/FilterValue/index.ts new file mode 100644 index 000000000..a4e4a517e --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterValue/index.ts @@ -0,0 +1 @@ +export { default } from './FilterValue'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.css b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.css new file mode 100644 index 000000000..eb2c457f7 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.tsx b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.tsx new file mode 100644 index 000000000..f2a54afc8 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.tsx @@ -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 ( +
+ } + /> +
+ { showCloseButton &&
} + { showOrButton &&
or
} +
+
+ ); +} + +export default FilterValueDropdown; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterValueDropdown/index.ts b/frontend/app/components/shared/Filters/FilterValueDropdown/index.ts new file mode 100644 index 000000000..0a0240086 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterValueDropdown/index.ts @@ -0,0 +1 @@ +export { default } from './FilterValueDropdown'; \ No newline at end of file diff --git a/frontend/app/components/shared/SaveFilterButton/SaveFilterButton.tsx b/frontend/app/components/shared/SaveFilterButton/SaveFilterButton.tsx new file mode 100644 index 000000000..82be6101a --- /dev/null +++ b/frontend/app/components/shared/SaveFilterButton/SaveFilterButton.tsx @@ -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 ( +
+ + setshowModal(false)} + /> +
+ ); +} + +export default connect(state => ({ + filter: state.getIn([ 'filters', 'appliedFilter' ]), +}), { save })(SaveFilterButton); \ No newline at end of file diff --git a/frontend/app/components/shared/SaveFilterButton/index.ts b/frontend/app/components/shared/SaveFilterButton/index.ts new file mode 100644 index 000000000..9f22c4ecb --- /dev/null +++ b/frontend/app/components/shared/SaveFilterButton/index.ts @@ -0,0 +1 @@ +export { default } from './SaveFilterButton' \ No newline at end of file diff --git a/frontend/app/components/shared/SaveSearchModal/SaveSearchModal.tsx b/frontend/app/components/shared/SaveSearchModal/SaveSearchModal.tsx new file mode 100644 index 000000000..29084ec42 --- /dev/null +++ b/frontend/app/components/shared/SaveSearchModal/SaveSearchModal.tsx @@ -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; + 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 ( + + +
{ 'Save Search' }
+ +
+ + +
+ + + + +
+
+ + + + +
+ ); +} + +export default connect(state => ({ + filter: state.getIn(['search', 'instance']), + loading: state.getIn([ 'filters', 'saveRequest', 'loading' ]) || + state.getIn([ 'filters', 'updateRequest', 'loading' ]), +}), { edit, save })(SaveSearchModal); \ No newline at end of file diff --git a/frontend/app/components/shared/SaveSearchModal/index.ts b/frontend/app/components/shared/SaveSearchModal/index.ts new file mode 100644 index 000000000..6c5515e82 --- /dev/null +++ b/frontend/app/components/shared/SaveSearchModal/index.ts @@ -0,0 +1 @@ +export { default } from './SaveSearchModal' \ No newline at end of file diff --git a/frontend/app/components/shared/SaveSearchModal/saveSearchModal.css b/frontend/app/components/shared/SaveSearchModal/saveSearchModal.css new file mode 100644 index 000000000..ed2600745 --- /dev/null +++ b/frontend/app/components/shared/SaveSearchModal/saveSearchModal.css @@ -0,0 +1,15 @@ +@import 'mixins.css'; + +.modalHeader { + display: flex !important; + align-items: center; + justify-content: space-between; +} + +.cancelButton { + @mixin plainButton; +} + +.applyButton { + @mixin basicButton; +} \ No newline at end of file diff --git a/frontend/app/components/shared/SavedSearch/SavedSearch.tsx b/frontend/app/components/shared/SavedSearch/SavedSearch.tsx new file mode 100644 index 000000000..170e90004 --- /dev/null +++ b/frontend/app/components/shared/SavedSearch/SavedSearch.tsx @@ -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 ( + setShowMenu(false)} + > +
+ + { showMenu && ( +
+ +
+ )} +
+
+ ); +} + +export default connect(state => ({ + list: state.getIn([ 'filters', 'list' ]), +}), { fetchListSavedSearch })(SavedSearch); \ No newline at end of file diff --git a/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/SavedSearchDropdown.css b/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/SavedSearchDropdown.css new file mode 100644 index 000000000..96609ccae --- /dev/null +++ b/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/SavedSearchDropdown.css @@ -0,0 +1,7 @@ +.wrapper { + position: relative; + display: inline-block; + z-index: 999; + display: flex; + flex-direction: column; +} \ No newline at end of file diff --git a/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/SavedSearchDropdown.tsx b/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/SavedSearchDropdown.tsx new file mode 100644 index 000000000..92746e78d --- /dev/null +++ b/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/SavedSearchDropdown.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import stl from './SavedSearchDropdown.css'; + +interface Props { + list: Array +} + +function Row ({ name }) { + return ( +
{name}
+ ) +} + +function SavedSearchDropdown(props: Props) { + return ( +
+ {props.list.map(item => ( + + ))} +
+ ); +} + +export default SavedSearchDropdown; \ No newline at end of file diff --git a/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/index.ts b/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/index.ts new file mode 100644 index 000000000..2fea67949 --- /dev/null +++ b/frontend/app/components/shared/SavedSearch/components/SavedSearchDropdown/index.ts @@ -0,0 +1 @@ +export { default } from './SavedSearchDropdown'; \ No newline at end of file diff --git a/frontend/app/components/shared/SavedSearch/index.ts b/frontend/app/components/shared/SavedSearch/index.ts new file mode 100644 index 000000000..71c14305d --- /dev/null +++ b/frontend/app/components/shared/SavedSearch/index.ts @@ -0,0 +1 @@ +export { default } from './SavedSearch' \ No newline at end of file diff --git a/frontend/app/components/shared/SessionSearch/SessionSearch.tsx b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx new file mode 100644 index 000000000..caba99eae --- /dev/null +++ b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx @@ -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 ( +
+
+ +
+ +
+
+ + + +
+
+ + + +
+
+
+ ); +} + +export default connect(state => ({ + appliedFilter: state.getIn([ 'search', 'instance' ]), +}), { edit })(SessionSearch); + +// appliedFilter: state.getIn([ 'filters', 'appliedFilter' ]), \ No newline at end of file diff --git a/frontend/app/components/shared/SessionSearch/index.ts b/frontend/app/components/shared/SessionSearch/index.ts new file mode 100644 index 000000000..d9c909f0d --- /dev/null +++ b/frontend/app/components/shared/SessionSearch/index.ts @@ -0,0 +1 @@ +export { default } from './SessionSearch'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionSearchField/SessionSearchField.css b/frontend/app/components/shared/SessionSearchField/SessionSearchField.css new file mode 100644 index 000000000..6a3a268ba --- /dev/null +++ b/frontend/app/components/shared/SessionSearchField/SessionSearchField.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx b/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx new file mode 100644 index 000000000..76aac5240 --- /dev/null +++ b/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx @@ -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 ( +
+ 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" + /> + + setShowModal(false) } + displayed={ showModal } + // displayed={ true } + // loading={ loading } + // searchedEvents={ searchedEvents } + searchQuery={ props.searchQuery } + /> +
+ ); +} + +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); \ No newline at end of file diff --git a/frontend/app/components/shared/SessionSearchField/index.ts b/frontend/app/components/shared/SessionSearchField/index.ts new file mode 100644 index 000000000..1f99e0c0b --- /dev/null +++ b/frontend/app/components/shared/SessionSearchField/index.ts @@ -0,0 +1 @@ +export { default } from './SessionSearchField'; \ No newline at end of file diff --git a/frontend/app/components/ui/SegmentSelection/SegmentSelection.js b/frontend/app/components/ui/SegmentSelection/SegmentSelection.js index c4d71a93a..d1d3ae6df 100644 --- a/frontend/app/components/ui/SegmentSelection/SegmentSelection.js +++ b/frontend/app/components/ui/SegmentSelection/SegmentSelection.js @@ -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 (
{ list.map(item => ( diff --git a/frontend/app/components/ui/SegmentSelection/segmentSelection.css b/frontend/app/components/ui/SegmentSelection/segmentSelection.css index 543016246..20007b010 100644 --- a/frontend/app/components/ui/SegmentSelection/segmentSelection.css +++ b/frontend/app/components/ui/SegmentSelection/segmentSelection.css @@ -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; } \ No newline at end of file diff --git a/frontend/app/constants/index.js b/frontend/app/constants/index.js index ba6f53cf0..8ba18841a 100644 --- a/frontend/app/constants/index.js +++ b/frontend/app/constants/index.js @@ -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, diff --git a/frontend/app/constants/platformOptions.js b/frontend/app/constants/platformOptions.js new file mode 100644 index 000000000..46747ea2e --- /dev/null +++ b/frontend/app/constants/platformOptions.js @@ -0,0 +1,5 @@ +export default [ + { value: 'desktop', text: 'Desktop' }, + { value: 'mobile', text: 'Mobile' }, + { value: 'tablet', text: 'Tablet' }, +] \ No newline at end of file diff --git a/frontend/app/duck/alerts.js b/frontend/app/duck/alerts.js index b93ff5878..1869db434 100644 --- a/frontend/app/duck/alerts.js +++ b/frontend/app/duck/alerts.js @@ -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; diff --git a/frontend/app/duck/customMetrics.js b/frontend/app/duck/customMetrics.js new file mode 100644 index 000000000..5842bb851 --- /dev/null +++ b/frontend/app/duck/customMetrics.js @@ -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, + }; +} \ No newline at end of file diff --git a/frontend/app/duck/filters.js b/frontend/app/duck/filters.js index a06a0b6bc..9122fb9c2 100644 --- a/frontend/app/duck/filters.js +++ b/frontend/app/duck/filters.js @@ -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 + } } \ No newline at end of file diff --git a/frontend/app/duck/funnels.js b/frontend/app/duck/funnels.js index 7dad8550a..7a611dbca 100644 --- a/frontend/app/duck/funnels.js +++ b/frontend/app/duck/funnels.js @@ -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] diff --git a/frontend/app/duck/index.js b/frontend/app/duck/index.js index c8d7a7c65..ffd0b945d 100644 --- a/frontend/app/duck/index.js +++ b/frontend/app/duck/index.js @@ -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, }); diff --git a/frontend/app/duck/search.js b/frontend/app/duck/search.js new file mode 100644 index 000000000..0404ca27a --- /dev/null +++ b/frontend/app/duck/search.js @@ -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, + }; +} \ No newline at end of file diff --git a/frontend/app/styles/main.css b/frontend/app/styles/main.css index c77533f3c..8915fd341 100644 --- a/frontend/app/styles/main.css +++ b/frontend/app/styles/main.css @@ -106,4 +106,10 @@ opacity: .5; } - +.form-group { + margin-bottom: 25px; + & label { + display: inline-block; + margin-bottom: 5px; + } +} \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/browser.svg b/frontend/app/svg/icons/filters/browser.svg new file mode 100644 index 000000000..9718fa3dc --- /dev/null +++ b/frontend/app/svg/icons/filters/browser.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/clickrage.svg b/frontend/app/svg/icons/filters/clickrage.svg new file mode 100644 index 000000000..3220e339e --- /dev/null +++ b/frontend/app/svg/icons/filters/clickrage.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/code.svg b/frontend/app/svg/icons/filters/code.svg new file mode 100644 index 000000000..c9c41b0d7 --- /dev/null +++ b/frontend/app/svg/icons/filters/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/country.svg b/frontend/app/svg/icons/filters/country.svg new file mode 100644 index 000000000..f72a9967b --- /dev/null +++ b/frontend/app/svg/icons/filters/country.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/custom.svg b/frontend/app/svg/icons/filters/custom.svg new file mode 100644 index 000000000..c9c41b0d7 --- /dev/null +++ b/frontend/app/svg/icons/filters/custom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/device.svg b/frontend/app/svg/icons/filters/device.svg new file mode 100644 index 000000000..b453af4aa --- /dev/null +++ b/frontend/app/svg/icons/filters/device.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/duration.svg b/frontend/app/svg/icons/filters/duration.svg new file mode 100644 index 000000000..e5ecc83ef --- /dev/null +++ b/frontend/app/svg/icons/filters/duration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/error.svg b/frontend/app/svg/icons/filters/error.svg new file mode 100644 index 000000000..a170d1363 --- /dev/null +++ b/frontend/app/svg/icons/filters/error.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/fetch.svg b/frontend/app/svg/icons/filters/fetch.svg new file mode 100644 index 000000000..40cbbbf86 --- /dev/null +++ b/frontend/app/svg/icons/filters/fetch.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/graphql.svg b/frontend/app/svg/icons/filters/graphql.svg new file mode 100644 index 000000000..86eafbec1 --- /dev/null +++ b/frontend/app/svg/icons/filters/graphql.svg @@ -0,0 +1 @@ +graphql \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/i-cursor.svg b/frontend/app/svg/icons/filters/i-cursor.svg new file mode 100644 index 000000000..ce8709fa8 --- /dev/null +++ b/frontend/app/svg/icons/filters/i-cursor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/input.svg b/frontend/app/svg/icons/filters/input.svg new file mode 100644 index 000000000..256542f8a --- /dev/null +++ b/frontend/app/svg/icons/filters/input.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/link.svg b/frontend/app/svg/icons/filters/link.svg new file mode 100644 index 000000000..ee908ebd0 --- /dev/null +++ b/frontend/app/svg/icons/filters/link.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/location.svg b/frontend/app/svg/icons/filters/location.svg new file mode 100644 index 000000000..499378c6b --- /dev/null +++ b/frontend/app/svg/icons/filters/location.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/os.svg b/frontend/app/svg/icons/filters/os.svg new file mode 100644 index 000000000..7a2781f28 --- /dev/null +++ b/frontend/app/svg/icons/filters/os.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/svg/icons/filters/phone-laptop.svg b/frontend/app/svg/icons/filters/platform.svg similarity index 100% rename from frontend/app/svg/icons/filters/phone-laptop.svg rename to frontend/app/svg/icons/filters/platform.svg diff --git a/frontend/app/svg/icons/filters/referrer.svg b/frontend/app/svg/icons/filters/referrer.svg new file mode 100644 index 000000000..daa9f0141 --- /dev/null +++ b/frontend/app/svg/icons/filters/referrer.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/resize.svg b/frontend/app/svg/icons/filters/resize.svg new file mode 100644 index 000000000..ca5a645f8 --- /dev/null +++ b/frontend/app/svg/icons/filters/resize.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/border-outer.svg b/frontend/app/svg/icons/filters/rev-id.svg similarity index 100% rename from frontend/app/svg/icons/filters/border-outer.svg rename to frontend/app/svg/icons/filters/rev-id.svg diff --git a/frontend/app/svg/icons/filters/state-action.svg b/frontend/app/svg/icons/filters/state-action.svg new file mode 100644 index 000000000..b4a93969f --- /dev/null +++ b/frontend/app/svg/icons/filters/state-action.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/view.svg b/frontend/app/svg/icons/filters/view.svg new file mode 100644 index 000000000..8feb3ec07 --- /dev/null +++ b/frontend/app/svg/icons/filters/view.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/types/customMetric.js b/frontend/app/types/customMetric.js new file mode 100644 index 000000000..0334ae2e6 --- /dev/null +++ b/frontend/app/types/customMetric.js @@ -0,0 +1,67 @@ +import Record from 'Types/Record'; +import { List } from 'immutable'; +import Filter from 'Types/filter'; + +export const FilterSeries = Record({ + seriesId: undefined, + index: undefined, + name: 'Filter Series', + filter: new Filter(), +}, { + idKey: 'seriesId', + methods: { + toData() { + const js = this.toJS(); + return js; + }, + }, + fromJS: ({ filter, ...rest }) => ({ + ...rest, + filter: new Filter(filter), + }), +}); + +export default Record({ + metricId: undefined, + name: 'Series', + type: 'session_count', + series: List(), + isPublic: false, +}, { + idKey: 'metricId', + methods: { + validate() { + return validateName(this.name, { diacritics: true }); + }, + + toSaveData() { + const js = this.toJS(); + + js.series = js.series.map(series => { + series.filter.filters = series.filter.filters.map(filter => { + filter.type = filter.key + delete filter.operatorOptions + delete filter.icon + delete filter.key + delete filter._key + return filter; + }); + delete series._key + return series; + }); + + delete js.key; + + return js; + }, + + toData() { + const js = this.toJS(); + return js; + }, + }, + fromJS: ({ series, ...rest }) => ({ + ...rest, + series: List(series).map(FilterSeries), + }), +}); diff --git a/frontend/app/types/dashboard/customMetric.js b/frontend/app/types/dashboard/customMetric.js new file mode 100644 index 000000000..9dd374b56 --- /dev/null +++ b/frontend/app/types/dashboard/customMetric.js @@ -0,0 +1,14 @@ +import { Record } from 'immutable'; + +const CustomMetric = Record({ + avg: undefined, + chart: [], +}); + + +function fromJS(data = {}) { + if (data instanceof CustomMetric) return data; + return new CustomMetric(data); +} + +export default fromJS; \ No newline at end of file diff --git a/frontend/app/types/filter/customFilter.js b/frontend/app/types/filter/customFilter.js index ea7d82af2..59e47d985 100644 --- a/frontend/app/types/filter/customFilter.js +++ b/frontend/app/types/filter/customFilter.js @@ -30,6 +30,20 @@ const REVID = 'REVID'; const USERANONYMOUSID = 'USERANONYMOUSID'; const USERID = 'USERID'; +const ISSUE = 'ISSUE'; +const EVENTS_COUNT = 'EVENTS_COUNT'; +const UTM_SOURCE = 'UTM_SOURCE'; +const UTM_MEDIUM = 'UTM_MEDIUM'; +const UTM_CAMPAIGN = 'UTM_CAMPAIGN'; + + +const DOM_COMPLETE = 'DOM_COMPLETE'; +const LARGEST_CONTENTFUL_PAINT_TIME = 'LARGEST_CONTENTFUL_PAINT_TIME'; +const TIME_BETWEEN_EVENTS = 'TIME_BETWEEN_EVENTS'; +const TTFB = 'TTFB'; +const AVG_CPU_LOAD = 'AVG_CPU_LOAD'; +const AVG_MEMORY_USAGE = 'AVG_MEMORY_USAGE'; + export const KEYS = { ERROR, MISSING_RESOURCE, @@ -56,7 +70,19 @@ export const KEYS = { STATEACTION, REVID, USERANONYMOUSID, - USERID + USERID, + ISSUE, + EVENTS_COUNT, + UTM_SOURCE, + UTM_MEDIUM, + UTM_CAMPAIGN, + + DOM_COMPLETE, + LARGEST_CONTENTFUL_PAINT_TIME, + TIME_BETWEEN_EVENTS, + TTFB, + AVG_CPU_LOAD, + AVG_MEMORY_USAGE, }; const getOperatorDefault = (type) => { @@ -85,7 +111,7 @@ export default Record({ label: '', icon: '', type: '', - value: '', + value: [""], custom: '', target: Target(), level: '', diff --git a/frontend/app/types/filter/event.js b/frontend/app/types/filter/event.js index 2a6f54da6..17d6357ff 100644 --- a/frontend/app/types/filter/event.js +++ b/frontend/app/types/filter/event.js @@ -17,6 +17,13 @@ const USERANONYMOUSID = 'USERANONYMOUSID'; const USERID = 'USERID'; const ERROR = 'ERROR'; +const DOM_COMPLETE = 'DOM_COMPLETE'; +const LARGEST_CONTENTFUL_PAINT_TIME = 'LARGEST_CONTENTFUL_PAINT_TIME'; +const TIME_BETWEEN_EVENTS = 'TIME_BETWEEN_EVENTS'; +const TTFB = 'TTFB'; +const AVG_CPU_LOAD = 'AVG_CPU_LOAD'; +const AVG_MEMORY_USAGE = 'AVG_MEMORY_USAGE'; + export const TYPES = { CONSOLE, GRAPHQL, @@ -31,7 +38,14 @@ export const TYPES = { METADATA, USERANONYMOUSID, USERID, - ERROR + ERROR, + + DOM_COMPLETE, + LARGEST_CONTENTFUL_PAINT_TIME, + TIME_BETWEEN_EVENTS, + TTFB, + AVG_CPU_LOAD, + AVG_MEMORY_USAGE, }; function getLabelText(type, source) { @@ -49,11 +63,19 @@ function getLabelText(type, source) { if (type === TYPES.USERID) return 'User Id'; if (type === TYPES.USERANONYMOUSID) return 'User Anonymous Id'; if (type === TYPES.REVID) return 'Rev ID'; + + if (type === TYPES.DOM_COMPLETE) return 'DOM Complete'; + if (type === TYPES.LARGEST_CONTENTFUL_PAINT_TIME) return 'Largest Contentful Paint Time'; + if (type === TYPES.TIME_BETWEEN_EVENTS) return 'Time Between Events'; + if (type === TYPES.TTFB) return 'TTFB'; + if (type === TYPES.AVG_CPU_LOAD) return 'Avg CPU Load'; + if (type === TYPES.AVG_MEMORY_USAGE) return 'Avg Memory Usage'; + if (type === TYPES.CUSTOM) { if (!source) return 'Custom'; return source; } - return '?'; + return type; } export default Record({ @@ -63,7 +85,7 @@ export default Record({ operator: 'on', type: '', searchType: '', - value: '', + value: [], custom: '', inputValue: '', target: Target(), @@ -73,16 +95,16 @@ export default Record({ }, { keyKey: "_key", fromJS: ({ target, type = "" , ...event }) => { - const viewType = type.split('_')[0]; + // const viewType = type.split('_')[0]; return { ...event, - type: viewType, + type: type, searchType: event.searchType || type, - label: getLabelText(viewType, event.source), + label: getLabelText(type, event.source), target: Target(target), operator: event.operator || defaultOperator(event), // value: target ? target.label : event.value, - icon: event.icon || getEventIcon({...event, type: viewType }) + icon: event.icon || getEventIcon({...event, type: type }) }; } }) diff --git a/frontend/app/types/filter/filter.js b/frontend/app/types/filter/filter.js index 8b3301aad..a9871c793 100644 --- a/frontend/app/types/filter/filter.js +++ b/frontend/app/types/filter/filter.js @@ -9,7 +9,8 @@ import { getDateRangeFromValue } from 'App/dateRange'; import Event from './event'; -import CustomFilter from './customFilter'; +// import CustomFilter from './customFilter'; +import NewFilter from './newFilter'; const rangeValue = DATE_RANGE_VALUES.LAST_7_DAYS; const range = getDateRangeFromValue(rangeValue); @@ -17,8 +18,8 @@ const startDate = range.start.unix() * 1000; const endDate = range.end.unix() * 1000; export default Record({ - name: undefined, - id: undefined, + name: '', + searchId: undefined, referrer: undefined, userBrowser: undefined, userOs: undefined, @@ -44,7 +45,41 @@ export default Record({ suspicious: undefined, consoleLevel: undefined, strict: false, + eventsOrder: 'and', }, { + idKey: 'searchId', + methods: { + toSaveData() { + const js = this.toJS(); + js.filters = js.filters.map(filter => { + filter.type = filter.key + + delete filter.category + delete filter.icon + delete filter.operatorOptions + delete filter._key + delete filter.key + return filter; + }); + + delete js.createdAt; + delete js.key; + delete js._key; + return js; + }, + toData() { + const js = this.toJS(); + js.filters = js.filters.map(filter => { + // delete filter.operatorOptions + // delete filter._key + return filter; + }); + + delete js.createdAt; + delete js.key; + return js; + } + }, fromJS({ filters, events, custom, ...filter }) { let startDate; let endDate; @@ -59,7 +94,7 @@ export default Record({ startDate, endDate, events: List(events).map(Event), - filters: List(filters).map(CustomFilter), + filters: List(filters).map(NewFilter), custom: Map(custom), } } @@ -73,6 +108,12 @@ export const defaultFilters = [ type: 'default', keys: [ { label: 'Click', key: KEYS.CLICK, type: KEYS.CLICK, filterKey: KEYS.CLICK, icon: 'filters/click', isFilter: false }, + { label: 'DOM Complete', key: KEYS.DOM_COMPLETE, type: KEYS.DOM_COMPLETE, filterKey: KEYS.DOM_COMPLETE, icon: 'filters/click', isFilter: false }, + { label: 'Largest Contentful Paint Time', key: KEYS.LARGEST_CONTENTFUL_PAINT_TIME, type: KEYS.LARGEST_CONTENTFUL_PAINT_TIME, filterKey: KEYS.LARGEST_CONTENTFUL_PAINT_TIME, icon: 'filters/click', isFilter: false }, + { label: 'Time Between Events', key: KEYS.TIME_BETWEEN_EVENTS, type: KEYS.TIME_BETWEEN_EVENTS, filterKey: KEYS.TIME_BETWEEN_EVENTS, icon: 'filters/click', isFilter: false }, + { label: 'Avg CPU Load', key: KEYS.AVG_CPU_LOAD, type: KEYS.AVG_CPU_LOAD, filterKey: KEYS.AVG_CPU_LOAD, icon: 'filters/click', isFilter: false }, + { label: 'Memory Usage', key: KEYS.AVG_MEMORY_USAGE, type: KEYS.AVG_MEMORY_USAGE, filterKey: KEYS.AVG_MEMORY_USAGE, icon: 'filters/click', isFilter: false }, + { label: 'Input', key: KEYS.INPUT, type: KEYS.INPUT, filterKey: KEYS.INPUT, icon: 'event/input', isFilter: false }, { label: 'Page', key: KEYS.LOCATION, type: KEYS.LOCATION, filterKey: KEYS.LOCATION, icon: 'event/link', isFilter: false }, // { label: 'View', key: KEYS.VIEW, type: KEYS.VIEW, filterKey: KEYS.VIEW, icon: 'event/view', isFilter: false } @@ -85,8 +126,8 @@ export const defaultFilters = [ { label: 'OS', key: KEYS.USER_OS, type: KEYS.USER_OS, filterKey: KEYS.USER_OS, icon: 'os', isFilter: true }, { label: 'Browser', key: KEYS.USER_BROWSER, type: KEYS.USER_BROWSER, filterKey: KEYS.USER_BROWSER, icon: 'window', isFilter: true }, { label: 'Device', key: KEYS.USER_DEVICE, type: KEYS.USER_DEVICE, filterKey: KEYS.USER_DEVICE, icon: 'device', isFilter: true }, - { label: 'Rev ID', key: KEYS.REVID, type: KEYS.REVID, filterKey: KEYS.REVID, icon: 'filters/border-outer', isFilter: true }, - { label: 'Platform', key: KEYS.PLATFORM, type: KEYS.PLATFORM, filterKey: KEYS.PLATFORM, icon: 'filters/phone-laptop', isFilter: true } + { label: 'Rev ID', key: KEYS.REVID, type: KEYS.REVID, filterKey: KEYS.REVID, icon: 'filters/rev-id', isFilter: true }, + { label: 'Platform', key: KEYS.PLATFORM, type: KEYS.PLATFORM, filterKey: KEYS.PLATFORM, icon: 'filters/platform', isFilter: true }, ] }, { @@ -103,6 +144,11 @@ export const defaultFilters = [ type: 'default', keys: [ { label: 'Errors', key: KEYS.ERROR, type: KEYS.ERROR, filterKey: KEYS.ERROR, icon: 'exclamation-circle', isFilter: false }, + { label: 'Issues', key: KEYS.ISSUES, type: KEYS.ISSUES, filterKey: KEYS.ISSUES, icon: 'exclamation-circle', isFilter: true }, + { label: 'UTM Source', key: KEYS.UTM_SOURCE, type: KEYS.UTM_SOURCE, filterKey: KEYS.UTM_SOURCE, icon: 'exclamation-circle', isFilter: true }, + { label: 'UTM Medium', key: KEYS.UTM_MEDIUM, type: KEYS.UTM_MEDIUM, filterKey: KEYS.UTM_MEDIUM, icon: 'exclamation-circle', isFilter: true }, + { label: 'UTM Campaign', key: KEYS.UTM_CAMPAIGN, type: KEYS.UTM_CAMPAIGN, filterKey: KEYS.UTM_CAMPAIGN, icon: 'exclamation-circle', isFilter: true }, + { label: 'Fetch Requests', key: KEYS.FETCH, type: KEYS.FETCH, filterKey: KEYS.FETCH, icon: 'fetch', isFilter: false }, { label: 'GraphQL Queries', key: KEYS.GRAPHQL, type: KEYS.GRAPHQL, filterKey: KEYS.GRAPHQL, icon: 'vendors/graphql', isFilter: false }, { label: 'Store Actions', key: KEYS.STATEACTION, type: KEYS.STATEACTION, filterKey: KEYS.STATEACTION, icon: 'store', isFilter: false }, @@ -127,6 +173,13 @@ export const getEventIcon = (filter) => { if (type === KEYS.USERBROWSER) return 'window'; if (type === KEYS.PLATFORM) return 'window'; + if (type === TYPES.DOM_COMPLETE) return 'filters/click'; + if (type === TYPES.LARGEST_CONTENTFUL_PAINT_TIME) return 'filters/click'; + if (type === TYPES.TIME_BETWEEN_EVENTS) return 'filters/click'; + if (type === TYPES.TTFB) return 'filters/click'; + if (type === TYPES.AVG_CPU_LOAD) return 'filters/click'; + if (type === TYPES.AVG_MEMORY_USAGE) return 'filters/click'; + if (type === TYPES.CLICK) return 'filters/click'; if (type === TYPES.LOCATION) return 'map-marker-alt'; if (type === TYPES.VIEW) return 'event/view'; diff --git a/frontend/app/types/filter/filterType.ts b/frontend/app/types/filter/filterType.ts new file mode 100644 index 000000000..b5449b161 --- /dev/null +++ b/frontend/app/types/filter/filterType.ts @@ -0,0 +1,62 @@ +export enum FilterCategory { + INTERACTIONS = "Interactions", + GEAR = "Gear", + RECORDING_ATTRIBUTES = "Recording Attributes", + JAVASCRIPT = "Javascript", + USER = "User", + METADATA = "Metadata", + PERFORMANCE = "Performance", +}; + +export enum FilterType { + ISSUE = "ISSUE", + BOOLEAN = "BOOLEAN", + NUMBER = "NUMBER", + DURATION = "DURATION", + MULTIPLE = "MULTIPLE", + COUNTRY = "COUNTRY", + DROPDOWN = "DROPDOWN", + MULTIPLE_DROPDOWN = "MULTIPLE_DROPDOWN", +}; + +export enum FilterKey { + ERROR = "ERROR", + MISSING_RESOURCE = "MISSING_RESOURCE", + SLOW_SESSION = "SLOW_SESSION", + CLICK_RAGE = "CLICK_RAGE", + CLICK = "CLICK", + INPUT = "INPUT", + LOCATION = "LOCATION", + VIEW = "VIEW", + CONSOLE = "CONSOLE", + METADATA = "METADATA", + CUSTOM = "CUSTOM", + URL = "URL", + USER_BROWSER = "USERBROWSER", + USER_OS = "USEROS", + USER_DEVICE = "USERDEVICE", + PLATFORM = "PLATFORM", + DURATION = "DURATION", + REFERRER = "REFERRER", + USER_COUNTRY = "USER_COUNTRY", + JOURNEY = "JOURNEY", + FETCH = "FETCH", + GRAPHQL = "GRAPHQL", + STATEACTION = "STATEACTION", + REVID = "REVID", + USERANONYMOUSID = "USERANONYMOUSID", + USERID = "USERID", + ISSUE = "ISSUE", + EVENTS_COUNT = "EVENTS_COUNT", + UTM_SOURCE = "UTM_SOURCE", + UTM_MEDIUM = "UTM_MEDIUM", + UTM_CAMPAIGN = "UTM_CAMPAIGN", + + DOM_COMPLETE = "DOM_COMPLETE", + LARGEST_CONTENTFUL_PAINT_TIME = "LARGEST_CONTENTFUL_PAINT_TIME", + TIME_BETWEEN_EVENTS = "TIME_BETWEEN_EVENTS", + TTFB = "TTFB", + AVG_CPU_LOAD = "AVG_CPU_LOAD", + AVG_MEMORY_USAGE = "AVG_MEMORY_USAGE", + FETCH_FAILED = "FETCH_FAILED", +} \ No newline at end of file diff --git a/frontend/app/types/filter/index.js b/frontend/app/types/filter/index.js index dba5512db..957e1dfb8 100644 --- a/frontend/app/types/filter/index.js +++ b/frontend/app/types/filter/index.js @@ -6,7 +6,7 @@ export * from './filter'; const filterKeys = ['is', 'isNot']; -const stringFilterKeys = ['is', 'isNot', 'contains']; +const stringFilterKeys = ['is', 'isNot', 'contains', 'startsWith', 'endsWith']; const targetFilterKeys = ['on', 'notOn']; const signUpStatusFilterKeys = ['isSignedUp', 'notSignedUp']; const rangeFilterKeys = ['before', 'after', 'on', 'inRange', 'notInRange', 'withInLast', 'notWithInLast']; @@ -209,6 +209,12 @@ export const operatorOptions = (filter) => { case TYPES.REVID: case 'metadata': case KEYS.ERROR: + case TYPES.DOM_COMPLETE: + case TYPES.LARGEST_CONTENTFUL_PAINT_TIME: + case TYPES.TIME_BETWEEN_EVENTS: + case TYPES.TTFB: + case TYPES.AVG_CPU_LOAD: + case TYPES.AVG_MEMORY_USAGE: return stringFilterOptions; case TYPES.INPUT: @@ -230,4 +236,22 @@ export const operatorOptions = (filter) => { case KEYS.CLICK_RAGE: return [{ key: 'onAnything', text: 'on anything', value: 'true' }] } -} \ No newline at end of file +} + +const NewFilterType = (key, category, label, icon, isEvent = false) => { + return { + key: key, + category: category, + label: label, + icon: icon, + isEvent: isEvent, + operators: operatorOptions({ key }), + value: [""] + } +} + +export const newFiltersList = [ + NewFilterType(TYPES.CLICK, 'Gear', 'Click', 'filters/click', true), + NewFilterType(TYPES.CLICK, 'Gear', 'Input', 'filters/click', true), + NewFilterType(TYPES.CONSOLE, 'Other', 'Console', 'filters/click', true), +]; \ No newline at end of file diff --git a/frontend/app/types/filter/newFilter.js b/frontend/app/types/filter/newFilter.js new file mode 100644 index 000000000..07628ffe2 --- /dev/null +++ b/frontend/app/types/filter/newFilter.js @@ -0,0 +1,277 @@ +import Record from 'Types/Record'; +import { FilterType, FilterKey, FilterCategory } from './filterType' +import { countries, platformOptions } from 'App/constants'; + +const countryOptions = Object.keys(countries).map(i => ({ text: countries[i], value: i })); + +const ISSUE_OPTIONS = [ + { text: 'Click Range', value: 'click_rage' }, + { text: 'Dead Click', value: 'dead_click' }, + { text: 'Excessive Scrolling', value: 'excessive_scrolling' }, + { text: 'Bad Request', value: 'bad_request' }, + { text: 'Missing Resource', value: 'missing_resource' }, + { text: 'Memory', value: 'memory' }, + { text: 'CPU', value: 'cpu' }, + { text: 'Slow Resource', value: 'slow_resource' }, + { text: 'Slow Page Load', value: 'slow_page_load' }, + { text: 'Crash', value: 'crash' }, + { text: 'Custom', value: 'custom' }, + { text: 'JS Exception', value: 'js_exception' }, +] + +const filterKeys = ['is', 'isNot']; +const stringFilterKeys = ['is', 'isNot', 'contains', 'startsWith', 'endsWith', 'notContains']; +const targetFilterKeys = ['on', 'notOn', 'onAny']; +const signUpStatusFilterKeys = ['isSignedUp', 'notSignedUp']; +const rangeFilterKeys = ['before', 'after', 'on', 'inRange', 'notInRange', 'withInLast', 'notWithInLast']; + +const options = [ + { + key: 'is', + text: 'is', + value: 'is' + }, { + key: 'isNot', + text: 'is not', + value: 'isNot' + }, { + key: 'startsWith', + text: 'starts with', + value: 'startsWith' + }, { + key: 'endsWith', + text: 'ends with', + value: 'endsWith' + }, { + key: 'contains', + text: 'contains', + value: 'contains' + }, { + key: 'notContains', + text: 'not contains', + value: 'notContains' + }, { + key: 'hasAnyValue', + text: 'has any value', + value: 'hasAnyValue' + }, { + key: 'hasNoValue', + text: 'has no value', + value: 'hasNoValue' + }, + + + { + key: 'isSignedUp', + text: 'is signed up', + value: 'isSignedUp' + }, { + key: 'notSignedUp', + text: 'not signed up', + value: 'notSignedUp' + }, + + + { + key: 'before', + text: 'before', + value: 'before' + }, { + key: 'after', + text: 'after', + value: 'after' + }, { + key: 'on', + text: 'on', + value: 'on' + }, { + key: 'notOn', + text: 'not on', + value: 'notOn' + }, { + key: 'inRage', + text: 'in rage', + value: 'inRage' + }, { + key: 'notInRage', + text: 'not in rage', + value: 'notInRage' + }, { + key: 'withinLast', + text: 'within last', + value: 'withinLast' + }, { + key: 'notWithinLast', + text: 'not within last', + value: 'notWithinLast' + }, + + { + key: 'greaterThan', + text: 'greater than', + value: 'greaterThan' + }, { + key: 'lessThan', + text: 'less than', + value: 'lessThan' + }, { + key: 'equal', + text: 'equal', + value: 'equal' + }, { + key: 'not equal', + text: 'not equal', + value: 'not equal' + }, + + + { + key: 'onSelector', + text: 'on selector', + value: 'onSelector' + }, { + key: 'onText', + text: 'on text', + value: 'onText' + }, { + key: 'onComponent', + text: 'on component', + value: 'onComponent' + }, + + + { + key: 'onAny', + text: 'on any', + value: 'onAny' + } +]; + +export const filterOptions = options.filter(({key}) => filterKeys.includes(key)); +export const stringFilterOptions = options.filter(({key}) => stringFilterKeys.includes(key)); +export const targetFilterOptions = options.filter(({key}) => targetFilterKeys.includes(key)); +export const booleanOptions = [ + { key: 'true', text: 'true', value: 'true' }, + { key: 'false', text: 'false', value: 'false' }, +] + +export const customOperators = [ + { key: '=', text: '=', value: '=' }, + { key: '<', text: '<', value: '<' }, + { key: '>', text: '>', value: '>' }, + { key: '<=', text: '<=', value: '<=' }, + { key: '>=', text: '>=', value: '>=' }, +] + +export const filtersMap = { + [FilterKey.CLICK]: { key: FilterKey.CLICK, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Click', operator: 'on', operatorOptions: targetFilterOptions, icon: 'filters/click', isEvent: true }, + [FilterKey.INPUT]: { key: FilterKey.INPUT, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Input', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/input', isEvent: true }, + [FilterKey.LOCATION]: { key: FilterKey.LOCATION, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Page', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/location', isEvent: true }, + [FilterKey.CUSTOM]: { key: FilterKey.CUSTOM, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Custom Events', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/custom', isEvent: true }, + [FilterKey.FETCH]: { key: FilterKey.FETCH, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Fetch', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/fetch', isEvent: true }, + [FilterKey.GRAPHQL]: { key: FilterKey.GRAPHQL, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'GraphQL', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/graphql', isEvent: true }, + [FilterKey.STATEACTION]: { key: FilterKey.STATEACTION, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'StateAction', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/state-action', isEvent: true }, + [FilterKey.ERROR]: { key: FilterKey.ERROR, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Error', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/error', isEvent: true }, + // [FilterKey.METADATA]: { key: FilterKey.METADATA, type: FilterType.MULTIPLE, category: FilterCategory.METADATA, label: 'Metadata', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/metadata', isEvent: true }, + + [FilterKey.USER_OS]: { key: FilterKey.USER_OS, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User OS', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/os' }, + [FilterKey.USER_BROWSER]: { key: FilterKey.USER_BROWSER, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Browser', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/browser' }, + [FilterKey.USER_DEVICE]: { key: FilterKey.USER_DEVICE, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Device', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/device' }, + [FilterKey.PLATFORM]: { key: FilterKey.PLATFORM, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.GEAR, label: 'Platform', operator: 'is', operatorOptions: filterOptions, icon: 'filters/platform', options: platformOptions }, + [FilterKey.REVID]: { key: FilterKey.REVID, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'RevId', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/rev-id' }, + + [FilterKey.REFERRER]: { key: FilterKey.REFERRER, type: FilterType.MULTIPLE, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Referrer', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/referrer' }, + [FilterKey.DURATION]: { key: FilterKey.DURATION, type: FilterType.DURATION, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Duration', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/duration' }, + [FilterKey.USER_COUNTRY]: { key: FilterKey.USER_COUNTRY, type: FilterType.DROPDOWN, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'User Country', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/country', options: countryOptions }, + + [FilterKey.CONSOLE]: { key: FilterKey.CONSOLE, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Console', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/console' }, + + + + + [FilterKey.USERID]: { key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'UserId', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/userid' }, + [FilterKey.USERANONYMOUSID]: { key: FilterKey.USERANONYMOUSID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'UserAnonymousId', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/userid' }, + + [FilterKey.DOM_COMPLETE]: { key: FilterKey.DOM_COMPLETE, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'DOM Complete', operator: 'is', operatorOptions: stringFilterOptions, sourcesourceOperatorOptions: customOperators, source: [], icon: 'filters/click', isEvent: true }, + [FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: { key: FilterKey.LARGEST_CONTENTFUL_PAINT_TIME, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Largest Contentful Paint Time', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/click', isEvent: true }, + // [FilterKey.TIME_BETWEEN_EVENTS]: { key: FilterKey.TIME_BETWEEN_EVENTS, type: FilterType.NUMBER, category: FilterCategory.PERFORMANCE, label: 'Time Between Events', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/click' }, + [FilterKey.TTFB]: { key: FilterKey.TTFB, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Time to First Byte', operator: 'is', operatorOptions: stringFilterOptions, sourceOperatorOptions: customOperators, source: [], icon: 'filters/click', isEvent: true }, + [FilterKey.AVG_CPU_LOAD]: { key: FilterKey.AVG_CPU_LOAD, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Avg CPU Load', operator: 'is', operatorOptions: stringFilterOptions, sourceOperatorOptions: customOperators, source: [], icon: 'filters/click', isEvent: true }, + [FilterKey.AVG_MEMORY_USAGE]: { key: FilterKey.AVG_MEMORY_USAGE, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Avg Memory Usage', operator: 'is', operatorOptions: stringFilterOptions, sourceOperatorOptions: customOperators, source: [], icon: 'filters/click', isEvent: true }, + [FilterKey.FETCH_FAILED]: { key: FilterKey.AVG_MEMORY_USAGE, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Fetch Failed', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/click', isEvent: true }, + + + // [FilterKey.AVG_CPU_LOAD]: { key: FilterKey.AVG_CPU_LOAD, type: FilterType.NUMBER, category: 'new', label: 'Avg CPU Load', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/click' }, + // [FilterKey.AVG_MEMORY_USAGE]: { key: FilterKey.AVG_MEMORY_USAGE, type: FilterType.NUMBER, category: 'new', label: 'Avg Memory Usage', operator: 'is', operatorOptions: stringFilterOptions, icon: 'filters/click' }, + // [FilterKey.SLOW_SESSION]: { key: FilterKey.SLOW_SESSION, type: FilterType.BOOLEAN, category: 'new', label: 'Slow Session', operator: 'true', operatorOptions: [{ key: 'true', text: 'true', value: 'true' }], icon: 'filters/click' }, + // [FilterKey.MISSING_RESOURCE]: { key: FilterKey.MISSING_RESOURCE, type: FilterType.BOOLEAN, category: 'new', label: 'Missing Resource', operator: 'true', operatorOptions: [{ key: 'inImages', text: 'in images', value: 'true' }], icon: 'filters/click' }, + // [FilterKey.CLICK_RAGE]: { key: FilterKey.CLICK_RAGE, type: FilterType.BOOLEAN, category: 'new', label: 'Click Rage', operator: 'onAnything', operatorOptions: [{ key: 'onAnything', text: 'on anything', value: 'true' }], icon: 'filters/click' }, + + [FilterKey.ISSUE]: { key: FilterKey.ISSUE, type: FilterType.ISSUE, category: FilterCategory.JAVASCRIPT, label: 'Issue', operator: 'is', operatorOptions: filterOptions, icon: 'filters/click', options: ISSUE_OPTIONS }, + // [FilterKey.URL]: { / [TYPES,TYPES. category: 'interactions', label: 'URL', operator: 'is', operatorOptions: stringFilterOptions }, + // [FilterKey.CUSTOM]: { / [TYPES,TYPES. category: 'interactions', label: 'Custom', operator: 'is', operatorOptions: stringFilterOptions }, + // [FilterKey.METADATA]: { / [TYPES,TYPES. category: 'interactions', label: 'Metadata', operator: 'is', operatorOptions: stringFilterOptions }, +} + +export default Record({ + timestamp: 0, + key: '', + label: '', + icon: '', + type: '', + value: [""], + source: [""], + category: '', + + custom: '', + // target: Target(), + level: '', + source: null, + hasNoValue: false, + isFilter: false, + actualValue: '', + + operator: '', + sourceOperator: '=', + operatorOptions: [], + sourceOptions: [], + isEvent: false, + index: 0, + options: [], +}, { + keyKey: "_key", + fromJS: ({ key, ...filter }) => ({ + ...filter, + key, + type: filter.type, // camelCased(filter.type.toLowerCase()), + // key: filter.type === METADATA ? filter.label : filter.key || filter.type, // || camelCased(filter.type.toLowerCase()), + // label: getLabel(filter), + // target: Target(target), + // operator: getOperatorDefault(key), + // value: target ? target.label : filter.value, + // value: typeof value === 'string' ? [value] : value, + // icon: filter.type ? getfilterIcon(filter.type) : 'filters/metadata' + }), +}) + +// const NewFilterType = (key, category, icon, isEvent = false) => { +// return { +// key: key, +// category: category, +// label: filterMap[key].label, +// icon: icon, +// isEvent: isEvent, +// operators: filterMap[key].operatorOptions, +// value: [""] +// } +// } + + +const getOperatorDefault = (type) => { + if (type === MISSING_RESOURCE) return 'true'; + if (type === SLOW_SESSION) return 'true'; + if (type === CLICK_RAGE) return 'true'; + if (type === CLICK) return 'on'; + + return 'is'; +} \ No newline at end of file diff --git a/frontend/env.js b/frontend/env.js index 75f421314..85984b376 100644 --- a/frontend/env.js +++ b/frontend/env.js @@ -5,13 +5,13 @@ require('dotenv').config() const oss = { name: 'oss', - PRODUCTION: true, + PRODUCTION: false, SENTRY_ENABLED: false, SENTRY_URL: "", CAPTCHA_ENABLED: process.env.CAPTCHA_ENABLED === 'true', CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY, ORIGIN: () => 'window.location.origin', - API_EDP: () => 'window.location.origin + "/api"', + API_EDP: "https://foss.openreplay.com/api", ASSETS_HOST: () => 'window.location.origin + "/assets"', VERSION: '1.4.0', SOURCEMAP: true,