mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 01:22:15 +03:00
Add second line for Filters (if saved_segments enabled) (#4729)
This commit is contained in:
parent
5d9e94770d
commit
13ad279820
@ -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
|
||||
)}
|
||||
>
|
||||
|
@ -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 &&
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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
|
||||
|
83
assets/js/dashboard/nav-menu/filter-menu.tsx
Normal file
83
assets/js/dashboard/nav-menu/filter-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
49
assets/js/dashboard/nav-menu/filter-pill.tsx
Normal file
49
assets/js/dashboard/nav-menu/filter-pill.tsx
Normal 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>
|
||||
)
|
||||
}
|
96
assets/js/dashboard/nav-menu/filter-pills-list.tsx
Normal file
96
assets/js/dashboard/nav-menu/filter-pills-list.tsx
Normal 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>
|
||||
)
|
||||
})
|
144
assets/js/dashboard/nav-menu/filters-bar.test.tsx
Normal file
144
assets/js/dashboard/nav-menu/filters-bar.test.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
218
assets/js/dashboard/nav-menu/filters-bar.tsx
Normal file
218
assets/js/dashboard/nav-menu/filters-bar.tsx
Normal 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>
|
||||
)
|
@ -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} />
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
|
25
assets/test-utils/index.ts
Normal file
25
assets/test-utils/index.ts
Normal 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 }
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user