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:
parent
8f4b63083e
commit
429b055920
|
|
@ -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.
|
||||
|
||||
- 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
|
||||
|
||||
- Make clicking Compare / Disable Comparison in period picker menu close the menu
|
||||
|
|
|
|||
|
|
@ -281,19 +281,6 @@ iframe[hidden] {
|
|||
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. */
|
||||
/* stylelint-disable */
|
||||
/* prettier-ignore */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
},
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/test-utils/extend-expect.ts",
|
||||
"<rootDir>/test-utils/jsdom-mocks.ts",
|
||||
"<rootDir>/test-utils/reset-state.ts"
|
||||
],
|
||||
"transform": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { Fragment, useRef } from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
import {
|
||||
FILTER_OPERATIONS,
|
||||
|
|
@ -7,97 +7,88 @@ import {
|
|||
supportsIsNot,
|
||||
supportsHasDoneNot
|
||||
} from '../util/filters'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Transition, Popover } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import classNames from 'classnames'
|
||||
import { BlurMenuButtonOnEscape } from '../keybinding'
|
||||
import { popover, BlurMenuButtonOnEscape } from './popover'
|
||||
|
||||
export default function FilterOperatorSelector(props) {
|
||||
const filterName = props.forFilter
|
||||
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 (
|
||||
<div className={containerClass}>
|
||||
<Menu as="div" className="relative inline-block text-left w-full">
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={classNames('w-full', {
|
||||
'opacity-20 cursor-default pointer-events-none': props.isDisabled
|
||||
})}
|
||||
>
|
||||
<Popover className="relative w-full">
|
||||
{({ close: closeDropdown }) => (
|
||||
<>
|
||||
<BlurMenuButtonOnEscape targetRef={buttonRef} />
|
||||
<div className="w-full">
|
||||
<Menu.Button
|
||||
ref={buttonRef}
|
||||
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"
|
||||
<Popover.Button
|
||||
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"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
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"
|
||||
{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"
|
||||
/>
|
||||
</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)}
|
||||
{renderTypeItem(
|
||||
FILTER_OPERATIONS.isNot,
|
||||
supportsIsNot(filterName)
|
||||
)}
|
||||
{renderTypeItem(
|
||||
{[
|
||||
[FILTER_OPERATIONS.is, true],
|
||||
[FILTER_OPERATIONS.isNot, supportsIsNot(filterName)],
|
||||
[
|
||||
FILTER_OPERATIONS.has_not_done,
|
||||
supportsHasDoneNot(filterName)
|
||||
)}
|
||||
{renderTypeItem(
|
||||
FILTER_OPERATIONS.contains,
|
||||
supportsContains(filterName)
|
||||
)}
|
||||
{renderTypeItem(
|
||||
],
|
||||
[FILTER_OPERATIONS.contains, supportsContains(filterName)],
|
||||
[
|
||||
FILTER_OPERATIONS.contains_not,
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { TransitionClasses } from '@headlessui/react'
|
||||
import React, { RefObject } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { isModifierPressed, isTyping, Keybind } from '../keybinding'
|
||||
import { TransitionClasses } from '@headlessui/react'
|
||||
|
||||
const TRANSITION_CONFIG: TransitionClasses = {
|
||||
enter: 'transition ease-out duration-100',
|
||||
|
|
@ -11,8 +13,14 @@ const TRANSITION_CONFIG: TransitionClasses = {
|
|||
}
|
||||
|
||||
const transition = {
|
||||
props: TRANSITION_CONFIG,
|
||||
classNames: { fullwidth: 'z-10 absolute left-0 right-0' }
|
||||
props: {
|
||||
...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 = {
|
||||
|
|
@ -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',
|
||||
ghost:
|
||||
'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: {
|
||||
navigationLink: classNames(
|
||||
'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'),
|
||||
hoverLink: classNames(
|
||||
|
|
@ -51,15 +62,9 @@ const items = {
|
|||
'dark:focus-within:bg-gray-900',
|
||||
'dark:focus-within:text-gray-100'
|
||||
),
|
||||
roundedStartEnd: classNames(
|
||||
'first-of-type:rounded-t-md',
|
||||
'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'
|
||||
)
|
||||
roundedStart: 'first-of-type:rounded-t-md',
|
||||
roundedEnd: 'last-of-type:rounded-b-md',
|
||||
groupRoundedEnd: 'group-last-of-type:rounded-b-md'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,3 +74,32 @@ export const popover = {
|
|||
transition,
|
||||
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]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { useDebounce } from '../custom-hooks'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export const SearchInput = ({
|
||||
searchRef,
|
||||
onSearch,
|
||||
className,
|
||||
placeholderFocused = 'Search',
|
||||
placeholderUnfocused = 'Press / to search'
|
||||
}: {
|
||||
searchRef: RefObject<HTMLInputElement>
|
||||
onSearch: (value: string) => void
|
||||
className?: string
|
||||
placeholderFocused?: string
|
||||
placeholderUnfocused?: string
|
||||
}) => {
|
||||
const searchBoxRef = useRef<HTMLInputElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
|
||||
const onSearchInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
|
|
@ -26,13 +32,16 @@ export const SearchInput = ({
|
|||
const debouncedOnSearchInputChange = useDebounce(onSearchInputChange)
|
||||
|
||||
const blurSearchBox = useCallback(() => {
|
||||
searchBoxRef.current?.blur()
|
||||
}, [])
|
||||
searchRef.current?.blur()
|
||||
}, [searchRef])
|
||||
|
||||
const focusSearchBox = useCallback((event: KeyboardEvent) => {
|
||||
searchBoxRef.current?.focus()
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
const focusSearchBox = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
searchRef.current?.focus()
|
||||
event.stopPropagation()
|
||||
},
|
||||
[searchRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -41,7 +50,7 @@ export const SearchInput = ({
|
|||
type="keyup"
|
||||
handler={blurSearchBox}
|
||||
shouldIgnoreWhen={[isModifierPressed, () => !isFocused]}
|
||||
targetRef={searchBoxRef}
|
||||
targetRef={searchRef}
|
||||
/>
|
||||
<Keybind
|
||||
keyboardKey="/"
|
||||
|
|
@ -53,7 +62,7 @@ export const SearchInput = ({
|
|||
<input
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
ref={searchBoxRef}
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
|
||||
className={classNames(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { remapToApiFilters } from '../util/filters'
|
||||
import {
|
||||
formatSegmentIdAsLabelKey,
|
||||
getFilterSegmentsByNameInsensitive,
|
||||
getSearchToApplySingleSegmentFilter,
|
||||
getSegmentNamePlaceholder,
|
||||
isSegmentIdLabelKey,
|
||||
|
|
@ -17,36 +16,6 @@ import { Filter } from '../query'
|
|||
import { PlausibleSite } from '../site-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}`, () => {
|
||||
it('gives readable result', () => {
|
||||
const placeholder = getSegmentNamePlaceholder({
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
query: Pick<DashboardQuery, 'labels' | 'filters'>
|
||||
) =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -153,32 +153,3 @@ export function KeybindHint({
|
|||
</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]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ import { PlausibleSite, useSiteContext } from '../site-context'
|
|||
import { filterRoute } from '../router'
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { popover } from '../components/popover'
|
||||
import { popover, BlurMenuButtonOnEscape } from '../components/popover'
|
||||
import classNames from 'classnames'
|
||||
import { AppNavigationLink } from '../navigation/use-app-navigate'
|
||||
import { BlurMenuButtonOnEscape } from '../keybinding'
|
||||
import { SearchableSegmentsSection } from './segments/searchable-segments-section'
|
||||
|
||||
export function getFilterListItems({
|
||||
|
|
@ -67,15 +66,16 @@ const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => {
|
|||
</span>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as="div"
|
||||
{...popover.transition.props}
|
||||
className={classNames(
|
||||
'mt-2',
|
||||
popover.transition.classNames.fullwidth,
|
||||
'md:left-auto md:w-80'
|
||||
'mt-2 md:left-auto md:w-80 md:origin-top-right'
|
||||
)}
|
||||
>
|
||||
<Popover.Panel
|
||||
className={classNames(popover.panel.classNames.roundedSheet)}
|
||||
data-testid="filtermenu"
|
||||
>
|
||||
<div className="flex">
|
||||
{columns.map((filterGroups, index) => (
|
||||
|
|
|
|||
|
|
@ -5,24 +5,14 @@ import { TestContextProviders } from '../../../test-utils/app-context-providers'
|
|||
import { FiltersBar, handleVisibility } from './filters-bar'
|
||||
import { getRouterBasepath } from '../router'
|
||||
import { stringifySearch } from '../util/url-search-params'
|
||||
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
|
||||
|
||||
mockAnimationsApi()
|
||||
const resizeObserver = mockResizeObserver()
|
||||
|
||||
const domain = 'dummy.site'
|
||||
|
||||
beforeAll(() => {
|
||||
const mockResizeObserver = jest.fn(
|
||||
(handleEntries) =>
|
||||
({
|
||||
observe: jest.fn().mockImplementation((entry) => {
|
||||
handleEntries([entry], null as unknown as ResizeObserver)
|
||||
}),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn()
|
||||
}) as unknown as ResizeObserver
|
||||
)
|
||||
global.ResizeObserver = mockResizeObserver
|
||||
})
|
||||
|
||||
test('user can see expected filters and clear them one by one or all together', async () => {
|
||||
test('user can see expected filters and clear them one by one or all together on small screens', async () => {
|
||||
const searchRecord = {
|
||||
filters: [
|
||||
['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 = () =>
|
||||
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([])
|
||||
|
||||
await userEvent.click(
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import { AppliedFilterPillsList, PILL_X_GAP_PX } from './filter-pills-list'
|
|||
import { useQueryContext } from '../query-context'
|
||||
import { AppNavigationLink } from '../navigation/use-app-navigate'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { popover } from '../components/popover'
|
||||
import { BlurMenuButtonOnEscape } from '../keybinding'
|
||||
import { popover, BlurMenuButtonOnEscape } from '../components/popover'
|
||||
import { isSegmentFilter } from '../filtering/segments'
|
||||
import { useRoutelessModalsContext } from '../navigation/routeless-modals-context'
|
||||
import { DashboardQuery } from '../query'
|
||||
|
|
@ -273,11 +272,11 @@ const SeeMoreMenu = ({
|
|||
)}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as="div"
|
||||
{...popover.transition.props}
|
||||
className={classNames(
|
||||
'mt-2',
|
||||
popover.transition.classNames.fullwidth,
|
||||
'md:right-auto'
|
||||
'mt-2 md:right-auto md:origin-top-left'
|
||||
)}
|
||||
>
|
||||
<Popover.Panel
|
||||
|
|
@ -305,14 +304,15 @@ const SeeMoreMenu = ({
|
|||
)}
|
||||
{showSomeActions && (
|
||||
<div className="flex flex-col">
|
||||
{actions.map((action, index) => {
|
||||
{actions.map((action) => {
|
||||
const linkClassName = classNames(
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
index === 0 && !showMoreFilters
|
||||
? popover.items.classNames.roundedStartEnd
|
||||
: popover.items.classNames.roundedEnd,
|
||||
{
|
||||
[popover.items.classNames.roundedStart]: !showMoreFilters // rounded start is needed when there's no filters panel above
|
||||
},
|
||||
popover.items.classNames.roundedEnd,
|
||||
'whitespace-nowrap'
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { clearedComparisonSearch } from '../../query'
|
|||
import classNames from 'classnames'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { BlurMenuButtonOnEscape } from '../../keybinding'
|
||||
import {
|
||||
AppNavigationLink,
|
||||
useAppNavigate
|
||||
|
|
@ -18,7 +17,7 @@ import {
|
|||
getSearchToApplyCustomComparisonDates
|
||||
} from '../../query-time-periods'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { popover } from '../../components/popover'
|
||||
import { popover, BlurMenuButtonOnEscape } from '../../components/popover'
|
||||
import {
|
||||
datemenuButtonClassName,
|
||||
DateMenuChevron,
|
||||
|
|
@ -46,11 +45,11 @@ export const ComparisonPeriodMenuItems = ({
|
|||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
{...popover.transition.props}
|
||||
className={classNames(
|
||||
'mt-2',
|
||||
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}>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { stringifySearch } from '../../util/url-search-params'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { getRouterBasepath } from '../../router'
|
||||
import { QueryPeriodsPicker } from './query-periods-picker'
|
||||
import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks'
|
||||
|
||||
mockAnimationsApi()
|
||||
mockResizeObserver()
|
||||
|
||||
const domain = 'picking-query-dates.test'
|
||||
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'))
|
||||
expect(screen.getByTestId('datemenu')).toBeVisible()
|
||||
await userEvent.click(screen.getByText('All time'))
|
||||
expect(screen.queryByTestId('datemenu')).toBeNull()
|
||||
expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument()
|
||||
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('Year to Date'))
|
||||
expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument()
|
||||
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe('year')
|
||||
|
||||
await userEvent.click(screen.getByText('Year to Date'))
|
||||
await userEvent.click(screen.getByText('Month to Date'))
|
||||
expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument()
|
||||
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe('month')
|
||||
|
||||
await userEvent.click(screen.getByTestId('browser-back'))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import classNames from 'classnames'
|
|||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import {
|
||||
BlurMenuButtonOnEscape,
|
||||
isModifierPressed,
|
||||
isTyping,
|
||||
Keybind,
|
||||
|
|
@ -25,7 +24,7 @@ import {
|
|||
import { useMatch } from 'react-router-dom'
|
||||
import { rootRoute } from '../../router'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { popover } from '../../components/popover'
|
||||
import { popover, BlurMenuButtonOnEscape } from '../../components/popover'
|
||||
import {
|
||||
datemenuButtonClassName,
|
||||
DateMenuChevron,
|
||||
|
|
@ -152,11 +151,11 @@ const QueryPeriodMenuInner = ({
|
|||
<>
|
||||
<QueryPeriodMenuKeybinds closeDropdown={closeDropdown} groups={groups} />
|
||||
<Transition
|
||||
as="div"
|
||||
{...popover.transition.props}
|
||||
className={classNames(
|
||||
'mt-2',
|
||||
popover.transition.classNames.fullwidth,
|
||||
'md:left-auto md:w-56'
|
||||
'mt-2 md:w-56 md:left-auto md:origin-top-right'
|
||||
)}
|
||||
>
|
||||
<Popover.Panel
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ export const linkClassName = classNames(
|
|||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.roundedStartEnd
|
||||
popover.items.classNames.roundedStart,
|
||||
popover.items.classNames.roundedEnd
|
||||
)
|
||||
|
||||
export const datemenuButtonClassName = classNames(
|
||||
|
|
@ -41,10 +42,11 @@ export const CalendarPanel = React.forwardRef<
|
|||
>(({ children, className }, ref) => {
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
{...popover.transition.props}
|
||||
className={classNames(
|
||||
popover.transition.classNames.fullwidth,
|
||||
'md:left-auto',
|
||||
'md:left-auto md:origin-top-right',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React from 'react'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import {
|
||||
formatSegmentIdAsLabelKey,
|
||||
getFilterSegmentsByNameInsensitive,
|
||||
isSegmentFilter,
|
||||
SavedSegmentPublic,
|
||||
SavedSegment,
|
||||
|
|
@ -21,15 +20,16 @@ import { AppNavigationLink } from '../../navigation/use-app-navigate'
|
|||
import { MenuSeparator } from '../nav-menu-components'
|
||||
import { Role, useUserContext } from '../../user-context'
|
||||
import { useSegmentsContext } from '../../filtering/segments-context'
|
||||
import { useSearchableItems } from '../../hooks/use-searchable-items'
|
||||
|
||||
const linkClassName = classNames(
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.groupRoundedStartEnd
|
||||
popover.items.classNames.groupRoundedEnd
|
||||
)
|
||||
|
||||
const initialSliceLength = 5
|
||||
const INITIAL_SEGMENTS_SHOWN = 5
|
||||
|
||||
export const SearchableSegmentsSection = ({
|
||||
closeList
|
||||
|
|
@ -44,105 +44,109 @@ export const SearchableSegmentsSection = ({
|
|||
|
||||
const isPublicListQuery = !user.loggedIn || user.role === Role.public
|
||||
|
||||
const data = segmentsContext.segments.filter((segment) =>
|
||||
isListableSegment({ segment, site, user })
|
||||
)
|
||||
|
||||
const [searchValue, setSearch] = useState<string>()
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const searching = !searchValue?.trim().length
|
||||
|
||||
useEffect(() => {
|
||||
setShowAll(false)
|
||||
}, [searching])
|
||||
|
||||
const filteredData = data?.filter(
|
||||
getFilterSegmentsByNameInsensitive(searchValue)
|
||||
)
|
||||
|
||||
const showableSlice = showAll
|
||||
? filteredData
|
||||
: filteredData?.slice(0, initialSliceLength)
|
||||
const {
|
||||
data,
|
||||
filteredData,
|
||||
showableData,
|
||||
showSearch,
|
||||
countOfMoreToShow,
|
||||
handleShowAll,
|
||||
handleClearSearch,
|
||||
handleSearchInput,
|
||||
searchRef,
|
||||
searching
|
||||
} = useSearchableItems({
|
||||
data: segmentsContext.segments.filter((segment) =>
|
||||
isListableSegment({ segment, site, user })
|
||||
),
|
||||
maxItemsInitially: INITIAL_SEGMENTS_SHOWN,
|
||||
itemMatchesSearchValue: (segment, trimmedSearch) =>
|
||||
segment.name.toLowerCase().includes(trimmedSearch.toLowerCase())
|
||||
})
|
||||
|
||||
if (expandedSegment) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!data.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!data?.length && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<div className="flex items-center pt-2 px-4 pb-2">
|
||||
<div className="text-sm font-bold uppercase text-indigo-500 dark:text-indigo-400 mr-4">
|
||||
Segments
|
||||
</div>
|
||||
{data.length > initialSliceLength && (
|
||||
<SearchInput
|
||||
placeholderUnfocused="Press / to search"
|
||||
className="ml-auto w-full py-1 text-sm"
|
||||
onSearch={setSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MenuSeparator />
|
||||
<div className="flex items-center py-2 px-4">
|
||||
<div className="text-sm font-bold uppercase text-indigo-500 dark:text-indigo-400 mr-4">
|
||||
Segments
|
||||
</div>
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
placeholderUnfocused="Press / to search"
|
||||
className="ml-auto w-full py-1 text-sm"
|
||||
onSearch={handleSearchInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showableSlice!.map((segment) => {
|
||||
return (
|
||||
<Tooltip
|
||||
className="group"
|
||||
key={segment.id}
|
||||
info={
|
||||
<div className="max-w-60">
|
||||
<div className="break-all">{segment.name}</div>
|
||||
<div className="font-normal text-xs">
|
||||
{SEGMENT_TYPE_LABELS[segment.type]}
|
||||
</div>
|
||||
|
||||
<SegmentAuthorship
|
||||
className="font-normal text-xs"
|
||||
{...(isPublicListQuery
|
||||
? {
|
||||
showOnlyPublicData: true,
|
||||
segment: segment as SavedSegmentPublic
|
||||
}
|
||||
: {
|
||||
showOnlyPublicData: false,
|
||||
segment: segment as SavedSegment
|
||||
})}
|
||||
/>
|
||||
<div className="max-h-[210px] overflow-y-scroll">
|
||||
{showableData.map((segment) => {
|
||||
return (
|
||||
<Tooltip
|
||||
className="group"
|
||||
key={segment.id}
|
||||
info={
|
||||
<div className="max-w-60">
|
||||
<div className="break-all">{segment.name}</div>
|
||||
<div className="font-normal text-xs">
|
||||
{SEGMENT_TYPE_LABELS[segment.type]}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SegmentLink {...segment} closeList={closeList} />
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{!!filteredData?.length &&
|
||||
!!showableSlice?.length &&
|
||||
filteredData?.length > showableSlice?.length &&
|
||||
showAll === false && (
|
||||
<Tooltip className="group" info={null}>
|
||||
<AppNavigationLink
|
||||
className={classNames(
|
||||
linkClassName,
|
||||
'font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
|
||||
)}
|
||||
search={(s) => s}
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
{`Show ${filteredData.length - showableSlice.length} more`}
|
||||
<EllipsisHorizontalIcon className="block w-5 h-5" />
|
||||
</AppNavigationLink>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!!data?.length && searchValue && !showableSlice?.length && (
|
||||
|
||||
<SegmentAuthorship
|
||||
className="font-normal text-xs"
|
||||
{...(isPublicListQuery
|
||||
? {
|
||||
showOnlyPublicData: true,
|
||||
segment: segment as SavedSegmentPublic
|
||||
}
|
||||
: {
|
||||
showOnlyPublicData: false,
|
||||
segment: segment as SavedSegment
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SegmentLink {...segment} closeList={closeList} />
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{countOfMoreToShow > 0 && (
|
||||
<Tooltip className="group" info={null}>
|
||||
<button
|
||||
className={classNames(
|
||||
linkClassName,
|
||||
'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500'
|
||||
)}
|
||||
onClick={handleShowAll}
|
||||
>
|
||||
{`Show ${countOfMoreToShow} more`}
|
||||
<EllipsisHorizontalIcon className="block w-5 h-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{searching && !filteredData.length && (
|
||||
<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.
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ const linkClassName = classNames(
|
|||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.roundedStartEnd
|
||||
popover.items.classNames.roundedStart,
|
||||
popover.items.classNames.roundedEnd
|
||||
)
|
||||
const buttonClassName = classNames(
|
||||
'text-white font-medium bg-indigo-600 hover:bg-indigo-700'
|
||||
|
|
@ -92,11 +93,11 @@ export const SegmentMenu = () => {
|
|||
/>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as="div"
|
||||
{...popover.transition.props}
|
||||
className={classNames(
|
||||
'mt-2',
|
||||
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}>
|
||||
|
|
|
|||
|
|
@ -10,30 +10,21 @@ import userEvent from '@testing-library/user-event'
|
|||
import { TestContextProviders } from '../../../test-utils/app-context-providers'
|
||||
import { TopBar } from './top-bar'
|
||||
import { MockAPI } from '../../../test-utils/mock-api'
|
||||
import {
|
||||
mockAnimationsApi,
|
||||
mockResizeObserver,
|
||||
mockIntersectionObserver
|
||||
} from 'jsdom-testing-mocks'
|
||||
|
||||
mockAnimationsApi()
|
||||
mockResizeObserver()
|
||||
mockIntersectionObserver()
|
||||
|
||||
const domain = 'dummy.site'
|
||||
const domains = [domain, 'example.com', 'blog.example.com']
|
||||
|
||||
let mockAPI: MockAPI
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
|
|
@ -43,10 +34,18 @@ afterAll(() => {
|
|||
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
mockAPI.get('/api/sites', {
|
||||
data: [domain, 'example.com', 'blog.example.com', 'aççented.ca'].map(
|
||||
(domain) => ({
|
||||
domain
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
render(<TopBar showCurrentVisitors={false} />, {
|
||||
wrapper: (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 })
|
||||
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'],
|
||||
['blog.example.com', '3']
|
||||
].map((a) => a.join(''))
|
||||
)
|
||||
expect(screen.queryAllByRole('menuitem').map((el) => el.textContent)).toEqual(
|
||||
['Site Settings', 'View All']
|
||||
{ text: ['Site settings'], href: `/${domain}/settings/general` },
|
||||
{ text: ['dummy.site', '1'], href: '#' },
|
||||
{ text: ['example.com', '2'], href: `/example.com` },
|
||||
{ text: ['blog.example.com', '3'], href: `/blog.example.com` },
|
||||
{ text: ['aççented.ca', '4'], href: `/a%C3%A7%C3%A7ented.ca` },
|
||||
{ text: ['View all'], href: '/sites' }
|
||||
].map((l) => ({ ...l, text: l.text.join('') }))
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('sitemenu')).toBeInTheDocument()
|
||||
await userEvent.click(toggleSiteSwitcher)
|
||||
expect(screen.queryByTestId('sitemenu')).not.toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('menuitem')).toEqual([])
|
||||
})
|
||||
|
||||
|
|
@ -89,7 +96,8 @@ test('user can open and close filters dropdown', async () => {
|
|||
'Goal'
|
||||
])
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { ReactNode, useRef } from 'react'
|
||||
import SiteSwitcher from '../site-switcher'
|
||||
import { SiteSwitcher } from '../site-switcher'
|
||||
import { useSiteContext } from '../site-context'
|
||||
import { useUserContext } from '../user-context'
|
||||
import CurrentVisitors from '../stats/current-visitors'
|
||||
import classNames from 'classnames'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
|
|
@ -44,18 +43,12 @@ function TopBarStickyWrapper({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
function TopBarInner({ showCurrentVisitors }: TopBarProps) {
|
||||
const site = useSiteContext()
|
||||
const user = useUserContext()
|
||||
const leftActionsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex items-center gap-x-4 shrink-0" ref={leftActionsRef}>
|
||||
<SiteSwitcher
|
||||
site={site}
|
||||
loggedIn={user.loggedIn}
|
||||
currentUserRole={user.role}
|
||||
/>
|
||||
<SiteSwitcher />
|
||||
{showCurrentVisitors && (
|
||||
<CurrentVisitors tooltipBoundaryRef={leftActionsRef} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,4 @@
|
|||
import 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 React, { useState, useEffect, useCallback } from 'react'
|
||||
import * as storage from '../../util/storage'
|
||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
|
||||
import GoalConversions, {
|
||||
|
|
@ -20,7 +11,7 @@ import { hasConversionGoalFilter } from '../../util/filters'
|
|||
import { useSiteContext } from '../../site-context'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useUserContext } from '../../user-context'
|
||||
import { BlurMenuButtonOnEscape } from '../../keybinding'
|
||||
import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
/*global require*/
|
||||
|
|
@ -35,10 +26,6 @@ function maybeRequire() {
|
|||
|
||||
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 PROPS = 'props'
|
||||
export const FUNNELS = 'funnels'
|
||||
|
|
@ -53,7 +40,6 @@ export default function Behaviours({ importedDataInView }) {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
const user = useUserContext()
|
||||
const buttonRef = useRef()
|
||||
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(
|
||||
user.role
|
||||
)
|
||||
|
|
@ -66,9 +52,6 @@ export default function Behaviours({ importedDataInView }) {
|
|||
const [mode, setMode] = useState(defaultMode())
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [funnelNames, _setFunnelNames] = useState(
|
||||
site.funnels.map(({ name }) => name)
|
||||
)
|
||||
const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
|
||||
|
||||
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] =
|
||||
|
|
@ -117,12 +100,12 @@ export default function Behaviours({ importedDataInView }) {
|
|||
)
|
||||
}
|
||||
|
||||
function setFunnel(selectedFunnel) {
|
||||
function setFunnelFactory(selectedFunnelName) {
|
||||
return () => {
|
||||
storage.setItem(tabKey, FUNNELS)
|
||||
storage.setItem(funnelKey, selectedFunnel)
|
||||
storage.setItem(funnelKey, selectedFunnelName)
|
||||
setMode(FUNNELS)
|
||||
setSelectedFunnel(selectedFunnel)
|
||||
setSelectedFunnel(selectedFunnelName)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,96 +123,11 @@ export default function Behaviours({ importedDataInView }) {
|
|||
}
|
||||
}
|
||||
|
||||
function hasFunnels() {
|
||||
return site.funnels.length > 0 && site.funnelsAvailable
|
||||
}
|
||||
|
||||
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)
|
||||
function setTabFactory(tab) {
|
||||
return () => {
|
||||
storage.setItem(tabKey, tab)
|
||||
setMode(tab)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -443,24 +341,63 @@ export default function Behaviours({ importedDataInView }) {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!mode) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
operatingSystemVersionsRoute,
|
||||
screenSizesRoute
|
||||
} from '../../router'
|
||||
import { TabButton, TabWrapper } from '../../components/tabs'
|
||||
|
||||
// Icons copied from https://github.com/alrra/browser-logos
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex justify-between w-full">
|
||||
|
|
@ -461,11 +441,21 @@ export default function Devices() {
|
|||
skipImportedReason={skipImportedReason}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||
{renderPill('Browser', 'browser')}
|
||||
{renderPill('OS', 'os')}
|
||||
{renderPill('Size', 'size')}
|
||||
</div>
|
||||
<TabWrapper>
|
||||
{[
|
||||
{ label: 'Browser', value: 'browser' },
|
||||
{ label: 'OS', value: 'os' },
|
||||
{ label: 'Size', value: 'size' }
|
||||
].map(({ label, value }) => (
|
||||
<TabButton
|
||||
key={value}
|
||||
active={mode === value}
|
||||
onClick={() => switchTab(value)}
|
||||
>
|
||||
{label}
|
||||
</TabButton>
|
||||
))}
|
||||
</TabWrapper>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warni
|
|||
import { citiesRoute, countriesRoute, regionsRoute } from '../../router'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { TabButton, TabWrapper } from '../../components/tabs'
|
||||
|
||||
function Countries({ query, site, onClick, afterFetchData }) {
|
||||
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() {
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -278,12 +258,22 @@ class Locations extends React.Component {
|
|||
skipImportedReason={this.state.skipImportedReason}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||
{this.renderPill('Map', 'map')}
|
||||
{this.renderPill('Countries', 'countries')}
|
||||
{this.renderPill('Regions', 'regions')}
|
||||
{this.renderPill('Cities', 'cities')}
|
||||
</div>
|
||||
<TabWrapper>
|
||||
{[
|
||||
{ label: 'Map', value: 'map' },
|
||||
{ label: 'Countries', value: 'countries' },
|
||||
{ label: 'Regions', value: 'regions' },
|
||||
{ label: 'Cities', value: 'cities' }
|
||||
].map(({ value, label }) => (
|
||||
<TabButton
|
||||
key={value}
|
||||
onClick={this.setMode(value)}
|
||||
active={this.state.mode === value}
|
||||
>
|
||||
{label}
|
||||
</TabButton>
|
||||
))}
|
||||
</TabWrapper>
|
||||
</div>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
import React, { ReactNode, useRef } from 'react'
|
||||
|
||||
import { SearchInput } from '../../components/search-input'
|
||||
import { ColumnConfiguraton, Table } from '../../components/table'
|
||||
|
|
@ -34,36 +34,41 @@ export const BreakdownTable = <TListItem extends { name: string }>({
|
|||
error?: Error | null
|
||||
/** Controls whether the component displays API request errors or ignores them. */
|
||||
displayError?: boolean
|
||||
}) => (
|
||||
<div className="w-full h-full">
|
||||
<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 />}
|
||||
}) => {
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<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>
|
||||
{!!onSearch && (
|
||||
<SearchInput
|
||||
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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const InitialLoadingSpinner = () => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { hasConversionGoalFilter } from '../../util/filters'
|
|||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { entryPagesRoute, exitPagesRoute, topPagesRoute } from '../../router'
|
||||
import { TabButton, TabWrapper } from '../../components/tabs'
|
||||
|
||||
function EntryPages({ afterFetchData }) {
|
||||
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 (
|
||||
<div>
|
||||
{/* Header Container */}
|
||||
|
|
@ -219,11 +199,21 @@ export default function Pages() {
|
|||
skipImportedReason={skipImportedReason}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2">
|
||||
{renderPill('Top Pages', 'pages')}
|
||||
{renderPill('Entry Pages', 'entry-pages')}
|
||||
{renderPill('Exit Pages', 'exit-pages')}
|
||||
</div>
|
||||
<TabWrapper>
|
||||
{[
|
||||
{ label: 'Top Pages', value: 'pages' },
|
||||
{ label: 'Entry Pages', value: 'entry-pages' },
|
||||
{ label: 'Exit Pages', value: 'exit-pages' }
|
||||
].map(({ value, label }) => (
|
||||
<TabButton
|
||||
active={mode === value}
|
||||
onClick={() => switchTab(value)}
|
||||
key={value}
|
||||
>
|
||||
{label}
|
||||
</TabButton>
|
||||
))}
|
||||
</TabWrapper>
|
||||
</div>
|
||||
{/* Main Contents */}
|
||||
{renderContent()}
|
||||
|
|
|
|||
|
|
@ -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 url from '../../util/url'
|
||||
|
|
@ -10,9 +10,6 @@ import {
|
|||
getFiltersByKeyPrefix,
|
||||
hasConversionGoalFilter
|
||||
} 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 { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
|
|
@ -25,7 +22,7 @@ import {
|
|||
utmSourcesRoute,
|
||||
utmTermsRoute
|
||||
} from '../../router'
|
||||
import { BlurMenuButtonOnEscape } from '../../keybinding'
|
||||
import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
|
||||
|
||||
const UTM_TAGS = {
|
||||
utm_medium: {
|
||||
|
|
@ -197,7 +194,6 @@ export default function SourceList() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [skipImportedReason, setSkipImportedReason] = useState(null)
|
||||
const previousQuery = usePrevious(query)
|
||||
const dropdownButtonRef = useRef(null)
|
||||
|
||||
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() {
|
||||
setTab('all')()
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
if (currentTab === 'all') {
|
||||
return <AllSources afterFetchData={afterFetchData} />
|
||||
} else if (currentTab == 'channels') {
|
||||
return (
|
||||
<Channels onClick={onChannelClick} afterFetchData={afterFetchData} />
|
||||
)
|
||||
} else {
|
||||
if (Object.keys(UTM_TAGS).includes(currentTab)) {
|
||||
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) {
|
||||
|
|
@ -343,7 +258,33 @@ export default function SourceList() {
|
|||
skipImportedReason={skipImportedReason}
|
||||
/>
|
||||
</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>
|
||||
{/* Main Contents */}
|
||||
{renderContent()}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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` */'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.10",
|
||||
"@heroicons/react": "^2.0.11",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@jsonurl/jsonurl": "^1.1.7",
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
|
|
@ -60,18 +60,19 @@
|
|||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jest": "^28.11.0",
|
||||
"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",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jsdom-testing-mocks": "^1.13.1",
|
||||
"json-schema-to-typescript": "^15.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"stylelint": "^16.8.1",
|
||||
"stylelint": "^16.17.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"ts-jest": "^29.2.4",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.28.0"
|
||||
"typescript-eslint": "^8.29.0"
|
||||
},
|
||||
"name": "assets"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
@ -29,7 +29,7 @@ defmodule PlausibleWeb.Favicon do
|
|||
import Plug.Conn
|
||||
alias Plausible.HTTPClient
|
||||
|
||||
@placeholder_icon_location "priv/placeholder_favicon.ico"
|
||||
@placeholder_icon_location "priv/placeholder_favicon.svg"
|
||||
@placeholder_icon File.read!(@placeholder_icon_location)
|
||||
@custom_icons %{
|
||||
"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
|
||||
but we filter that out. When the icon request fails, we show a placeholder
|
||||
favicon instead. The placeholder is an emoji from
|
||||
[https://favicon.io/emoji-favicons/](https://favicon.io/emoji-favicons/)
|
||||
favicon instead. The placeholder is an svg from [https://heroicons.com/](https://heroicons.com/).
|
||||
|
||||
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
|
||||
|
|
@ -123,7 +122,7 @@ defmodule PlausibleWeb.Favicon do
|
|||
|
||||
defp send_placeholder(conn) do
|
||||
conn
|
||||
|> put_resp_content_type("image/x-icon")
|
||||
|> put_resp_content_type("image/svg+xml")
|
||||
|> put_resp_header("cache-control", "public, max-age=2592000")
|
||||
|> send_resp(200, @placeholder_icon)
|
||||
|> halt
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -145,7 +145,7 @@ defmodule PlausibleWeb.FaviconTest do
|
|||
end
|
||||
|
||||
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
|
||||
expect(
|
||||
|
|
@ -163,7 +163,7 @@ defmodule PlausibleWeb.FaviconTest do
|
|||
|
||||
assert conn.halted
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body == @placholder_icon
|
||||
assert conn.resp_body == @placeholder_icon
|
||||
end
|
||||
|
||||
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.status == 200
|
||||
assert conn.resp_body == @placholder_icon
|
||||
assert conn.resp_body == @placeholder_icon
|
||||
end
|
||||
|
||||
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.status == 200
|
||||
assert conn.resp_body == @placholder_icon
|
||||
assert conn.resp_body == @placeholder_icon
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue