Update headlessui to v1.7.19, refactor site switcher (#5255)

* Migrate some

* Making progress

* All fixed

* Convert interval picker to tsx

* Fix format

* Fix tests

* Make sure focus outline looks right on DropdownTabButton

* Refactor Site Switcher to Popover

* Fix site switcher test

* Better jsdom mocks in assets tests

* Try svg placeholder favicon

* Update favicon test

* Try giving transition config directly

* Remove empty props

* Remove unnecessary closeDropdown to prevent Firefox transition issue

* Register open dropmenus globally

This is needed to prevent invalid state when navigating with site hotkeys with Firefox while a dropdown is open and coming back using browser

* Colocate popover-specific component

* Clarify behaviour on hitting hotkey for current site

* Try fix Firefox issue

* Try 1.7.19

* Commit to @headlessui/react v1.7.x

* Fix last two transition origins

* Align active tab on baseline

* Remove unneeded global dropmenu state

* Add changelog

* Funnels menu is searchable and scrollable

* Fix transform origin

* Stop funnels menu from holding onto search state

* Mandate ref be passed to SearchInput from the outside
This commit is contained in:
Artur Pata 2025-05-07 08:22:53 +03:00 committed by GitHub
parent 8f4b63083e
commit 429b055920
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1686 additions and 2950 deletions

View File

@ -16,6 +16,8 @@ All notable changes to this project will be documented in this file.
- A session is now marked as a bounce if it has less than 2 pageviews and no interactive custom events. - A session is now marked as a bounce if it has less than 2 pageviews and no interactive custom events.
- All dropmenus on dashboard are navigable with Tab (used to be a mix between tab and arrow keys), and no two dropmenus can be open at once on the dashboard
### Fixed ### Fixed
- Make clicking Compare / Disable Comparison in period picker menu close the menu - Make clicking Compare / Disable Comparison in period picker menu close the menu

View File

@ -281,19 +281,6 @@ iframe[hidden] {
z-index: 100; z-index: 100;
} }
.active-prop-heading {
/* Properties related to text-decoration are all here in one place. TailwindCSS does support underline but that's about it. */
text-decoration-line: underline;
text-decoration-color: #4338ca; /* tailwind's indigo-700 */
text-decoration-thickness: 2px;
}
@media (prefers-color-scheme: dark) {
.active-prop-heading {
text-decoration-color: #6366f1; /* tailwind's indigo-500 */
}
}
/* This class is used for styling embedded dashboards. Do not remove. */ /* This class is used for styling embedded dashboards. Do not remove. */
/* stylelint-disable */ /* stylelint-disable */
/* prettier-ignore */ /* prettier-ignore */

View File

