Update site switcher UI to accommodate for consolidated view (#5838)

* Update site switcher UI to accommodate for consolidated view

* Implement logic to display consolidated view in site picker

* Fix "All sites" selected state in site switcher

* Fixup tests

* Include consolidated view assigns in shared links

* Format

* Extract `ConsolidatedView.ok_to_display?/2`

* Format

* I'll pretend no one saw this

* Skip unnecessary `on_ee`

* oops

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
This commit is contained in:
Sanne de Vries 2025-10-29 05:58:45 -05:00 committed by GitHub
parent 638a59b8f5
commit ce424bf436
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 328 additions and 84 deletions

View File

@ -64,12 +64,21 @@ if (container && container.dataset) {
? { ? {
loggedIn: true, loggedIn: true,
id: parseInt(container.dataset.currentUserId!, 10), id: parseInt(container.dataset.currentUserId!, 10),
role: container.dataset.currentUserRole as Role role: container.dataset.currentUserRole as Role,
team: {
identifier: container.dataset.teamIdentifier ?? null,
hasConsolidatedView:
container.dataset.teamHasConsolidatedView === 'true'
}
} }
: { : {
loggedIn: false, loggedIn: false,
id: null, id: null,
role: container.dataset.currentUserRole as Role role: container.dataset.currentUserRole as Role,
team: {
identifier: null,
hasConsolidatedView: false
}
} }
} }
> >

View File

@ -76,9 +76,7 @@ export default function FilterOperatorSelector(props) {
'w-full text-left ', 'w-full text-left ',
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink
popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd
)} )}
> >
{FILTER_OPERATIONS_DISPLAY_NAMES[operation]} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}

View File

