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 */
import React, {
AriaAttributes,
DetailedHTMLProps,
forwardRef,
HTMLAttributes,
@ -18,32 +17,60 @@ import {
export const ToggleDropdownButton = forwardRef<
HTMLDivElement,
{
variant?: 'ghost' | 'button'
withDropdownIndicator?: boolean
className?: string
currentOption: ReactNode
children: ReactNode
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
onClick={onClick}
className="w-full flex items-center justify-between rounded bg-white dark:bg-gray-800 shadow px-2 md:px-3
py-2 leading-tight cursor-pointer text-xs md:text-sm text-gray-800
dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900"
tabIndex={0}
aria-haspopup="true"
{...dropdownContainerProps}
>
<span className="truncate mr-1 md:mr-2">
<span className="font-medium">{currentOption}</span>
</span>
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500" />
</button>
{children}
</div>
)
})
>(
(
{
className,
currentOption,
withDropdownIndicator,
children,
onClick,
dropdownContainerProps,
...props
},
ref
) => {
const { variant } = { variant: 'button', ...props }
const sharedButtonClass =
'flex items-center rounded text-sm leading-tight px-2 py-2 h-9'
const buttonClass = {
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<
HTMLDivElement,
@ -57,7 +84,7 @@ export const DropdownMenuWrapper = forwardRef<
ref={ref}
{...props}
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
)}
>

View File

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

View File

@ -208,7 +208,7 @@ function Filters() {
const filterCount = query.filters.length
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'}
</>
)
@ -216,7 +216,7 @@ function Filters() {
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. */}
<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 { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours'
import { FiltersBar } from './nav-menu/filters-bar'
function DashboardStats({
importedDataInView,
@ -53,7 +54,10 @@ function Dashboard() {
return (
<div className="mb-12">
<TopBar showCurrentVisitors={!isRealTimeDashboard} />
<TopBar
showCurrentVisitors={!isRealTimeDashboard}
extraBar={<FiltersBar />}
/>
<DashboardStats
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 { MockAPI } from '../../../test-utils/mock-api'
const flags = {
saved_segments: true
}
const domain = 'dummy.site'
const domains = [domain, 'example.com', 'blog.example.com']
@ -42,7 +45,7 @@ beforeEach(() => {
test('user can open and close site switcher', async () => {
render(<TopBar showCurrentVisitors={false} />, {
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 () => {
render(<TopBar showCurrentVisitors={false} />, {
wrapper: (props) => (
<TestContextProviders siteOptions={{ domain }} {...props} />
<TestContextProviders siteOptions={{ domain, flags }} {...props} />
)
})
const toggleFilters = screen.getByRole('button', { name: /Filter/ })
await userEvent.click(toggleFilters)
expect(screen.queryAllByRole('menuitem').map((el) => el.textContent)).toEqual(
[
'Page',
'Source',
'Location',
'Screen size',
'Browser',
'Operating System',
'UTM tags',
'Goal',
'Hostname'
]
)
expect(screen.queryAllByRole('link').map((el) => el.textContent)).toEqual([
'Page',
'Source',
'Location',
'Screen size',
'Browser',
'Operating System',
'UTM tags',
'Goal',
'Hostname'
])
await userEvent.click(toggleFilters)
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)
render(<TopBar showCurrentVisitors={true} />, {
wrapper: (props) => (
<TestContextProviders siteOptions={{ domain }} {...props} />
<TestContextProviders siteOptions={{ domain, flags }} {...props} />
)
})

View File

@ -1,6 +1,6 @@
/** @format */
import React, { useRef } from 'react'
import React, { ReactNode, useRef } from 'react'
import SiteSwitcher from '../site-switcher'
import { useSiteContext } from '../site-context'
import { useUserContext } from '../user-context'
@ -9,29 +9,32 @@ import QueryPeriodPicker from '../datepicker'
import Filters from '../filters'
import classNames from 'classnames'
import { useInView } from 'react-intersection-observer'
import { FilterMenu } from './filter-menu'
interface TopBarProps {
showCurrentVisitors: boolean
extraBar?: ReactNode
}
export function TopBar({ showCurrentVisitors }: TopBarProps) {
export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) {
const site = useSiteContext()
const user = useUserContext()
const tooltipBoundary = useRef(null)
const { ref, inView } = useInView({ threshold: 0 })
const { saved_segments } = site.flags
return (
<>
<div id="stats-container-top" ref={ref} />
<div
className={classNames(
'relative top-0 sm:py-3 py-2 z-10',
'relative top-0 py-2 sm:py-3 z-10',
!site.embedded &&
!inView &&
'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}>
<SiteSwitcher
site={site}
@ -41,10 +44,11 @@ export function TopBar({ showCurrentVisitors }: TopBarProps) {
{showCurrentVisitors && (
<CurrentVisitors tooltipBoundary={tooltipBoundary.current} />
)}
<Filters />
{saved_segments ? <FilterMenu /> : <Filters />}
</div>
<QueryPeriodPicker />
</div>
{!!saved_segments && !!extraBar && extraBar}
</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 = {
domain: '',
/** offset in seconds from UTC at site load time, @example 7200 */
@ -45,7 +51,7 @@ const siteContextDefaultValue = {
embedded: false,
background: undefined as string | undefined,
isDbip: false,
flags: {} as { breakdown_comparisons_ui?: boolean },
flags: {} as FeatureFlags,
validIntervalsByPeriod: {} as Record<string, Array<string>>,
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">
<button
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
domain={this.props.site.domain}
className="w-4 mr-1 md:mr-2 align-middle w-4 mr-2 align-middle"
></Favicon>
className="w-4 mr-2 align-middle w-4"
/>
<span className="hidden sm:inline-block">
{this.props.site.domain}
</span>
{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>

View File

@ -33,38 +33,45 @@ export default function CurrentVisitors({ tooltipBoundary }) {
updateCount()
}, [query, updateCount])
function tooltipInfo() {
if (
site.flags.saved_segments
? currentVisitors !== null
: currentVisitors !== null && query.filters.length === 0
) {
return (
<div>
<p className="whitespace-nowrap text-small">
Last updated{' '}
<SecondsSinceLastLoad lastLoadTimestamp={lastLoadTimestamp} />s ago
</p>
<p className="whitespace-nowrap font-normal text-xs">
Click to view realtime dashboard
</p>
</div>
)
}
if (currentVisitors !== null && query.filters.length === 0) {
return (
<Tooltip info={tooltipInfo()} boundary={tooltipBoundary}>
<Tooltip
info={
<div>
<p className="whitespace-nowrap text-small">
Last updated{' '}
<SecondsSinceLastLoad lastLoadTimestamp={lastLoadTimestamp} />s
ago
</p>
<p className="whitespace-nowrap font-normal text-xs">
Click to view realtime dashboard
</p>
</div>
}
boundary={tooltipBoundary}
>
<AppNavigationLink
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
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"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="8" cy="8" r="8" />
</svg>
{currentVisitors}{' '}
<span className="hidden sm:inline-block">
current visitor{currentVisitors === 1 ? '' : 's'}
</span>
<div className="inline-block">
{currentVisitors}
<span className="hidden lg:inline">
{' '}
current visitor{currentVisitors === 1 ? '' : 's'}
</span>
</div>
</AppNavigationLink>
</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 get_flags(user, site),
do: %{
channels:
FunWithFlags.enabled?(:channels, for: user) || FunWithFlags.enabled?(:channels, for: site),
breakdown_comparisons_ui:
FunWithFlags.enabled?(:breakdown_comparisons_ui, for: user) ||
FunWithFlags.enabled?(:breakdown_comparisons_ui, for: site)
}
do:
[:channels, :breakdown_comparisons_ui, :saved_segments]
|> Enum.map(fn flag ->
{flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)}
end)
|> Map.new()
defp is_dbip() do
on_ee do