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 ( + + {({ open }) => ( + <> +
+ + {props.selectedType} + +
+ + + +
+ {renderTypeItem(FILTER_TYPES.is, true)} + {renderTypeItem(FILTER_TYPES.isNot, supportsIsNot(filterName))} + {renderTypeItem(FILTER_TYPES.contains, isFreeChoiceFilter(filterName))} +
+
+
+ + )} +
+ ) +} \ 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 ( - - {({ open }) => ( - <> -
- - {this.selectedFilterType(filterName)} - -
- - - -
- {this.renderTypeItem(filterName, FILTER_TYPES.is, true)} - {this.renderTypeItem(filterName, FILTER_TYPES.isNot, supportsIsNot(filterName))} - {this.renderTypeItem(filterName, FILTER_TYPES.contains, this.isFreeChoice())} -
-
-
- - )} -
- ) - } - - 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") ])