@ -26,7 +26,7 @@ const transition = {
const panel = { const panel = {
classNames: { classNames: {
roundedSheet: roundedSheet:
'focus:outline-hidden rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black/5 font-medium text-gray-800 dark:text-gray-200' 'flex flex-col gap-0.5 p-1 focus:outline-hidden rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black/5 font-medium text-gray-800 dark:text-gray-200'
} }
} }
@ -48,10 +48,16 @@ const items = {
classNames: { classNames: {
navigationLink: classNames( navigationLink: classNames(
'flex items-center justify-between', 'flex items-center justify-between',
'px-4 py-2 text-sm leading-tight', 'px-4 py-2.5 text-sm leading-tight rounded-md',
'cursor-pointer' 'cursor-pointer'
), ),
selectedOption: classNames('data-[selected=true]:font-bold'), selectedOption: classNames(
'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'
),
hoverLink: classNames( hoverLink: classNames(
'hover:bg-gray-100', 'hover:bg-gray-100',
'hover:text-gray-900', 'hover:text-gray-900',
@ -62,10 +68,7 @@ const items = {
'focus-within:text-gray-900', 'focus-within:text-gray-900',
'dark:focus-within:bg-gray-700', 'dark:focus-within:bg-gray-700',
'dark:focus-within:text-gray-100' 'dark:focus-within:text-gray-100'
), )
roundedStart: 'first-of-type:rounded-t-md',
roundedEnd: 'last-of-type:rounded-b-md',
groupRoundedEnd: 'group-last-of-type:rounded-b-md'
} }
} }

View File

@ -145,12 +145,7 @@ const Items = ({
'w-full text-left', 'w-full text-left',
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink
{
[popover.items.classNames.roundedStart]: !searchable || !showSearch
// when the menu is not searchable, the first item needs rounded top
},
popover.items.classNames.roundedEnd
) )
return ( return (

View File

@ -90,7 +90,12 @@ describe(`${isListableSegment.name}`, () => {
const site: Pick<PlausibleSite, 'siteSegmentsAvailable'> = { const site: Pick<PlausibleSite, 'siteSegmentsAvailable'> = {
siteSegmentsAvailable: true siteSegmentsAvailable: true
} }
const user: UserContextValue = { loggedIn: true, id: 1, role: Role.editor } const user: UserContextValue = {
loggedIn: true,
id: 1,
role: Role.editor,
team: { identifier: null, hasConsolidatedView: false }
}
it('should return true for site segment when siteSegmentsAvailable is true', () => { it('should return true for site segment when siteSegmentsAvailable is true', () => {
const segment = { id: 1, type: SegmentType.site, owner_id: 1 } const segment = { id: 1, type: SegmentType.site, owner_id: 1 }
@ -103,7 +108,12 @@ describe(`${isListableSegment.name}`, () => {
isListableSegment({ isListableSegment({
segment, segment,
site, site,
user: { loggedIn: false, role: Role.public, id: null } user: {
loggedIn: false,
role: Role.public,
id: null,
team: { identifier: null, hasConsolidatedView: false }
}
}) })
).toBe(false) ).toBe(false)
}) })
@ -175,7 +185,12 @@ describe(`${resolveFilters.name}`, () => {
describe(`${canSeeSegmentDetails.name}`, () => { describe(`${canSeeSegmentDetails.name}`, () => {
it('should return true if the user is logged in and not a public role', () => { it('should return true if the user is logged in and not a public role', () => {
const user: UserContextValue = { loggedIn: true, role: Role.admin, id: 1 } const user: UserContextValue = {
loggedIn: true,
role: Role.admin,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
}
expect(canSeeSegmentDetails({ user })).toBe(true) expect(canSeeSegmentDetails({ user })).toBe(true)
}) })
@ -183,13 +198,19 @@ describe(`${canSeeSegmentDetails.name}`, () => {
const user: UserContextValue = { const user: UserContextValue = {
loggedIn: false, loggedIn: false,
role: Role.editor, role: Role.editor,
id: null id: null,
team: { identifier: null, hasConsolidatedView: false }
} }
expect(canSeeSegmentDetails({ user })).toBe(false) expect(canSeeSegmentDetails({ user })).toBe(false)
}) })
it('should return false if the user has a public role', () => { it('should return false if the user has a public role', () => {
const user: UserContextValue = { loggedIn: true, role: Role.public, id: 1 } const user: UserContextValue = {
loggedIn: true,
role: Role.public,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
}
expect(canSeeSegmentDetails({ user })).toBe(false) expect(canSeeSegmentDetails({ user })).toBe(false)
}) })
}) })

View File

@ -145,7 +145,7 @@ export function KeybindHint({
return ( return (
<kbd <kbd
className={classNames( className={classNames(
'rounded border border-gray-200 dark:border-gray-600 px-1.5 font-medium text-xs text-gray-400', 'rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 px-1.5 font-medium text-xs text-gray-400',
className className
)} )}
> >

View File

@ -309,10 +309,6 @@ const SeeMoreMenu = ({
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink,
{
[popover.items.classNames.roundedStart]: !showMoreFilters // rounded start is needed when there's no filters panel above
},
popover.items.classNames.roundedEnd,
'whitespace-nowrap' 'whitespace-nowrap'
) )

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
export const MenuSeparator = () => ( export const MenuSeparator = () => (
<div className="my-1 border-gray-200 dark:border-gray-700 border-b" /> <div className="my-0.5 -mx-1 border-gray-200 dark:border-gray-700 border-b" />
) )

View File

@ -7,9 +7,7 @@ import { Popover, Transition } from '@headlessui/react'
export const linkClassName = classNames( export const linkClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink
popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd
) )
export const datemenuButtonClassName = classNames( export const datemenuButtonClassName = classNames(

View File

@ -25,8 +25,7 @@ import { useSearchableItems } from '../../hooks/use-searchable-items'
const linkClassName = classNames( const linkClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink
popover.items.classNames.groupRoundedEnd
) )
const INITIAL_SEGMENTS_SHOWN = 5 const INITIAL_SEGMENTS_SHOWN = 5

View File

@ -20,9 +20,7 @@ import { DashboardQuery } from '../../query'
const linkClassName = classNames( const linkClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink
popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd
) )
const buttonClassName = classNames( const buttonClassName = classNames(
'text-white font-medium bg-indigo-600 hover:bg-indigo-700' 'text-white font-medium bg-indigo-600 hover:bg-indigo-700'

View File

@ -60,12 +60,12 @@ test('user can open and close site switcher', async () => {
.map((el) => ({ text: el.textContent, href: el.getAttribute('href') })) .map((el) => ({ text: el.textContent, href: el.getAttribute('href') }))
).toEqual( ).toEqual(
[ [
{ text: ['Back to sites'], href: '/sites' },
{ text: ['Site settings'], href: `/${domain}/settings/general` }, { text: ['Site settings'], href: `/${domain}/settings/general` },
{ text: ['dummy.site', '1'], href: '#' }, { text: ['dummy.site', '1'], href: '#' },
{ text: ['example.com', '2'], href: `/example.com` }, { text: ['example.com', '2'], href: `/example.com` },
{ text: ['blog.example.com', '3'], href: `/blog.example.com` }, { text: ['blog.example.com', '3'], href: `/blog.example.com` },
{ text: ['aççented.ca', '4'], href: `/a%C3%A7%C3%A7ented.ca` }, { text: ['aççented.ca', '4'], href: `/a%C3%A7%C3%A7ented.ca` }
{ text: ['View all'], href: '/sites' }
].map((l) => ({ ...l, text: l.text.join('') })) ].map((l) => ({ ...l, text: l.text.join('') }))
) )

View File

@ -50,7 +50,12 @@ describe('Segment details modal - errors', () => {
case: 'segment is not in list', case: 'segment is not in list',
segments: [anyPersonalSegment, anySiteSegment], segments: [anyPersonalSegment, anySiteSegment],
segmentId: 202020, segmentId: 202020,
user: { loggedIn: true, id: 1, role: Role.owner }, user: {
loggedIn: true,
id: 1,
role: Role.owner,
team: { identifier: null, hasConsolidatedView: false }
},
message: `Segment not found with with ID "202020"`, message: `Segment not found with with ID "202020"`,
siteOptions: { siteSegmentsAvailable: true } siteOptions: { siteSegmentsAvailable: true }
}, },
@ -58,7 +63,12 @@ describe('Segment details modal - errors', () => {
case: 'site segment is in list but not listable because site segments are not available', case: 'site segment is in list but not listable because site segments are not available',
segments: [anyPersonalSegment, anySiteSegment], segments: [anyPersonalSegment, anySiteSegment],
segmentId: anySiteSegment.id, segmentId: anySiteSegment.id,
user: { loggedIn: true, id: 1, role: Role.owner }, user: {
loggedIn: true,
id: 1,
role: Role.owner,
team: { identifier: null, hasConsolidatedView: false }
},
message: `Segment not found with with ID "${anySiteSegment.id}"`, message: `Segment not found with with ID "${anySiteSegment.id}"`,
siteOptions: { siteSegmentsAvailable: false } siteOptions: { siteSegmentsAvailable: false }
}, },
@ -66,7 +76,12 @@ describe('Segment details modal - errors', () => {
case: 'personal segment is in list but not listable because it is a public dashboard', case: 'personal segment is in list but not listable because it is a public dashboard',
segments: [{ ...anyPersonalSegment, owner_id: null, owner_name: null }], segments: [{ ...anyPersonalSegment, owner_id: null, owner_name: null }],
segmentId: anyPersonalSegment.id, segmentId: anyPersonalSegment.id,
user: { loggedIn: false, id: null, role: Role.public }, user: {
loggedIn: false,
id: null,
role: Role.public,
team: { identifier: null, hasConsolidatedView: false }
},
message: `Segment not found with with ID "${anyPersonalSegment.id}"`, message: `Segment not found with with ID "${anyPersonalSegment.id}"`,
siteOptions: { siteSegmentsAvailable: true } siteOptions: { siteSegmentsAvailable: true }
}, },
@ -74,7 +89,12 @@ describe('Segment details modal - errors', () => {
case: 'segment is in list and listable, but detailed view is not available because user is not logged in', case: 'segment is in list and listable, but detailed view is not available because user is not logged in',
segments: [{ ...anySiteSegment, owner_id: null, owner_name: null }], segments: [{ ...anySiteSegment, owner_id: null, owner_name: null }],
segmentId: anySiteSegment.id, segmentId: anySiteSegment.id,
user: { loggedIn: false, id: null, role: Role.public }, user: {
loggedIn: false,
id: null,
role: Role.public,
team: { identifier: null, hasConsolidatedView: false }
},
message: 'Not enough permissions to see segment details', message: 'Not enough permissions to see segment details',
siteOptions: { siteSegmentsAvailable: true } siteOptions: { siteSegmentsAvailable: true }
} }
@ -118,7 +138,12 @@ describe('Segment details modal - other cases', () => {
render(<SegmentModal id={anySiteSegment.id} />, { render(<SegmentModal id={anySiteSegment.id} />, {
wrapper: (props) => ( wrapper: (props) => (
<TestContextProviders <TestContextProviders
user={{ loggedIn: true, role: Role.editor, id: 1 }} user={{
loggedIn: true,
role: Role.editor,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
}}
preloaded={{ preloaded={{
segments: [anySiteSegment] segments: [anySiteSegment]
}} }}

View File

@ -67,7 +67,8 @@ describe('parseSiteFromDataset', () => {
realtime: ['minute'], realtime: ['minute'],
year: ['day', 'week', 'month'] year: ['day', 'week', 'month']
}, },
shared: false shared: false,
isConsolidatedView: false
} }
it('parses from dom string map correctly', () => { it('parses from dom string map correctly', () => {

View File

@ -21,7 +21,8 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
isDbip: dataset.isDbip === 'true', isDbip: dataset.isDbip === 'true',
flags: JSON.parse(dataset.flags!), flags: JSON.parse(dataset.flags!),
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!), validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!),
shared: !!dataset.sharedLinkAuth shared: !!dataset.sharedLinkAuth,
isConsolidatedView: dataset.isConsolidatedView === 'true'
} }
} }
@ -51,7 +52,8 @@ const siteContextDefaultValue = {
isDbip: false, isDbip: false,
flags: {} as FeatureFlags, flags: {} as FeatureFlags,
validIntervalsByPeriod: {} as Record<string, Array<string>>, validIntervalsByPeriod: {} as Record<string, Array<string>>,
shared: false shared: false,
isConsolidatedView: false
} }
export type PlausibleSite = typeof siteContextDefaultValue export type PlausibleSite = typeof siteContextDefaultValue

View File

@ -4,7 +4,7 @@
import React, { useRef } from 'react' import React, { useRef } from 'react'
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { Cog8ToothIcon } from '@heroicons/react/24/outline' import { Cog8ToothIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
import classNames from 'classnames' import classNames from 'classnames'
import { isModifierPressed, isTyping, Keybind, KeybindHint } from './keybinding' import { isModifierPressed, isTyping, Keybind, KeybindHint } from './keybinding'
import { popover, BlurMenuButtonOnEscape } from './components/popover' import { popover, BlurMenuButtonOnEscape } from './components/popover'
@ -39,12 +39,44 @@ const Favicon = ({
/> />
) )
const GlobeIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className={className}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M22 12H2M12 22c5.714-5.442 5.714-14.558 0-20M12 22C6.286 16.558 6.286 7.442 12 2"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"
/>
</svg>
)
const menuItemClassName = classNames( const menuItemClassName = classNames(
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink
popover.items.classNames.roundedStart, )
popover.items.classNames.roundedEnd
const buttonLinkClassName = classNames(
'flex-1 flex items-center justify-center',
'my-1 mx-1',
'border border-gray-300 dark:border-gray-700',
'px-3 py-2 text-sm font-medium rounded-md',
'bg-white text-gray-700 dark:text-gray-300 dark:bg-gray-700',
'transition-all duration-200',
'hover:text-gray-900 hover:border-gray-400/70 dark:hover:bg-gray-600 dark:hover:border-gray-600 dark:hover:text-white'
) )
const getSwitchToSiteURL = ( const getSwitchToSiteURL = (
@ -110,6 +142,30 @@ export const SiteSwitcher = () => {
/> />
))} ))}
{!!dashboardRouteMatch &&
!modal &&
user.team?.hasConsolidatedView &&
user.team.identifier && (
<Keybind
key={user.team.identifier}
keyboardKey="0"
type="keydown"
handler={() => {
const url = getSwitchToSiteURL(currentSite, {
domain: user.team.identifier!
})
if (!url) {
closePopover()
} else {
closePopover()
window.location.assign(url)
}
}}
shouldIgnoreWhen={[isModifierPressed, isTyping]}
targetRef="document"
/>
)}
<BlurMenuButtonOnEscape targetRef={buttonRef} /> <BlurMenuButtonOnEscape targetRef={buttonRef} />
<Popover.Button <Popover.Button
ref={buttonRef} ref={buttonRef}
@ -119,12 +175,18 @@ export const SiteSwitcher = () => {
)} )}
title={currentSite.domain} title={currentSite.domain}
> >
<Favicon {currentSite.isConsolidatedView ? (
domain={currentSite.domain} <GlobeIcon className="size-4 block mx-1 h-4 w-4 text-indigo-600 dark:text-white" />
className="block h-4 w-4 mx-1" ) : (
/> <Favicon
domain={currentSite.domain}
className="block h-4 w-4 mx-1"
/>
)}
<span className={'truncate hidden sm:block sm:mr-1 lg:mr-0'}> <span className={'truncate hidden sm:block sm:mr-1 lg:mr-0'}>
{currentSite.domain} {currentSite.isConsolidatedView
? 'All sites'
: currentSite.domain}
</span> </span>
<ChevronDownIcon className="hidden lg:block h-5 w-5 ml-2 dark:text-gray-100" /> <ChevronDownIcon className="hidden lg:block h-5 w-5 ml-2 dark:text-gray-100" />
</Popover.Button> </Popover.Button>
@ -140,18 +202,24 @@ export const SiteSwitcher = () => {
data-testid="sitemenu" data-testid="sitemenu"
className={classNames(popover.panel.classNames.roundedSheet)} className={classNames(popover.panel.classNames.roundedSheet)}
> >
{canSeeSiteSettings && ( <div className="flex">
<> {canSeeViewAllSites && (
<a className={buttonLinkClassName} href={`/sites`}>
<ArrowLeftIcon className="size-4 mr-1.5" />
Back to sites
</a>
)}
{canSeeSiteSettings && (
<a <a
className={menuItemClassName} className={buttonLinkClassName}
href={`/${encodeURIComponent(currentSite.domain)}/settings/general`} href={`/${encodeURIComponent(currentSite.domain)}/settings/general`}
> >
<Cog8ToothIcon className="h-4 w-4 block mr-2" /> <Cog8ToothIcon className="size-4 mr-1.5" />
<span className="mr-auto">Site settings</span> Site settings
</a> </a>
<MenuSeparator /> )}
</> </div>
)} {(canSeeSiteSettings || canSeeViewAllSites) && <MenuSeparator />}
{sitesQuery.isLoading && ( {sitesQuery.isLoading && (
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="loading sm"> <div className="loading sm">
@ -167,6 +235,22 @@ export const SiteSwitcher = () => {
/> />
</div> </div>
)} )}
{user.team.hasConsolidatedView && user.team.identifier && (
<a
data-selected={currentSite.isConsolidatedView}
className={menuItemClassName}
href={
getSwitchToSiteURL(currentSite, {
domain: user.team.identifier
}) ?? '#'
}
onClick={() => closePopover()}
>
<GlobeIcon className="size-4 block mr-2 text-indigo-600 dark:text-white" />
<span className="truncate mr-auto">All sites</span>
<KeybindHint>0</KeybindHint>
</a>
)}
{!!sitesInDropdown && {!!sitesInDropdown &&
sitesInDropdown.map(({ domain }, index) => ( sitesInDropdown.map(({ domain }, index) => (
<a <a
@ -187,14 +271,6 @@ export const SiteSwitcher = () => {
)} )}
</a> </a>
))} ))}
{canSeeViewAllSites && (
<>
<MenuSeparator />
<a className={menuItemClassName} href={`/sites`}>
View all
</a>
</>
)}
</Popover.Panel> </Popover.Panel>
</Transition> </Transition>
</> </>

View File

@ -172,8 +172,6 @@ export function IntervalPicker({
popover.items.classNames.navigationLink, popover.items.classNames.navigationLink,
popover.items.classNames.selectedOption, popover.items.classNames.selectedOption,
popover.items.classNames.hoverLink, popover.items.classNames.hoverLink,
popover.items.classNames.roundedStart,
popover.items.classNames.roundedEnd,
'w-full text-left' 'w-full text-left'
)} )}
> >

View File

@ -12,10 +12,24 @@ export enum Role {
const userContextDefaultValue = { const userContextDefaultValue = {
loggedIn: false, loggedIn: false,
id: null, id: null,
role: Role.public role: Role.public,
team: {
identifier: null,
hasConsolidatedView: false
}
} as } as
| { loggedIn: false; id: null; role: Role } | {
| { loggedIn: true; id: number; role: Role } loggedIn: false
id: null
role: Role
team: { identifier: null; hasConsolidatedView: false }
}
| {
loggedIn: true
id: number
role: Role
team: { identifier: string | null; hasConsolidatedView: boolean }
}
export type UserContextValue = typeof userContextDefaultValue export type UserContextValue = typeof userContextDefaultValue

View File

@ -42,7 +42,8 @@ export const DEFAULT_SITE: PlausibleSite = {
isDbip: false, isDbip: false,
flags: {}, flags: {},
validIntervalsByPeriod: {}, validIntervalsByPeriod: {},
shared: false shared: false,
isConsolidatedView: false
} }
export const TestContextProviders = ({ export const TestContextProviders = ({
@ -68,7 +69,14 @@ export const TestContextProviders = ({
// <ThemeContextProvider> not interactive component, default value is suitable // <ThemeContextProvider> not interactive component, default value is suitable
<SiteContextProvider site={site}> <SiteContextProvider site={site}>
<UserContextProvider <UserContextProvider
user={user ?? { role: Role.editor, loggedIn: true, id: 1 }} user={
user ?? {
role: Role.editor,
loggedIn: true,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
}
}
> >
<SegmentsContextProvider preloadedSegments={preloaded?.segments ?? []}> <SegmentsContextProvider preloadedSegments={preloaded?.segments ?? []}>
<MemoryRouter <MemoryRouter

View File

@ -16,6 +16,20 @@ defmodule Plausible.ConsolidatedView do
import Ecto.Query import Ecto.Query
@spec ok_to_display?(Team.t() | nil, User.t() | nil) :: boolean()
def ok_to_display?(team, user) do
with %Team{} <- team,
%User{} <- user,
true <- Plausible.Auth.is_super_admin?(user),
true <- enabled?(team),
true <- has_sites_to_consolidate?(team) do
true
else
_ ->
false
end
end
@spec reset_if_enabled(Team.t()) :: :ok @spec reset_if_enabled(Team.t()) :: :ok
def reset_if_enabled(%Team{} = team) do def reset_if_enabled(%Team{} = team) do
case get(team) do case get(team) do

View File

@ -61,8 +61,15 @@ defmodule PlausibleWeb.StatsController do
demo = site.domain == "plausible.io" demo = site.domain == "plausible.io"
dogfood_page_path = if demo, do: "/#{site.domain}", else: "/:dashboard" dogfood_page_path = if demo, do: "/#{site.domain}", else: "/:dashboard"
consolidated_view? = Plausible.Sites.consolidated?(site)
team_has_consolidated_view? =
on_ee(do: Plausible.ConsolidatedView.ok_to_display?(site.team, current_user), else: false)
team_identifier = site.team.identifier
skip_to_dashboard? = skip_to_dashboard? =
conn.params["skip_to_dashboard"] == "true" or Plausible.Sites.consolidated?(site) conn.params["skip_to_dashboard"] == "true" or consolidated_view?
{:ok, segments} = Plausible.Segments.get_all_for_site(site, site_role) {:ok, segments} = Plausible.Segments.get_all_for_site(site, site_role)
@ -87,7 +94,10 @@ defmodule PlausibleWeb.StatsController do
is_dbip: is_dbip(), is_dbip: is_dbip(),
segments: segments, segments: segments,
load_dashboard_js: true, load_dashboard_js: true,
hide_footer?: if(ce?() || demo, do: false, else: site_role != :public) hide_footer?: if(ce?() || demo, do: false, else: site_role != :public),
consolidated_view?: consolidated_view?,
team_has_consolidated_view?: team_has_consolidated_view?,
team_identifier: team_identifier
) )
!stats_start_date && can_see_stats? -> !stats_start_date && can_see_stats? ->
@ -392,6 +402,16 @@ defmodule PlausibleWeb.StatsController do
embedded? = conn.params["embed"] == "true" embedded? = conn.params["embed"] == "true"
consolidated_view? = Plausible.Sites.consolidated?(shared_link.site)
team_has_consolidated_view? =
on_ee(
do: Plausible.ConsolidatedView.ok_to_display?(shared_link.site.team, current_user),
else: false
)
team_identifier = shared_link.site.team.identifier
conn conn
|> put_resp_header("x-robots-tag", "noindex, nofollow") |> put_resp_header("x-robots-tag", "noindex, nofollow")
|> delete_resp_header("x-frame-options") |> delete_resp_header("x-frame-options")
@ -414,7 +434,10 @@ defmodule PlausibleWeb.StatsController do
is_dbip: is_dbip(), is_dbip: is_dbip(),
segments: segments, segments: segments,
load_dashboard_js: true, load_dashboard_js: true,
hide_footer?: if(ce?(), do: embedded?, else: embedded? || site_role != :public) hide_footer?: if(ce?(), do: embedded?, else: embedded? || site_role != :public),
consolidated_view?: consolidated_view?,
team_has_consolidated_view?: team_has_consolidated_view?,
team_identifier: team_identifier
) )
end end
end end

View File

@ -115,7 +115,7 @@ defmodule PlausibleWeb.Live.Sites do
<ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<!-- Insert upgrade_card here --> <!-- Insert upgrade_card here -->
<.consolidated_view_card <.consolidated_view_card
:if={@consolidated_view && Plausible.Auth.is_super_admin?(@current_user)} :if={@consolidated_view && consolidated_view_ok_to_display?(@current_team, @current_user)}
can_manage_consolidated_view?={@can_manage_consolidated_view?} can_manage_consolidated_view?={@can_manage_consolidated_view?}
consolidated_view={@consolidated_view} consolidated_view={@consolidated_view}
consolidated_stats={@consolidated_stats} consolidated_stats={@consolidated_stats}
@ -272,7 +272,7 @@ defmodule PlausibleWeb.Live.Sites do
<.consolidated_view_stat <.consolidated_view_stat
value={@consolidated_stats.views_per_visit} value={@consolidated_stats.views_per_visit}
label="Views per visit" label="Views per visit"
change={1} change={@consolidated_stats.views_per_visit_change}
/> />
</div> </div>
</div> </div>
@ -951,6 +951,10 @@ defmodule PlausibleWeb.Live.Sites do
on_ee do on_ee do
alias Plausible.ConsolidatedView alias Plausible.ConsolidatedView
defp consolidated_view_ok_to_display?(team, user) do
ConsolidatedView.ok_to_display?(team, user)
end
defp init_consolidated_view_assigns(_user, nil), do: @no_consolidated_view defp init_consolidated_view_assigns(_user, nil), do: @no_consolidated_view
defp init_consolidated_view_assigns(user, team) do defp init_consolidated_view_assigns(user, team) do
@ -975,6 +979,7 @@ defmodule PlausibleWeb.Live.Sites do
end end
end end
else else
defp consolidated_view_ok_to_display?(_team, _user), do: false
defp init_consolidated_view_assigns(_user, _team), do: @no_consolidated_view defp init_consolidated_view_assigns(_user, _team), do: @no_consolidated_view
defp load_consolidated_stats(_consolidated_view), do: nil defp load_consolidated_stats(_consolidated_view), do: nil
end end

View File

@ -49,6 +49,9 @@
data-valid-intervals-by-period={ data-valid-intervals-by-period={
Plausible.Stats.Interval.valid_by_period(site: @site) |> Jason.encode!() Plausible.Stats.Interval.valid_by_period(site: @site) |> Jason.encode!()
} }
data-is-consolidated-view={Jason.encode!(@consolidated_view?)}
data-team-has-consolidated-view={Jason.encode!(@team_has_consolidated_view?)}
data-team-identifier={@team_identifier}
> >
</div> </div>
<div id="modal_root"></div> <div id="modal_root"></div>

View File

@ -0,0 +1,55 @@
defmodule Plausible.Stats.ConsolidatedViewSyncTest do
use Plausible.DataCase, async: true
on_ee do
import Plausible.Teams.Test
import Plausible.ConsolidatedView, only: [ok_to_display?: 2, enable: 1]
describe "ok_to_display?/2" do
setup [:create_user, :create_team]
test "no user", %{team: team} do
refute ok_to_display?(team, nil)
end
test "no team", %{user: user} do
refute ok_to_display?(nil, user)
end
test "success", %{team: team, user: user} do
new_site(owner: user)
new_site(owner: user)
team = Plausible.Teams.complete_setup(team)
{:ok, _} = enable(team)
patch_env(:super_admin_user_ids, [user.id])
assert ok_to_display?(team, user)
end
test "not super-admin (temporary - feature-flag-like)", %{team: team, user: user} do
new_site(owner: user)
new_site(owner: user)
team = Plausible.Teams.complete_setup(team)
{:ok, _} = enable(team)
refute ok_to_display?(team, user)
end
test "not enabled", %{team: team, user: user} do
new_site(owner: user)
new_site(owner: user)
team = Plausible.Teams.complete_setup(team)
patch_env(:super_admin_user_ids, [user.id])
refute ok_to_display?(team, user)
end
end
end
end

View File

@ -29,6 +29,9 @@ defmodule PlausibleWeb.StatsControllerTest do
assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" assert text_of_attr(resp, @react_container, "data-current-user-role") == "public"
assert text_of_attr(resp, @react_container, "data-current-user-id") == "null" assert text_of_attr(resp, @react_container, "data-current-user-id") == "null"
assert text_of_attr(resp, @react_container, "data-embedded") == "" assert text_of_attr(resp, @react_container, "data-embedded") == ""
assert text_of_attr(resp, @react_container, "data-is-consolidated-view") == "false"
assert text_of_attr(resp, @react_container, "data-team-has-consolidated-view") == "false"
assert text_of_attr(resp, @react_container, "data-team-identifier") == site.team.identifier
assert "noindex, nofollow" == assert "noindex, nofollow" ==
resp resp