mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 09:33:19 +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 */
|
/** @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
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -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 &&
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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
|
||||||
|
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 { 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} />
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
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 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
|
||||||
|
Loading…
Reference in New Issue
Block a user