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:
RobertJoonas 2023-04-05 16:20:35 +01:00 committed by GitHub
parent 4edf126196
commit 7204e470a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 129 additions and 111 deletions

View File

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

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

View File

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

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

View File

@ -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 <Combobox fetchOptions={this.fetchOptions(filter)} freeChoice={this.isFreeChoice()} values={this.state.formState[filter].clauses} onChange={this.onChange(filter)} placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`} />
}
renderFilterInputs() {
const groups = FILTER_GROUPS[this.state.selectedFilterGroup]
const filtersInGroup = FILTER_GROUPS[this.props.filterGroup]
return groups.map((filter) => {
return filtersInGroup.map((filter) => {
return (
<div className="mt-4" key={filter}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">{formattedFilters[filter]}</div>
<div className="flex items-start mt-1">
{this.renderFilterTypeSelector(filter)}
{this.renderSearchBox(filter)}
<FilterTypeSelector forFilter={filter} onSelect={this.onFilterTypeSelect(filter)} selectedType={this.selectedFilterType(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>
)
})
}
renderFilterTypeSelector(filterName) {
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">
{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])
render() {
const { filterGroup } = this.props
const { query } = this.state
const showClear = FILTER_GROUPS[filterGroup].some((filterName) => query.filters[filterName])
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>
<main className="modal__content">
@ -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)
}}
>
<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>
)}
</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)

View File

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

View File

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