Add second line for Filters (if saved_segments enabled) (#4729)

This commit is contained in:
Artur Pata 2024-11-04 10:22:48 +02:00 committed by GitHub
parent 5d9e94770d
commit 13ad279820
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 755 additions and 83 deletions

View File

@ -1,7 +1,6 @@
/** @format */ /** @format */
import React, { import React, {
AriaAttributes,
DetailedHTMLProps, DetailedHTMLProps,
forwardRef, forwardRef,
HTMLAttributes, HTMLAttributes,
@ -18,32 +17,60 @@ import {
export const ToggleDropdownButton = forwardRef< export const ToggleDropdownButton = forwardRef<
HTMLDivElement, HTMLDivElement,
{ {
variant?: 'ghost' | 'button'
withDropdownIndicator?: boolean
className?: string
currentOption: ReactNode currentOption: ReactNode
children: ReactNode children: ReactNode
onClick: () => void onClick: () => void
dropdownContainerProps: AriaAttributes dropdownContainerProps: DetailedHTMLProps<
HTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>
} }
>(({ currentOption, children, onClick, dropdownContainerProps }, ref) => { >(
return ( (
<div className="min-w-32 md:w-48 md:relative" ref={ref}> {
<button className,
onClick={onClick} currentOption,
className="w-full flex items-center justify-between rounded bg-white dark:bg-gray-800 shadow px-2 md:px-3 withDropdownIndicator,
py-2 leading-tight cursor-pointer text-xs md:text-sm text-gray-800 children,
dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900" onClick,
tabIndex={0} dropdownContainerProps,
aria-haspopup="true" ...props
{...dropdownContainerProps} },
> ref
<span className="truncate mr-1 md:mr-2"> ) => {
<span className="font-medium">{currentOption}</span> const { variant } = { variant: 'button', ...props }
</span> const sharedButtonClass =
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500" /> 'flex items-center rounded text-sm leading-tight px-2 py-2 h-9'
</button>
{children} const buttonClass = {
</div> ghost:
) 'text-gray-500 hover:text-gray-800 hover:bg-gray-200 dark:hover:text-gray-200 dark:hover:bg-gray-900',
}) button:
'w-full justify-between bg-white dark:bg-gray-800 shadow text-gray-800 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900'
}[variant]
return (
<div className={className} ref={ref}>
<button
onClick={onClick}
className={classNames(sharedButtonClass, buttonClass)}
tabIndex={0}
aria-haspopup="true"
{...dropdownContainerProps}
>
<span className="truncate block font-medium">{currentOption}</span>
{!!withDropdownIndicator && (
<ChevronDownIcon className="hidden lg:inline-block h-4 w-4 md:h-5 md:w-5 ml-1 md:ml-2 text-gray-500" />
)}
</button>
{children}
</div>
)
}
)
export const DropdownMenuWrapper = forwardRef< export const DropdownMenuWrapper = forwardRef<
HTMLDivElement, HTMLDivElement,
@ -57,7 +84,7 @@ export const DropdownMenuWrapper = forwardRef<
ref={ref} ref={ref}
{...props} {...props}
className={classNames( className={classNames(
'absolute w-full left-0 right-0 md:w-56 md:top-auto md:left-auto md:right-0 mt-2 origin-top-right z-10', 'absolute left-0 right-0 mt-2 origin-top-right z-10',
className className
)} )}
> >

View File

@ -175,7 +175,11 @@ function ComparisonMenu({
const { query } = useQueryContext() const { query } = useQueryContext()
return ( return (
<DropdownMenuWrapper id="compare-menu" data-testid="compare-menu"> <DropdownMenuWrapper
id="compare-menu"
data-testid="compare-menu"
className="md:left-auto md:w-56"
>
<DropdownLinkGroup> <DropdownLinkGroup>
{[ {[
ComparisonMode.off, ComparisonMode.off,
@ -236,6 +240,7 @@ function QueryPeriodsMenu({
id="datemenu" id="datemenu"
data-testid="datemenu" data-testid="datemenu"
innerContainerClassName="date-options" innerContainerClassName="date-options"
className="md:left-auto md:w-56"
> >
{groups.map((group, index) => ( {groups.map((group, index) => (
<DropdownLinkGroup key={index} className="date-options-group"> <DropdownLinkGroup key={index} className="date-options-group">
@ -348,6 +353,8 @@ export default function QueryPeriodPicker() {
<div className="flex ml-auto pl-2"> <div className="flex ml-auto pl-2">
<MovePeriodArrows /> <MovePeriodArrows />
<ToggleDropdownButton <ToggleDropdownButton
withDropdownIndicator
className="min-w-36 md:relative lg:w-48"
currentOption={<DisplaySelectedPeriod />} currentOption={<DisplaySelectedPeriod />}
ref={dropdownRef} ref={dropdownRef}
onClick={toggleDateMenu} onClick={toggleDateMenu}
@ -379,6 +386,8 @@ export default function QueryPeriodPicker() {
<span className="hidden md:inline px-1">vs.</span> <span className="hidden md:inline px-1">vs.</span>
</div> </div>
<ToggleDropdownButton <ToggleDropdownButton
withDropdownIndicator
className="min-w-36 md:relative lg:w-48"
ref={compareDropdownRef} ref={compareDropdownRef}
currentOption={ currentOption={
query.comparison === ComparisonMode.custom && query.comparison === ComparisonMode.custom &&

View File

@ -208,7 +208,7 @@ function Filters() {
const filterCount = query.filters.length const filterCount = query.filters.length
return ( return (
<> <>
<AdjustmentsVerticalIcon className="-ml-1 mr-1 h-4 w-4" aria-hidden="true" /> <AdjustmentsVerticalIcon className="-ml-1 mr-1 h-4 w-4 shrink-0" aria-hidden="true" />
{filterCount} Filter{filterCount === 1 ? '' : 's'} {filterCount} Filter{filterCount === 1 ? '' : 's'}
</> </>
) )
@ -216,7 +216,7 @@ function Filters() {
return ( return (
<> <>
<MagnifyingGlassIcon className="-ml-1 mr-1 h-4 w-4 md:h-4 md:w-4" aria-hidden="true" /> <MagnifyingGlassIcon className="-ml-1 mr-1 h-4 w-4 shrink-0" aria-hidden="true" />
{/* This would have been a good use-case for JSX! But in the interest of keeping the breakpoint width logic with TailwindCSS, this is a better long-term way to deal with it. */} {/* This would have been a good use-case for JSX! But in the interest of keeping the breakpoint width logic with TailwindCSS, this is a better long-term way to deal with it. */}
<span className="sm:hidden">Filter</span><span className="hidden sm:inline-block">Filter</span> <span className="sm:hidden">Filter</span><span className="hidden sm:inline-block">Filter</span>
</> </>

View File

@ -10,6 +10,7 @@ import Locations from './stats/locations'
import Devices from './stats/devices' import Devices from './stats/devices'
import { TopBar } from './nav-menu/top-bar' import { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours' import Behaviours from './stats/behaviours'
import { FiltersBar } from './nav-menu/filters-bar'
function DashboardStats({ function DashboardStats({
importedDataInView, importedDataInView,
@ -53,7 +54,10 @@ function Dashboard() {
return ( return (
<div className="mb-12"> <div className="mb-12">
<TopBar showCurrentVisitors={!isRealTimeDashboard} /> <TopBar
showCurrentVisitors={!isRealTimeDashboard}
extraBar={<FiltersBar />}
/>
<DashboardStats <DashboardStats
importedDataInView={ importedDataInView={
isRealTimeDashboard ? undefined : importedDataInView isRealTimeDashboard ? undefined : importedDataInView

View File

@ -0,0 +1,83 @@
/** @format */
import React, { useMemo, useRef, useState } from 'react'
import {
DropdownLinkGroup,
DropdownMenuWrapper,
DropdownNavigationLink,
ToggleDropdownButton
} from '../components/dropdown'
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import {
FILTER_MODAL_TO_FILTER_GROUP,
formatFilterGroup
} from '../util/filters'
import { PlausibleSite, useSiteContext } from '../site-context'
import { filterRoute } from '../router'
import { useOnClickOutside } from '../util/use-on-click-outside'
export function getFilterListItems({
propsAvailable
}: Pick<PlausibleSite, 'propsAvailable'>): {
modalKey: string
label: string
}[] {
const allKeys = Object.keys(FILTER_MODAL_TO_FILTER_GROUP) as Array<
keyof typeof FILTER_MODAL_TO_FILTER_GROUP
>
const keysToOmit: Array<keyof typeof FILTER_MODAL_TO_FILTER_GROUP> =
propsAvailable ? [] : ['props']
return allKeys
.filter((k) => !keysToOmit.includes(k))
.map((modalKey) => ({ modalKey, label: formatFilterGroup(modalKey) }))
}
export const FilterMenu = () => {
const dropdownRef = useRef<HTMLDivElement>(null)
const [opened, setOpened] = useState(false)
const site = useSiteContext()
const filterListItems = useMemo(() => getFilterListItems(site), [site])
useOnClickOutside({
ref: dropdownRef,
active: opened,
handler: () => setOpened(false)
})
return (
<ToggleDropdownButton
ref={dropdownRef}
variant="ghost"
className="ml-auto md:relative"
dropdownContainerProps={{
['aria-controls']: 'filter-menu',
['aria-expanded']: opened
}}
onClick={() => setOpened((opened) => !opened)}
currentOption={
<span className="flex items-center">
<MagnifyingGlassIcon className="block h-4 w-4" />
<span className="block ml-1">Filter</span>
</span>
}
>
{opened && (
<DropdownMenuWrapper id="filter-menu" className="md:left-auto md:w-56">
<DropdownLinkGroup>
{filterListItems.map(({ modalKey, label }) => (
<DropdownNavigationLink
active={false}
key={modalKey}
path={filterRoute.path}
params={{ field: modalKey }}
search={(search) => search}
>
{label}
</DropdownNavigationLink>
))}
</DropdownLinkGroup>
</DropdownMenuWrapper>
)}
</ToggleDropdownButton>
)
}

View File

@ -0,0 +1,49 @@
/** @format */
import React, { ReactNode } from 'react'
import { AppNavigationLink } from '../navigation/use-app-navigate'
import { filterRoute } from '../router'
import { XMarkIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
export function FilterPill({
className,
plainText,
children,
modalToOpen,
onRemoveClick
}: {
className?: string
plainText: string
modalToOpen: string
children: ReactNode
onRemoveClick: () => void
}) {
return (
<div
className={classNames(
'flex h-9 shadow rounded bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm items-center',
className
)}
>
<AppNavigationLink
title={`Edit filter: ${plainText}`}
className="flex w-full h-full items-center py-2 pl-3"
path={filterRoute.path}
params={{ field: modalToOpen }}
search={(search) => search}
>
<span className="inline-block max-w-2xs md:max-w-xs truncate">
{children}
</span>
</AppNavigationLink>
<button
title={`Remove filter: ${plainText}`}
className="flex items-center h-full px-2 mr-1 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500 "
onClick={() => onRemoveClick()}
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
)
}

View File

@ -0,0 +1,96 @@
/** @format */
import React, { DetailedHTMLProps, HTMLAttributes } from 'react'
import { useQueryContext } from '../query-context'
import { FilterPill } from './filter-pill'
import {
cleanLabels,
EVENT_PROPS_PREFIX,
FILTER_GROUP_TO_MODAL_TYPE,
plainFilterText,
styledFilterText
} from '../util/filters'
import { useAppNavigate } from '../navigation/use-app-navigate'
import classNames from 'classnames'
export const PILL_X_GAP = 16
export const PILL_Y_GAP = 8
/** Restricts output to slice of DashboardQuery['filters'], or makes the output outside the slice invisible */
type Slice = {
/** The beginning index of the specified portion of the array. If start is undefined, then the slice begins at index 0. */
start?: number
/** The end index of the specified portion of the array. This is exclusive of the element at the index 'end'. If end is undefined, then the slice extends to the end of the array. */
end?: number
/** Determines if it renders the elements outside the slice with invisible or doesn't render the elements at all */
type: 'hide-outside' | 'no-render-outside'
}
type FilterPillsProps = {
direction: 'horizontal' | 'vertical'
slice?: Slice
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>
export const FilterPillsList = React.forwardRef<
HTMLDivElement,
FilterPillsProps
>(({ className, style, slice, direction }, ref) => {
const { query } = useQueryContext()
const navigate = useAppNavigate()
const renderableFilters =
slice?.type === 'no-render-outside'
? query.filters.slice(slice.start, slice.end)
: query.filters
const indexAdjustment =
slice?.type === 'no-render-outside' ? (slice.start ?? 0) : 0
const isInvisible = (index: number) => {
return slice?.type === 'hide-outside'
? index < (slice.start ?? 0) ||
index > (slice.end ?? query.filters.length) - 1
: false
}
return (
<div
ref={ref}
className={classNames(
'flex',
{
'flex-row': direction === 'horizontal',
'flex-col items-start': direction === 'vertical'
},
className
)}
style={{ columnGap: PILL_X_GAP, rowGap: PILL_Y_GAP, ...style }}
>
{renderableFilters.map((filter, index) => (
<FilterPill
className={classNames(isInvisible(index) && 'invisible')}
modalToOpen={
FILTER_GROUP_TO_MODAL_TYPE[
filter[1].startsWith(EVENT_PROPS_PREFIX) ? 'props' : filter[1]
]
}
plainText={plainFilterText(query, filter)}
key={index}
onRemoveClick={() =>
navigate({
search: (search) => ({
...search,
filters: query.filters.filter(
(_, i) => i !== index + indexAdjustment
),
labels: cleanLabels(query.filters, query.labels)
})
})
}
>
{styledFilterText(query, filter)}
</FilterPill>
))}
</div>
)
})

View File

@ -0,0 +1,144 @@
/** @format */
import React from 'react'
import { render, screen } from '../../../test-utils'
import userEvent from '@testing-library/user-event'
import { TestContextProviders } from '../../../test-utils/app-context-providers'
import { FiltersBar, handleVisibility } from './filters-bar'
import { getRouterBasepath } from '../router'
import { stringifySearch } from '../util/url'
const domain = 'dummy.site'
beforeAll(() => {
const mockResizeObserver = jest.fn(
(handleEntries) =>
({
observe: jest
.fn()
.mockImplementation((entry) =>
handleEntries([entry], null as unknown as ResizeObserver)
),
unobserve: jest.fn(),
disconnect: jest.fn()
}) as unknown as ResizeObserver
)
global.ResizeObserver = mockResizeObserver
})
test('user can see expected filters and clear them one by one or all together', async () => {
const searchRecord = {
filters: [
['is', 'country', ['DE']],
['is', 'goal', ['Subscribed to Newsletter']],
['is', 'page', ['/docs', '/blog']]
],
labels: { DE: 'Germany' }
}
const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}`
render(<FiltersBar />, {
wrapper: (props) => (
<TestContextProviders
routerProps={{ initialEntries: [startUrl] }}
siteOptions={{ domain }}
{...props}
/>
)
})
const queryFilterPills = () =>
screen.queryAllByRole('link', { hidden: false, name: /.* is .*/i })
// all filters appear in See more menu
expect(queryFilterPills().map((m) => m.textContent)).toEqual([])
await userEvent.click(
screen.getByRole('button', {
hidden: false,
name: 'Show rest of the filters'
})
)
expect(queryFilterPills().map((m) => m.textContent)).toEqual([
'Country is Germany ',
'Goal is Subscribed to Newsletter ',
'Page is /docs or /blog '
])
await userEvent.click(
screen.getByRole('button', {
hidden: false,
name: 'Remove filter: Country is Germany'
})
)
expect(queryFilterPills().map((m) => m.textContent)).toEqual([
'Goal is Subscribed to Newsletter ',
'Page is /docs or /blog '
])
await userEvent.click(
screen.getByRole('link', { hidden: false, name: 'Clear all filters' })
)
expect(queryFilterPills().map((m) => m.textContent)).toEqual([])
})
describe(`${handleVisibility.name}`, () => {
it('is able to fit all exactly, whether "See more" is rendered in the actions or not', () => {
const setVisibility = jest.fn()
const input = {
setVisibility,
topBarWidth: 1000,
actionsWidth: 100,
seeMorePresent: false,
seeMoreWidth: 50,
pillWidths: [200, 200, 200, 200],
pillGap: 25
}
handleVisibility(input)
expect(setVisibility).toHaveBeenCalledTimes(1)
expect(setVisibility).toHaveBeenLastCalledWith({
width: 900,
visibleCount: 4
})
handleVisibility({
...input,
seeMorePresent: true,
actionsWidth: input.actionsWidth + input.seeMoreWidth
})
expect(setVisibility).toHaveBeenCalledTimes(2)
expect(setVisibility).toHaveBeenLastCalledWith({
width: 900,
visibleCount: 4
})
handleVisibility({ ...input, topBarWidth: 999 })
expect(setVisibility).toHaveBeenCalledTimes(3)
expect(setVisibility).toHaveBeenLastCalledWith({
width: 675,
visibleCount: 3
})
})
it('can shrink to 0 width', () => {
const setVisibility = jest.fn()
const input = {
setVisibility,
topBarWidth: 300,
actionsWidth: 100,
seeMorePresent: true,
seeMoreWidth: 50,
pillWidths: [250],
pillGap: 25
}
handleVisibility(input)
expect(setVisibility).toHaveBeenCalledTimes(1)
expect(setVisibility).toHaveBeenLastCalledWith({
width: 0,
visibleCount: 0
})
})
})

View File

@ -0,0 +1,218 @@
/** @format */
import { EllipsisHorizontalIcon, XMarkIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import React, { useRef, useState, useLayoutEffect, useEffect } from 'react'
import { AppNavigationLink } from '../navigation/use-app-navigate'
import { useOnClickOutside } from '../util/use-on-click-outside'
import {
DropdownMenuWrapper,
ToggleDropdownButton
} from '../components/dropdown'
import { FilterPillsList, PILL_X_GAP } from './filter-pills-list'
import { useQueryContext } from '../query-context'
const SEE_MORE_GAP_PX = 16
const SEE_MORE_WIDTH_PX = 36
export const handleVisibility = ({
setVisibility,
topBarWidth,
actionsWidth,
seeMorePresent,
seeMoreWidth,
pillWidths,
pillGap
}: {
setVisibility: (v: VisibilityState) => void
topBarWidth: number | null
actionsWidth: number | null
pillWidths: (number | null)[] | null
seeMorePresent: boolean
seeMoreWidth: number
pillGap: number
}): void => {
if (topBarWidth === null || actionsWidth === null || pillWidths === null) {
return
}
const fitToWidth = (maxWidth: number) => {
let visibleCount = 0
let currentWidth = 0
let lastValidWidth = 0
for (const pillWidth of pillWidths) {
currentWidth += (pillWidth ?? 0) + pillGap
if (currentWidth <= maxWidth) {
lastValidWidth = currentWidth
visibleCount += 1
} else {
break
}
}
return { visibleCount, lastValidWidth }
}
const fits = fitToWidth(topBarWidth - actionsWidth)
// Check if possible to fit one more if "See more" is removed
if (seeMorePresent && fits.visibleCount === pillWidths.length - 1) {
const maybeFitsMore = fitToWidth(topBarWidth - actionsWidth + seeMoreWidth)
if (maybeFitsMore.visibleCount === pillWidths.length) {
return setVisibility({
width: maybeFitsMore.lastValidWidth,
visibleCount: maybeFitsMore.visibleCount
})
}
}
// Check if the appearance of "See more" would cause overflow
if (!seeMorePresent && fits.visibleCount < pillWidths.length) {
const maybeFitsLess = fitToWidth(topBarWidth - actionsWidth - seeMoreWidth)
if (maybeFitsLess.visibleCount < fits.visibleCount) {
return setVisibility({
width: maybeFitsLess.lastValidWidth,
visibleCount: maybeFitsLess.visibleCount
})
}
}
return setVisibility({
width: fits.lastValidWidth,
visibleCount: fits.visibleCount
})
}
const getElementWidthOrNull = <T extends HTMLElement>(element: T | null) =>
element === null ? null : element.getBoundingClientRect().width
type VisibilityState = {
width: number
visibleCount: number
}
export const FiltersBar = () => {
const containerRef = useRef<HTMLDivElement>(null)
const pillsRef = useRef<HTMLDivElement>(null)
const actionsRef = useRef<HTMLDivElement>(null)
const seeMoreRef = useRef<HTMLDivElement>(null)
const [visibility, setVisibility] = useState<null | VisibilityState>(null)
const { query } = useQueryContext()
const [opened, setOpened] = useState(false)
useEffect(() => {
if (visibility?.visibleCount === query.filters.length) {
setOpened(false)
}
}, [visibility?.visibleCount, query.filters.length])
useOnClickOutside({
ref: seeMoreRef,
active: opened,
handler: () => setOpened(false)
})
useLayoutEffect(() => {
const resizeObserver = new ResizeObserver((_entries) => {
const pillWidths = pillsRef.current
? Array.from(pillsRef.current.children).map((el) =>
getElementWidthOrNull(el as HTMLElement)
)
: null
handleVisibility({
setVisibility,
pillWidths,
pillGap: PILL_X_GAP,
topBarWidth: getElementWidthOrNull(containerRef.current),
actionsWidth: getElementWidthOrNull(actionsRef.current),
seeMorePresent: !!seeMoreRef.current,
seeMoreWidth: SEE_MORE_WIDTH_PX + SEE_MORE_GAP_PX
})
})
if (containerRef.current) {
resizeObserver.observe(containerRef.current)
}
return () => {
resizeObserver.disconnect()
}
}, [query.filters])
if (!query.filters.length) {
return null
}
return (
<div
className={classNames(
'flex w-full mt-4',
visibility === null && 'invisible' // hide until we've calculated the positions
)}
ref={containerRef}
>
<FilterPillsList
ref={pillsRef}
direction="horizontal"
slice={{
type: 'hide-outside',
start: 0,
end: visibility?.visibleCount
}}
className="pb-1 overflow-hidden"
style={{ width: visibility?.width ?? '100%' }}
/>
<div className="flex items-center gap-x-4 pb-1" ref={actionsRef}>
{visibility !== null &&
visibility.visibleCount !== query.filters.length && (
<ToggleDropdownButton
className={classNames('w-9 md:relative')}
ref={seeMoreRef}
dropdownContainerProps={{
['title']: opened
? 'Hide rest of the filters'
: 'Show rest of the filters',
['aria-controls']: 'more-filters-menu',
['aria-expanded']: opened
}}
onClick={() => setOpened((opened) => !opened)}
currentOption={
<EllipsisHorizontalIcon className="h-full w-full" />
}
>
{opened && typeof visibility.visibleCount === 'number' ? (
<DropdownMenuWrapper
id={'more-filters-menu'}
className="md:left-auto md:w-auto"
innerContainerClassName="p-4"
>
<FilterPillsList
direction="vertical"
slice={{
type: 'no-render-outside',
start: visibility.visibleCount
}}
/>
</DropdownMenuWrapper>
) : null}
</ToggleDropdownButton>
)}
<ClearAction />
</div>
</div>
)
}
export const ClearAction = () => (
<AppNavigationLink
title="Clear all filters"
className="w-9 text-gray-500 hover:text-indigo-700 dark:hover:text-indigo-500 flex items-center justify-center"
search={(search) => ({
...search,
filters: null,
labels: null
})}
>
<XMarkIcon className="w-4 h-4" />
</AppNavigationLink>
)

View File

@ -13,6 +13,9 @@ import { TestContextProviders } from '../../../test-utils/app-context-providers'
import { TopBar } from './top-bar' import { TopBar } from './top-bar'
import { MockAPI } from '../../../test-utils/mock-api' import { MockAPI } from '../../../test-utils/mock-api'
const flags = {
saved_segments: true
}
const domain = 'dummy.site' const domain = 'dummy.site'
const domains = [domain, 'example.com', 'blog.example.com'] const domains = [domain, 'example.com', 'blog.example.com']
@ -42,7 +45,7 @@ beforeEach(() => {
test('user can open and close site switcher', async () => { test('user can open and close site switcher', async () => {
render(<TopBar showCurrentVisitors={false} />, { render(<TopBar showCurrentVisitors={false} />, {
wrapper: (props) => ( wrapper: (props) => (
<TestContextProviders siteOptions={{ domain }} {...props} /> <TestContextProviders siteOptions={{ domain, flags }} {...props} />
) )
}) })
@ -64,25 +67,23 @@ test('user can open and close site switcher', async () => {
test('user can open and close filters dropdown', async () => { test('user can open and close filters dropdown', async () => {
render(<TopBar showCurrentVisitors={false} />, { render(<TopBar showCurrentVisitors={false} />, {
wrapper: (props) => ( wrapper: (props) => (
<TestContextProviders siteOptions={{ domain }} {...props} /> <TestContextProviders siteOptions={{ domain, flags }} {...props} />
) )
}) })
const toggleFilters = screen.getByRole('button', { name: /Filter/ }) const toggleFilters = screen.getByRole('button', { name: /Filter/ })
await userEvent.click(toggleFilters) await userEvent.click(toggleFilters)
expect(screen.queryAllByRole('menuitem').map((el) => el.textContent)).toEqual( expect(screen.queryAllByRole('link').map((el) => el.textContent)).toEqual([
[ 'Page',
'Page', 'Source',
'Source', 'Location',
'Location', 'Screen size',
'Screen size', 'Browser',
'Browser', 'Operating System',
'Operating System', 'UTM tags',
'UTM tags', 'Goal',
'Goal', 'Hostname'
'Hostname' ])
]
)
await userEvent.click(toggleFilters) await userEvent.click(toggleFilters)
expect(screen.queryAllByRole('menuitem')).toEqual([]) expect(screen.queryAllByRole('menuitem')).toEqual([])
}) })
@ -91,7 +92,7 @@ test('current visitors renders when visitors are present and disappears after vi
mockAPI.get(`/api/stats/${domain}/current-visitors?`, 500) mockAPI.get(`/api/stats/${domain}/current-visitors?`, 500)
render(<TopBar showCurrentVisitors={true} />, { render(<TopBar showCurrentVisitors={true} />, {
wrapper: (props) => ( wrapper: (props) => (
<TestContextProviders siteOptions={{ domain }} {...props} /> <TestContextProviders siteOptions={{ domain, flags }} {...props} />
) )
}) })

View File

@ -1,6 +1,6 @@
/** @format */ /** @format */
import React, { useRef } from 'react' import React, { ReactNode, useRef } from 'react'
import SiteSwitcher from '../site-switcher' import SiteSwitcher from '../site-switcher'
import { useSiteContext } from '../site-context' import { useSiteContext } from '../site-context'
import { useUserContext } from '../user-context' import { useUserContext } from '../user-context'
@ -9,29 +9,32 @@ import QueryPeriodPicker from '../datepicker'
import Filters from '../filters' import Filters from '../filters'
import classNames from 'classnames' import classNames from 'classnames'
import { useInView } from 'react-intersection-observer' import { useInView } from 'react-intersection-observer'
import { FilterMenu } from './filter-menu'
interface TopBarProps { interface TopBarProps {
showCurrentVisitors: boolean showCurrentVisitors: boolean
extraBar?: ReactNode
} }
export function TopBar({ showCurrentVisitors }: TopBarProps) { export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) {
const site = useSiteContext() const site = useSiteContext()
const user = useUserContext() const user = useUserContext()
const tooltipBoundary = useRef(null) const tooltipBoundary = useRef(null)
const { ref, inView } = useInView({ threshold: 0 }) const { ref, inView } = useInView({ threshold: 0 })
const { saved_segments } = site.flags
return ( return (
<> <>
<div id="stats-container-top" ref={ref} /> <div id="stats-container-top" ref={ref} />
<div <div
className={classNames( className={classNames(
'relative top-0 sm:py-3 py-2 z-10', 'relative top-0 py-2 sm:py-3 z-10',
!site.embedded && !site.embedded &&
!inView && !inView &&
'sticky fullwidth-shadow bg-gray-50 dark:bg-gray-850' 'sticky fullwidth-shadow bg-gray-50 dark:bg-gray-850'
)} )}
> >
<div className="items-center w-full flex"> <div className="flex items-center w-full">
<div className="flex items-center w-full" ref={tooltipBoundary}> <div className="flex items-center w-full" ref={tooltipBoundary}>
<SiteSwitcher <SiteSwitcher
site={site} site={site}
@ -41,10 +44,11 @@ export function TopBar({ showCurrentVisitors }: TopBarProps) {
{showCurrentVisitors && ( {showCurrentVisitors && (
<CurrentVisitors tooltipBoundary={tooltipBoundary.current} /> <CurrentVisitors tooltipBoundary={tooltipBoundary.current} />
)} )}
<Filters /> {saved_segments ? <FilterMenu /> : <Filters />}
</div> </div>
<QueryPeriodPicker /> <QueryPeriodPicker />
</div> </div>
{!!saved_segments && !!extraBar && extraBar}
</div> </div>
</> </>
) )

View File

@ -25,6 +25,12 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
} }
} }
type FeatureFlags = {
channels?: boolean
breakdown_comparisons_ui?: boolean
saved_segments?: boolean
}
const siteContextDefaultValue = { const siteContextDefaultValue = {
domain: '', domain: '',
/** offset in seconds from UTC at site load time, @example 7200 */ /** offset in seconds from UTC at site load time, @example 7200 */
@ -45,7 +51,7 @@ const siteContextDefaultValue = {
embedded: false, embedded: false,
background: undefined as string | undefined, background: undefined as string | undefined,
isDbip: false, isDbip: false,
flags: {} as { breakdown_comparisons_ui?: boolean }, flags: {} as FeatureFlags,
validIntervalsByPeriod: {} as Record<string, Array<string>>, validIntervalsByPeriod: {} as Record<string, Array<string>>,
shared: false shared: false
} }

View File

@ -237,17 +237,17 @@ export default class SiteSwitcher extends React.Component {
<div className="relative inline-block text-left mr-2 sm:mr-4"> <div className="relative inline-block text-left mr-2 sm:mr-4">
<button <button
ref={this.siteSwitcherButton} ref={this.siteSwitcherButton}
className={`inline-flex items-center md:text-lg w-full rounded-md py-2 leading-5 font-bold text-gray-700 dark:text-gray-300 focus:outline-none transition ease-in-out duration-150 ${hoverClass}`} className={`inline-flex items-center rounded-md h-9 leading-5 font-bold text-gray-700 dark:text-gray-300 focus:outline-none transition ease-in-out duration-150 ${hoverClass}`}
> >
<Favicon <Favicon
domain={this.props.site.domain} domain={this.props.site.domain}
className="w-4 mr-1 md:mr-2 align-middle w-4 mr-2 align-middle" className="w-4 mr-2 align-middle w-4"
></Favicon> />
<span className="hidden sm:inline-block"> <span className="hidden sm:inline-block">
{this.props.site.domain} {this.props.site.domain}
</span> </span>
{this.props.loggedIn && ( {this.props.loggedIn && (
<ChevronDownIcon className="-mr-1 ml-1 md:ml-2 h-5 w-5" /> <ChevronDownIcon className="ml-2 h-5 w-5 shrink-0" />
)} )}
</button> </button>

View File

@ -33,38 +33,45 @@ export default function CurrentVisitors({ tooltipBoundary }) {
updateCount() updateCount()
}, [query, updateCount]) }, [query, updateCount])
function tooltipInfo() { if (
site.flags.saved_segments
? currentVisitors !== null
: currentVisitors !== null && query.filters.length === 0
) {
return ( return (
<div> <Tooltip
<p className="whitespace-nowrap text-small"> info={
Last updated{' '} <div>
<SecondsSinceLastLoad lastLoadTimestamp={lastLoadTimestamp} />s ago <p className="whitespace-nowrap text-small">
</p> Last updated{' '}
<p className="whitespace-nowrap font-normal text-xs"> <SecondsSinceLastLoad lastLoadTimestamp={lastLoadTimestamp} />s
Click to view realtime dashboard ago
</p> </p>
</div> <p className="whitespace-nowrap font-normal text-xs">
) Click to view realtime dashboard
} </p>
</div>
if (currentVisitors !== null && query.filters.length === 0) { }
return ( boundary={tooltipBoundary}
<Tooltip info={tooltipInfo()} boundary={tooltipBoundary}> >
<AppNavigationLink <AppNavigationLink
search={(prev) => ({ ...prev, period: 'realtime' })} search={(prev) => ({ ...prev, period: 'realtime' })}
className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300" className="h-9 flex items-center ml-1 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300"
> >
<svg <svg
className="inline w-2 mr-1 md:mr-2 text-green-500 fill-current" className="inline-block w-2 mr-1 text-green-500 fill-current"
viewBox="0 0 16 16" viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<circle cx="8" cy="8" r="8" /> <circle cx="8" cy="8" r="8" />
</svg> </svg>
{currentVisitors}{' '} <div className="inline-block">
<span className="hidden sm:inline-block"> {currentVisitors}
current visitor{currentVisitors === 1 ? '' : 's'} <span className="hidden lg:inline">
</span> {' '}
current visitor{currentVisitors === 1 ? '' : 's'}
</span>
</div>
</AppNavigationLink> </AppNavigationLink>
</Tooltip> </Tooltip>
) )

View File

@ -0,0 +1,25 @@
/** @format */
import { render, RenderOptions } from '@testing-library/react'
import { ReactNode } from 'react'
/**
* Makes the fake document in unit tests aware of some tailwind class definitions.
* Needed for the matcher option ({ hidden: false }) to function at least partially.
*/
const registerPartialTailwindStyle = () => {
const tailwindStyle = `.invisible { visibility: hidden; }`
const style = document.createElement('style')
style.innerHTML = tailwindStyle
document.head.appendChild(style)
}
const customRender = (ui: ReactNode, options: RenderOptions) => {
const output = render(ui, options)
registerPartialTailwindStyle()
return output
}
export * from '@testing-library/react'
export { customRender as render }

View File

@ -365,13 +365,12 @@ defmodule PlausibleWeb.StatsController do
defp shared_link_cookie_name(slug), do: "shared-link-" <> slug defp shared_link_cookie_name(slug), do: "shared-link-" <> slug
defp get_flags(user, site), defp get_flags(user, site),
do: %{ do:
channels: [:channels, :breakdown_comparisons_ui, :saved_segments]
FunWithFlags.enabled?(:channels, for: user) || FunWithFlags.enabled?(:channels, for: site), |> Enum.map(fn flag ->
breakdown_comparisons_ui: {flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)}
FunWithFlags.enabled?(:breakdown_comparisons_ui, for: user) || end)
FunWithFlags.enabled?(:breakdown_comparisons_ui, for: site) |> Map.new()
}
defp is_dbip() do defp is_dbip() do
on_ee do on_ee do