mirror of
https://github.com/plausible/analytics.git
synced 2024-12-27 19:47:26 +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) {
|
||||
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)
|
||||
}
|
||||
|
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 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();
|
||||
|
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 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)
|
@ -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;
|
||||
|
@ -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")
|
||||
])
|
||||
|
Loading…
Reference in New Issue
Block a user