mirror of
https://github.com/plausible/analytics.git
synced 2024-12-28 12:01:39 +03:00
Filter modal refactor (#2806)
* Refactor: FilterModal -> RegularFilterModal * Rename filter.js to regular-filter-modal.js * Refactor: extract FilterTypeSelector component * fix undefined order in test * classNames function style improvement
This commit is contained in:
parent
4edf126196
commit
7204e470a8
@ -129,7 +129,7 @@ export default function PlausibleCombobox(props) {
|
|||||||
function selectOption(option) {
|
function selectOption(option) {
|
||||||
if (isDisabled(option)) return
|
if (isDisabled(option)) return
|
||||||
|
|
||||||
props.onChange([...props.values, option])
|
props.onSelect([...props.values, option])
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setInput('')
|
setInput('')
|
||||||
searchRef.current.focus()
|
searchRef.current.focus()
|
||||||
@ -138,7 +138,7 @@ export default function PlausibleCombobox(props) {
|
|||||||
function removeOption(option, e) {
|
function removeOption(option, e) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const newValues = props.values.filter((val) => val.value !== option.value)
|
const newValues = props.values.filter((val) => val.value !== option.value)
|
||||||
props.onChange(newValues)
|
props.onSelect(newValues)
|
||||||
searchRef.current.focus()
|
searchRef.current.focus()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
69
assets/js/dashboard/components/filter-type-selector.js
Normal file
69
assets/js/dashboard/components/filter-type-selector.js
Normal file
@ -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 && (
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<span
|
||||||
|
onClick={() => 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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<div className="w-24">
|
||||||
|
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 dark:focus:ring-offset-gray-900 focus:ring-indigo-500">
|
||||||
|
{props.selectedType}
|
||||||
|
<ChevronDownIcon className="-mr-2 ml-2 h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
|
||||||
|
</Menu.Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
static
|
||||||
|
className="z-10 origin-top-left absolute left-0 mt-2 w-24 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{renderTypeItem(FILTER_TYPES.is, true)}
|
||||||
|
{renderTypeItem(FILTER_TYPES.isNot, supportsIsNot(filterName))}
|
||||||
|
{renderTypeItem(FILTER_TYPES.contains, isFreeChoiceFilter(filterName))}
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
@ -9,7 +9,7 @@ import PagesModal from './stats/modals/pages'
|
|||||||
import EntryPagesModal from './stats/modals/entry-pages'
|
import EntryPagesModal from './stats/modals/entry-pages'
|
||||||
import ExitPagesModal from './stats/modals/exit-pages'
|
import ExitPagesModal from './stats/modals/exit-pages'
|
||||||
import ModalTable from './stats/modals/table'
|
import ModalTable from './stats/modals/table'
|
||||||
import FilterModal from './stats/modals/filter'
|
import FilterModal from './stats/modals/filter-modal'
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
20
assets/js/dashboard/stats/modals/filter-modal.js
Normal file
20
assets/js/dashboard/stats/modals/filter-modal.js
Normal file
@ -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 <RegularFilterModal site={props.site} filterGroup={filterGroup}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal site={props.site} maxWidth="460px">
|
||||||
|
{renderBody()}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(FilterModal)
|
@ -1,16 +1,14 @@
|
|||||||
import React, { Fragment } from "react";
|
import React from "react";
|
||||||
import { withRouter } from 'react-router-dom'
|
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 Combobox from '../../components/combobox'
|
||||||
import Modal from './modal'
|
|
||||||
import { FILTER_GROUPS, parseQueryFilter, formatFilterGroup, formattedFilters, toFilterQuery, FILTER_TYPES } from '../../util/filters'
|
import { FILTER_GROUPS, parseQueryFilter, formatFilterGroup, formattedFilters, toFilterQuery, FILTER_TYPES } from '../../util/filters'
|
||||||
import { parseQuery } from '../../query'
|
import { parseQuery } from '../../query'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import { apiPath, siteBasePath } from '../../util/url'
|
import { apiPath, siteBasePath } from '../../util/url'
|
||||||
import { shouldIgnoreKeypress } from '../../keybinding'
|
import { shouldIgnoreKeypress } from '../../keybinding'
|
||||||
|
import { isFreeChoiceFilter } from "../../util/filters";
|
||||||
|
|
||||||
function getFormState(filterGroup, query) {
|
function getFormState(filterGroup, query) {
|
||||||
if (filterGroup === 'props') {
|
if (filterGroup === 'props') {
|
||||||
@ -35,10 +33,6 @@ function getFormState(filterGroup, query) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function supportsIsNot(filterName) {
|
|
||||||
return !['goal', 'prop_key'].includes(filterName)
|
|
||||||
}
|
|
||||||
|
|
||||||
function withIndefiniteArticle(word) {
|
function withIndefiniteArticle(word) {
|
||||||
if (word.startsWith('UTM')) {
|
if (word.startsWith('UTM')) {
|
||||||
return `a ${word}`
|
return `a ${word}`
|
||||||
@ -46,17 +40,15 @@ function withIndefiniteArticle(word) {
|
|||||||
return `an ${word}`
|
return `an ${word}`
|
||||||
}
|
}
|
||||||
return `a ${word}`
|
return `a ${word}`
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FilterModal extends React.Component {
|
class RegularFilterModal extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
const query = parseQuery(props.location.search, props.site)
|
const query = parseQuery(props.location.search, props.site)
|
||||||
const selectedFilterGroup = this.props.match.params.field || 'page'
|
const formState = getFormState(props.filterGroup, query)
|
||||||
const formState = getFormState(selectedFilterGroup, query)
|
|
||||||
|
|
||||||
this.state = { selectedFilterGroup, query, formState }
|
this.state = { query, formState }
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@ -97,7 +89,7 @@ class FilterModal extends React.Component {
|
|||||||
this.selectFiltersAndCloseModal(filters)
|
this.selectFiltersAndCloseModal(filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(filterName) {
|
onComboboxSelect(filterName) {
|
||||||
return (selection) => {
|
return (selection) => {
|
||||||
this.setState(prevState => ({
|
this.setState(prevState => ({
|
||||||
formState: Object.assign(prevState.formState, {
|
formState: Object.assign(prevState.formState, {
|
||||||
@ -107,12 +99,14 @@ class FilterModal extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilterType(filterName, newType) {
|
onFilterTypeSelect(filterName) {
|
||||||
this.setState(prevState => ({
|
return (newType) => {
|
||||||
formState: Object.assign(prevState.formState, {
|
this.setState(prevState => ({
|
||||||
[filterName]: Object.assign(prevState.formState[filterName], { type: newType })
|
formState: Object.assign(prevState.formState, {
|
||||||
})
|
[filterName]: Object.assign(prevState.formState[filterName], { type: newType })
|
||||||
}))
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchOptions(filter) {
|
fetchOptions(filter) {
|
||||||
@ -159,7 +153,7 @@ class FilterModal extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isDisabled() {
|
isDisabled() {
|
||||||
if (this.state.selectedFilterGroup === 'props') {
|
if (this.props.filterGroup === 'props') {
|
||||||
return Object.entries(this.state.formState).some(([_key, { clauses }]) => clauses.length === 0)
|
return Object.entries(this.state.formState).some(([_key, { clauses }]) => clauses.length === 0)
|
||||||
} else {
|
} else {
|
||||||
return Object.entries(this.state.formState).every(([_key, { clauses }]) => clauses.length === 0)
|
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() })
|
this.props.history.replace({ pathname: siteBasePath(this.props.site), search: queryString.toString() })
|
||||||
}
|
}
|
||||||
|
|
||||||
isFreeChoice() {
|
|
||||||
return ['page', 'utm'].includes(this.state.selectedFilterGroup)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSearchBox(filter) {
|
|
||||||
return <Combobox fetchOptions={this.fetchOptions(filter)} freeChoice={this.isFreeChoice()} values={this.state.formState[filter].clauses} onChange={this.onChange(filter)} placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`} />
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFilterInputs() {
|
renderFilterInputs() {
|
||||||
const groups = FILTER_GROUPS[this.state.selectedFilterGroup]
|
const filtersInGroup = FILTER_GROUPS[this.props.filterGroup]
|
||||||
|
|
||||||
return groups.map((filter) => {
|
return filtersInGroup.map((filter) => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4" key={filter}>
|
<div className="mt-4" key={filter}>
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">{formattedFilters[filter]}</div>
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">{formattedFilters[filter]}</div>
|
||||||
<div className="flex items-start mt-1">
|
<div className="flex items-start mt-1">
|
||||||
{this.renderFilterTypeSelector(filter)}
|
<FilterTypeSelector forFilter={filter} onSelect={this.onFilterTypeSelect(filter)} selectedType={this.selectedFilterType(filter)}/>
|
||||||
{this.renderSearchBox(filter)}
|
<Combobox fetchOptions={this.fetchOptions(filter)} freeChoice={isFreeChoiceFilter(filter)} values={this.state.formState[filter].clauses} onSelect={this.onComboboxSelect(filter)} placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFilterTypeSelector(filterName) {
|
render() {
|
||||||
return (
|
const { filterGroup } = this.props
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
const { query } = this.state
|
||||||
{({ open }) => (
|
const showClear = FILTER_GROUPS[filterGroup].some((filterName) => query.filters[filterName])
|
||||||
<>
|
|
||||||
<div className="w-24">
|
|
||||||
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 dark:focus:ring-offset-gray-900 focus:ring-indigo-500">
|
|
||||||
{this.selectedFilterType(filterName)}
|
|
||||||
<ChevronDownIcon className="-mr-2 ml-2 h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
|
|
||||||
</Menu.Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
show={open}
|
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-out duration-100"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Menu.Items
|
|
||||||
static
|
|
||||||
className="z-10 origin-top-left absolute left-0 mt-2 w-24 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none"
|
|
||||||
>
|
|
||||||
<div className="py-1">
|
|
||||||
{this.renderTypeItem(filterName, FILTER_TYPES.is, true)}
|
|
||||||
{this.renderTypeItem(filterName, FILTER_TYPES.isNot, supportsIsNot(filterName))}
|
|
||||||
{this.renderTypeItem(filterName, FILTER_TYPES.contains, this.isFreeChoice())}
|
|
||||||
</div>
|
|
||||||
</Menu.Items>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTypeItem(filterName, type, shouldDisplay) {
|
|
||||||
return (
|
|
||||||
shouldDisplay && (
|
|
||||||
<Menu.Item>
|
|
||||||
{({ active }) => (
|
|
||||||
<span
|
|
||||||
onClick={() => 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}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBody() {
|
|
||||||
const { selectedFilterGroup, query } = this.state;
|
|
||||||
const showClear = FILTER_GROUPS[selectedFilterGroup].some((filterName) => query.filters[filterName])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">Filter by {formatFilterGroup(selectedFilterGroup)}</h1>
|
<h1 className="text-xl font-bold dark:text-gray-100">Filter by {formatFilterGroup(filterGroup)}</h1>
|
||||||
|
|
||||||
<div className="mt-4 border-b border-gray-300"></div>
|
<div className="mt-4 border-b border-gray-300"></div>
|
||||||
<main className="modal__content">
|
<main className="modal__content">
|
||||||
@ -290,12 +218,12 @@ class FilterModal extends React.Component {
|
|||||||
type="button"
|
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"
|
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={() => {
|
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)
|
this.selectFiltersAndCloseModal(updatedFilters)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
Remove filter{FILTER_GROUPS[selectedFilterGroup].length > 1 ? 's' : ''}
|
Remove filter{FILTER_GROUPS[filterGroup].length > 1 ? 's' : ''}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -304,14 +232,6 @@ class FilterModal extends React.Component {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Modal site={this.props.site} maxWidth="460px">
|
|
||||||
{this.renderBody()}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(FilterModal)
|
export default withRouter(RegularFilterModal)
|
@ -22,6 +22,14 @@ export const FILTER_PREFIXES = {
|
|||||||
[FILTER_TYPES.is]: ''
|
[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
|
// 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
|
// escaping pipe characters in filters does not currently work in Safari
|
||||||
let NON_ESCAPED_PIPE_REGEX;
|
let NON_ESCAPED_PIPE_REGEX;
|
||||||
|
@ -57,6 +57,7 @@ defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do
|
|||||||
|
|
||||||
test "returns suggestions for sources", %{conn: conn, site: site} do
|
test "returns suggestions for sources", %{conn: conn, site: site} do
|
||||||
populate_stats(site, [
|
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: "Bing"),
|
||||||
build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "10words")
|
build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "10words")
|
||||||
])
|
])
|
||||||
|
Loading…
Reference in New Issue
Block a user