From 23a6431adbd61553fed1b658485d469c025b492b Mon Sep 17 00:00:00 2001 From: Karl-Aksel Puulmann Date: Wed, 22 May 2024 11:01:41 +0300 Subject: [PATCH] Frontend: Use jsonurl, update filtering (#4117) * Start refactoring, supporting jsonurl * WIP: Listing of applied filters works * WIP: Remove some unneeded hackery * WIP: Handle country labels better * WIP: Handle multiple filters better * WIP: Serializing filters for API queries * Split modal code into 3 * Merge code paths for prop and other filter modals * Get suggestions working again * Display prop filters properly * Handle re-opening a props modal properly * Better label handling * Better linking to filter modals * Remove unneeded component * Standardize how we update query more * Use updatedQuery to remove more usecases of URLSearchParams * Dont export toFilterQuery * Custom toString for PlausibleSearchParams * Fix props suggestions/filtering * Refactor isFilteringOnFixedValue * Improved encoding - goals now work again * fix a typo * Handle more cases where query.filters[ is used * Fix locations tab changing behavior * Fix for `setQuery` not to double up ? * Handle goal filters properly now * Delete dead code * Update special goals handling * Update linking * Show labeled values in list of filters * Updae Props component handling of storage keys * re-add special case handling in devices view * Fix modal-related typo * Get updatedQuery callsites working * Update location modals linking * Update props details model linking logic * Switch back tab from props when removing goal filter * Remove query.filters usage from within component * Private escapeFilterValue * Fix sources/index.js * Legacy redirect logic * Update comment * Disabled options in props modal * Update escaping and is_not operator * Restore `false` search property handling meaning unset * changelog * Fix filtering after clicking on a map * FilterOperatorSelector * replaceFilterByPrefix * Improve naming for filter modals/groups --- CHANGELOG.md | 1 + assets/js/dashboard.js | 3 + assets/js/dashboard/api.js | 9 +- ...elector.js => filter-operator-selector.js} | 2 +- assets/js/dashboard/filters.js | 115 +++++----- assets/js/dashboard/query.js | 141 ++++++------ assets/js/dashboard/router.js | 6 +- .../dashboard/stats/behaviours/conversions.js | 6 +- .../stats/behaviours/goal-conversions.js | 33 ++- assets/js/dashboard/stats/behaviours/index.js | 7 +- assets/js/dashboard/stats/behaviours/props.js | 29 ++- assets/js/dashboard/stats/current-visitors.js | 3 +- assets/js/dashboard/stats/devices/index.js | 45 +++- assets/js/dashboard/stats/graph/graph-util.js | 18 +- assets/js/dashboard/stats/graph/line-graph.js | 5 +- assets/js/dashboard/stats/locations/index.js | 22 +- assets/js/dashboard/stats/locations/map.js | 7 +- .../js/dashboard/stats/modals/conversions.js | 7 +- .../js/dashboard/stats/modals/entry-pages.js | 11 +- .../js/dashboard/stats/modals/exit-pages.js | 19 +- .../stats/modals/filter-modal-group.js | 66 ++++++ .../stats/modals/filter-modal-props-row.js | 93 ++++++++ .../stats/modals/filter-modal-row.js | 73 ++++++ .../js/dashboard/stats/modals/filter-modal.js | 191 ++++++++++++++-- assets/js/dashboard/stats/modals/pages.js | 23 +- .../stats/modals/prop-filter-modal.js | 205 ----------------- .../dashboard/stats/modals/prop-filter-row.js | 86 ------- assets/js/dashboard/stats/modals/props.js | 18 +- .../stats/modals/referrer-drilldown.js | 16 +- .../stats/modals/regular-filter-modal.js | 212 ------------------ assets/js/dashboard/stats/modals/sources.js | 22 +- assets/js/dashboard/stats/modals/table.js | 20 +- assets/js/dashboard/stats/pages/index.js | 15 +- assets/js/dashboard/stats/reports/list.js | 25 ++- assets/js/dashboard/stats/reports/metrics.js | 7 +- assets/js/dashboard/stats/sources/index.js | 7 +- .../dashboard/stats/sources/referrer-list.js | 12 +- .../js/dashboard/stats/sources/source-list.js | 11 +- assets/js/dashboard/util/filters.js | 171 +++++++++----- assets/js/dashboard/util/url.js | 48 +++- assets/package-lock.json | 6 + assets/package.json | 1 + 42 files changed, 974 insertions(+), 843 deletions(-) rename assets/js/dashboard/components/{filter-type-selector.js => filter-operator-selector.js} (98%) create mode 100644 assets/js/dashboard/stats/modals/filter-modal-group.js create mode 100644 assets/js/dashboard/stats/modals/filter-modal-props-row.js create mode 100644 assets/js/dashboard/stats/modals/filter-modal-row.js delete mode 100644 assets/js/dashboard/stats/modals/prop-filter-modal.js delete mode 100644 assets/js/dashboard/stats/modals/prop-filter-row.js delete mode 100644 assets/js/dashboard/stats/modals/regular-filter-modal.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 210c9021f59e..25b4412a8dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ All notable changes to this project will be documented in this file. - GA/SC sections moved to new settings: Integrations - Replace `CLICKHOUSE_MAX_BUFFER_SIZE` with `CLICKHOUSE_MAX_BUFFER_SIZE_BYTES` - Validate metric isn't queried multiple times +- Filters in dashboard are represented by jsonurl ### Fixed - Creating many sites no longer leads to cookie overflow diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 227514849ebe..a172b05264d4 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -6,6 +6,7 @@ import Router from './dashboard/router' import ErrorBoundary from './dashboard/error-boundary' import * as api from './dashboard/api' import * as timer from './dashboard/util/realtime-update-timer' +import { filtersBackwardsCompatibilityRedirect } from './dashboard/query'; timer.start() @@ -40,6 +41,8 @@ if (container) { api.setSharedLinkAuth(sharedLinkAuth) } + filtersBackwardsCompatibilityRedirect() + const app = ( diff --git a/assets/js/dashboard/api.js b/assets/js/dashboard/api.js index 26fd29916e7e..6aaae36339f3 100644 --- a/assets/js/dashboard/api.js +++ b/assets/js/dashboard/api.js @@ -1,4 +1,5 @@ import { formatISO } from './util/date' +import { serializeApiFilters } from './util/filters' let abortController = new AbortController() let SHARED_LINK_AUTH = null @@ -30,19 +31,13 @@ export function cancelAll() { abortController = new AbortController() } -function serializeFilters(filters) { - const cleaned = {} - Object.entries(filters).forEach(([key, val]) => val ? cleaned[key] = val : null); - return JSON.stringify(cleaned) -} - export function serializeQuery(query, extraQuery = []) { const queryObj = {} if (query.period) { queryObj.period = query.period } if (query.date) { queryObj.date = formatISO(query.date) } if (query.from) { queryObj.from = formatISO(query.from) } if (query.to) { queryObj.to = formatISO(query.to) } - if (query.filters) { queryObj.filters = serializeFilters(query.filters) } + if (query.filters) { queryObj.filters = serializeApiFilters(query.filters) } if (query.experimental_session_count) { queryObj.experimental_session_count = query.experimental_session_count } if (query.with_imported) { queryObj.with_imported = query.with_imported } if (SHARED_LINK_AUTH) { queryObj.auth = SHARED_LINK_AUTH } diff --git a/assets/js/dashboard/components/filter-type-selector.js b/assets/js/dashboard/components/filter-operator-selector.js similarity index 98% rename from assets/js/dashboard/components/filter-type-selector.js rename to assets/js/dashboard/components/filter-operator-selector.js index 2baf85f0a377..a33edd80b797 100644 --- a/assets/js/dashboard/components/filter-type-selector.js +++ b/assets/js/dashboard/components/filter-operator-selector.js @@ -6,7 +6,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid' import { isFreeChoiceFilter, supportsIsNot } from "../util/filters"; import classNames from "classnames"; -export default function FilterTypeSelector(props) { +export default function FilterOperatorSelector(props) { const filterName = props.forFilter function renderTypeItem(type, shouldDisplay) { diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js index a835fda764d4..4581a91f77e9 100644 --- a/assets/js/dashboard/filters.js +++ b/assets/js/dashboard/filters.js @@ -4,81 +4,70 @@ import { AdjustmentsVerticalIcon, MagnifyingGlassIcon, XMarkIcon, PencilSquareIc import classNames from 'classnames' import { Menu, Transition } from '@headlessui/react' -import { appliedFilters, navigateToQuery } from './query' +import { navigateToQuery } from './query' import { - FILTER_GROUPS, + FILTER_GROUP_TO_MODAL_TYPE, + cleanLabels, + FILTER_MODAL_TO_FILTER_GROUP, formatFilterGroup, - filterGroupForFilter, - parseQueryFilter, - parseQueryPropsFilter, - formattedFilters -} from "./util/filters"; - -function removeFilter(filterType, key, history, query) { - const newOpts = {} - if (filterType === 'props') { - if (Object.keys(query.filters.props).length == 1) { - newOpts.props = false - } else { - newOpts.props = JSON.stringify({ - ...query.filters.props, - [key]: undefined, - }) - } - } else { - newOpts[key] = false - } - if (key === 'country') { newOpts.country_labels = false } - if (key === 'region') { newOpts.region_labels = false } - if (key === 'city') { newOpts.city_labels = false } + formattedFilters, + EVENT_PROPS_PREFIX, + getPropertyKeyFromFilterKey, + getLabel +} from "./util/filters" + +function removeFilter(filterIndex, history, query) { + const newFilters = query.filters.filter((_filter, index) => filterIndex != index) + const newLabels = cleanLabels(newFilters, query.labels) navigateToQuery( history, query, - newOpts + { filters: newFilters, labels: newLabels } ) } function clearAllFilters(history, query) { - const newOpts = Object.keys(query.filters).reduce((acc, red) => ({ ...acc, [red]: false }), {}); navigateToQuery( history, query, - newOpts + { filters: false, labels: false } ); } -function filterText(filterType, key, query) { - const formattedFilter = formattedFilters[key] +function filterText(query, [operation, filterKey, clauses]) { + const formattedFilter = formattedFilters[filterKey] - if (filterType === "props") { - const { propKey, clauses, type } = parseQueryPropsFilter(query).find((filter) => filter.propKey.value === key) - return <>Property {propKey.label} {type} {clauses.map(({label}) => {label}).reduce((prev, curr) => [prev, ' or ', curr])} - } else if (formattedFilter) { - const {type, clauses} = parseQueryFilter(query, key) - return <>{formattedFilter} {type} {clauses.map(({label}) => {label}).reduce((prev, curr) => [prev, ' or ', curr])} + if (formattedFilter) { + return <>{formattedFilter} {operation} {clauses.map((value) => {getLabel(query.labels, filterKey, value)}).reduce((prev, curr) => [prev, ' or ', curr])} + } else if (filterKey.startsWith(EVENT_PROPS_PREFIX)) { + const propKey = getPropertyKeyFromFilterKey(filterKey) + return <>Property {propKey} {operation} {clauses.map((label) => {label}).reduce((prev, curr) => [prev, ' or ', curr])} } - throw new Error(`Unknown filter: ${key}`) + throw new Error(`Unknown filter: ${filterKey}`) } -function renderDropdownFilter(site, history, { key, value, filterType }, query) { +function renderDropdownFilter(filterIndex, filter, site, history, query) { + const [_operation, filterKey, _clauses] = filter + + const type = filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey return ( - -
+ +
- {filterText(filterType, key, query)} + {filterText(query, filter)} removeFilter(filterType, key, history, query)} + onClick={() => removeFilter(filterIndex, history, query)} > @@ -109,10 +98,10 @@ function DropdownContent({ history, site, query, wrapped }) { const [addingFilter, setAddingFilter] = useState(false); if (wrapped === 0 || addingFilter) { - let filterGroups = {...FILTER_GROUPS} - if (!site.propsAvailable) delete filterGroups.props + let filterModals = {...FILTER_MODAL_TO_FILTER_GROUP} + if (!site.propsAvailable) delete filterModals.props - return Object.keys(filterGroups).map((option) => filterDropdownOption(site, option)) + return Object.keys(filterModals).map((option) => filterDropdownOption(site, option)) } return ( @@ -120,7 +109,7 @@ function DropdownContent({ history, site, query, wrapped }) {
setAddingFilter(true)}> + Add filter
- {appliedFilters(query).map((filter) => renderDropdownFilter(site, history, filter, query))} + {query.filters.map((filter, index) => renderDropdownFilter(index, filter, site, history, query))}
clearAllFilters(history, query)}> Clear All Filters @@ -193,7 +182,7 @@ class Filters extends React.Component { const { wrapped, viewport } = this.state; // Always wrap on mobile - if (appliedFilters(this.props.query).length > 0 && viewport <= 768) { + if (this.props.query.filters.length > 0 && viewport <= 768) { this.setState({ wrapped: 2 }) return; } @@ -201,7 +190,7 @@ class Filters extends React.Component { this.setState({ wrapped: 0 }); // Don't rewrap if we're already properly wrapped, there are no DOM children, or there is only filter - if (wrapped !== 1 || !items || appliedFilters(this.props.query).length === 1) { + if (wrapped !== 1 || !items || this.props.query.filters.length === 1) { return; }; @@ -217,20 +206,26 @@ class Filters extends React.Component { }); }; - renderListFilter(history, { key, value, filterType }, query) { + renderListFilter(filterIndex, filter, history, query) { + const text = filterText(query, filter) + const [_operation, filterKey, _clauses] = filter + const type = filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey return ( - + - {filterText(filterType, key, query)} + {text} removeFilter(filterType, key, history, query)} + onClick={() => removeFilter(filterIndex, history, query)} > @@ -240,7 +235,7 @@ class Filters extends React.Component { renderDropdownButton() { if (this.state.wrapped === 2) { - const filterCount = appliedFilters(this.props.query).length + const filterCount = this.props.query.filters.length return ( <>