diff --git a/assets/css/app.css b/assets/css/app.css index 71879d553ed0..064bc1523c90 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -31,6 +31,10 @@ *:focus-visible { @apply ring-2 ring-indigo-500 ring-offset-2 dark:ring-offset-gray-900 outline-none; } + + :focus:not(:focus-visible) { + @apply outline-none; + } } @layer components { diff --git a/assets/css/modal.css b/assets/css/modal.css index 365c0f5a30f4..7fa04121b426 100644 --- a/assets/css/modal.css +++ b/assets/css/modal.css @@ -28,7 +28,7 @@ position: fixed; inset: 0; background: rgb(0 0 0 / 60%); - z-index: 99; + z-index: 999; overflow: auto; } diff --git a/assets/js/dashboard/components/notice.js b/assets/js/dashboard/components/notice.js index c091b36c11fb..e3e3520d2f3a 100644 --- a/assets/js/dashboard/components/notice.js +++ b/assets/js/dashboard/components/notice.js @@ -70,13 +70,13 @@ export function FeatureSetupNotice({ } return ( -
-
-
+
+
+
{title}
-
+
{info}
diff --git a/assets/js/dashboard/components/pill.tsx b/assets/js/dashboard/components/pill.tsx new file mode 100644 index 000000000000..15bb1d124c1e --- /dev/null +++ b/assets/js/dashboard/components/pill.tsx @@ -0,0 +1,20 @@ +import React, { ReactNode } from 'react' +import classNames from 'classnames' + +export type PillProps = { + className?: string + children: ReactNode +} + +export function Pill({ className, children }: PillProps) { + return ( +
+ {children} +
+ ) +} diff --git a/assets/js/dashboard/components/popover.tsx b/assets/js/dashboard/components/popover.tsx index 56662badb541..66ca3fc91c2f 100644 --- a/assets/js/dashboard/components/popover.tsx +++ b/assets/js/dashboard/components/popover.tsx @@ -55,8 +55,7 @@ const items = { 'data-[selected=true]:bg-gray-100', 'data-[selected=true]:dark:bg-gray-700', 'data-[selected=true]:text-gray-900', - 'data-[selected=true]:dark:text-gray-100', - 'data-[selected=true]:font-semibold' + 'data-[selected=true]:dark:text-gray-100' ), hoverLink: classNames( 'hover:bg-gray-100', diff --git a/assets/js/dashboard/components/tabs.tsx b/assets/js/dashboard/components/tabs.tsx index cc33f7290ca0..1ed9ec022b01 100644 --- a/assets/js/dashboard/components/tabs.tsx +++ b/assets/js/dashboard/components/tabs.tsx @@ -1,6 +1,6 @@ import { Popover, Transition } from '@headlessui/react' import classNames from 'classnames' -import React, { ReactNode, useRef } from 'react' +import React, { ReactNode, useRef, useEffect } from 'react' import { ChevronDownIcon } from '@heroicons/react/20/solid' import { popover, BlurMenuButtonOnEscape } from './popover' import { useSearchableItems } from '../hooks/use-searchable-items' @@ -16,7 +16,7 @@ export const TabWrapper = ({ }) => (
@@ -32,11 +32,10 @@ const TabButtonText = ({ active: boolean }) => ( {children} @@ -54,9 +53,18 @@ export const TabButton = ({ onClick: () => void active: boolean }) => ( - +
+ +
) export const DropdownTabButton = ({ @@ -78,25 +86,34 @@ export const DropdownTabButton = ({ {({ close: closeDropdown }) => ( <> - - {children} - - - + {children} + + + +
void options: Array<{ selected: boolean; onClick: () => void; label: string }> searchable?: boolean - collectionTitle?: string } -const Items = ({ - options, - searchable, - collectionTitle, - closeDropdown -}: ItemsProps) => { +const Items = ({ options, searchable, closeDropdown }: ItemsProps) => { const { filteredData, showableData, @@ -136,7 +147,7 @@ const Items = ({ countOfMoreToShow } = useSearchableItems({ data: options, - maxItemsInitially: searchable ? 5 : options.length, + maxItemsInitially: searchable ? 10 : options.length, itemMatchesSearchValue: (option, trimmedSearchString) => option.label.toLowerCase().includes(trimmedSearchString.toLowerCase()) }) @@ -148,24 +159,30 @@ const Items = ({ popover.items.classNames.hoverLink ) + useEffect(() => { + if (searchable && showSearch && searchRef.current) { + const timeoutId = setTimeout(() => { + searchRef.current?.focus() + }, 100) + return () => clearTimeout(timeoutId) + } + }, [searchable, showSearch, searchRef]) + return ( <> {searchable && showSearch && ( -
- {collectionTitle && ( -
- {collectionTitle} -
- )} +
)} -
+
{showableData.map(({ selected, label, onClick }, index) => { return ( ) })} @@ -186,7 +203,7 @@ const Items = ({ onClick={handleShowAll} className={classNames( itemClassName, - 'w-full text-left font-bold hover:text-indigo-700 dark:hover:text-indigo-500' + 'w-full text-left text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200' )} > {`Show ${countOfMoreToShow} more`} @@ -197,11 +214,14 @@ const Items = ({ )}
diff --git a/assets/js/dashboard/extra/funnel.js b/assets/js/dashboard/extra/funnel.js index a2a11cf95db1..a818e02a659c 100644 --- a/assets/js/dashboard/extra/funnel.js +++ b/assets/js/dashboard/extra/funnel.js @@ -294,7 +294,9 @@ export default function Funnel({ funnelName, tabs }) { const header = () => { return (
-

{funnelName}

+

+ {funnelName} +

{tabs}
) @@ -346,7 +348,7 @@ export default function Funnel({ funnelName, tabs }) { return (
{header()} -

+

{funnel.steps.length}-step funnel • {conversionRate}% conversion rate

diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index d0c6fc0a36ad..72b947896a63 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -50,37 +50,21 @@ function DashboardStats({ } }, [onLiveNavigate]) - const statsBoxClass = - 'relative min-h-[436px] w-full mt-5 p-4 flex flex-col bg-white dark:bg-gray-900 shadow-sm rounded-md md:min-h-initial md:h-27.25rem md:w-[calc(50%-10px)] md:ml-[10px] md:mr-[10px] first:ml-0 last:mr-0' - return ( <> -
-
- -
-
- {site.flags.live_dashboard ? ( - - ) : ( - - )} -
-
- -
-
- -
-
- -
-
+ + {site.flags.live_dashboard ? ( + + ) : ( + + )} + + ) @@ -98,7 +82,7 @@ function Dashboard() { const [importedDataInView, setImportedDataInView] = useState(false) return ( -
+
-
+
{showableData.map((segment) => { return ( -
+
-
+
{showCurrentVisitors && ( diff --git a/assets/js/dashboard/site-switcher.tsx b/assets/js/dashboard/site-switcher.tsx index 53bfcd3832b8..29eb3a2c7f11 100644 --- a/assets/js/dashboard/site-switcher.tsx +++ b/assets/js/dashboard/site-switcher.tsx @@ -170,7 +170,7 @@ export const SiteSwitcher = () => { { ? 'All sites' : currentSite.domain} - + search - }} color="bg-red-50 group-hover/row:bg-red-100" colMinWidth={90} /> diff --git a/assets/js/dashboard/stats/behaviours/goal-conversions.js b/assets/js/dashboard/stats/behaviours/goal-conversions.js index e83c7fb19a8b..64925b6f6b3c 100644 --- a/assets/js/dashboard/stats/behaviours/goal-conversions.js +++ b/assets/js/dashboard/stats/behaviours/goal-conversions.js @@ -11,7 +11,6 @@ import { } from '../../util/filters' import { useSiteContext } from '../../site-context' import { useQueryContext } from '../../query-context' -import { customPropsRoute } from '../../router' export const SPECIAL_GOALS = { 404: { title: '404 Pages', prop: 'path' }, @@ -87,11 +86,6 @@ function SpecialPropBreakdown({ prop, afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel={prop} metrics={chooseMetrics()} - detailsLinkProps={{ - path: customPropsRoute.path, - params: { propKey: url.maybeEncodeRouteParam(prop) }, - search: (search) => search - }} getExternalLinkUrl={getExternalLinkUrlFactory()} color="bg-red-50" colMinWidth={90} diff --git a/assets/js/dashboard/stats/behaviours/index.js b/assets/js/dashboard/stats/behaviours/index.js index b0638d18263b..d5e88f26350e 100644 --- a/assets/js/dashboard/stats/behaviours/index.js +++ b/assets/js/dashboard/stats/behaviours/index.js @@ -7,11 +7,23 @@ import GoalConversions, { } from './goal-conversions' import Properties from './props' import { FeatureSetupNotice } from '../../components/notice' -import { hasConversionGoalFilter } from '../../util/filters' +import { + hasConversionGoalFilter, + getGoalFilter, + FILTER_OPERATIONS +} from '../../util/filters' import { useSiteContext } from '../../site-context' import { useQueryContext } from '../../query-context' import { useUserContext } from '../../user-context' import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' +import { Pill } from '../../components/pill' +import * as api from '../../api' +import * as url from '../../util/url' +import { conversionsRoute, customPropsRoute } from '../../router' /*global BUILD_EXTRA*/ /*global require*/ @@ -48,16 +60,52 @@ export default function Behaviours({ importedDataInView }) { 'behavioursTabFunnel', site.domain ) + const propKeyStorageName = `prop_key__${site.domain}` + const propKeyStorageNameForGoal = () => { + const [_operation, _filterKey, [goal]] = getGoalFilter(query) + return `${goal}__prop_key__${site.domain}` + } const [enabledModes, setEnabledModes] = useState(getEnabledModes()) const [mode, setMode] = useState(defaultMode()) const [loading, setLoading] = useState(true) const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel()) + const [propertyKeys, setPropertyKeys] = useState([]) + // Initialize selectedPropKey from storage immediately to show dropdown on page refresh + const [selectedPropKey, setSelectedPropKey] = useState(() => { + // Inline storage logic to avoid dependency on functions defined later + const goalFilter = getGoalFilter(query) + let stored = null + + if (goalFilter) { + const [operation, _filterKey, clauses] = goalFilter + if (operation === FILTER_OPERATIONS.is && clauses.length === 1) { + const [goal] = clauses + const goalStorageKey = `${goal}__prop_key__${site.domain}` + stored = storage.getItem(goalStorageKey) + } + } + + if (!stored) { + stored = storage.getItem(propKeyStorageName) + } + + return stored || null + }) + + // Optimistically add selectedPropKey to propertyKeys on mount so dropdown shows immediately + useEffect(() => { + if (selectedPropKey && propertyKeys.length === 0) { + setPropertyKeys([selectedPropKey]) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false) const [skipImportedReason, setSkipImportedReason] = useState(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) const onGoalFilterClick = useCallback((e) => { const goalName = e.target.innerHTML @@ -91,6 +139,13 @@ export default function Behaviours({ importedDataInView }) { }, [enabledModes]) useEffect(() => setLoading(true), [query, mode]) + useEffect(() => { + if (mode === PROPS && !selectedPropKey) { + setMoreLinkState(MoreLinkState.HIDDEN) + } else { + setMoreLinkState(MoreLinkState.LOADING) + } + }, [query, mode, selectedPropKey]) function disableMode(mode) { setEnabledModes( @@ -109,6 +164,81 @@ export default function Behaviours({ importedDataInView }) { } } + function setPropKeyFactory(selectedPropKeyName) { + return () => { + storage.setItem(tabKey, PROPS) + const storageName = singleGoalFilterApplied() + ? propKeyStorageNameForGoal() + : propKeyStorageName + storage.setItem(storageName, selectedPropKeyName) + setMode(PROPS) + setSelectedPropKey(selectedPropKeyName) + } + } + + function singleGoalFilterApplied() { + const goalFilter = getGoalFilter(query) + if (goalFilter) { + const [operation, _filterKey, clauses] = goalFilter + return operation === FILTER_OPERATIONS.is && clauses.length === 1 + } else { + return false + } + } + + function getPropKeyFromStorage() { + if (singleGoalFilterApplied()) { + const storedForGoal = storage.getItem(propKeyStorageNameForGoal()) + if (storedForGoal) { + return storedForGoal + } + } + + return storage.getItem(propKeyStorageName) + } + + useEffect(() => { + // Fetch property keys when PROPS mode is enabled (not just when active) + // This ensures the dropdown appears immediately on page refresh + if (enabledModes.includes(PROPS) && site.hasProps && site.propsAvailable) { + api + .get(url.apiPath(site, '/suggestions/prop_key'), query, { + q: '' + }) + .then((propKeys) => { + const propKeyValues = propKeys.map((entry) => entry.value) + setPropertyKeys(propKeyValues) + if (propKeyValues.length > 0) { + const stored = getPropKeyFromStorage() + const storedExists = stored && propKeyValues.includes(stored) + + if (storedExists) { + setSelectedPropKey(stored) + } else { + const firstAvailable = propKeyValues[0] + setSelectedPropKey(firstAvailable) + const storageName = singleGoalFilterApplied() + ? propKeyStorageNameForGoal() + : propKeyStorageName + storage.setItem(storageName, firstAvailable) + } + } else { + setSelectedPropKey(null) + } + }) + .catch((error) => { + console.error('Failed to fetch property keys:', error) + setPropertyKeys([]) + setSelectedPropKey(null) + }) + } else { + // Clear property keys when PROPS is not available + setPropertyKeys([]) + setSelectedPropKey(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, enabledModes, site.hasProps, site.propsAvailable]) + function defaultSelectedFunnel() { const stored = storage.getItem(funnelKey) const storedExists = stored && site.funnels.some((f) => f.name === stored) @@ -133,6 +263,11 @@ export default function Behaviours({ importedDataInView }) { function afterFetchData(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } function renderConversions() { @@ -198,7 +333,9 @@ export default function Behaviours({ importedDataInView }) { function renderProps() { if (site.hasProps && site.propsAvailable) { - return + return ( + + ) } else if (adminAccess) { let callToAction @@ -279,6 +416,27 @@ export default function Behaviours({ importedDataInView }) { return FUNNELS } + function moreLinkProps() { + switch (mode) { + case CONVERSIONS: + return { + path: conversionsRoute.path, + search: (search) => search + } + case PROPS: + if (!selectedPropKey) { + return null + } + return { + path: customPropsRoute.path, + params: { propKey: url.maybeEncodeRouteParam(selectedPropKey) }, + search: (search) => search + } + default: + return null + } + } + function getEnabledModes() { let enabledModes = [] @@ -307,14 +465,6 @@ export default function Behaviours({ importedDataInView }) { return query.period === 'realtime' } - function sectionTitle() { - if (mode === CONVERSIONS) { - return specialTitleWhenGoalFilter(query, sectionTitles[mode]) - } else { - return sectionTitles[mode] - } - } - function renderImportedQueryUnsupportedWarning() { if (mode === CONVERSIONS) { return ( @@ -346,42 +496,66 @@ export default function Behaviours({ importedDataInView }) { } return ( -
-
-
-
-

- {sectionTitle() + (isRealtime() ? ' (last 30min)' : '')} -

- {renderImportedQueryUnsupportedWarning()} -
+ + +
{isEnabled(CONVERSIONS) && ( - Goals - - )} - {isEnabled(PROPS) && ( - - Properties + {specialTitleWhenGoalFilter(query, 'Goals')} )} + {isEnabled(PROPS) && + ((propertyKeys.length > 0 || selectedPropKey) && + site.propsAvailable ? ( + 0 + ? propertyKeys.map((key) => ({ + label: key, + onClick: setPropKeyFactory(key), + selected: mode === PROPS && selectedPropKey === key + })) + : selectedPropKey + ? [ + { + label: selectedPropKey, + onClick: setPropKeyFactory(selectedPropKey), + selected: true + } + ] + : [] + } + searchable={true} + > + Properties + + ) : ( + + Properties + + ))} {isEnabled(FUNNELS) && Funnel && (site.funnels.length > 0 && site.funnelsAvailable ? ( ({ label: name, onClick: setFunnelFactory(name), selected: mode === FUNNELS && selectedFunnel === name }))} - collectionTitle="Funnels" searchable={true} > Funnels @@ -395,9 +569,12 @@ export default function Behaviours({ importedDataInView }) { ))} + {isRealtime() && last 30min} + {renderImportedQueryUnsupportedWarning()}
- {renderContent()} -
-
+ + + {renderContent()} + ) } diff --git a/assets/js/dashboard/stats/behaviours/props.js b/assets/js/dashboard/stats/behaviours/props.js index d02cfb21c0af..2a453c654e56 100644 --- a/assets/js/dashboard/stats/behaviours/props.js +++ b/assets/js/dashboard/stats/behaviours/props.js @@ -1,77 +1,16 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React from 'react' import ListReport, { MIN_HEIGHT } from '../reports/list' -import Combobox from '../../components/combobox' import * as metrics from '../reports/metrics' import * as api from '../../api' import * as url from '../../util/url' -import * as storage from '../../util/storage' -import { - EVENT_PROPS_PREFIX, - getGoalFilter, - FILTER_OPERATIONS, - hasConversionGoalFilter -} from '../../util/filters' -import classNames from 'classnames' +import { EVENT_PROPS_PREFIX, hasConversionGoalFilter } from '../../util/filters' import { useQueryContext } from '../../query-context' import { useSiteContext } from '../../site-context' -import { customPropsRoute } from '../../router' -export default function Properties({ afterFetchData }) { +export default function Properties({ propKey, afterFetchData }) { const { query } = useQueryContext() const site = useSiteContext() - const propKeyStorageName = `prop_key__${site.domain}` - const propKeyStorageNameForGoal = () => { - const [_operation, _filterKey, [goal]] = getGoalFilter(query) - return `${goal}__prop_key__${site.domain}` - } - - const [propKey, setPropKey] = useState(null) - const [propKeyLoading, setPropKeyLoading] = useState(true) - - function singleGoalFilterApplied() { - const goalFilter = getGoalFilter(query) - if (goalFilter) { - const [operation, _filterKey, clauses] = goalFilter - return operation === FILTER_OPERATIONS.is && clauses.length === 1 - } else { - return false - } - } - - useEffect(() => { - setPropKeyLoading(true) - setPropKey(null) - - fetchPropKeyOptions()('').then((propKeys) => { - const propKeyValues = propKeys.map((entry) => entry.value) - - if (propKeyValues.length > 0) { - const storedPropKey = getPropKeyFromStorage() - - if (propKeyValues.includes(storedPropKey)) { - setPropKey(storedPropKey) - } else { - setPropKey(propKeys[0].value) - } - } - - setPropKeyLoading(false) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]) - - function getPropKeyFromStorage() { - if (singleGoalFilterApplied()) { - const storedForGoal = storage.getItem(propKeyStorageNameForGoal()) - if (storedForGoal) { - return storedForGoal - } - } - - return storage.getItem(propKeyStorageName) - } - function fetchProps() { return api.get( url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`), @@ -79,31 +18,6 @@ export default function Properties({ afterFetchData }) { ) } - const fetchPropKeyOptions = useCallback(() => { - return (input) => { - return api.get(url.apiPath(site, '/suggestions/prop_key'), query, { - q: input.trim() - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]) - - function onPropKeySelect() { - return (selectedOptions) => { - const newPropKey = - selectedOptions.length === 0 ? null : selectedOptions[0].value - - if (newPropKey) { - const storageName = singleGoalFilterApplied() - ? propKeyStorageNameForGoal() - : propKeyStorageName - storage.setItem(storageName, newPropKey) - } - - setPropKey(newPropKey) - } - } - /*global BUILD_EXTRA*/ function chooseMetrics() { return [ @@ -132,11 +46,6 @@ export default function Properties({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel={propKey} metrics={chooseMetrics()} - detailsLinkProps={{ - path: customPropsRoute.path, - params: { propKey }, - search: (search) => search - }} color="bg-red-50 group-hover/row:bg-red-100" colMinWidth={90} /> @@ -148,37 +57,17 @@ export default function Properties({ afterFetchData }) { filter: ['is', `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]] }) - const comboboxDisabled = !propKeyLoading && !propKey - const comboboxPlaceholder = comboboxDisabled - ? 'No custom properties found' - : '' - const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : [] - const boxClass = classNames( - 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:bg-gray-750 dark:border-gray-750', - { - 'pointer-events-none': comboboxDisabled - } - ) - - const COMBOBOX_HEIGHT = 40 + if (!propKey) { + return ( +
+ No custom properties found +
+ ) + } return ( -
-
- -
- {propKey && renderBreakdown()} +
+ {renderBreakdown()}
) } diff --git a/assets/js/dashboard/stats/current-visitors.js b/assets/js/dashboard/stats/current-visitors.js index 4df0bc23c45e..4d95c7694236 100644 --- a/assets/js/dashboard/stats/current-visitors.js +++ b/assets/js/dashboard/stats/current-visitors.js @@ -55,7 +55,7 @@ export default function CurrentVisitors({ ({ ...prev, period: 'realtime' })} className={classNames( - 'h-9 flex items-center text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300', + 'h-9 flex items-center text-xs md:text-sm font-medium text-gray-500 dark:text-gray-300', className )} > diff --git a/assets/js/dashboard/stats/devices/index.js b/assets/js/dashboard/stats/devices/index.js index 4019c257eae3..98b323392e7c 100644 --- a/assets/js/dashboard/stats/devices/index.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -12,6 +12,9 @@ import * as url from '../../util/url' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import { useQueryContext } from '../../query-context' import { useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' import { browsersRoute, browserVersionsRoute, @@ -19,7 +22,8 @@ import { operatingSystemVersionsRoute, screenSizesRoute } from '../../router' -import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' // Icons copied from https://github.com/alrra/browser-logos const BROWSER_ICONS = { @@ -90,10 +94,6 @@ function Browsers({ afterFetchData }) { keyLabel="Browser" metrics={chooseMetrics()} renderIcon={renderIcon} - detailsLinkProps={{ - path: browsersRoute.path, - search: (search) => search - }} /> ) } @@ -136,10 +136,6 @@ function BrowserVersions({ afterFetchData }) { keyLabel="Browser version" metrics={chooseMetrics()} renderIcon={renderIcon} - detailsLinkProps={{ - path: browserVersionsRoute.path, - search: (search) => search - }} /> ) } @@ -209,10 +205,6 @@ function OperatingSystems({ afterFetchData }) { renderIcon={renderIcon} keyLabel="Operating system" metrics={chooseMetrics()} - detailsLinkProps={{ - path: operatingSystemsRoute.path, - search: (search) => search - }} /> ) } @@ -256,10 +248,6 @@ function OperatingSystemVersions({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Operating System Version" metrics={chooseMetrics()} - detailsLinkProps={{ - path: operatingSystemVersionsRoute.path, - search: (search) => search - }} /> ) } @@ -297,13 +285,9 @@ function ScreenSizes({ afterFetchData }) { fetchData={fetchData} afterFetchData={afterFetchData} getFilterInfo={getFilterInfo} - keyLabel="Screen size" + keyLabel="Device" metrics={chooseMetrics()} renderIcon={renderIcon} - detailsLinkProps={{ - path: screenSizesRoute.path, - search: (search) => search - }} /> ) } @@ -392,6 +376,42 @@ export function screenSizeIconFor(screenSize) { ) + } else if (screenSize === 'Ultra-wide') { + svg = ( + + + + + + ) + } else if (screenSize === '(not set)') { + svg = ( + + + + + + ) } return {svg} @@ -406,6 +426,7 @@ export default function Devices() { const [mode, setMode] = useState(storedTab || 'browser') const [loading, setLoading] = useState(true) const [skipImportedReason, setSkipImportedReason] = useState(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) function switchTab(mode) { storage.setItem(tabKey, mode) @@ -415,9 +436,50 @@ export default function Devices() { function afterFetchData(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } - useEffect(() => setLoading(true), [query, mode]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [query, mode]) + + function moreLinkProps() { + switch (mode) { + case 'browser': + if (isFilteringOnFixedValue(query, 'browser')) { + return { + path: browserVersionsRoute.path, + search: (search) => search + } + } + return { + path: browsersRoute.path, + search: (search) => search + } + case 'os': + if (isFilteringOnFixedValue(query, 'os')) { + return { + path: operatingSystemVersionsRoute.path, + search: (search) => search + } + } + return { + path: operatingSystemsRoute.path, + search: (search) => search + } + case 'size': + default: + return { + path: screenSizesRoute.path, + search: (search) => search + } + } + } function renderContent() { switch (mode) { @@ -438,33 +500,33 @@ export default function Devices() { } return ( -
-
-
-

Devices

+ + +
+ + {[ + { label: 'Browsers', value: 'browser' }, + { label: 'Operating systems', value: 'os' }, + { label: 'Devices', value: 'size' } + ].map(({ label, value }) => ( + switchTab(value)} + > + {label} + + ))} +
- - {[ - { label: 'Browser', value: 'browser' }, - { label: 'OS', value: 'os' }, - { label: 'Size', value: 'size' } - ].map(({ label, value }) => ( - switchTab(value)} - > - {label} - - ))} - -
+ + {renderContent()} -
+ ) } diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js index 2846457fc3a8..cf33ed8dfe13 100644 --- a/assets/js/dashboard/stats/graph/graph-util.js +++ b/assets/js/dashboard/stats/graph/graph-util.js @@ -31,45 +31,39 @@ const buildComparisonDataset = function (comparisonPlot) { return [ { data: plottable(comparisonPlot), - borderColor: 'rgba(60,70,110,0.2)', - pointBackgroundColor: 'rgba(60,70,110,0.2)', - pointHoverBackgroundColor: 'rgba(60, 70, 110)', + borderColor: 'rgb(199, 210, 254)', + pointBackgroundColor: 'rgb(199, 210, 254)', + pointHoverBackgroundColor: 'rgb(199, 210, 254)', yAxisID: 'yComparison' } ] } - const buildDashedDataset = function (plot, presentIndex) { if (!presentIndex) return [] - const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1) const dashedPlot = new Array(presentIndex - 1).concat(dashedPart) - return [ { data: plottable(dashedPlot), borderDash: [3, 3], - borderColor: 'rgba(101,116,205)', - pointHoverBackgroundColor: 'rgba(71, 87, 193)', + borderColor: 'rgb(99, 102, 241)', + pointHoverBackgroundColor: 'rgb(99, 102, 241)', yAxisID: 'y' } ] } - const buildMainPlotDataset = function (plot, presentIndex) { const data = presentIndex ? plot.slice(0, presentIndex) : plot - return [ { data: plottable(data), - borderColor: 'rgba(101,116,205)', - pointBackgroundColor: 'rgba(101,116,205)', - pointHoverBackgroundColor: 'rgba(71, 87, 193)', + borderColor: 'rgb(99, 102, 241)', + pointBackgroundColor: 'rgb(99, 102, 241)', + pointHoverBackgroundColor: 'rgb(99, 102, 241)', yAxisID: 'y' } ] } - export const buildDataSet = ( plot, comparisonPlot, @@ -79,10 +73,10 @@ export const buildDataSet = ( ) => { var gradient = ctx.createLinearGradient(0, 0, 0, 300) var prev_gradient = ctx.createLinearGradient(0, 0, 0, 300) - gradient.addColorStop(0, 'rgba(101,116,205, 0.2)') - gradient.addColorStop(1, 'rgba(101,116,205, 0)') - prev_gradient.addColorStop(0, 'rgba(101,116,205, 0.075)') - prev_gradient.addColorStop(1, 'rgba(101,116,205, 0)') + gradient.addColorStop(0, 'rgba(79, 70, 229, 0.15)') + gradient.addColorStop(1, 'rgba(79, 70, 229, 0)') + prev_gradient.addColorStop(0, 'rgba(79, 70, 229, 0.05)') + prev_gradient.addColorStop(1, 'rgba(79, 70, 229, 0)') const defaultOptions = { label, diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index db4f06a2a9be..e370dca7c50e 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -126,11 +126,11 @@ export default function TopStats({ const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g) const statDisplayNameClass = classNames( - 'text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-fit border-b', + 'text-xs text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-fit border-b', { - 'text-indigo-600 dark:text-indigo-500 border-indigo-600 dark:border-indigo-500': + 'text-indigo-600 dark:text-indigo-500 font-bold tracking-[-.01em] border-indigo-600 dark:border-indigo-500': isSelected, - 'group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent': + 'font-semibold group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent': !isSelected } ) diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js index 2db7b61a20c7..c9e19a538ce2 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ b/assets/js/dashboard/stats/graph/visitor-graph.js @@ -166,7 +166,7 @@ export default function VisitorGraph({ updateImportedDataInView }) { return (
{(topStatsLoading || graphLoading) && renderLoader()} diff --git a/assets/js/dashboard/stats/imported-query-unsupported-warning.js b/assets/js/dashboard/stats/imported-query-unsupported-warning.js index a6aab6128c80..3f442fb45939 100644 --- a/assets/js/dashboard/stats/imported-query-unsupported-warning.js +++ b/assets/js/dashboard/stats/imported-query-unsupported-warning.js @@ -1,7 +1,8 @@ -import React from 'react' +import React, { useRef, useEffect } from 'react' import { ExclamationCircleIcon } from '@heroicons/react/24/outline' import FadeIn from '../fade-in' import { useQueryContext } from '../query-context' +import { Tooltip } from '../util/tooltip' export default function ImportedQueryUnsupportedWarning({ loading, @@ -10,6 +11,7 @@ export default function ImportedQueryUnsupportedWarning({ message }) { const { query } = useQueryContext() + const portalRef = useRef(null) const tooltipMessage = message || 'Imported data is excluded due to applied filters' const show = @@ -18,12 +20,18 @@ export default function ImportedQueryUnsupportedWarning({ skipImportedReason === 'unsupported_query' && query.period !== 'realtime' + useEffect(() => { + if (typeof document !== 'undefined') { + portalRef.current = document.body + } + }, []) + if (show || altCondition) { return ( - - - - + + + + ) } else { diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js index 699d354e5d06..073b1518097f 100644 --- a/assets/js/dashboard/stats/locations/index.js +++ b/assets/js/dashboard/stats/locations/index.js @@ -15,7 +15,11 @@ import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warni import { citiesRoute, countriesRoute, regionsRoute } from '../../router' import { useQueryContext } from '../../query-context' import { useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' function Countries({ query, site, onClick, afterFetchData }) { function fetchData() { @@ -51,10 +55,6 @@ function Countries({ query, site, onClick, afterFetchData }) { onClick={onClick} keyLabel="Country" metrics={chooseMetrics()} - detailsLinkProps={{ - path: countriesRoute.path, - search: (search) => search - }} renderIcon={renderIcon} color="bg-orange-50 group-hover/row:bg-orange-100" /> @@ -95,7 +95,6 @@ function Regions({ query, site, onClick, afterFetchData }) { onClick={onClick} keyLabel="Region" metrics={chooseMetrics()} - detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }} renderIcon={renderIcon} color="bg-orange-50 group-hover/row:bg-orange-100" /> @@ -135,19 +134,12 @@ function Cities({ query, site, afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="City" metrics={chooseMetrics()} - detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }} renderIcon={renderIcon} color="bg-orange-50 group-hover/row:bg-orange-100" /> ) } -const labelFor = { - countries: 'Countries', - regions: 'Regions', - cities: 'Cities' -} - class Locations extends React.Component { constructor(props) { super(props) @@ -159,7 +151,8 @@ class Locations extends React.Component { this.state = { mode: storedTab || 'map', loading: true, - skipImportedReason: null + skipImportedReason: null, + moreLinkState: MoreLinkState.LOADING } } @@ -183,7 +176,7 @@ class Locations extends React.Component { this.props.query !== prevProps.query || this.state.mode !== prevState.mode ) { - this.setState({ loading: true }) + this.setState({ loading: true, moreLinkState: MoreLinkState.LOADING }) } } @@ -206,8 +199,16 @@ class Locations extends React.Component { } afterFetchData(apiResponse) { + let newMoreLinkState + + if (apiResponse.results && apiResponse.results.length > 0) { + newMoreLinkState = MoreLinkState.READY + } else { + newMoreLinkState = MoreLinkState.HIDDEN + } this.setState({ loading: false, + moreLinkState: newMoreLinkState, skipImportedReason: apiResponse.skip_imported_reason }) } @@ -251,38 +252,55 @@ class Locations extends React.Component { } } + getMoreLinkProps() { + let path + + if (this.state.mode === 'regions') { + path = regionsRoute.path + } else if (this.state.mode === 'cities') { + path = citiesRoute.path + } else { + path = countriesRoute.path + } + + return { path: path, search: (search) => search } + } + render() { return ( -
-
-
-

- {labelFor[this.state.mode] || 'Locations'} -

+ + +
+ + {[ + { label: 'Map', value: 'map' }, + { label: 'Countries', value: 'countries' }, + { label: 'Regions', value: 'regions' }, + { label: 'Cities', value: 'cities' } + ].map(({ value, label }) => ( + + {label} + + ))} +
- - {[ - { label: 'Map', value: 'map' }, - { label: 'Countries', value: 'countries' }, - { label: 'Regions', value: 'regions' }, - { label: 'Cities', value: 'cities' } - ].map(({ value, label }) => ( - - {label} - - ))} - -
+ + {this.renderContent()} -
+ ) } } diff --git a/assets/js/dashboard/stats/locations/map-tooltip.tsx b/assets/js/dashboard/stats/locations/map-tooltip.tsx index 7a36a9a8f85a..9e28c5c3f947 100644 --- a/assets/js/dashboard/stats/locations/map-tooltip.tsx +++ b/assets/js/dashboard/stats/locations/map-tooltip.tsx @@ -32,7 +32,10 @@ export const MapTooltip = ({ name, value, label, x, y }: MapTooltipProps) => ( top: y }} > -
{name}
- {value} {label} +
{name}
+
+ {value} + {label} +
) diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 593a5747d8f0..8a0a53668955 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -2,7 +2,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as d3 from 'd3' import classNames from 'classnames' import * as api from '../../api' -import { replaceFilterByPrefix, cleanLabels } from '../../util/filters' +import { + replaceFilterByPrefix, + cleanLabels, + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' import { useAppNavigate } from '../../navigation/use-app-navigate' import { numberShortFormatter } from '../../util/number-formatter' import * as topojson from 'topojson-client' @@ -12,8 +17,6 @@ import { useQueryContext } from '../../query-context' import worldJson from 'visionscarto-world-atlas/world/110m.json' import { UIMode, useTheme } from '../../theme-context' import { apiPath } from '../../util/url' -import MoreLink from '../more-link' -import { countriesRoute } from '../../router' import { MIN_HEIGHT } from '../reports/list' import { MapTooltip } from './map-tooltip' import { GeolocationNotice } from './geolocation-notice' @@ -47,10 +50,15 @@ const WorldMap = ({ hoveredCountryAlpha3Code: string | null }>({ x: 0, y: 0, hoveredCountryAlpha3Code: null }) - const labels = - query.period === 'realtime' - ? { singular: 'Current visitor', plural: 'Current visitors' } - : { singular: 'Visitor', plural: 'Visitors' } + const labels = (() => { + if (hasConversionGoalFilter(query)) { + return { singular: 'Conversion', plural: 'Conversions' } + } + if (isRealTimeDashboard(query)) { + return { singular: 'Current visitor', plural: 'Current visitors' } + } + return { singular: 'Visitor', plural: 'Visitors' } + })() const { data, refetch, isFetching, isError } = useQuery({ queryKey: ['countries', 'map', query], @@ -78,7 +86,7 @@ const WorldMap = ({ if (data) { afterFetchData(data) } - }, [afterFetchData, data]) + }, [afterFetchData, data, isFetching]) const { maxValue, dataByCountryCode } = useMemo(() => { const dataByCountryCode: Map = new Map() @@ -148,10 +156,12 @@ const WorldMap = ({ : undefined return ( -
-
+
))}
- ) => search - }} - className="mt-3" - onClick={undefined} - /> {site.isDbip && }
) } const colorScales = { - [UIMode.dark]: ['#2e3954', '#6366f1'], - [UIMode.light]: ['#f5f3ff', '#a78bfa'] + [UIMode.dark]: ['#2a276d', '#6366f1'], // custom color between indigo-900 and indigo-950, indigo-500 + [UIMode.light]: ['#e0e7ff', '#818cf8'] // indigo-100, indigo-400 } const sharedCountryClass = classNames('transition-colors') @@ -203,19 +204,19 @@ const sharedCountryClass = classNames('transition-colors') const countryClass = classNames( sharedCountryClass, 'stroke-1', - 'fill-[#fafafa]', - 'stroke-[#dae1e7]', - 'dark:fill-[#323236]', - 'dark:stroke-[#18181b]' + 'fill-gray-150', + 'stroke-white', + 'dark:fill-gray-750', + 'dark:stroke-gray-900' ) const highlightedCountryClass = classNames( sharedCountryClass, - 'stroke-2', - 'fill-[#f4f4f5]', - 'stroke-[#a78bfa]', - 'dark:fill-[#3f3f46]', - 'dark:stroke-[#6366f1]' + 'stroke-[1.5px]', + 'fill-gray-150', + 'stroke-indigo-400', + 'dark:fill-gray-750', + 'dark:stroke-indigo-500' ) /** diff --git a/assets/js/dashboard/stats/modals/devices/choose-metrics.js b/assets/js/dashboard/stats/modals/devices/choose-metrics.js index bf647fcdb7cf..34959eddcb1d 100644 --- a/assets/js/dashboard/stats/modals/devices/choose-metrics.js +++ b/assets/js/dashboard/stats/modals/devices/choose-metrics.js @@ -22,7 +22,7 @@ export default function chooseMetrics(query, site) { ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if (isRealTimeDashboard(query) && !hasConversionGoalFilter(query)) { return [ metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', diff --git a/assets/js/dashboard/stats/modals/devices/screen-sizes.js b/assets/js/dashboard/stats/modals/devices/screen-sizes.js index 5b86d8a56b37..637ea4bcfd3b 100644 --- a/assets/js/dashboard/stats/modals/devices/screen-sizes.js +++ b/assets/js/dashboard/stats/modals/devices/screen-sizes.js @@ -13,10 +13,10 @@ function ScreenSizesModal() { const site = useSiteContext() const reportInfo = { - title: 'Screen sizes', + title: 'Devices', dimension: 'screen', endpoint: url.apiPath(site, '/screen-sizes'), - dimensionLabel: 'Screen size', + dimensionLabel: 'Device', defaultOrder: ['visitors', SortDirection.desc] } diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 61f09ea37fa5..51840cd36e2c 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -63,7 +63,7 @@ function EntryPagesModal() { ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if (isRealTimeDashboard(query) && !hasConversionGoalFilter(query)) { return [ metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 433f8f9b293d..0cb072a7642c 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -1,6 +1,9 @@ import React, { useCallback } from 'react' import Modal from './modal' -import { hasConversionGoalFilter } from '../../util/filters' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' import { addFilter, revenueAvailable } from '../../query' import BreakdownModal from './breakdown-modal' import * as metrics from '../reports/metrics' @@ -60,7 +63,7 @@ function ExitPagesModal() { ].filter((metric) => !!metric) } - if (query.period === 'realtime') { + if (isRealTimeDashboard(query) && !hasConversionGoalFilter(query)) { return [ metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', diff --git a/assets/js/dashboard/stats/modals/google-keywords.tsx b/assets/js/dashboard/stats/modals/google-keywords.tsx index 31979bac4776..01bd2c65fcfb 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.tsx +++ b/assets/js/dashboard/stats/modals/google-keywords.tsx @@ -91,7 +91,7 @@ function GoogleKeywordsModal() { return ( !!metric) } - if (query.period === 'realtime') { + if (isRealTimeDashboard(query) && !hasConversionGoalFilter(query)) { return [ metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index b4c05d17676e..3445480abf28 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -63,7 +63,7 @@ function PagesModal() { ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if (isRealTimeDashboard(query) && !hasConversionGoalFilter(query)) { return [ metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 324aafd2e87e..21b1b9bf38b8 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -70,7 +70,7 @@ function ReferrerDrilldownModal() { ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if (isRealTimeDashboard(query) && !hasConversionGoalFilter(query)) { return [ metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index 43c83b2cfcdc..383c4937a356 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -136,7 +136,7 @@ function SourcesModal({ currentView }) { ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if (isRealTimeDashboard(query) && !hasConversionGoalFilter(query)) { return [ metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', diff --git a/assets/js/dashboard/stats/more-link-state.js b/assets/js/dashboard/stats/more-link-state.js new file mode 100644 index 000000000000..45052d31d909 --- /dev/null +++ b/assets/js/dashboard/stats/more-link-state.js @@ -0,0 +1,5 @@ +export const MoreLinkState = { + HIDDEN: 'hidden', + LOADING: 'loading', + READY: 'ready' +} diff --git a/assets/js/dashboard/stats/more-link.js b/assets/js/dashboard/stats/more-link.js index 13c91782aec3..99da748ef8ed 100644 --- a/assets/js/dashboard/stats/more-link.js +++ b/assets/js/dashboard/stats/more-link.js @@ -1,11 +1,12 @@ -import React from 'react' +import React, { useRef, useEffect } from 'react' import { AppNavigationLink } from '../navigation/use-app-navigate' +import { Tooltip } from '../util/tooltip' +import { MoreLinkState } from './more-link-state' function detailsIcon() { return ( 0) { +export default function MoreLink({ linkProps, state }) { + const portalRef = useRef(null) + + useEffect(() => { + if (typeof document !== 'undefined') { + portalRef.current = document.body + } + }, []) + + const baseClassName = + 'flex mt-px text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150' + const icon = detailsIcon() + + if (state === MoreLinkState.HIDDEN) { + return null + } + + if (state === MoreLinkState.LOADING || !linkProps) { return ( -
- - {detailsIcon()} - DETAILS - -
+ +
{icon}
+
) } - return null + + return ( + + + {icon} + + + ) } diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index 36953b40c3e8..30650a2d511e 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -10,7 +10,11 @@ import { hasConversionGoalFilter } from '../../util/filters' import { useQueryContext } from '../../query-context' import { useSiteContext } from '../../site-context' import { entryPagesRoute, exitPagesRoute, topPagesRoute } from '../../router' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' function EntryPages({ afterFetchData }) { const { query } = useQueryContext() @@ -50,10 +54,6 @@ function EntryPages({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Entry page" metrics={chooseMetrics()} - detailsLinkProps={{ - path: entryPagesRoute.path, - search: (search) => search - }} getExternalLinkUrl={getExternalLinkUrl} color="bg-orange-50 group-hover/row:bg-orange-100" /> @@ -98,10 +98,6 @@ function ExitPages({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Exit page" metrics={chooseMetrics()} - detailsLinkProps={{ - path: exitPagesRoute.path, - search: (search) => search - }} getExternalLinkUrl={getExternalLinkUrl} color="bg-orange-50 group-hover/row:bg-orange-100" /> @@ -142,22 +138,12 @@ function TopPages({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Page" metrics={chooseMetrics()} - detailsLinkProps={{ - path: topPagesRoute.path, - search: (search) => search - }} getExternalLinkUrl={getExternalLinkUrl} color="bg-orange-50 group-hover/row:bg-orange-100" /> ) } -const labelFor = { - pages: 'Top pages', - 'entry-pages': 'Entry pages', - 'exit-pages': 'Exit pages' -} - export default function Pages() { const { query } = useQueryContext() const site = useSiteContext() @@ -167,6 +153,7 @@ export default function Pages() { const [mode, setMode] = useState(storedTab || 'pages') const [loading, setLoading] = useState(true) const [skipImportedReason, setSkipImportedReason] = useState(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) function switchTab(mode) { storage.setItem(tabKey, mode) @@ -176,9 +163,38 @@ export default function Pages() { function afterFetchData(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } - useEffect(() => setLoading(true), [query, mode]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [query, mode]) + + function moreLinkProps() { + switch (mode) { + case 'entry-pages': + return { + path: entryPagesRoute.path, + search: (search) => search + } + case 'exit-pages': + return { + path: exitPagesRoute.path, + search: (search) => search + } + case 'pages': + default: + return { + path: topPagesRoute.path, + search: (search) => search + } + } + } function renderContent() { switch (mode) { @@ -193,36 +209,37 @@ export default function Pages() { } return ( -
- {/* Header Container */} -
-
-

- {labelFor[mode] || 'Page Visits'} -

+ + +
+ + {[ + { + label: hasConversionGoalFilter(query) + ? 'Conversion pages' + : 'Top pages', + value: 'pages' + }, + { label: 'Entry pages', value: 'entry-pages' }, + { label: 'Exit pages', value: 'exit-pages' } + ].map(({ value, label }) => ( + switchTab(value)} + > + {label} + + ))} +
- - {[ - { label: 'Top pages', value: 'pages' }, - { label: 'Entry pages', value: 'entry-pages' }, - { label: 'Exit pages', value: 'exit-pages' } - ].map(({ value, label }) => ( - switchTab(value)} - key={value} - > - {label} - - ))} - -
- {/* Main Contents */} + + {renderContent()} -
+ ) } diff --git a/assets/js/dashboard/stats/reports/list.tsx b/assets/js/dashboard/stats/reports/list.tsx index ae8c50957aff..79cf70cddba1 100644 --- a/assets/js/dashboard/stats/reports/list.tsx +++ b/assets/js/dashboard/stats/reports/list.tsx @@ -1,9 +1,7 @@ import React, { useState, useEffect, useCallback, ReactNode } from 'react' -import { AppNavigationLinkProps } from '../../navigation/use-app-navigate' import FlipMove from 'react-flip-move' import FadeIn from '../../fade-in' -import MoreLink from '../more-link' import Bar from '../bar' import LazyLoader from '../../components/lazy-loader' import { trimURL } from '../../util/url' @@ -17,7 +15,7 @@ import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { BreakdownResultMeta } from '../../query' const MAX_ITEMS = 9 -export const MIN_HEIGHT = 380 +export const MIN_HEIGHT = 356 const ROW_HEIGHT = 32 const ROW_GAP_HEIGHT = 4 const DATA_CONTAINER_HEIGHT = @@ -93,8 +91,6 @@ type ListReportProps = { keyLabel: string metrics: Metric[] colMinWidth?: number - /** Navigation props to be passed to "More" link, if any. */ - detailsLinkProps?: AppNavigationLinkProps /** Function with additional action to be taken when a list entry is clicked. */ onClick?: () => void /** Color of the comparison bars in light-mode. */ @@ -115,7 +111,6 @@ export default function ListReport< metrics, colMinWidth = COL_MIN_WIDTH, afterFetchData, - detailsLinkProps, onClick, color, getFilterInfo, @@ -242,15 +237,6 @@ export default function ListReport< {state.list.slice(0, MAX_ITEMS).map(renderRow)}
- - {!!detailsLinkProps && !state.loading && ( - - )}
) } @@ -273,7 +259,7 @@ export default function ListReport< }) return ( -
+
{keyLabel} {metricLabels}
diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index d3089714c59d..107b373f2ef6 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -77,12 +77,12 @@ export const createVisitors = (props) => { const realtimeLabel = props.realtimeLabel || 'Current visitors' const goalFilterLabel = props.goalFilterLabel || 'Conversions' - if (query.period === 'realtime') { - return realtimeLabel - } if (query && hasConversionGoalFilter(query)) { return goalFilterLabel } + if (query.period === 'realtime') { + return realtimeLabel + } return defaultLabel } } diff --git a/assets/js/dashboard/stats/reports/report-header.js b/assets/js/dashboard/stats/reports/report-header.js new file mode 100644 index 000000000000..c5b8c381abdb --- /dev/null +++ b/assets/js/dashboard/stats/reports/report-header.js @@ -0,0 +1,9 @@ +import React from 'react' + +export function ReportHeader({ children }) { + return ( +
+ {children} +
+ ) +} diff --git a/assets/js/dashboard/stats/reports/report-layout.js b/assets/js/dashboard/stats/reports/report-layout.js new file mode 100644 index 000000000000..acbd83ce9a71 --- /dev/null +++ b/assets/js/dashboard/stats/reports/report-layout.js @@ -0,0 +1,15 @@ +import React from 'react' +import classNames from 'classnames' + +export function ReportLayout({ children, className = undefined }) { + return ( +
+ {children} +
+ ) +} diff --git a/assets/js/dashboard/stats/sources/referrer-list.js b/assets/js/dashboard/stats/sources/referrer-list.js index 8f7269d88673..42ce1d3e2865 100644 --- a/assets/js/dashboard/stats/sources/referrer-list.js +++ b/assets/js/dashboard/stats/sources/referrer-list.js @@ -9,6 +9,11 @@ import { useQueryContext } from '../../query-context' import { useSiteContext } from '../../site-context' import { referrersDrilldownRoute } from '../../router' import { SourceFavicon } from './source-favicon' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' const NO_REFERRER = 'Direct / None' @@ -17,8 +22,12 @@ export default function Referrers({ source }) { const site = useSiteContext() const [skipImportedReason, setSkipImportedReason] = useState(null) const [loading, setLoading] = useState(true) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) - useEffect(() => setLoading(true), [query]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [query]) function fetchReferrers() { return api.get( @@ -31,6 +40,11 @@ export default function Referrers({ source }) { function afterFetchReferrers(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } function getExternalLinkUrl(referrer) { @@ -68,29 +82,38 @@ export default function Referrers({ source }) { } return ( -
-
-

Top Referrers

- + +
+ + {}}> + Top referrers + + + +
+ search + }} /> -
+ search - }} getExternalLinkUrl={getExternalLinkUrl} renderIcon={renderIcon} color="bg-blue-50" /> -
+ ) } diff --git a/assets/js/dashboard/stats/sources/search-terms.tsx b/assets/js/dashboard/stats/sources/search-terms.tsx index bab9205f1e34..7cb15c2d5565 100644 --- a/assets/js/dashboard/stats/sources/search-terms.tsx +++ b/assets/js/dashboard/stats/sources/search-terms.tsx @@ -1,14 +1,18 @@ import React, { useEffect, useCallback } from 'react' import FadeIn from '../../fade-in' import Bar from '../bar' -import MoreLink from '../more-link' import { numberShortFormatter } from '../../util/number-formatter' import RocketIcon from '../modals/rocket-icon' import * as api from '../../api' import LazyLoader from '../../components/lazy-loader' -import { referrersGoogleRoute } from '../../router' import { useQueryContext } from '../../query-context' import { PlausibleSite, useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' +import { referrersGoogleRoute } from '../../router' interface SearchTerm { name: string @@ -74,6 +78,9 @@ function ConfigureSearchTermsCTA({ export function SearchTerms() { const site = useSiteContext() const { query } = useQueryContext() + const [moreLinkState, setMoreLinkState] = React.useState( + MoreLinkState.LOADING + ) const [loading, setLoading] = React.useState(true) const [errorPayload, setErrorPayload] = React.useState( @@ -94,11 +101,17 @@ export function SearchTerms() { setLoading(false) setSearchTerms(res.results) setErrorPayload(null) + if (res.results && res.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } }) .catch((error) => { setLoading(false) setSearchTerms(null) setErrorPayload(error.payload) + setMoreLinkState(MoreLinkState.HIDDEN) }) }, [query, site.domain]) @@ -106,6 +119,7 @@ export function SearchTerms() { if (visible) { setLoading(true) setSearchTerms([]) + setMoreLinkState(MoreLinkState.LOADING) fetchSearchTerms() } }, [query, fetchSearchTerms, visible]) @@ -143,15 +157,6 @@ export function SearchTerms() {
))} - ) => search - }} - className="w-full mt-3" - onClick={undefined} - /> ) } @@ -186,8 +191,23 @@ export function SearchTerms() { } return ( -
-

Search Terms

+ + +
+ + {}}> + Search terms + + +
+ search + }} + /> +
{loading && (
@@ -204,6 +224,6 @@ export function SearchTerms() {
-
+
) } diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index 1984101c9a8a..44d3c9f2e85b 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -23,7 +23,11 @@ import { utmSourcesRoute, utmTermsRoute } from '../../router' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' const UTM_TAGS = { utm_medium: { @@ -83,7 +87,6 @@ function AllSources({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Source" metrics={chooseMetrics()} - detailsLinkProps={{ path: sourcesRoute.path, search: (search) => search }} renderIcon={renderIcon} color="bg-blue-50 group-hover/row:bg-blue-100" /> @@ -122,10 +125,6 @@ function Channels({ onClick, afterFetchData }) { keyLabel="Channel" onClick={onClick} metrics={chooseMetrics()} - detailsLinkProps={{ - path: channelsRoute.path, - search: (search) => search - }} color="bg-blue-50 group-hover/row:bg-blue-100" /> ) @@ -136,14 +135,6 @@ function UTMSources({ tab, afterFetchData }) { const site = useSiteContext() const utmTag = UTM_TAGS[tab] - const route = { - utm_medium: utmMediumsRoute, - utm_source: utmSourcesRoute, - utm_campaign: utmCampaignsRoute, - utm_content: utmContentsRoute, - utm_term: utmTermsRoute - }[tab] - function fetchData() { return api.get(url.apiPath(site, utmTag.endpoint), query, { limit: 9 }) } @@ -171,21 +162,11 @@ function UTMSources({ tab, afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel={utmTag.label} metrics={chooseMetrics()} - detailsLinkProps={{ path: route?.path, search: (search) => search }} color="bg-blue-50 group-hover/row:bg-blue-100" /> ) } -const labelFor = { - channels: 'Top channels', - all: 'Top sources' -} - -for (const [key, utm_tag] of Object.entries(UTM_TAGS)) { - labelFor[key] = utm_tag.title -} - export default function SourceList() { const site = useSiteContext() const { query } = useQueryContext() @@ -194,9 +175,13 @@ export default function SourceList() { const [currentTab, setCurrentTab] = useState(storedTab || 'all') const [loading, setLoading] = useState(true) const [skipImportedReason, setSkipImportedReason] = useState(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) const previousQuery = usePrevious(query) - useEffect(() => setLoading(true), [query, currentTab]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [query, currentTab]) useEffect(() => { const isRemovingFilter = (filterName) => { @@ -225,6 +210,48 @@ export default function SourceList() { setTab('all')() } + function afterFetchData(apiResponse) { + setLoading(false) + setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } + } + + function moreLinkProps() { + if (Object.keys(UTM_TAGS).includes(currentTab)) { + const route = { + utm_medium: utmMediumsRoute, + utm_source: utmSourcesRoute, + utm_campaign: utmCampaignsRoute, + utm_content: utmContentsRoute, + utm_term: utmTermsRoute + }[currentTab] + return route + ? { + path: route.path, + search: (search) => search + } + : null + } + + switch (currentTab) { + case 'channels': + return { + path: channelsRoute.path, + search: (search) => search + } + case 'all': + default: + return { + path: sourcesRoute.path, + search: (search) => search + } + } + } + function renderContent() { if (Object.keys(UTM_TAGS).includes(currentTab)) { return @@ -241,54 +268,45 @@ export default function SourceList() { } } - function afterFetchData(apiResponse) { - setLoading(false) - setSkipImportedReason(apiResponse.skip_imported_reason) - } - return ( -
- {/* Header Container */} -
-
-

- {labelFor[currentTab]} -

+ + +
+ + {[ + { value: 'channels', label: 'Channels' }, + { value: 'all', label: 'Sources' } + ].map(({ value, label }) => ( + + {label} + + ))} + ({ + value, + label: title, + onClick: setTab(value), + selected: currentTab === value + }))} + > + {UTM_TAGS[currentTab] ? UTM_TAGS[currentTab].title : 'Campaigns'} + +
- - {[ - { value: 'channels', label: 'Channels' }, - { value: 'all', label: 'Sources' } - ].map(({ value, label }) => ( - - {label} - - ))} - ({ - value, - label: title, - onClick: setTab(value), - selected: currentTab === value - }))} - > - {UTM_TAGS[currentTab] ? UTM_TAGS[currentTab].title : 'Campaigns'} - - -
- {/* Main Contents */} + + {renderContent()} -
+ ) } diff --git a/assets/js/dashboard/util/tooltip.tsx b/assets/js/dashboard/util/tooltip.tsx index fbce1a08133f..0ec0010427c6 100644 --- a/assets/js/dashboard/util/tooltip.tsx +++ b/assets/js/dashboard/util/tooltip.tsx @@ -91,7 +91,7 @@ function TooltipMessage({ ref={setPopperElement} style={popperStyle} {...popperAttributes} - className="z-[999] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700" + className="z-[99] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700" role="tooltip" > {children}