Skip to content

Commit

Permalink
Excluding goals in dashboard (#4983)
Browse files Browse the repository at this point in the history
* Simple frontend for has_done_not

* Simple UI for goal filter adding or removal

* Better alignment on trash icons, avoid moving around if row expands

* Refactor filter text functions, share code

* has_not_done, special casing for has not done when formatting filter text

* Changelog

* Fix lint

* prettier format

* Add tests

* Lowercase Goal

* Update changelog

* has_not_done for goals is now named `is not` in the UI

* prettier

* Document and test serializeApiFilters
  • Loading branch information
macobo authored Jan 23, 2025
1 parent 117cf46 commit c0a762a
Show file tree
Hide file tree
Showing 14 changed files with 211 additions and 79 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
- Dashboard shows comparisons for all reports
- UTM Medium report and API shows (gclid) and (msclkid) for paid searches when no explicit utm medium present.
- Support for `case_sensitive: false` modifiers in Stats API V2 filters for case-insensitive searches.
- Add filter `is not` for goals in dashboard plausible/analytics#4983

### Removed

Expand Down
7 changes: 6 additions & 1 deletion assets/js/dashboard/components/filter-operator-selector.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
FILTER_OPERATIONS,
FILTER_OPERATIONS_DISPLAY_NAMES,
supportsContains,
supportsIsNot
supportsIsNot,
supportsHasDoneNot
} from '../util/filters'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
Expand Down Expand Up @@ -75,6 +76,10 @@ export default function FilterOperatorSelector(props) {
FILTER_OPERATIONS.isNot,
supportsIsNot(filterName)
)}
{renderTypeItem(
FILTER_OPERATIONS.has_not_done,
supportsHasDoneNot(filterName)
)}
{renderTypeItem(
FILTER_OPERATIONS.contains,
supportsContains(filterName)
Expand Down
7 changes: 3 additions & 4 deletions assets/js/dashboard/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ import {
cleanLabels,
FILTER_MODAL_TO_FILTER_GROUP,
formatFilterGroup,
EVENT_PROPS_PREFIX,
plainFilterText,
styledFilterText
} from "./util/filters";
EVENT_PROPS_PREFIX
} from "./util/filters"
import { plainFilterText, styledFilterText } from "./util/filter-text"

const WRAPSTATE = { unwrapped: 0, waiting: 1, wrapped: 2 }

Expand Down
5 changes: 2 additions & 3 deletions assets/js/dashboard/nav-menu/filter-pills-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import { FilterPill } from './filter-pill'
import {
cleanLabels,
EVENT_PROPS_PREFIX,
FILTER_GROUP_TO_MODAL_TYPE,
plainFilterText,
styledFilterText
FILTER_GROUP_TO_MODAL_TYPE
} from '../util/filters'
import { styledFilterText, plainFilterText } from '../util/filter-text'
import { useAppNavigate } from '../navigation/use-app-navigate'
import classNames from 'classnames'

Expand Down
10 changes: 5 additions & 5 deletions assets/js/dashboard/nav-menu/filters-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ test('user can see expected filters and clear them one by one or all together',
)

expect(queryFilterPills().map((m) => m.textContent)).toEqual([
'Country is Germany ',
'Goal is Subscribed to Newsletter ',
'Page is /docs or /blog '
'Country is Germany',
'Goal is Subscribed to Newsletter',
'Page is /docs or /blog'
])

await userEvent.click(
Expand All @@ -74,8 +74,8 @@ test('user can see expected filters and clear them one by one or all together',
)

expect(queryFilterPills().map((m) => m.textContent)).toEqual([
'Goal is Subscribed to Newsletter ',
'Page is /docs or /blog '
'Goal is Subscribed to Newsletter',
'Page is /docs or /blog'
])

await userEvent.click(
Expand Down
5 changes: 4 additions & 1 deletion assets/js/dashboard/stats/modals/filter-modal-group.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function FilterModalGroup({
[filterGroup, rows]
)

const showAddRow = filterGroup == 'props'
const showAddRow = ['props', 'goal'].includes(filterGroup)
const showTitle = filterGroup != 'props'

return (
Expand All @@ -42,7 +42,10 @@ export default function FilterModalGroup({
key={id}
filter={filter}
labels={labels}
canDelete={showAddRow}
showDelete={rows.length > 1}
onUpdate={(newFilter, labelUpdate) => onUpdateRowValue(id, newFilter, labelUpdate)}
onDelete={() => onDeleteRow(id)}
/>
)
)}
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/filter-modal-props-row.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export default function FilterModalPropsRow({
/>
</div>
{showDelete && (
<div className="col-span-1 flex flex-col justify-center">
<div className="col-span-1 flex flex-col mt-2">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
className="ml-2 text-red-600 h-5 w-5 cursor-pointer"
Expand Down
29 changes: 27 additions & 2 deletions assets/js/dashboard/stats/modals/filter-modal-row.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** @format */

import React, { useMemo } from 'react'
import { TrashIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'

import FilterOperatorSelector from '../../components/filter-operator-selector'
import Combobox from '../../components/combobox'
Expand All @@ -16,7 +18,14 @@ import { apiPath } from '../../util/url'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'

export default function FilterModalRow({ filter, labels, onUpdate }) {
export default function FilterModalRow({
filter,
labels,
canDelete,
showDelete,
onUpdate,
onDelete
}) {
const { query } = useQueryContext()
const site = useSiteContext()
const [operation, filterKey, clauses] = filter
Expand Down Expand Up @@ -64,7 +73,12 @@ export default function FilterModalRow({ filter, labels, onUpdate }) {
}

return (
<div className="grid grid-cols-11 mt-1">
<div
className={classNames('grid mt-1', {
'grid-cols-12': canDelete,
'grid-cols-11': !canDelete
})}
>
<div className="col-span-3">
<FilterOperatorSelector
forFilter={filterKey}
Expand All @@ -83,6 +97,17 @@ export default function FilterModalRow({ filter, labels, onUpdate }) {
placeholder={`Select ${withIndefiniteArticle(formattedFilters[filterKey])}`}
/>
</div>
{showDelete && (
<div className="col-span-1 flex flex-col mt-2">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
className="ml-2 text-red-600 h-5 w-5 cursor-pointer"
onClick={onDelete}
>
<TrashIcon />
</a>
</div>
)}
</div>
)
}
Expand Down
4 changes: 2 additions & 2 deletions assets/js/dashboard/stats/reports/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import {
cleanLabels,
replaceFilterByPrefix,
isRealTimeDashboard,
hasGoalFilter,
plainFilterText
hasGoalFilter
} from '../../util/filters'
import { plainFilterText } from '../../util/filter-text'
import { useQueryContext } from '../../query-context'

const MAX_ITEMS = 9
Expand Down
24 changes: 24 additions & 0 deletions assets/js/dashboard/util/filter-text.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react'
import { DashboardQuery, Filter, FilterClauseLabels } from '../query'
import { plainFilterText, styledFilterText } from './filter-text'
import { render, screen } from '@testing-library/react'

describe('styledFilterText() and plainFilterText()', () => {
it.each<[Filter, FilterClauseLabels, string]>([
[['is', 'page', ['/docs', '/blog']], {}, 'Page is /docs or /blog'],
[['is', 'country', ['US']], { US: 'United States' }, 'Country is United States'],
[['is', 'goal', ['Signup']], {}, 'Goal is Signup'],
[['is', 'props:browser_language', ['en-US']], {}, 'Property browser_language is en-US'],
[['has_not_done', 'goal', ['Signup', 'Login']], {}, 'Goal is not Signup or Login'],
])(
'when filter is %p and labels are %p, functions return %p',
(filter, labels, expectedPlainText) => {
const query = { labels } as unknown as DashboardQuery

expect(plainFilterText(query, filter)).toBe(expectedPlainText)

render(<p data-testid="filter-text">{styledFilterText(query, filter)}</p>)
expect(screen.getByTestId('filter-text')).toHaveTextContent(expectedPlainText)
}
)
})
77 changes: 77 additions & 0 deletions assets/js/dashboard/util/filter-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* @format */

import React, { ReactNode, isValidElement, Fragment } from 'react'
import { DashboardQuery, Filter } from '../query'
import {
EVENT_PROPS_PREFIX,
FILTER_OPERATIONS_DISPLAY_NAMES,
formattedFilters,
getLabel,
getPropertyKeyFromFilterKey
} from './filters'

export function styledFilterText(
query: DashboardQuery,
[operation, filterKey, clauses]: Filter
) {
if (filterKey.startsWith(EVENT_PROPS_PREFIX)) {
const propKey = getPropertyKeyFromFilterKey(filterKey)
return (
<>
Property <b>{propKey}</b> {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
{formatClauses(clauses)}
</>
)
}

const formattedFilter = (
formattedFilters as Record<string, string | undefined>
)[filterKey]
const clausesLabels = clauses.map((value) =>
getLabel(query.labels, filterKey, value)
)

if (!formattedFilter) {
throw new Error(`Unknown filter: ${filterKey}`)
}

return (
<>
{capitalize(formattedFilter)} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
{formatClauses(clausesLabels)}
</>
)
}

export function plainFilterText(query: DashboardQuery, filter: Filter) {
return reactNodeToString(styledFilterText(query, filter))
}

function formatClauses(labels: Array<string | number>): ReactNode[] {
return labels.map((label, index) => (
<Fragment key={index}>
{index > 0 && ' or '}
<b>{label}</b>
</Fragment>
))
}

function capitalize(str: string): string {
return str[0].toUpperCase() + str.slice(1)
}

function reactNodeToString(reactNode: ReactNode): string {
let string = ''
if (typeof reactNode === 'string') {
string = reactNode
} else if (typeof reactNode === 'number') {
string = reactNode.toString()
} else if (reactNode instanceof Array) {
reactNode.forEach(function (child) {
string += reactNodeToString(child)
})
} else if (isValidElement(reactNode)) {
string += reactNodeToString(reactNode.props.children)
}
return string
}
Loading

0 comments on commit c0a762a

Please sign in to comment.