@ -8,6 +8,7 @@
}, },
"setupFilesAfterEnv": [ "setupFilesAfterEnv": [
"<rootDir>/test-utils/extend-expect.ts", "<rootDir>/test-utils/extend-expect.ts",
"<rootDir>/test-utils/jsdom-mocks.ts",
"<rootDir>/test-utils/reset-state.ts" "<rootDir>/test-utils/reset-state.ts"
], ],
"transform": { "transform": {

View File

@ -1,4 +1,4 @@
import React, { Fragment, useRef } from 'react' import React, { useRef } from 'react'
import { import {
FILTER_OPERATIONS, FILTER_OPERATIONS,
@ -7,97 +7,88 @@ import {
supportsIsNot, supportsIsNot,
supportsHasDoneNot supportsHasDoneNot
} from '../util/filters' } from '../util/filters'
import { Menu, Transition } from '@headlessui/react' import { Transition, Popover } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames' import classNames from 'classnames'
import { BlurMenuButtonOnEscape } from '../keybinding' import { popover, BlurMenuButtonOnEscape } from './popover'
export default function FilterOperatorSelector(props) { export default function FilterOperatorSelector(props) {
const filterName = props.forFilter const filterName = props.forFilter
const buttonRef = useRef() const buttonRef = useRef()
function renderTypeItem(operation, shouldDisplay) {
return (
shouldDisplay && (
<Menu.Item>
{({ active }) => (
<span
onClick={() => props.onSelect(operation)}
className={classNames('cursor-pointer block px-4 py-2 text-sm', {
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100':
active,
'text-gray-700 dark:text-gray-200': !active
})}
>
{FILTER_OPERATIONS_DISPLAY_NAMES[operation]}
</span>
)}
</Menu.Item>
)
)
}
const containerClass = classNames('w-full', {
'opacity-20 cursor-default pointer-events-none': props.isDisabled
})
return ( return (
<div className={containerClass}> <div
<Menu as="div" className="relative inline-block text-left w-full"> className={classNames('w-full', {
{({ open }) => ( 'opacity-20 cursor-default pointer-events-none': props.isDisabled
})}
>
<Popover className="relative w-full">
{({ close: closeDropdown }) => (
<> <>
<BlurMenuButtonOnEscape targetRef={buttonRef} /> <BlurMenuButtonOnEscape targetRef={buttonRef} />
<div className="w-full"> <Popover.Button
<Menu.Button ref={buttonRef}
ref={buttonRef} className="relative flex justify-between items-center w-full rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 dark:focus:ring-offset-gray-900 focus:ring-indigo-500 text-left"
className="inline-flex justify-between items-center w-full rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 dark:focus:ring-offset-gray-900 focus:ring-indigo-500 text-left"
>
{FILTER_OPERATIONS_DISPLAY_NAMES[props.selectedType]}
<ChevronDownIcon
className="-mr-2 ml-2 h-4 w-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
> >
<Menu.Items {FILTER_OPERATIONS_DISPLAY_NAMES[props.selectedType]}
static <ChevronDownIcon
className="z-10 origin-top-left absolute left-0 mt-2 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none" className="-mr-2 ml-2 h-4 w-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
/>
</Popover.Button>
<Transition
as="div"
{...popover.transition.props}
className={classNames(popover.transition.classNames.left, 'mt-2')}
>
<Popover.Panel
className={classNames(
popover.panel.classNames.roundedSheet,
'font-normal'
)}
> >
<div className="py-1"> {[
{renderTypeItem(FILTER_OPERATIONS.is, true)} [FILTER_OPERATIONS.is, true],
{renderTypeItem( [FILTER_OPERATIONS.isNot, supportsIsNot(filterName)],
FILTER_OPERATIONS.isNot, [
supportsIsNot(filterName)
)}
{renderTypeItem(
FILTER_OPERATIONS.has_not_done, FILTER_OPERATIONS.has_not_done,
supportsHasDoneNot(filterName) supportsHasDoneNot(filterName)
)} ],
{renderTypeItem( [FILTER_OPERATIONS.contains, supportsContains(filterName)],
FILTER_OPERATIONS.contains, [
supportsContains(filterName)
)}
{renderTypeItem(
FILTER_OPERATIONS.contains_not, FILTER_OPERATIONS.contains_not,
supportsContains(filterName) && supportsIsNot(filterName) supportsContains(filterName) && supportsIsNot(filterName)
)} ]
</div> ]
</Menu.Items> .filter(([_operation, supported]) => supported)
.map(([operation]) => (
<button
key={operation}
data-selected={operation === props.selectedType}
onClick={(e) => {
// Prevent the click propagating and closing modal
e.preventDefault()
e.stopPropagation()
props.onSelect(operation)
closeDropdown()
}}
className={classNames(
'w-full text-left ',
popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink,
popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd
)}
>
{FILTER_OPERATIONS_DISPLAY_NAMES[operation]}
</button>
))}
</Popover.Panel>
</Transition> </Transition>
</> </>
)} )}
</Menu> </Popover>
</div> </div>
) )
} }

View File

@ -1,5 +1,7 @@
import { TransitionClasses } from '@headlessui/react' import React, { RefObject } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { isModifierPressed, isTyping, Keybind } from '../keybinding'
import { TransitionClasses } from '@headlessui/react'
const TRANSITION_CONFIG: TransitionClasses = { const TRANSITION_CONFIG: TransitionClasses = {
enter: 'transition ease-out duration-100', enter: 'transition ease-out duration-100',
@ -11,8 +13,14 @@ const TRANSITION_CONFIG: TransitionClasses = {
} }
const transition = { const transition = {
props: TRANSITION_CONFIG, props: {
classNames: { fullwidth: 'z-10 absolute left-0 right-0' } ...TRANSITION_CONFIG
},
classNames: {
fullwidth: 'z-10 absolute left-0 right-0 origin-top',
left: 'z-10 absolute left-0 origin-top-left',
right: 'z-10 absolute right-0 origin-top-right'
}
} }
const panel = { const panel = {
@ -29,7 +37,9 @@ const toggleButton = {
'bg-white dark:bg-gray-800 shadow text-gray-800 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900', 'bg-white dark:bg-gray-800 shadow text-gray-800 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900',
ghost: ghost:
'text-gray-700 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-900', 'text-gray-700 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-900',
truncatedText: 'truncate block font-medium' truncatedText: 'truncate block font-medium',
linkLike:
'text-gray-700 dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-600'
} }
} }
@ -37,7 +47,8 @@ const items = {
classNames: { classNames: {
navigationLink: classNames( navigationLink: classNames(
'flex items-center justify-between', 'flex items-center justify-between',
'px-4 py-2 text-sm leading-tight' 'px-4 py-2 text-sm leading-tight',
'cursor-pointer'
), ),
selectedOption: classNames('data-[selected=true]:font-bold'), selectedOption: classNames('data-[selected=true]:font-bold'),
hoverLink: classNames( hoverLink: classNames(
@ -51,15 +62,9 @@ const items = {
'dark:focus-within:bg-gray-900', 'dark:focus-within:bg-gray-900',
'dark:focus-within:text-gray-100' 'dark:focus-within:text-gray-100'
), ),
roundedStartEnd: classNames( roundedStart: 'first-of-type:rounded-t-md',
'first-of-type:rounded-t-md', roundedEnd: 'last-of-type:rounded-b-md',
'last-of-type:rounded-b-md' groupRoundedEnd: 'group-last-of-type:rounded-b-md'
),
roundedEnd: classNames('last-of-type:rounded-b-md'),
groupRoundedStartEnd: classNames(
'group-first-of-type:rounded-t-md',
'group-last-of-type:rounded-b-md'
)
} }
} }
@ -69,3 +74,32 @@ export const popover = {
transition, transition,
items items
} }
/**
* Rendering this component captures the Escape key on targetRef.current, a PopoverButton,
* blurring the element on Escape, and stopping the event from propagating.
* Needed to prevent other Escape handlers that may exist from running.
*/
export function BlurMenuButtonOnEscape({
targetRef
}: {
targetRef: RefObject<HTMLElement>
}) {
return (
<Keybind
keyboardKey="Escape"
type="keyup"
handler={(event) => {
const t = event.target as HTMLElement | null
if (typeof t?.blur === 'function') {
if (t === targetRef.current) {
t.blur()
event.stopPropagation()
}
}
}}
targetRef={targetRef}
shouldIgnoreWhen={[isModifierPressed, isTyping]}
/>
)
}

View File

@ -1,20 +1,26 @@
import React, { ChangeEventHandler, useCallback, useState, useRef } from 'react' import React, {
ChangeEventHandler,
useCallback,
useState,
RefObject
} from 'react'
import { isModifierPressed, Keybind } from '../keybinding' import { isModifierPressed, Keybind } from '../keybinding'
import { useDebounce } from '../custom-hooks' import { useDebounce } from '../custom-hooks'
import classNames from 'classnames' import classNames from 'classnames'
export const SearchInput = ({ export const SearchInput = ({
searchRef,
onSearch, onSearch,
className, className,
placeholderFocused = 'Search', placeholderFocused = 'Search',
placeholderUnfocused = 'Press / to search' placeholderUnfocused = 'Press / to search'
}: { }: {
searchRef: RefObject<HTMLInputElement>
onSearch: (value: string) => void onSearch: (value: string) => void
className?: string className?: string
placeholderFocused?: string placeholderFocused?: string
placeholderUnfocused?: string placeholderUnfocused?: string
}) => { }) => {
const searchBoxRef = useRef<HTMLInputElement>(null)
const [isFocused, setIsFocused] = useState(false) const [isFocused, setIsFocused] = useState(false)
const onSearchInputChange: ChangeEventHandler<HTMLInputElement> = useCallback( const onSearchInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
@ -26,13 +32,16 @@ export const SearchInput = ({
const debouncedOnSearchInputChange = useDebounce(onSearchInputChange) const debouncedOnSearchInputChange = useDebounce(onSearchInputChange)
const blurSearchBox = useCallback(() => { const blurSearchBox = useCallback(() => {
searchBoxRef.current?.blur() searchRef.current?.blur()
}, []) }, [searchRef])
const focusSearchBox = useCallback((event: KeyboardEvent) => { const focusSearchBox = useCallback(
searchBoxRef.current?.focus() (event: KeyboardEvent) => {
event.stopPropagation() searchRef.current?.focus()
}, []) event.stopPropagation()
},
[searchRef]
)
return ( return (
<> <>
@ -41,7 +50,7 @@ export const SearchInput = ({
type="keyup" type="keyup"
handler={blurSearchBox} handler={blurSearchBox}
shouldIgnoreWhen={[isModifierPressed, () => !isFocused]} shouldIgnoreWhen={[isModifierPressed, () => !isFocused]}
targetRef={searchBoxRef} targetRef={searchRef}
/> />
<Keybind <Keybind
keyboardKey="/" keyboardKey="/"
@ -53,7 +62,7 @@ export const SearchInput = ({
<input <input
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
ref={searchBoxRef} ref={searchRef}
type="text" type="text"
placeholder={isFocused ? placeholderFocused : placeholderUnfocused} placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
className={classNames( className={classNames(

View File

@ -0,0 +1,214 @@
import { Popover, Transition } from '@headlessui/react'
import classNames from 'classnames'
import React, { ReactNode, useRef } from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { popover, BlurMenuButtonOnEscape } from './popover'
import { useSearchableItems } from '../hooks/use-searchable-items'
import { SearchInput } from './search-input'
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'
export const TabWrapper = ({
className,
children
}: {
className?: string
children: ReactNode
}) => (
<div
className={classNames(
'flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline',
className
)}
>
{children}
</div>
)
const TabButtonText = ({
children,
active
}: {
children: ReactNode
active: boolean
}) => (
<span
className={classNames('truncate text-left', {
'hover:text-indigo-600 cursor-pointer': !active,
'text-indigo-700 dark:text-indigo-500 font-bold underline decoration-2 decoration-indigo-700 dark:decoration-indigo-500':
active
})}
>
{children}
</span>
)
export const TabButton = ({
className,
children,
onClick,
active
}: {
className?: string
children: ReactNode
onClick: () => void
active: boolean
}) => (
<button className={classNames('rounded-sm', className)} onClick={onClick}>
<TabButtonText active={active}>{children}</TabButtonText>
</button>
)
export const DropdownTabButton = ({
className,
transitionClassName,
active,
children,
...optionsProps
}: {
className?: string
transitionClassName?: string
active: boolean
children: ReactNode
} & Omit<ItemsProps, 'closeDropdown'>) => {
const dropdownButtonRef = useRef<HTMLButtonElement>(null)
return (
<Popover className={className}>
{({ close: closeDropdown }) => (
<>
<BlurMenuButtonOnEscape targetRef={dropdownButtonRef} />
<Popover.Button
className="inline-flex justify-between rounded-sm"
ref={dropdownButtonRef}
>
<TabButtonText active={active}>{children}</TabButtonText>
<div
className="flex self-stretch -mr-1 ml-1 items-center"
aria-hidden="true"
>
<ChevronDownIcon className="h-4 w-4" />
</div>
</Popover.Button>
<Transition
as="div"
{...popover.transition.props}
className={classNames(
popover.transition.classNames.fullwidth,
'mt-2',
transitionClassName
)}
>
<Popover.Panel className={popover.panel.classNames.roundedSheet}>
<Items closeDropdown={closeDropdown} {...optionsProps} />
</Popover.Panel>
</Transition>
</>
)}
</Popover>
)
}
type ItemsProps = {
closeDropdown: () => void
options: Array<{ selected: boolean; onClick: () => void; label: string }>
searchable?: boolean
collectionTitle?: string
}
const Items = ({
options,
searchable,
collectionTitle,
closeDropdown
}: ItemsProps) => {
const {
filteredData,
showableData,
showSearch,
searching,
searchRef,
handleSearchInput,
handleClearSearch,
handleShowAll,
countOfMoreToShow
} = useSearchableItems({
data: options,
maxItemsInitially: searchable ? 5 : options.length,
itemMatchesSearchValue: (option, trimmedSearchString) =>
option.label.toLowerCase().includes(trimmedSearchString.toLowerCase())
})
const itemClassName = classNames(
'w-full text-left',
popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink,
{
[popover.items.classNames.roundedStart]: !searchable || !showSearch
// when the menu is not searchable, the first item needs rounded top
},
popover.items.classNames.roundedEnd
)
return (
<>
{searchable && showSearch && (
<div className="flex items-center py-2 px-4">
{collectionTitle && (
<div className="text-sm font-bold uppercase text-indigo-500 dark:text-indigo-400 mr-4">
{collectionTitle}
</div>
)}
<SearchInput
searchRef={searchRef}
placeholderUnfocused="Press / to search"
className="ml-auto w-full py-1 text-sm"
onSearch={handleSearchInput}
/>
</div>
)}
<div className={'max-h-[210px] overflow-y-scroll'}>
{showableData.map(({ selected, label, onClick }, index) => {
return (
<button
key={index}
onClick={() => {
onClick()
closeDropdown()
}}
data-selected={selected}
className={itemClassName}
>
{label}
</button>
)
})}
{countOfMoreToShow > 0 && (
<button
onClick={handleShowAll}
className={classNames(
itemClassName,
'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
)}
>
{`Show ${countOfMoreToShow} more`}
<EllipsisHorizontalIcon className="block w-5 h-5" />
</button>
)}
{searching && !filteredData.length && (
<button
className={classNames(
itemClassName,
'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
)}
onClick={handleClearSearch}
>
No items found. Clear search to show all.
</button>
)}
</div>
</>
)
}

View File

@ -1,7 +1,6 @@
import { remapToApiFilters } from '../util/filters' import { remapToApiFilters } from '../util/filters'
import { import {
formatSegmentIdAsLabelKey, formatSegmentIdAsLabelKey,
getFilterSegmentsByNameInsensitive,
getSearchToApplySingleSegmentFilter, getSearchToApplySingleSegmentFilter,
getSegmentNamePlaceholder, getSegmentNamePlaceholder,
isSegmentIdLabelKey, isSegmentIdLabelKey,
@ -17,36 +16,6 @@ import { Filter } from '../query'
import { PlausibleSite } from '../site-context' import { PlausibleSite } from '../site-context'
import { Role, UserContextValue } from '../user-context' import { Role, UserContextValue } from '../user-context'
describe(`${getFilterSegmentsByNameInsensitive.name}`, () => {
const unfilteredSegments = [
{ name: 'APAC Region' },
{ name: 'EMEA Region' },
{ name: 'Scandinavia' }
]
it('generates insensitive filter function', () => {
expect(
unfilteredSegments.filter(getFilterSegmentsByNameInsensitive('region'))
).toEqual([{ name: 'APAC Region' }, { name: 'EMEA Region' }])
})
it('ignores preceding and following whitespace', () => {
expect(
unfilteredSegments.filter(getFilterSegmentsByNameInsensitive(' scandi '))
).toEqual([{ name: 'Scandinavia' }])
})
it.each([[undefined], [''], [' '], ['\n\n']])(
'generates always matching filter for search value %p',
(searchValue) => {
expect(
unfilteredSegments.filter(
getFilterSegmentsByNameInsensitive(searchValue)
)
).toEqual(unfilteredSegments)
}
)
})
describe(`${getSegmentNamePlaceholder.name}`, () => { describe(`${getSegmentNamePlaceholder.name}`, () => {
it('gives readable result', () => { it('gives readable result', () => {
const placeholder = getSegmentNamePlaceholder({ const placeholder = getSegmentNamePlaceholder({

View File

@ -66,15 +66,6 @@ export function handleSegmentResponse(
} }
} }
export function getFilterSegmentsByNameInsensitive(
search?: string
): (s: Pick<SavedSegment, 'name'>) => boolean {
return (s) =>
search?.trim().length
? s.name.toLowerCase().includes(search.trim().toLowerCase())
: true
}
export const getSegmentNamePlaceholder = ( export const getSegmentNamePlaceholder = (
query: Pick<DashboardQuery, 'labels' | 'filters'> query: Pick<DashboardQuery, 'labels' | 'filters'>
) => ) =>

View File

@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export function useSearchableItems<TItem>({
data,
maxItemsInitially,
itemMatchesSearchValue
}: {
data: TItem[]
maxItemsInitially: number
itemMatchesSearchValue: (t: TItem, trimmedSearchString: string) => boolean
}): {
data: TItem[]
filteredData: TItem[]
showableData: TItem[]
searchRef: React.RefObject<HTMLInputElement>
showSearch: boolean
searching: boolean
countOfMoreToShow: number
handleSearchInput: (v: string) => void
handleClearSearch: () => void
handleShowAll: () => void
} {
const searchRef = useRef<HTMLInputElement>(null)
const [searchValue, setSearch] = useState<string>()
const [showAll, setShowAll] = useState(false)
const trimmedSearch = searchValue?.trim()
const searching = !!trimmedSearch?.length
useEffect(() => {
setShowAll(false)
}, [searching])
const filteredData = searching
? data.filter((item) => itemMatchesSearchValue(item, trimmedSearch))
: data
const showableData = showAll
? filteredData
: filteredData.slice(0, maxItemsInitially)
const handleClearSearch = useCallback(() => {
if (searchRef.current) {
searchRef.current.value = ''
setSearch(undefined)
}
}, [])
return {
searchRef,
data,
filteredData,
showableData,
showSearch: data.length > maxItemsInitially,
searching,
countOfMoreToShow: filteredData.length - showableData.length,
handleSearchInput: setSearch,
handleClearSearch,
handleShowAll: () => setShowAll(true)
}
}

View File

@ -153,32 +153,3 @@ export function KeybindHint({
</kbd> </kbd>
) )
} }
/**
* Rendering this component captures the Escape key on targetRef.current,
* blurring the element on Escape, and stopping the event from propagating.
* Needed to prevent other Escape handlers that may exist from running.
*/
export function BlurMenuButtonOnEscape({
targetRef: targetRef
}: {
targetRef: RefObject<HTMLElement>
}) {
return (
<Keybind
keyboardKey="Escape"
type="keyup"
handler={(event) => {
const t = event.target as HTMLElement | null
if (typeof t?.blur === 'function') {
if (t === targetRef.current) {
t.blur()
event.stopPropagation()
}
}
}}
targetRef={targetRef}
shouldIgnoreWhen={[isModifierPressed, isTyping]}
/>
)
}

View File

@ -7,10 +7,9 @@ import { PlausibleSite, useSiteContext } from '../site-context'
import { filterRoute } from '../router' import { filterRoute } from '../router'
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import { popover } from '../components/popover' import { popover, BlurMenuButtonOnEscape } from '../components/popover'
import classNames from 'classnames' import classNames from 'classnames'
import { AppNavigationLink } from '../navigation/use-app-navigate' import { AppNavigationLink } from '../navigation/use-app-navigate'
import { BlurMenuButtonOnEscape } from '../keybinding'
import { SearchableSegmentsSection } from './segments/searchable-segments-section' import { SearchableSegmentsSection } from './segments/searchable-segments-section'
export function getFilterListItems({ export function getFilterListItems({
@ -67,15 +66,16 @@ const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => {
</span> </span>
</Popover.Button> </Popover.Button>
<Transition <Transition
as="div"
{...popover.transition.props} {...popover.transition.props}
className={classNames( className={classNames(
'mt-2',
popover.transition.classNames.fullwidth, popover.transition.classNames.fullwidth,
'md:left-auto md:w-80' 'mt-2 md:left-auto md:w-80 md:origin-top-right'
)} )}
> >
<Popover.Panel <Popover.Panel
className={classNames(popover.panel.classNames.roundedSheet)} className={classNames(popover.panel.classNames.roundedSheet)}
data-testid="filtermenu"
> >
<div className="flex"> <div className="flex">
{columns.map((filterGroups, index) => ( {columns.map((filterGroups, index) => (

View File

@ -5,24 +5,14 @@ import { TestContextProviders } from '../../../test-utils/app-context-providers'
import { FiltersBar, handleVisibility } from './filters-bar' import { FiltersBar, handleVisibility } from './filters-bar'
import { getRouterBasepath } from '../router' import { getRouterBasepath } from '../router'
import { stringifySearch } from '../util/url-search-params' import { stringifySearch } from '../util/url-search-params'
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
mockAnimationsApi()
const resizeObserver = mockResizeObserver()
const domain = 'dummy.site' const domain = 'dummy.site'
beforeAll(() => { test('user can see expected filters and clear them one by one or all together on small screens', async () => {
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 = { const searchRecord = {
filters: [ filters: [
['is', 'country', ['DE']], ['is', 'country', ['DE']],
@ -67,10 +57,13 @@ test('user can see expected filters and clear them one by one or all together',
} }
) )
// needed to initiate the layout calculation effect of the component
resizeObserver.resize()
const queryFilterPills = () => const queryFilterPills = () =>
screen.queryAllByRole('link', { hidden: false, name: /.* is .*/i }) screen.queryAllByRole('link', { hidden: false, name: /.* is .*/i })
// all filters appear in See more menu // all filters appear in See more menu (see the mock widths in props)
expect(queryFilterPills().map((m) => m.textContent)).toEqual([]) expect(queryFilterPills().map((m) => m.textContent)).toEqual([])
await userEvent.click( await userEvent.click(

View File

@ -5,8 +5,7 @@ import { AppliedFilterPillsList, PILL_X_GAP_PX } from './filter-pills-list'
import { useQueryContext } from '../query-context' import { useQueryContext } from '../query-context'
import { AppNavigationLink } from '../navigation/use-app-navigate' import { AppNavigationLink } from '../navigation/use-app-navigate'
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import { popover } from '../components/popover' import { popover, BlurMenuButtonOnEscape } from '../components/popover'
import { BlurMenuButtonOnEscape } from '../keybinding'
import { isSegmentFilter } from '../filtering/segments' import { isSegmentFilter } from '../filtering/segments'
import { useRoutelessModalsContext } from '../navigation/routeless-modals-context' import { useRoutelessModalsContext } from '../navigation/routeless-modals-context'
import { DashboardQuery } from '../query' import { DashboardQuery } from '../query'
@ -273,11 +272,11 @@ const SeeMoreMenu = ({
)} )}
</Popover.Button> </Popover.Button>
<Transition <Transition
as="div"
{...popover.transition.props} {...popover.transition.props}
className={classNames( className={classNames(
'mt-2',
popover.transition.classNames.fullwidth, popover.transition.classNames.fullwidth,
'md:right-auto' 'mt-2 md:right-auto md:origin-top-left'
)} )}
> >
<Popover.Panel <Popover.Panel
@ -305,14 +304,15 @@ const SeeMoreMenu = ({
)} )}
{showSomeActions && ( {showSomeActions && (
<div className="flex flex-col"> <div className="flex flex-col">
{actions.map((action, index) => { {actions.map((action) => {
const linkClassName = classNames( const linkClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink,
index === 0 && !showMoreFilters {
? popover.items.classNames.roundedStartEnd [popover.items.classNames.roundedStart]: !showMoreFilters // rounded start is needed when there's no filters panel above
: popover.items.classNames.roundedEnd, },
popover.items.classNames.roundedEnd,
'whitespace-nowrap' 'whitespace-nowrap'
) )

View File

@ -3,7 +3,6 @@ import { clearedComparisonSearch } from '../../query'
import classNames from 'classnames' import classNames from 'classnames'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { BlurMenuButtonOnEscape } from '../../keybinding'
import { import {
AppNavigationLink, AppNavigationLink,
useAppNavigate useAppNavigate
@ -18,7 +17,7 @@ import {
getSearchToApplyCustomComparisonDates getSearchToApplyCustomComparisonDates
} from '../../query-time-periods' } from '../../query-time-periods'
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import { popover } from '../../components/popover' import { popover, BlurMenuButtonOnEscape } from '../../components/popover'
import { import {
datemenuButtonClassName, datemenuButtonClassName,
DateMenuChevron, DateMenuChevron,
@ -46,11 +45,11 @@ export const ComparisonPeriodMenuItems = ({
return ( return (
<Transition <Transition
as="div"
{...popover.transition.props} {...popover.transition.props}
className={classNames( className={classNames(
'mt-2',
popover.transition.classNames.fullwidth, popover.transition.classNames.fullwidth,
'md:left-auto md:w-56' 'mt-2 md:w-56 md:left-auto md:origin-top-right'
)} )}
> >
<Popover.Panel className={popover.panel.classNames.roundedSheet}> <Popover.Panel className={popover.panel.classNames.roundedSheet}>

View File

@ -6,6 +6,10 @@ import { stringifySearch } from '../../util/url-search-params'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getRouterBasepath } from '../../router' import { getRouterBasepath } from '../../router'
import { QueryPeriodsPicker } from './query-periods-picker' import { QueryPeriodsPicker } from './query-periods-picker'
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
mockAnimationsApi()
mockResizeObserver()
const domain = 'picking-query-dates.test' const domain = 'picking-query-dates.test'
const periodStorageKey = `period__${domain}` const periodStorageKey = `period__${domain}`
@ -47,10 +51,11 @@ test('user can select a new period and its value is stored', async () => {
) )
}) })
expect(screen.queryByTestId('datemenu')).toBeNull()
await userEvent.click(screen.getByText('Last 28 days')) await userEvent.click(screen.getByText('Last 28 days'))
expect(screen.getByTestId('datemenu')).toBeVisible() expect(screen.getByTestId('datemenu')).toBeVisible()
await userEvent.click(screen.getByText('All time')) await userEvent.click(screen.getByText('All time'))
expect(screen.queryByTestId('datemenu')).toBeNull() expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument()
expect(localStorage.getItem(periodStorageKey)).toBe('all') expect(localStorage.getItem(periodStorageKey)).toBe('all')
}) })
@ -165,10 +170,14 @@ test('going back resets the stored query period to previous value', async () =>
await userEvent.click(screen.getByText('Last 28 days')) await userEvent.click(screen.getByText('Last 28 days'))
await userEvent.click(screen.getByText('Year to Date')) await userEvent.click(screen.getByText('Year to Date'))
expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument()
expect(localStorage.getItem(periodStorageKey)).toBe('year') expect(localStorage.getItem(periodStorageKey)).toBe('year')
await userEvent.click(screen.getByText('Year to Date')) await userEvent.click(screen.getByText('Year to Date'))
await userEvent.click(screen.getByText('Month to Date')) await userEvent.click(screen.getByText('Month to Date'))
expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument()
expect(localStorage.getItem(periodStorageKey)).toBe('month') expect(localStorage.getItem(periodStorageKey)).toBe('month')
await userEvent.click(screen.getByTestId('browser-back')) await userEvent.click(screen.getByTestId('browser-back'))

View File

@ -3,7 +3,6 @@ import classNames from 'classnames'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { import {
BlurMenuButtonOnEscape,
isModifierPressed, isModifierPressed,
isTyping, isTyping,
Keybind, Keybind,
@ -25,7 +24,7 @@ import {
import { useMatch } from 'react-router-dom' import { useMatch } from 'react-router-dom'
import { rootRoute } from '../../router' import { rootRoute } from '../../router'
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import { popover } from '../../components/popover' import { popover, BlurMenuButtonOnEscape } from '../../components/popover'
import { import {
datemenuButtonClassName, datemenuButtonClassName,
DateMenuChevron, DateMenuChevron,
@ -152,11 +151,11 @@ const QueryPeriodMenuInner = ({
<> <>
<QueryPeriodMenuKeybinds closeDropdown={closeDropdown} groups={groups} /> <QueryPeriodMenuKeybinds closeDropdown={closeDropdown} groups={groups} />
<Transition <Transition
as="div"
{...popover.transition.props} {...popover.transition.props}
className={classNames( className={classNames(
'mt-2',
popover.transition.classNames.fullwidth, popover.transition.classNames.fullwidth,
'md:left-auto md:w-56' 'mt-2 md:w-56 md:left-auto md:origin-top-right'
)} )}
> >
<Popover.Panel <Popover.Panel

View File

@ -8,7 +8,8 @@ export const linkClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink,
popover.items.classNames.roundedStartEnd popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd
) )
export const datemenuButtonClassName = classNames( export const datemenuButtonClassName = classNames(
@ -41,10 +42,11 @@ export const CalendarPanel = React.forwardRef<
>(({ children, className }, ref) => { >(({ children, className }, ref) => {
return ( return (
<Transition <Transition
as="div"
{...popover.transition.props} {...popover.transition.props}
className={classNames( className={classNames(
popover.transition.classNames.fullwidth, popover.transition.classNames.fullwidth,
'md:left-auto', 'md:left-auto md:origin-top-right',
className className
)} )}
> >

View File

@ -1,9 +1,8 @@
import React, { useEffect, useState } from 'react' import React from 'react'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { import {
formatSegmentIdAsLabelKey, formatSegmentIdAsLabelKey,
getFilterSegmentsByNameInsensitive,
isSegmentFilter, isSegmentFilter,
SavedSegmentPublic, SavedSegmentPublic,
SavedSegment, SavedSegment,
@ -21,15 +20,16 @@ import { AppNavigationLink } from '../../navigation/use-app-navigate'
import { MenuSeparator } from '../nav-menu-components' import { MenuSeparator } from '../nav-menu-components'
import { Role, useUserContext } from '../../user-context' import { Role, useUserContext } from '../../user-context'
import { useSegmentsContext } from '../../filtering/segments-context' import { useSegmentsContext } from '../../filtering/segments-context'
import { useSearchableItems } from '../../hooks/use-searchable-items'
const linkClassName = classNames( const linkClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink,
popover.items.classNames.groupRoundedStartEnd popover.items.classNames.groupRoundedEnd
) )
const initialSliceLength = 5 const INITIAL_SEGMENTS_SHOWN = 5
export const SearchableSegmentsSection = ({ export const SearchableSegmentsSection = ({
closeList closeList
@ -44,105 +44,109 @@ export const SearchableSegmentsSection = ({
const isPublicListQuery = !user.loggedIn || user.role === Role.public const isPublicListQuery = !user.loggedIn || user.role === Role.public
const data = segmentsContext.segments.filter((segment) => const {
isListableSegment({ segment, site, user }) data,
) filteredData,
showableData,
const [searchValue, setSearch] = useState<string>() showSearch,
const [showAll, setShowAll] = useState(false) countOfMoreToShow,
handleShowAll,
const searching = !searchValue?.trim().length handleClearSearch,
handleSearchInput,
useEffect(() => { searchRef,
setShowAll(false) searching
}, [searching]) } = useSearchableItems({
data: segmentsContext.segments.filter((segment) =>
const filteredData = data?.filter( isListableSegment({ segment, site, user })
getFilterSegmentsByNameInsensitive(searchValue) ),
) maxItemsInitially: INITIAL_SEGMENTS_SHOWN,
itemMatchesSearchValue: (segment, trimmedSearch) =>
const showableSlice = showAll segment.name.toLowerCase().includes(trimmedSearch.toLowerCase())
? filteredData })
: filteredData?.slice(0, initialSliceLength)
if (expandedSegment) { if (expandedSegment) {
return null return null
} }
if (!data.length) {
return null
}
return ( return (
<> <>
{!!data?.length && ( <MenuSeparator />
<> <div className="flex items-center py-2 px-4">
<MenuSeparator /> <div className="text-sm font-bold uppercase text-indigo-500 dark:text-indigo-400 mr-4">
<div className="flex items-center pt-2 px-4 pb-2"> Segments
<div className="text-sm font-bold uppercase text-indigo-500 dark:text-indigo-400 mr-4"> </div>
Segments {showSearch && (
</div> <SearchInput
{data.length > initialSliceLength && ( searchRef={searchRef}
<SearchInput placeholderUnfocused="Press / to search"
placeholderUnfocused="Press / to search" className="ml-auto w-full py-1 text-sm"
className="ml-auto w-full py-1 text-sm" onSearch={handleSearchInput}
onSearch={setSearch} />
/> )}
)} </div>
</div>
{showableSlice!.map((segment) => { <div className="max-h-[210px] overflow-y-scroll">
return ( {showableData.map((segment) => {
<Tooltip return (
className="group" <Tooltip
key={segment.id} className="group"
info={ key={segment.id}
<div className="max-w-60"> info={
<div className="break-all">{segment.name}</div> <div className="max-w-60">
<div className="font-normal text-xs"> <div className="break-all">{segment.name}</div>
{SEGMENT_TYPE_LABELS[segment.type]} <div className="font-normal text-xs">
</div> {SEGMENT_TYPE_LABELS[segment.type]}
<SegmentAuthorship
className="font-normal text-xs"
{...(isPublicListQuery
? {
showOnlyPublicData: true,
segment: segment as SavedSegmentPublic
}
: {
showOnlyPublicData: false,
segment: segment as SavedSegment
})}
/>
</div> </div>
}
> <SegmentAuthorship
<SegmentLink {...segment} closeList={closeList} /> className="font-normal text-xs"
</Tooltip> {...(isPublicListQuery
) ? {
})} showOnlyPublicData: true,
{!!filteredData?.length && segment: segment as SavedSegmentPublic
!!showableSlice?.length && }
filteredData?.length > showableSlice?.length && : {
showAll === false && ( showOnlyPublicData: false,
<Tooltip className="group" info={null}> segment: segment as SavedSegment
<AppNavigationLink })}
className={classNames( />
linkClassName, </div>
'font-bold hover:text-indigo-700 dark:hover:text-indigo-500' }
)} >
search={(s) => s} <SegmentLink {...segment} closeList={closeList} />
onClick={() => setShowAll(true)} </Tooltip>
> )
{`Show ${filteredData.length - showableSlice.length} more`} })}
<EllipsisHorizontalIcon className="block w-5 h-5" /> {countOfMoreToShow > 0 && (
</AppNavigationLink> <Tooltip className="group" info={null}>
</Tooltip> <button
)} className={classNames(
</> linkClassName,
)} 'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
{!!data?.length && searchValue && !showableSlice?.length && ( )}
onClick={handleShowAll}
>
{`Show ${countOfMoreToShow} more`}
<EllipsisHorizontalIcon className="block w-5 h-5" />
</button>
</Tooltip>
)}
</div>
{searching && !filteredData.length && (
<Tooltip className="group" info={null}> <Tooltip className="group" info={null}>
<div className={classNames(linkClassName)}> <button
className={classNames(
linkClassName,
'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
)}
onClick={handleClearSearch}
>
No segments found. Clear search to show all. No segments found. Clear search to show all.
</div> </button>
</Tooltip> </Tooltip>
)} )}
</> </>

View File

@ -21,7 +21,8 @@ const linkClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink,
popover.items.classNames.roundedStartEnd popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd
) )
const buttonClassName = classNames( const buttonClassName = classNames(
'text-white font-medium bg-indigo-600 hover:bg-indigo-700' 'text-white font-medium bg-indigo-600 hover:bg-indigo-700'
@ -92,11 +93,11 @@ export const SegmentMenu = () => {
/> />
</Popover.Button> </Popover.Button>
<Transition <Transition
as="div"
{...popover.transition.props} {...popover.transition.props}
className={classNames( className={classNames(
'mt-2',
popover.transition.classNames.fullwidth, popover.transition.classNames.fullwidth,
'md:w-auto md:left-auto' 'mt-2 md:w-auto md:left-auto md:origin-top-right'
)} )}
> >
<Popover.Panel className={popover.panel.classNames.roundedSheet}> <Popover.Panel className={popover.panel.classNames.roundedSheet}>

View File

@ -10,30 +10,21 @@ import userEvent from '@testing-library/user-event'
import { TestContextProviders } from '../../../test-utils/app-context-providers' 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'
import {
mockAnimationsApi,
mockResizeObserver,
mockIntersectionObserver
} from 'jsdom-testing-mocks'
mockAnimationsApi()
mockResizeObserver()
mockIntersectionObserver()
const domain = 'dummy.site' const domain = 'dummy.site'
const domains = [domain, 'example.com', 'blog.example.com']
let mockAPI: MockAPI let mockAPI: MockAPI
beforeAll(() => { beforeAll(() => {
global.IntersectionObserver = jest.fn(
() =>
({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn()
}) as unknown as IntersectionObserver
)
global.ResizeObserver = jest.fn(
() =>
({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn()
}) as unknown as ResizeObserver
)
mockAPI = new MockAPI().start() mockAPI = new MockAPI().start()
}) })
@ -43,10 +34,18 @@ afterAll(() => {
beforeEach(() => { beforeEach(() => {
mockAPI.clear() mockAPI.clear()
mockAPI.get('/api/sites', { data: domains.map((domain) => ({ domain })) }) mockAPI.get('/api/sites', { data: [{ domain }] })
}) })
test('user can open and close site switcher', async () => { test('user can open and close site switcher', async () => {
mockAPI.get('/api/sites', {
data: [domain, 'example.com', 'blog.example.com', 'aççented.ca'].map(
(domain) => ({
domain
})
)
})
render(<TopBar showCurrentVisitors={false} />, { render(<TopBar showCurrentVisitors={false} />, {
wrapper: (props) => ( wrapper: (props) => (
<TestContextProviders siteOptions={{ domain }} {...props} /> <TestContextProviders siteOptions={{ domain }} {...props} />
@ -55,16 +54,24 @@ test('user can open and close site switcher', async () => {
const toggleSiteSwitcher = screen.getByRole('button', { name: domain }) const toggleSiteSwitcher = screen.getByRole('button', { name: domain })
await userEvent.click(toggleSiteSwitcher) await userEvent.click(toggleSiteSwitcher)
expect(screen.queryAllByRole('link').map((el) => el.textContent)).toEqual( expect(
screen
.queryAllByRole('link')
.map((el) => ({ text: el.textContent, href: el.getAttribute('href') }))
).toEqual(
[ [
['example.com', '2'], { text: ['Site settings'], href: `/${domain}/settings/general` },
['blog.example.com', '3'] { text: ['dummy.site', '1'], href: '#' },
].map((a) => a.join('')) { text: ['example.com', '2'], href: `/example.com` },
) { text: ['blog.example.com', '3'], href: `/blog.example.com` },
expect(screen.queryAllByRole('menuitem').map((el) => el.textContent)).toEqual( { text: ['aççented.ca', '4'], href: `/a%C3%A7%C3%A7ented.ca` },
['Site Settings', 'View All'] { text: ['View all'], href: '/sites' }
].map((l) => ({ ...l, text: l.text.join('') }))
) )
expect(screen.queryByTestId('sitemenu')).toBeInTheDocument()
await userEvent.click(toggleSiteSwitcher) await userEvent.click(toggleSiteSwitcher)
expect(screen.queryByTestId('sitemenu')).not.toBeInTheDocument()
expect(screen.queryAllByRole('menuitem')).toEqual([]) expect(screen.queryAllByRole('menuitem')).toEqual([])
}) })
@ -89,7 +96,8 @@ test('user can open and close filters dropdown', async () => {
'Goal' 'Goal'
]) ])
await userEvent.click(toggleFilters) await userEvent.click(toggleFilters)
expect(screen.queryAllByRole('menuitem')).toEqual([]) expect(screen.queryByTestId('filtermenu')).not.toBeInTheDocument()
expect(screen.queryAllByRole('link')).toEqual([])
}) })
test('current visitors renders when visitors are present and disappears after visitors are null', async () => { test('current visitors renders when visitors are present and disappears after visitors are null', async () => {

View File

@ -1,7 +1,6 @@
import React, { ReactNode, 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 CurrentVisitors from '../stats/current-visitors' import CurrentVisitors from '../stats/current-visitors'
import classNames from 'classnames' import classNames from 'classnames'
import { useInView } from 'react-intersection-observer' import { useInView } from 'react-intersection-observer'
@ -44,18 +43,12 @@ function TopBarStickyWrapper({ children }: { children: ReactNode }) {
} }
function TopBarInner({ showCurrentVisitors }: TopBarProps) { function TopBarInner({ showCurrentVisitors }: TopBarProps) {
const site = useSiteContext()
const user = useUserContext()
const leftActionsRef = useRef<HTMLDivElement>(null) const leftActionsRef = useRef<HTMLDivElement>(null)
return ( return (
<div className="flex items-center w-full"> <div className="flex items-center w-full">
<div className="flex items-center gap-x-4 shrink-0" ref={leftActionsRef}> <div className="flex items-center gap-x-4 shrink-0" ref={leftActionsRef}>
<SiteSwitcher <SiteSwitcher />
site={site}
loggedIn={user.loggedIn}
currentUserRole={user.role}
/>
{showCurrentVisitors && ( {showCurrentVisitors && (
<CurrentVisitors tooltipBoundaryRef={leftActionsRef} /> <CurrentVisitors tooltipBoundaryRef={leftActionsRef} />
)} )}

View File

@ -1,283 +0,0 @@
/**
* @prettier
*/
import React from 'react'
import { Transition } from '@headlessui/react'
import { Cog8ToothIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
function Favicon({ domain, className }) {
return (
<img
alt=""
src={`/favicon/sources/${encodeURIComponent(domain)}`}
onError={(e) => {
e.target.onerror = null
e.target.src = '/favicon/sources/placeholder'
}}
referrerPolicy="no-referrer"
className={className}
/>
)
}
export default class SiteSwitcher extends React.Component {
constructor() {
super()
this.handleClick = this.handleClick.bind(this)
this.handleKeydown = this.handleKeydown.bind(this)
this.populateSites = this.populateSites.bind(this)
this.toggle = this.toggle.bind(this)
this.siteSwitcherButton = React.createRef()
this.state = {
open: false,
sites: null,
error: null,
loading: true
}
}
componentDidMount() {
this.populateSites()
this.siteSwitcherButton.current.addEventListener('click', this.toggle)
document.addEventListener('keydown', this.handleKeydown)
document.addEventListener('click', this.handleClick, false)
}
componentWillUnmount() {
this.siteSwitcherButton.current.removeEventListener('click', this.toggle)
document.removeEventListener('keydown', this.handleKeydown)
document.removeEventListener('click', this.handleClick, false)
}
populateSites() {
if (!this.props.loggedIn) return
fetch('/api/sites')
.then((response) => {
if (!response.ok) {
throw response
}
return response.json()
})
.then((sites) =>
this.setState({
loading: false,
sites: sites.data.map((s) => s.domain)
})
)
.catch((e) => this.setState({ loading: false, error: e }))
}
handleClick(e) {
// If this is an interaction with the dropdown menu itself, do nothing.
if (this.dropDownNode && this.dropDownNode.contains(e.target)) return
// If the dropdown is not open, do nothing.
if (!this.state.open) return
// In any other case, close it.
this.setState({ open: false })
}
handleKeydown(e) {
if (!this.props.loggedIn) return
const { site } = this.props
const { sites } = this.state
if (e.target.tagName === 'INPUT') return true
if (
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.isComposing ||
e.keyCode === 229 ||
!sites
)
return
const siteNum = parseInt(e.key)
if (
1 <= siteNum &&
siteNum <= 9 &&
siteNum <= sites.length &&
sites[siteNum - 1] !== site.domain
) {
// has to change window.location because Router is rendered with /${site.domain} as the basepath
window.location = `/${encodeURIComponent(sites[siteNum - 1])}`
}
}
toggle(e) {
/**
* React doesn't seem to prioritise its own events when events are bubbling, and is unable to stop its events from propagating to the document's (root) event listeners which are attached on the DOM.
*
* A simple trick is to hook up our own click event listener via a ref node, which allows React to manage events in this situation better between the two.
*/
e.stopPropagation()
e.preventDefault()
if (!this.props.loggedIn) return
this.setState((prevState) => ({
open: !prevState.open
}))
if (this.props.loggedIn && !this.state.sites) {
this.populateSites()
}
}
renderSiteLink(domain, index) {
const extraClass =
domain === this.props.site.domain
? 'font-medium text-gray-900 dark:text-gray-100 cursor-default font-bold'
: 'hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100'
const showHotkey = this.props.loggedIn && this.state.sites.length > 1
return (
<a
href={
domain === this.props.site.domain
? null
: `/${encodeURIComponent(domain)}`
}
key={domain}
className={`flex items-center justify-between truncate px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 ${extraClass}`}
>
<span>
<Favicon
domain={domain}
className="inline w-4 mr-2 align-middle"
></Favicon>
<span className="truncate inline-block align-middle max-w-3xs pr-2">
{domain}
</span>
</span>
{showHotkey && index < 9 ? <span>{index + 1}</span> : null}
</a>
)
}
renderSettingsLink() {
if (
['owner', 'admin', 'editor', 'super_admin'].includes(
this.props.currentUserRole
)
) {
return (
<React.Fragment>
<div className="py-1">
<a
href={`/${encodeURIComponent(
this.props.site.domain
)}/settings/general`}
className="group flex items-center px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100"
role="menuitem"
>
<Cog8ToothIcon className="mr-2 h-4 w-4 text-gray-500 dark:text-gray-200 group-hover:text-gray-600 dark:group-hover:text-gray-400 group-focus:text-gray-500 dark:group-focus:text-gray-200" />
Site Settings
</a>
</div>
<div className="border-t border-gray-200 dark:border-gray-500"></div>
</React.Fragment>
)
}
}
/**
* Render a dropdown regardless of whether the user is logged in or not. In case they are not logged in (such as in an embed), the dropdown merely contains the current domain name.
*/
renderDropdown() {
if (this.state.loading) {
return (
<div className="px-4 py-6">
<div className="loading sm mx-auto">
<div></div>
</div>
</div>
)
} else if (this.state.error) {
return (
<div className="mx-auto px-4 py-6 dark:text-gray-100">
Something went wrong, try again
</div>
)
} else if (!this.props.loggedIn) {
return (
<React.Fragment>
<div className="py-1">
{[this.props.site.domain].map(this.renderSiteLink.bind(this))}
</div>
</React.Fragment>
)
} else {
return (
<React.Fragment>
{this.renderSettingsLink()}
<div className="py-1">
{this.state.sites.map(this.renderSiteLink.bind(this))}
</div>
<div className="border-t border-gray-200 dark:border-gray-500"></div>
<a
href="/sites"
className="flex px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100"
role="menuitem"
>
View All
</a>
</React.Fragment>
)
}
}
render() {
const hoverClass = this.props.loggedIn
? 'hover:text-gray-500 dark:hover:text-gray-200 focus:border-blue-300 focus:ring '
: 'cursor-default'
return (
<div
className={classNames(
'relative inline-block text-left shrink-0',
this.props.className
)}
>
<button
ref={this.siteSwitcherButton}
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 align-middle"
/>
<span className="hidden sm:inline-block ml-2">
{this.props.site.domain}
</span>
{this.props.loggedIn && (
<ChevronDownIcon className="ml-2 h-5 w-5 shrink-0 hidden sm:inline-block" />
)}
</button>
<Transition
show={this.state.open}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="origin-top-left absolute left-0 mt-2 w-64 rounded-md shadow-lg z-10"
ref={(node) => (this.dropDownNode = node)}
>
<div className="rounded-md bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
{this.renderDropdown()}
</div>
</div>
</Transition>
</div>
)
}
}

View File

@ -0,0 +1,204 @@
/**
* @prettier
*/
import React, { useRef } from 'react'
import { Popover, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { Cog8ToothIcon } from '@heroicons/react/24/outline'
import classNames from 'classnames'
import { isModifierPressed, isTyping, Keybind, KeybindHint } from './keybinding'
import { popover, BlurMenuButtonOnEscape } from './components/popover'
import { useQuery } from '@tanstack/react-query'
import { Role, useUserContext } from './user-context'
import { PlausibleSite, useSiteContext } from './site-context'
import { MenuSeparator } from './nav-menu/nav-menu-components'
import { useMatch } from 'react-router-dom'
import { rootRoute } from './router'
import { get } from './api'
import { ErrorPanel } from './components/error-panel'
import { useRoutelessModalsContext } from './navigation/routeless-modals-context'
const Favicon = ({
domain,
className
}: {
domain: string
className?: string
}) => (
<img
aria-hidden="true"
alt=""
src={`/favicon/sources/${encodeURIComponent(domain)}`}
onError={(e) => {
const target = e.target as HTMLImageElement
target.onerror = null
target.src = '/favicon/sources/placeholder'
}}
referrerPolicy="no-referrer"
className={className}
/>
)
const menuItemClassName = classNames(
popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink,
popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd
)
const getSwitchToSiteURL = (
currentSite: PlausibleSite,
site: { domain: string }
): string | null => {
// Prevents reloading the page when the current site is selected
if (currentSite.domain === site.domain) {
return null
}
return `/${encodeURIComponent(site.domain)}`
}
export const SiteSwitcher = () => {
const dashboardRouteMatch = useMatch(rootRoute.path)
const { modal } = useRoutelessModalsContext()
const user = useUserContext()
const currentSite = useSiteContext()
const buttonRef = useRef<HTMLButtonElement>(null)
const sitesQuery = useQuery({
enabled: user.loggedIn,
queryKey: ['sites'],
queryFn: async (): Promise<{ data: Array<{ domain: string }> }> => {
const response = await get('/api/sites')
return response
},
placeholderData: (previousData) => previousData
})
const sitesInDropdown = user.loggedIn
? sitesQuery.data?.data
: // show only current site in dropdown when viewing public / embedded dashboard
[{ domain: currentSite.domain }]
const canSeeSiteSettings: boolean =
user.loggedIn &&
[Role.owner, Role.admin, Role.editor, 'super_admin'].includes(user.role)
const canSeeViewAllSites: boolean = user.loggedIn
return (
<Popover className="md:relative">
{({ close: closePopover }) => (
<>
{!!dashboardRouteMatch &&
!modal &&
sitesQuery.data?.data.slice(0, 8).map(({ domain }, index) => (
<Keybind
key={domain}
keyboardKey={`${index + 1}`}
type="keydown"
handler={() => {
const url = getSwitchToSiteURL(currentSite, { domain })
if (!url) {
closePopover()
} else {
closePopover()
window.location.assign(url)
}
}}
shouldIgnoreWhen={[isModifierPressed, isTyping]}
targetRef="document"
/>
))}
<BlurMenuButtonOnEscape targetRef={buttonRef} />
<Popover.Button
ref={buttonRef}
className={classNames(
'flex items-center rounded h-9 leading-5 font-bold dark:text-gray-100',
'hover:bg-gray-100 dark:hover:bg-gray-800'
)}
title={currentSite.domain}
>
<Favicon
domain={currentSite.domain}
className="block h-4 w-4 mx-1"
/>
<span className={'truncate hidden sm:block sm:mr-1 lg:mr-0'}>
{currentSite.domain}
</span>
<ChevronDownIcon className="hidden lg:block h-5 w-5 ml-2 dark:text-gray-100" />
</Popover.Button>
<Transition
as="div"
{...popover.transition.props}
className={classNames(
popover.transition.classNames.fullwidth,
'mt-2 md:w-80 md:right-auto md:origin-top-left'
)}
>
<Popover.Panel
data-testid="sitemenu"
className={classNames(popover.panel.classNames.roundedSheet)}
>
{canSeeSiteSettings && (
<>
<a
className={menuItemClassName}
href={`/${encodeURIComponent(currentSite.domain)}/settings/general`}
>
<Cog8ToothIcon className="h-4 w-4 block mr-2" />
<span className="mr-auto">Site settings</span>
</a>
<MenuSeparator />
</>
)}
{sitesQuery.isLoading && (
<div className="px-3 py-2">
<div className="loading sm">
<div />
</div>
</div>
)}
{sitesQuery.isError && (
<div className="px-3 py-2">
<ErrorPanel
errorMessage={'Error loading sites'}
onClose={sitesQuery.refetch}
/>
</div>
)}
{!!sitesInDropdown &&
sitesInDropdown.map(({ domain }, index) => (
<a
data-selected={currentSite.domain === domain}
key={domain}
className={menuItemClassName}
href={getSwitchToSiteURL(currentSite, { domain }) ?? '#'}
onClick={
currentSite.domain === domain
? () => closePopover()
: () => {}
}
>
<Favicon domain={domain} className="h-4 w-4 block mr-2" />
<span className="truncate mr-auto">{domain}</span>
{sitesInDropdown.length > 1 && (
<KeybindHint>{index + 1}</KeybindHint>
)}
</a>
))}
{canSeeViewAllSites && (
<>
<MenuSeparator />
<a className={menuItemClassName} href={`/sites`}>
View all
</a>
</>
)}
</Popover.Panel>
</Transition>
</>
)}
</Popover>
)
}

View File

@ -1,13 +1,4 @@
import React, { import React, { useState, useEffect, useCallback } from 'react'
Fragment,
useState,
useEffect,
useCallback,
useRef
} from 'react'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from '../../util/storage' import * as storage from '../../util/storage'
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
import GoalConversions, { import GoalConversions, {
@ -20,7 +11,7 @@ import { hasConversionGoalFilter } from '../../util/filters'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useUserContext } from '../../user-context' import { useUserContext } from '../../user-context'
import { BlurMenuButtonOnEscape } from '../../keybinding' import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
/*global require*/ /*global require*/
@ -35,10 +26,6 @@ function maybeRequire() {
const Funnel = maybeRequire().default const Funnel = maybeRequire().default
const ACTIVE_CLASS =
'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
export const CONVERSIONS = 'conversions' export const CONVERSIONS = 'conversions'
export const PROPS = 'props' export const PROPS = 'props'
export const FUNNELS = 'funnels' export const FUNNELS = 'funnels'
@ -53,7 +40,6 @@ export default function Behaviours({ importedDataInView }) {
const { query } = useQueryContext() const { query } = useQueryContext()
const site = useSiteContext() const site = useSiteContext()
const user = useUserContext() const user = useUserContext()
const buttonRef = useRef()
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes( const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(
user.role user.role
) )
@ -66,9 +52,6 @@ export default function Behaviours({ importedDataInView }) {
const [mode, setMode] = useState(defaultMode()) const [mode, setMode] = useState(defaultMode())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [funnelNames, _setFunnelNames] = useState(
site.funnels.map(({ name }) => name)
)
const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel()) const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] =
@ -117,12 +100,12 @@ export default function Behaviours({ importedDataInView }) {
) )
} }
function setFunnel(selectedFunnel) { function setFunnelFactory(selectedFunnelName) {
return () => { return () => {
storage.setItem(tabKey, FUNNELS) storage.setItem(tabKey, FUNNELS)
storage.setItem(funnelKey, selectedFunnel) storage.setItem(funnelKey, selectedFunnelName)
setMode(FUNNELS) setMode(FUNNELS)
setSelectedFunnel(selectedFunnel) setSelectedFunnel(selectedFunnelName)
} }
} }
@ -140,96 +123,11 @@ export default function Behaviours({ importedDataInView }) {
} }
} }
function hasFunnels() { function setTabFactory(tab) {
return site.funnels.length > 0 && site.funnelsAvailable return () => {
} storage.setItem(tabKey, tab)
setMode(tab)
function tabFunnelPicker() {
return (
<Menu as="div" className="relative inline-block text-left">
<BlurMenuButtonOnEscape targetRef={buttonRef} />
<div>
<Menu.Button
ref={buttonRef}
className="inline-flex justify-between focus:outline-none"
>
<span className={mode == FUNNELS ? ACTIVE_CLASS : DEFAULT_CLASS}>
Funnels
</span>
<ChevronDownIcon
className="-mr-1 ml-1 h-4 w-4"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="text-left origin-top-right absolute right-0 mt-2 w-96 max-h-72 overflow-auto rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="py-1">
{funnelNames.map((funnelName) => {
return (
<Menu.Item key={funnelName}>
{({ active }) => (
<span
onClick={setFunnel(funnelName)}
className={classNames(
active
? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer'
: 'text-gray-700 dark:text-gray-200',
'block px-4 py-2 text-sm',
mode === FUNNELS && selectedFunnel === funnelName
? 'font-bold text-gray-500'
: ''
)}
>
{funnelName}
</span>
)}
</Menu.Item>
)
})}
</div>
</Menu.Items>
</Transition>
</Menu>
)
}
function tabSwitcher(toMode, displayName) {
const className = classNames({
[ACTIVE_CLASS]: mode == toMode,
[DEFAULT_CLASS]: mode !== toMode
})
const setTab = () => {
storage.setItem(tabKey, toMode)
setMode(toMode)
} }
return (
<div className={className} onClick={setTab}>
{displayName}
</div>
)
}
function tabs() {
return (
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
{isEnabled(FUNNELS) &&
Funnel &&
(hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
</div>
)
} }
function afterFetchData(apiResponse) { function afterFetchData(apiResponse) {
@ -443,24 +341,63 @@ export default function Behaviours({ importedDataInView }) {
} }
} }
if (mode) { if (!mode) {
return (
<div className="items-start justify-between block w-full mt-6 md:flex">
<div className="w-full p-4 bg-white rounded shadow-xl dark:bg-gray-825">
<div className="flex justify-between w-full">
<div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">
{sectionTitle() + (isRealtime() ? ' (last 30min)' : '')}
</h3>
{renderImportedQueryUnsupportedWarning()}
</div>
{tabs()}
</div>
{renderContent()}
</div>
</div>
)
} else {
return null return null
} }
return (
<div className="items-start justify-between block w-full mt-6 md:flex relative">
<div className="w-full p-4 bg-white rounded shadow-xl dark:bg-gray-825">
<div className="flex justify-between w-full">
<div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">
{sectionTitle() + (isRealtime() ? ' (last 30min)' : '')}
</h3>
{renderImportedQueryUnsupportedWarning()}
</div>
<TabWrapper>
{isEnabled(CONVERSIONS) && (
<TabButton
active={mode === CONVERSIONS}
onClick={setTabFactory(CONVERSIONS)}
>
Goals
</TabButton>
)}
{isEnabled(PROPS) && (
<TabButton active={mode === PROPS} onClick={setTabFactory(PROPS)}>
Properties
</TabButton>
)}
{isEnabled(FUNNELS) &&
Funnel &&
(site.funnels.length > 0 && site.funnelsAvailable ? (
<DropdownTabButton
className="md:relative"
transitionClassName="md:left-auto md:w-96 md:origin-top-right"
active={mode === FUNNELS}
options={site.funnels.map(({ name }) => ({
label: name,
onClick: setFunnelFactory(name),
selected: mode === FUNNELS && selectedFunnel === name
}))}
collectionTitle="Funnels"
searchable={true}
>
Funnels
</DropdownTabButton>
) : (
<TabButton
active={mode === FUNNELS}
onClick={setTabFactory(FUNNELS)}
>
Funnels
</TabButton>
))}
</TabWrapper>
</div>
{renderContent()}
</div>
</div>
)
} }

View File

@ -19,6 +19,7 @@ import {
operatingSystemVersionsRoute, operatingSystemVersionsRoute,
screenSizesRoute screenSizesRoute
} from '../../router' } from '../../router'
import { TabButton, TabWrapper } from '../../components/tabs'
// Icons copied from https://github.com/alrra/browser-logos // Icons copied from https://github.com/alrra/browser-logos
const BROWSER_ICONS = { const BROWSER_ICONS = {
@ -430,27 +431,6 @@ export default function Devices() {
} }
} }
function renderPill(name, pill) {
const isActive = mode === pill
if (isActive) {
return (
<button className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500">
{name}
</button>
)
}
return (
<button
className="cursor-pointer hover:text-indigo-600"
onClick={() => switchTab(pill)}
>
{name}
</button>
)
}
return ( return (
<div> <div>
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">
@ -461,11 +441,21 @@ export default function Devices() {
skipImportedReason={skipImportedReason} skipImportedReason={skipImportedReason}
/> />
</div> </div>
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2"> <TabWrapper>
{renderPill('Browser', 'browser')} {[
{renderPill('OS', 'os')} { label: 'Browser', value: 'browser' },
{renderPill('Size', 'size')} { label: 'OS', value: 'os' },
</div> { label: 'Size', value: 'size' }
].map(({ label, value }) => (
<TabButton
key={value}
active={mode === value}
onClick={() => switchTab(value)}
>
{label}
</TabButton>
))}
</TabWrapper>
</div> </div>
{renderContent()} {renderContent()}
</div> </div>

View File

@ -1,173 +0,0 @@
import React, { Fragment, useRef } from 'react'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from '../../util/storage'
import {
BlurMenuButtonOnEscape,
isModifierPressed,
isTyping,
Keybind
} from '../../keybinding'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { useMatch } from 'react-router-dom'
import { rootRoute } from '../../router'
import { popover } from '../../components/popover'
const INTERVAL_LABELS = {
minute: 'Minutes',
hour: 'Hours',
day: 'Days',
week: 'Weeks',
month: 'Months'
}
function validIntervals(site, query) {
if (query.period === 'custom') {
if (query.to.diff(query.from, 'days') < 7) {
return ['day']
} else if (query.to.diff(query.from, 'months') < 1) {
return ['day', 'week']
} else if (query.to.diff(query.from, 'months') < 12) {
return ['day', 'week', 'month']
} else {
return ['week', 'month']
}
} else {
return site.validIntervalsByPeriod[query.period]
}
}
function getDefaultInterval(query, validIntervals) {
const defaultByPeriod = {
day: 'hour',
'7d': 'day',
'6mo': 'month',
'12mo': 'month',
year: 'month'
}
if (query.period === 'custom') {
return defaultForCustomPeriod(query.from, query.to)
} else {
return defaultByPeriod[query.period] || validIntervals[0]
}
}
function defaultForCustomPeriod(from, to) {
if (to.diff(from, 'days') < 30) {
return 'day'
} else if (to.diff(from, 'months') < 6) {
return 'week'
} else {
return 'month'
}
}
function getStoredInterval(period, domain) {
const stored = storage.getItem(`interval__${period}__${domain}`)
if (stored === 'date') {
return 'day'
} else {
return stored
}
}
function storeInterval(period, domain, interval) {
storage.setItem(`interval__${period}__${domain}`, interval)
}
export const getCurrentInterval = function (site, query) {
const options = validIntervals(site, query)
const storedInterval = getStoredInterval(query.period, site.domain)
const defaultInterval = getDefaultInterval(query, options)
if (storedInterval && options.includes(storedInterval)) {
return storedInterval
} else {
return defaultInterval
}
}
export function IntervalPicker({ onIntervalUpdate }) {
const menuElement = useRef(null)
const { query } = useQueryContext()
const site = useSiteContext()
const dashboardRouteMatch = useMatch(rootRoute.path)
if (query.period == 'realtime') return null
const options = validIntervals(site, query)
const currentInterval = getCurrentInterval(site, query)
function updateInterval(interval) {
storeInterval(query.period, site.domain, interval)
onIntervalUpdate(interval)
}
function renderDropdownItem(option) {
return (
<Menu.Item
onClick={() => updateInterval(option)}
key={option}
disabled={option == currentInterval}
>
{({ active }) => (
<span
className={classNames(
{
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer':
active,
'text-gray-700 dark:text-gray-200': !active,
'font-bold cursor-none select-none': option == currentInterval
},
'block px-4 py-2 text-sm'
)}
>
{INTERVAL_LABELS[option]}
</span>
)}
</Menu.Item>
)
}
return (
<Menu as="div" className="relative inline-block pl-2">
{({ open }) => (
<>
{!!dashboardRouteMatch && (
<Keybind
targetRef="document"
type="keydown"
keyboardKey="i"
handler={() => {
menuElement.current?.click()
}}
shouldIgnoreWhen={[isModifierPressed, isTyping]}
/>
)}
<BlurMenuButtonOnEscape targetRef={menuElement} />
<Menu.Button
ref={menuElement}
className="text-sm inline-flex focus:outline-none text-gray-700 dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-600 items-center"
>
{INTERVAL_LABELS[currentInterval]}
<ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" />
</Menu.Button>
<Transition as={Fragment} show={open} {...popover.transition.props}>
<Menu.Items
className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
static
>
{options.map(renderDropdownItem)}
</Menu.Items>
</Transition>
</>
)}
</Menu>
)
}

View File

@ -0,0 +1,190 @@
import React, { useRef } from 'react'
import { Popover, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from '../../util/storage'
import { isModifierPressed, isTyping, Keybind } from '../../keybinding'
import { useQueryContext } from '../../query-context'
import { useSiteContext, PlausibleSite } from '../../site-context'
import { useMatch } from 'react-router-dom'
import { rootRoute } from '../../router'
import { BlurMenuButtonOnEscape, popover } from '../../components/popover'
import { DashboardQuery } from '../../query'
import { Dayjs } from 'dayjs'
import { QueryPeriod } from '../../query-time-periods'
const INTERVAL_LABELS: Record<string, string> = {
minute: 'Minutes',
hour: 'Hours',
day: 'Days',
week: 'Weeks',
month: 'Months'
}
function validIntervals(site: PlausibleSite, query: DashboardQuery): string[] {
if (query.period === QueryPeriod.custom && query.from && query.to) {
if (query.to.diff(query.from, 'days') < 7) {
return ['day']
} else if (query.to.diff(query.from, 'months') < 1) {
return ['day', 'week']
} else if (query.to.diff(query.from, 'months') < 12) {
return ['day', 'week', 'month']
} else {
return ['week', 'month']
}
} else {
return site.validIntervalsByPeriod[query.period]
}
}
function getDefaultInterval(
query: DashboardQuery,
validIntervals: string[]
): string {
const defaultByPeriod: Record<string, string> = {
day: 'hour',
'7d': 'day',
'6mo': 'month',
'12mo': 'month',
year: 'month'
}
if (query.period === QueryPeriod.custom && query.from && query.to) {
return defaultForCustomPeriod(query.from, query.to)
} else {
return defaultByPeriod[query.period] || validIntervals[0]
}
}
function defaultForCustomPeriod(from: Dayjs, to: Dayjs): string {
if (to.diff(from, 'days') < 30) {
return 'day'
} else if (to.diff(from, 'months') < 6) {
return 'week'
} else {
return 'month'
}
}
function getStoredInterval(period: string, domain: string): string | null {
const stored = storage.getItem(`interval__${period}__${domain}`)
if (stored === 'date') {
return 'day'
} else {
return stored
}
}
function storeInterval(period: string, domain: string, interval: string): void {
storage.setItem(`interval__${period}__${domain}`, interval)
}
export const getCurrentInterval = function (
site: PlausibleSite,
query: DashboardQuery
): string {
const options = validIntervals(site, query)
const storedInterval = getStoredInterval(query.period, site.domain)
const defaultInterval = getDefaultInterval(query, options)
if (storedInterval && options.includes(storedInterval)) {
return storedInterval
} else {
return defaultInterval
}
}
export function IntervalPicker({
onIntervalUpdate
}: {
onIntervalUpdate: (interval: string) => void
}): JSX.Element | null {
const menuElement = useRef<HTMLButtonElement>(null)
const { query } = useQueryContext()
const site = useSiteContext()
const dashboardRouteMatch = useMatch(rootRoute.path)
if (query.period == 'realtime') {
return null
}
const options = validIntervals(site, query)
const currentInterval = getCurrentInterval(site, query)
function updateInterval(interval: string): void {
storeInterval(query.period, site.domain, interval)
onIntervalUpdate(interval)
}
return (
<>
{!!dashboardRouteMatch && (
<Keybind
targetRef="document"
type="keydown"
keyboardKey="i"
handler={() => {
menuElement.current?.click()
}}
shouldIgnoreWhen={[isModifierPressed, isTyping]}
/>
)}
<Popover className="relative inline-block pl-2">
{({ close: closeDropdown }) => (
<>
<BlurMenuButtonOnEscape targetRef={menuElement} />
<Popover.Button
ref={menuElement}
className={classNames(
popover.toggleButton.classNames.linkLike,
'rounded-sm text-sm flex items-center'
)}
>
{INTERVAL_LABELS[currentInterval]}
<ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as="div"
{...popover.transition.props}
className={classNames(
popover.transition.classNames.right,
'mt-2 w-56'
)}
>
<Popover.Panel
className={classNames(
popover.panel.classNames.roundedSheet,
'font-normal'
)}
>
{options.map((option) => (
<button
key={option}
onClick={() => {
updateInterval(option)
closeDropdown()
}}
data-selected={option == currentInterval}
className={classNames(
popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink,
popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd,
'w-full text-left'
)}
>
{INTERVAL_LABELS[option]}
</button>
))}
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</>
)
}

View File

@ -15,6 +15,7 @@ import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warni
import { citiesRoute, countriesRoute, regionsRoute } from '../../router' import { citiesRoute, countriesRoute, regionsRoute } from '../../router'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { TabButton, TabWrapper } from '../../components/tabs'
function Countries({ query, site, onClick, afterFetchData }) { function Countries({ query, site, onClick, afterFetchData }) {
function fetchData() { function fetchData() {
@ -244,27 +245,6 @@ class Locations extends React.Component {
} }
} }
renderPill(name, mode) {
const isActive = this.state.mode === mode
if (isActive) {
return (
<button className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading">
{name}
</button>
)
}
return (
<button
className="hover:text-indigo-600 cursor-pointer"
onClick={this.setMode(mode)}
>
{name}
</button>
)
}
render() { render() {
return ( return (
<div> <div>
@ -278,12 +258,22 @@ class Locations extends React.Component {
skipImportedReason={this.state.skipImportedReason} skipImportedReason={this.state.skipImportedReason}
/> />
</div> </div>
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2"> <TabWrapper>
{this.renderPill('Map', 'map')} {[
{this.renderPill('Countries', 'countries')} { label: 'Map', value: 'map' },
{this.renderPill('Regions', 'regions')} { label: 'Countries', value: 'countries' },
{this.renderPill('Cities', 'cities')} { label: 'Regions', value: 'regions' },
</div> { label: 'Cities', value: 'cities' }
].map(({ value, label }) => (
<TabButton
key={value}
onClick={this.setMode(value)}
active={this.state.mode === value}
>
{label}
</TabButton>
))}
</TabWrapper>
</div> </div>
{this.renderContent()} {this.renderContent()}
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react' import React, { ReactNode, useRef } from 'react'
import { SearchInput } from '../../components/search-input' import { SearchInput } from '../../components/search-input'
import { ColumnConfiguraton, Table } from '../../components/table' import { ColumnConfiguraton, Table } from '../../components/table'
@ -34,36 +34,41 @@ export const BreakdownTable = <TListItem extends { name: string }>({
error?: Error | null error?: Error | null
/** Controls whether the component displays API request errors or ignores them. */ /** Controls whether the component displays API request errors or ignores them. */
displayError?: boolean displayError?: boolean
}) => ( }) => {
<div className="w-full h-full"> const searchRef = useRef<HTMLInputElement>(null)
<div className="flex justify-between items-center">
<div className="flex items-center gap-x-2"> return (
<h1 className="text-xl font-bold dark:text-gray-100">{title}</h1> <div className="w-full h-full">
{!isPending && isFetching && <SmallLoadingSpinner />} <div className="flex justify-between items-center">
<div className="flex items-center gap-x-2">
<h1 className="text-xl font-bold dark:text-gray-100">{title}</h1>
{!isPending && isFetching && <SmallLoadingSpinner />}
</div>
{!!onSearch && (
<SearchInput
searchRef={searchRef}
onSearch={onSearch}
className={
displayError && status === 'error' ? 'pointer-events-none' : ''
}
/>
)}
</div>
<div className="my-4 border-b border-gray-300"></div>
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
{displayError && status === 'error' && <ErrorMessage error={error} />}
{isPending && <InitialLoadingSpinner />}
{data && <Table<TListItem> data={data} columns={columns} />}
{!isPending && !isFetching && hasNextPage && (
<LoadMore
onClick={() => fetchNextPage()}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</div> </div>
{!!onSearch && (
<SearchInput
onSearch={onSearch}
className={
displayError && status === 'error' ? 'pointer-events-none' : ''
}
/>
)}
</div> </div>
<div className="my-4 border-b border-gray-300"></div> )
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}> }
{displayError && status === 'error' && <ErrorMessage error={error} />}
{isPending && <InitialLoadingSpinner />}
{data && <Table<TListItem> data={data} columns={columns} />}
{!isPending && !isFetching && hasNextPage && (
<LoadMore
onClick={() => fetchNextPage()}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</div>
</div>
)
const InitialLoadingSpinner = () => ( const InitialLoadingSpinner = () => (
<div <div

View File

@ -10,6 +10,7 @@ import { hasConversionGoalFilter } from '../../util/filters'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { entryPagesRoute, exitPagesRoute, topPagesRoute } from '../../router' import { entryPagesRoute, exitPagesRoute, topPagesRoute } from '../../router'
import { TabButton, TabWrapper } from '../../components/tabs'
function EntryPages({ afterFetchData }) { function EntryPages({ afterFetchData }) {
const { query } = useQueryContext() const { query } = useQueryContext()
@ -185,27 +186,6 @@ export default function Pages() {
} }
} }
function renderPill(name, pill) {
const isActive = mode === pill
if (isActive) {
return (
<button className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading">
{name}
</button>
)
}
return (
<button
className="hover:text-indigo-600 cursor-pointer"
onClick={() => switchTab(pill)}
>
{name}
</button>
)
}
return ( return (
<div> <div>
{/* Header Container */} {/* Header Container */}
@ -219,11 +199,21 @@ export default function Pages() {
skipImportedReason={skipImportedReason} skipImportedReason={skipImportedReason}
/> />
</div> </div>
<div className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2"> <TabWrapper>
{renderPill('Top Pages', 'pages')} {[
{renderPill('Entry Pages', 'entry-pages')} { label: 'Top Pages', value: 'pages' },
{renderPill('Exit Pages', 'exit-pages')} { label: 'Entry Pages', value: 'entry-pages' },
</div> { label: 'Exit Pages', value: 'exit-pages' }
].map(({ value, label }) => (
<TabButton
active={mode === value}
onClick={() => switchTab(value)}
key={value}
>
{label}
</TabButton>
))}
</TabWrapper>
</div> </div>
{/* Main Contents */} {/* Main Contents */}
{renderContent()} {renderContent()}

View File

@ -1,4 +1,4 @@
import React, { Fragment, useEffect, useRef, useState } from 'react' import React, { useEffect, useState } from 'react'
import * as storage from '../../util/storage' import * as storage from '../../util/storage'
import * as url from '../../util/url' import * as url from '../../util/url'
@ -10,9 +10,6 @@ import {
getFiltersByKeyPrefix, getFiltersByKeyPrefix,
hasConversionGoalFilter hasConversionGoalFilter
} from '../../util/filters' } from '../../util/filters'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
@ -25,7 +22,7 @@ import {
utmSourcesRoute, utmSourcesRoute,
utmTermsRoute utmTermsRoute
} from '../../router' } from '../../router'
import { BlurMenuButtonOnEscape } from '../../keybinding' import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
const UTM_TAGS = { const UTM_TAGS = {
utm_medium: { utm_medium: {
@ -197,7 +194,6 @@ export default function SourceList() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [skipImportedReason, setSkipImportedReason] = useState(null) const [skipImportedReason, setSkipImportedReason] = useState(null)
const previousQuery = usePrevious(query) const previousQuery = usePrevious(query)
const dropdownButtonRef = useRef(null)
useEffect(() => setLoading(true), [query, currentTab]) useEffect(() => setLoading(true), [query, currentTab])
@ -224,105 +220,24 @@ export default function SourceList() {
} }
} }
function renderTabs() {
const activeClass =
'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
const defaultClass =
'hover:text-indigo-600 cursor-pointer truncate text-left'
const dropdownOptions = Object.keys(UTM_TAGS)
let buttonText = UTM_TAGS[currentTab]
? UTM_TAGS[currentTab].title
: 'Campaigns'
return (
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
<div
className={currentTab === 'channels' ? activeClass : defaultClass}
onClick={setTab('channels')}
>
Channels
</div>
<div
className={currentTab === 'all' ? activeClass : defaultClass}
onClick={setTab('all')}
>
Sources
</div>
<Menu as="div" className="relative inline-block text-left">
<BlurMenuButtonOnEscape targetRef={dropdownButtonRef} />
<div>
<Menu.Button
className="inline-flex justify-between focus:outline-none"
ref={dropdownButtonRef}
>
<span
className={
currentTab.startsWith('utm_') ? activeClass : defaultClass
}
>
{buttonText}
</span>
<ChevronDownIcon
className="-mr-1 ml-1 h-4 w-4"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="py-1">
{dropdownOptions.map((option) => {
return (
<Menu.Item key={option}>
{({ active }) => (
<span
onClick={setTab(option)}
className={classNames(
active
? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer'
: 'text-gray-700 dark:text-gray-200',
'block px-4 py-2 text-sm',
currentTab === option ? 'font-bold' : ''
)}
>
{UTM_TAGS[option].title}
</span>
)}
</Menu.Item>
)
})}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
)
}
function onChannelClick() { function onChannelClick() {
setTab('all')() setTab('all')()
} }
function renderContent() { function renderContent() {
if (currentTab === 'all') { if (Object.keys(UTM_TAGS).includes(currentTab)) {
return <AllSources afterFetchData={afterFetchData} />
} else if (currentTab == 'channels') {
return (
<Channels onClick={onChannelClick} afterFetchData={afterFetchData} />
)
} else {
return <UTMSources tab={currentTab} afterFetchData={afterFetchData} /> return <UTMSources tab={currentTab} afterFetchData={afterFetchData} />
} }
switch (currentTab) {
case 'channels':
return (
<Channels onClick={onChannelClick} afterFetchData={afterFetchData} />
)
case 'all':
default:
return <AllSources afterFetchData={afterFetchData} />
}
} }
function afterFetchData(apiResponse) { function afterFetchData(apiResponse) {
@ -343,7 +258,33 @@ export default function SourceList() {
skipImportedReason={skipImportedReason} skipImportedReason={skipImportedReason}
/> />
</div> </div>
{renderTabs()} <TabWrapper>
{[
{ value: 'channels', label: 'Channels' },
{ value: 'all', label: 'Sources' }
].map(({ value, label }) => (
<TabButton
key={value}
onClick={setTab(value)}
active={currentTab === value}
>
{label}
</TabButton>
))}
<DropdownTabButton
className="md:relative"
transitionClassName="md:left-auto md:w-56 md:origin-top-right"
active={Object.keys(UTM_TAGS).includes(currentTab)}
options={Object.entries(UTM_TAGS).map(([value, { title }]) => ({
value,
label: title,
onClick: setTab(value),
selected: currentTab === value
}))}
>
{UTM_TAGS[currentTab] ? UTM_TAGS[currentTab].title : 'Campaigns'}
</DropdownTabButton>
</TabWrapper>
</div> </div>
{/* Main Contents */} {/* Main Contents */}
{renderContent()} {renderContent()}

2294
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,8 @@
"generate-types": "json2ts ../priv/json-schemas/query-api-schema.json ../assets/js/types/query-api.d.ts --bannerComment '/* Autogenerated, recreate with `npm run --prefix assets generate-types` */'" "generate-types": "json2ts ../priv/json-schemas/query-api-schema.json ../assets/js/types/query-api.d.ts --bannerComment '/* Autogenerated, recreate with `npm run --prefix assets generate-types` */'"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.10", "@headlessui/react": "^1.7.19",
"@heroicons/react": "^2.0.11", "@heroicons/react": "^2.2.0",
"@jsonurl/jsonurl": "^1.1.7", "@jsonurl/jsonurl": "^1.1.7",
"@juggle/resize-observer": "^3.3.1", "@juggle/resize-observer": "^3.3.1",
"@popperjs/core": "^2.11.6", "@popperjs/core": "^2.11.6",
@ -60,18 +60,19 @@
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^28.11.0", "eslint-plugin-jest": "^28.11.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jsdom-testing-mocks": "^1.13.1",
"json-schema-to-typescript": "^15.0.2", "json-schema-to-typescript": "^15.0.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"stylelint": "^16.8.1", "stylelint": "^16.17.0",
"stylelint-config-standard": "^36.0.1", "stylelint-config-standard": "^36.0.1",
"ts-jest": "^29.2.4", "ts-jest": "^29.2.4",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.28.0" "typescript-eslint": "^8.29.0"
}, },
"name": "assets" "name": "assets"
} }

View File

@ -0,0 +1,5 @@
import { configMocks } from 'jsdom-testing-mocks'
import { act } from '@testing-library/react'
// as per jsdom-testing-mocks docs, this is needed to avoid having to wrap everything in act calls
configMocks({ act })

View File

@ -29,7 +29,7 @@ defmodule PlausibleWeb.Favicon do
import Plug.Conn import Plug.Conn
alias Plausible.HTTPClient alias Plausible.HTTPClient
@placeholder_icon_location "priv/placeholder_favicon.ico" @placeholder_icon_location "priv/placeholder_favicon.svg"
@placeholder_icon File.read!(@placeholder_icon_location) @placeholder_icon File.read!(@placeholder_icon_location)
@custom_icons %{ @custom_icons %{
"Brave" => "search.brave.com", "Brave" => "search.brave.com",
@ -65,8 +65,7 @@ defmodule PlausibleWeb.Favicon do
I'm not sure why DDG sometimes returns a broken PNG image in their response I'm not sure why DDG sometimes returns a broken PNG image in their response
but we filter that out. When the icon request fails, we show a placeholder but we filter that out. When the icon request fails, we show a placeholder
favicon instead. The placeholder is an emoji from favicon instead. The placeholder is an svg from [https://heroicons.com/](https://heroicons.com/).
[https://favicon.io/emoji-favicons/](https://favicon.io/emoji-favicons/)
DuckDuckGo favicon service has some issues with [SVG favicons](https://css-tricks.com/svg-favicons-and-all-the-fun-things-we-can-do-with-them/). DuckDuckGo favicon service has some issues with [SVG favicons](https://css-tricks.com/svg-favicons-and-all-the-fun-things-we-can-do-with-them/).
For some reason, they return them with `content-type=image/x-icon` whereas SVG For some reason, they return them with `content-type=image/x-icon` whereas SVG
@ -123,7 +122,7 @@ defmodule PlausibleWeb.Favicon do
defp send_placeholder(conn) do defp send_placeholder(conn) do
conn conn
|> put_resp_content_type("image/x-icon") |> put_resp_content_type("image/svg+xml")
|> put_resp_header("cache-control", "public, max-age=2592000") |> put_resp_header("cache-control", "public, max-age=2592000")
|> send_resp(200, @placeholder_icon) |> send_resp(200, @placeholder_icon)
|> halt |> halt

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="#64748b"><path d="M12.232 4.232a2.5 2.5 0 0 1 3.536 3.536l-1.225 1.224a.75.75 0 0 0 1.061 1.06l1.224-1.224a4 4 0 0 0-5.656-5.656l-3 3a4 4 0 0 0 .225 5.865.75.75 0 0 0 .977-1.138 2.5 2.5 0 0 1-.142-3.667l3-3Z"></path><path d="M11.603 7.963a.75.75 0 0 0-.977 1.138 2.5 2.5 0 0 1 .142 3.667l-3 3a2.5 2.5 0 0 1-3.536-3.536l1.225-1.224a.75.75 0 0 0-1.061-1.06l-1.224 1.224a4 4 0 1 0 5.656 5.656l3-3a4 4 0 0 0-.225-5.865Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -145,7 +145,7 @@ defmodule PlausibleWeb.FaviconTest do
end end
describe "Fallback to placeholder icon" do describe "Fallback to placeholder icon" do
@placholder_icon File.read!("priv/placeholder_favicon.ico") @placeholder_icon File.read!("priv/placeholder_favicon.svg")
test "falls back to placeholder when DDG returns a non-2xx response", %{plug_opts: plug_opts} do test "falls back to placeholder when DDG returns a non-2xx response", %{plug_opts: plug_opts} do
expect( expect(
@ -163,7 +163,7 @@ defmodule PlausibleWeb.FaviconTest do
assert conn.halted assert conn.halted
assert conn.status == 200 assert conn.status == 200
assert conn.resp_body == @placholder_icon assert conn.resp_body == @placeholder_icon
end end
test "falls back to placeholder in case of a network error", %{plug_opts: plug_opts} do test "falls back to placeholder in case of a network error", %{plug_opts: plug_opts} do
@ -181,7 +181,7 @@ defmodule PlausibleWeb.FaviconTest do
assert conn.halted assert conn.halted
assert conn.status == 200 assert conn.status == 200
assert conn.resp_body == @placholder_icon assert conn.resp_body == @placeholder_icon
end end
test "falls back to placeholder when DDG returns a broken image response", %{ test "falls back to placeholder when DDG returns a broken image response", %{
@ -201,7 +201,7 @@ defmodule PlausibleWeb.FaviconTest do
assert conn.halted assert conn.halted
assert conn.status == 200 assert conn.status == 200
assert conn.resp_body == @placholder_icon assert conn.resp_body == @placeholder_icon
end end
end end
end end