diff --git a/assets/js/dashboard/components/combobox.js b/assets/js/dashboard/components/combobox.js
index 98eeb2512..7a727d422 100644
--- a/assets/js/dashboard/components/combobox.js
+++ b/assets/js/dashboard/components/combobox.js
@@ -129,7 +129,7 @@ export default function PlausibleCombobox(props) {
function selectOption(option) {
if (isDisabled(option)) return
- props.onChange([...props.values, option])
+ props.onSelect([...props.values, option])
setOpen(false)
setInput('')
searchRef.current.focus()
@@ -138,7 +138,7 @@ export default function PlausibleCombobox(props) {
function removeOption(option, e) {
e.stopPropagation()
const newValues = props.values.filter((val) => val.value !== option.value)
- props.onChange(newValues)
+ props.onSelect(newValues)
searchRef.current.focus()
setOpen(false)
}
diff --git a/assets/js/dashboard/components/filter-type-selector.js b/assets/js/dashboard/components/filter-type-selector.js
new file mode 100644
index 000000000..5fbaa2da8
--- /dev/null
+++ b/assets/js/dashboard/components/filter-type-selector.js
@@ -0,0 +1,69 @@
+import React, { Fragment } from "react";
+
+import { FILTER_TYPES } from "../util/filters";
+import { Menu, Transition } from "@headlessui/react";
+import { ChevronDownIcon } from '@heroicons/react/20/solid'
+import { isFreeChoiceFilter, supportsIsNot } from "../util/filters";
+import classNames from "classnames";
+
+export default function FilterTypeSelector(props) {
+ const filterName = props.forFilter
+
+ function renderTypeItem(type, shouldDisplay) {
+ return (
+ shouldDisplay && (
+
+ {({ active }) => (
+ props.onSelect(type)}
+ className={classNames("cursor-pointer block px-4 py-2 text-sm", {
+ "bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100": active,
+ "text-gray-700 dark:text-gray-200": !active
+ }
+ )}
+ >
+ {type}
+
+ )}
+
+ )
+ )
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/assets/js/dashboard/router.js b/assets/js/dashboard/router.js
index b6d769435..1ca097288 100644
--- a/assets/js/dashboard/router.js
+++ b/assets/js/dashboard/router.js
@@ -9,7 +9,7 @@ import PagesModal from './stats/modals/pages'
import EntryPagesModal from './stats/modals/entry-pages'
import ExitPagesModal from './stats/modals/exit-pages'
import ModalTable from './stats/modals/table'
-import FilterModal from './stats/modals/filter'
+import FilterModal from './stats/modals/filter-modal'
function ScrollToTop() {
const location = useLocation();
diff --git a/assets/js/dashboard/stats/modals/filter-modal.js b/assets/js/dashboard/stats/modals/filter-modal.js
new file mode 100644
index 000000000..f8accc2ec
--- /dev/null
+++ b/assets/js/dashboard/stats/modals/filter-modal.js
@@ -0,0 +1,20 @@
+import React from "react"
+import { withRouter } from 'react-router-dom'
+import Modal from './modal'
+import RegularFilterModal from './regular-filter-modal'
+
+function FilterModal(props) {
+ function renderBody() {
+ const filterGroup = props.match.params.field || 'page'
+
+ return
+ }
+
+ return (
+
+ {renderBody()}
+
+ )
+}
+
+export default withRouter(FilterModal)
diff --git a/assets/js/dashboard/stats/modals/filter.js b/assets/js/dashboard/stats/modals/regular-filter-modal.js
similarity index 60%
rename from assets/js/dashboard/stats/modals/filter.js
rename to assets/js/dashboard/stats/modals/regular-filter-modal.js
index a1690d28c..9a5dd5ed6 100644
--- a/assets/js/dashboard/stats/modals/filter.js
+++ b/assets/js/dashboard/stats/modals/regular-filter-modal.js
@@ -1,16 +1,14 @@
-import React, { Fragment } from "react";
+import React from "react";
import { withRouter } from 'react-router-dom'
-import classNames from 'classnames'
-import { Menu, Transition } from '@headlessui/react'
-import { ChevronDownIcon } from '@heroicons/react/20/solid'
+import FilterTypeSelector from "../../components/filter-type-selector";
import Combobox from '../../components/combobox'
-import Modal from './modal'
import { FILTER_GROUPS, parseQueryFilter, formatFilterGroup, formattedFilters, toFilterQuery, FILTER_TYPES } from '../../util/filters'
import { parseQuery } from '../../query'
import * as api from '../../api'
import { apiPath, siteBasePath } from '../../util/url'
import { shouldIgnoreKeypress } from '../../keybinding'
+import { isFreeChoiceFilter } from "../../util/filters";
function getFormState(filterGroup, query) {
if (filterGroup === 'props') {
@@ -35,10 +33,6 @@ function getFormState(filterGroup, query) {
}, {})
}
-function supportsIsNot(filterName) {
- return !['goal', 'prop_key'].includes(filterName)
-}
-
function withIndefiniteArticle(word) {
if (word.startsWith('UTM')) {
return `a ${word}`
@@ -46,17 +40,15 @@ function withIndefiniteArticle(word) {
return `an ${word}`
}
return `a ${word}`
-
}
-class FilterModal extends React.Component {
+class RegularFilterModal extends React.Component {
constructor(props) {
super(props)
const query = parseQuery(props.location.search, props.site)
- const selectedFilterGroup = this.props.match.params.field || 'page'
- const formState = getFormState(selectedFilterGroup, query)
+ const formState = getFormState(props.filterGroup, query)
- this.state = { selectedFilterGroup, query, formState }
+ this.state = { query, formState }
}
componentDidMount() {
@@ -97,7 +89,7 @@ class FilterModal extends React.Component {
this.selectFiltersAndCloseModal(filters)
}
- onChange(filterName) {
+ onComboboxSelect(filterName) {
return (selection) => {
this.setState(prevState => ({
formState: Object.assign(prevState.formState, {
@@ -107,12 +99,14 @@ class FilterModal extends React.Component {
}
}
- setFilterType(filterName, newType) {
- this.setState(prevState => ({
- formState: Object.assign(prevState.formState, {
- [filterName]: Object.assign(prevState.formState[filterName], { type: newType })
- })
- }))
+ onFilterTypeSelect(filterName) {
+ return (newType) => {
+ this.setState(prevState => ({
+ formState: Object.assign(prevState.formState, {
+ [filterName]: Object.assign(prevState.formState[filterName], { type: newType })
+ })
+ }))
+ }
}
fetchOptions(filter) {
@@ -159,7 +153,7 @@ class FilterModal extends React.Component {
}
isDisabled() {
- if (this.state.selectedFilterGroup === 'props') {
+ if (this.props.filterGroup === 'props') {
return Object.entries(this.state.formState).some(([_key, { clauses }]) => clauses.length === 0)
} else {
return Object.entries(this.state.formState).every(([_key, { clauses }]) => clauses.length === 0)
@@ -180,96 +174,30 @@ class FilterModal extends React.Component {
this.props.history.replace({ pathname: siteBasePath(this.props.site), search: queryString.toString() })
}
- isFreeChoice() {
- return ['page', 'utm'].includes(this.state.selectedFilterGroup)
- }
-
- renderSearchBox(filter) {
- return
- }
-
renderFilterInputs() {
- const groups = FILTER_GROUPS[this.state.selectedFilterGroup]
+ const filtersInGroup = FILTER_GROUPS[this.props.filterGroup]
- return groups.map((filter) => {
+ return filtersInGroup.map((filter) => {
return (
{formattedFilters[filter]}
- {this.renderFilterTypeSelector(filter)}
- {this.renderSearchBox(filter)}
+
+
)
})
}
- renderFilterTypeSelector(filterName) {
- return (
-
- )
- }
-
- renderTypeItem(filterName, type, shouldDisplay) {
- return (
- shouldDisplay && (
-
- {({ active }) => (
- this.setFilterType(filterName, type)}
- className={classNames(
- active ? "bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100" : "text-gray-700 dark:text-gray-200",
- "cursor-pointer block px-4 py-2 text-sm"
- )}
- >
- {type}
-
- )}
-
- )
- );
- }
-
- renderBody() {
- const { selectedFilterGroup, query } = this.state;
- const showClear = FILTER_GROUPS[selectedFilterGroup].some((filterName) => query.filters[filterName])
+ render() {
+ const { filterGroup } = this.props
+ const { query } = this.state
+ const showClear = FILTER_GROUPS[filterGroup].some((filterName) => query.filters[filterName])
return (
<>
- Filter by {formatFilterGroup(selectedFilterGroup)}
+ Filter by {formatFilterGroup(filterGroup)}
@@ -290,12 +218,12 @@ class FilterModal extends React.Component {
type="button"
className="ml-2 button px-4 flex bg-red-500 dark:bg-red-500 hover:bg-red-600 dark:hover:bg-red-700 items-center"
onClick={() => {
- const updatedFilters = FILTER_GROUPS[selectedFilterGroup].map((filterName) => ({ filter: filterName, value: null }))
+ const updatedFilters = FILTER_GROUPS[filterGroup].map((filterName) => ({ filter: filterName, value: null }))
this.selectFiltersAndCloseModal(updatedFilters)
}}
>
- Remove filter{FILTER_GROUPS[selectedFilterGroup].length > 1 ? 's' : ''}
+ Remove filter{FILTER_GROUPS[filterGroup].length > 1 ? 's' : ''}
)}
@@ -304,14 +232,6 @@ class FilterModal extends React.Component {
>
)
}
-
- render() {
- return (
-
- {this.renderBody()}
-
- )
- }
}
-export default withRouter(FilterModal)
+export default withRouter(RegularFilterModal)
diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js
index 64bff909f..baed92371 100644
--- a/assets/js/dashboard/util/filters.js
+++ b/assets/js/dashboard/util/filters.js
@@ -22,6 +22,14 @@ export const FILTER_PREFIXES = {
[FILTER_TYPES.is]: ''
};
+export function supportsIsNot(filterName) {
+ return !['goal', 'prop_key'].includes(filterName)
+}
+
+export function isFreeChoiceFilter(filterName) {
+ return FILTER_GROUPS['page'].concat(FILTER_GROUPS['utm']).includes(filterName)
+}
+
// As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means
// escaping pipe characters in filters does not currently work in Safari
let NON_ESCAPED_PIPE_REGEX;
diff --git a/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs
index eef27ce2c..ffa727973 100644
--- a/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs
@@ -57,6 +57,7 @@ defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do
test "returns suggestions for sources", %{conn: conn, site: site} do
populate_stats(site, [
+ build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "Bing"),
build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "Bing"),
build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "10words")
])