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:
parent
638a59b8f5
commit
ce424bf436
|
|
@ -64,12 +64,21 @@ if (container && container.dataset) {
|
|||
? {
|
||||
loggedIn: true,
|
||||
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,
|
||||
id: null,
|
||||
role: container.dataset.currentUserRole as Role
|
||||
role: container.dataset.currentUserRole as Role,
|
||||
team: {
|
||||
identifier: null,
|
||||
hasConsolidatedView: false
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -76,9 +76,7 @@ export default function FilterOperatorSelector(props) {
|
|||
'w-full text-left ',
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.roundedStart,
|
||||
popover.items.classNames.roundedEnd
|
||||
popover.items.classNames.hoverLink
|
||||
)}
|
||||
>
|
||||
{FILTER_OPERATIONS_DISPLAY_NAMES[operation]}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const transition = {
|
|||
const panel = {
|
||||
classNames: {
|
||||
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: {
|
||||
navigationLink: classNames(
|
||||
'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'
|
||||
),
|
||||
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(
|
||||
'hover:bg-gray-100',
|
||||
'hover:text-gray-900',
|
||||
|
|
@ -62,10 +68,7 @@ const items = {
|
|||
'focus-within:text-gray-900',
|
||||
'dark:focus-within:bg-gray-700',
|
||||
'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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,12 +145,7 @@ const Items = ({
|
|||
'w-full text-left',
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
{
|
||||
[popover.items.classNames.roundedStart]: !searchable || !showSearch
|
||||
// when the menu is not searchable, the first item needs rounded top
|
||||
},
|
||||
popover.items.classNames.roundedEnd
|
||||
popover.items.classNames.hoverLink
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -90,7 +90,12 @@ describe(`${isListableSegment.name}`, () => {
|
|||
const site: Pick<PlausibleSite, 'siteSegmentsAvailable'> = {
|
||||
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', () => {
|
||||
const segment = { id: 1, type: SegmentType.site, owner_id: 1 }
|
||||
|
|
@ -103,7 +108,12 @@ describe(`${isListableSegment.name}`, () => {
|
|||
isListableSegment({
|
||||
segment,
|
||||
site,
|
||||
user: { loggedIn: false, role: Role.public, id: null }
|
||||
user: {
|
||||
loggedIn: false,
|
||||
role: Role.public,
|
||||
id: null,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
|
@ -175,7 +185,12 @@ describe(`${resolveFilters.name}`, () => {
|
|||
|
||||
describe(`${canSeeSegmentDetails.name}`, () => {
|
||||
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)
|
||||
})
|
||||
|
||||
|
|
@ -183,13 +198,19 @@ describe(`${canSeeSegmentDetails.name}`, () => {
|
|||
const user: UserContextValue = {
|
||||
loggedIn: false,
|
||||
role: Role.editor,
|
||||
id: null
|
||||
id: null,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
expect(canSeeSegmentDetails({ user })).toBe(false)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export function KeybindHint({
|
|||
return (
|
||||
<kbd
|
||||
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
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -309,10 +309,6 @@ const SeeMoreMenu = ({
|
|||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
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'
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
|
||||
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" />
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ import { Popover, Transition } from '@headlessui/react'
|
|||
export const linkClassName = classNames(
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.roundedStart,
|
||||
popover.items.classNames.roundedEnd
|
||||
popover.items.classNames.hoverLink
|
||||
)
|
||||
|
||||
export const datemenuButtonClassName = classNames(
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ import { useSearchableItems } from '../../hooks/use-searchable-items'
|
|||
const linkClassName = classNames(
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.groupRoundedEnd
|
||||
popover.items.classNames.hoverLink
|
||||
)
|
||||
|
||||
const INITIAL_SEGMENTS_SHOWN = 5
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ import { DashboardQuery } from '../../query'
|
|||
const linkClassName = classNames(
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.roundedStart,
|
||||
popover.items.classNames.roundedEnd
|
||||
popover.items.classNames.hoverLink
|
||||
)
|
||||
const buttonClassName = classNames(
|
||||
'text-white font-medium bg-indigo-600 hover:bg-indigo-700'
|
||||
|
|
|
|||
|
|
@ -60,12 +60,12 @@ test('user can open and close site switcher', async () => {
|
|||
.map((el) => ({ text: el.textContent, href: el.getAttribute('href') }))
|
||||
).toEqual(
|
||||
[
|
||||
{ text: ['Back to sites'], href: '/sites' },
|
||||
{ text: ['Site settings'], href: `/${domain}/settings/general` },
|
||||
{ text: ['dummy.site', '1'], href: '#' },
|
||||
{ text: ['example.com', '2'], href: `/example.com` },
|
||||
{ text: ['blog.example.com', '3'], href: `/blog.example.com` },
|
||||
{ text: ['aççented.ca', '4'], href: `/a%C3%A7%C3%A7ented.ca` },
|
||||
{ text: ['View all'], href: '/sites' }
|
||||
{ text: ['aççented.ca', '4'], href: `/a%C3%A7%C3%A7ented.ca` }
|
||||
].map((l) => ({ ...l, text: l.text.join('') }))
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@ describe('Segment details modal - errors', () => {
|
|||
case: 'segment is not in list',
|
||||
segments: [anyPersonalSegment, anySiteSegment],
|
||||
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"`,
|
||||
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',
|
||||
segments: [anyPersonalSegment, anySiteSegment],
|
||||
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}"`,
|
||||
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',
|
||||
segments: [{ ...anyPersonalSegment, owner_id: null, owner_name: null }],
|
||||
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}"`,
|
||||
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',
|
||||
segments: [{ ...anySiteSegment, owner_id: null, owner_name: null }],
|
||||
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',
|
||||
siteOptions: { siteSegmentsAvailable: true }
|
||||
}
|
||||
|
|
@ -118,7 +138,12 @@ describe('Segment details modal - other cases', () => {
|
|||
render(<SegmentModal id={anySiteSegment.id} />, {
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders
|
||||
user={{ loggedIn: true, role: Role.editor, id: 1 }}
|
||||
user={{
|
||||
loggedIn: true,
|
||||
role: Role.editor,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}}
|
||||
preloaded={{
|
||||
segments: [anySiteSegment]
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ describe('parseSiteFromDataset', () => {
|
|||
realtime: ['minute'],
|
||||
year: ['day', 'week', 'month']
|
||||
},
|
||||
shared: false
|
||||
shared: false,
|
||||
isConsolidatedView: false
|
||||
}
|
||||
|
||||
it('parses from dom string map correctly', () => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
|||
isDbip: dataset.isDbip === 'true',
|
||||
flags: JSON.parse(dataset.flags!),
|
||||
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!),
|
||||
shared: !!dataset.sharedLinkAuth
|
||||
shared: !!dataset.sharedLinkAuth,
|
||||
isConsolidatedView: dataset.isConsolidatedView === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +52,8 @@ const siteContextDefaultValue = {
|
|||
isDbip: false,
|
||||
flags: {} as FeatureFlags,
|
||||
validIntervalsByPeriod: {} as Record<string, Array<string>>,
|
||||
shared: false
|
||||
shared: false,
|
||||
isConsolidatedView: false
|
||||
}
|
||||
|
||||
export type PlausibleSite = typeof siteContextDefaultValue
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import React, { useRef } from 'react'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { Cog8ToothIcon } from '@heroicons/react/24/outline'
|
||||
import { Cog8ToothIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
|
||||
import classNames from 'classnames'
|
||||
import { isModifierPressed, isTyping, Keybind, KeybindHint } from './keybinding'
|
||||
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(
|
||||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.roundedStart,
|
||||
popover.items.classNames.roundedEnd
|
||||
popover.items.classNames.hoverLink
|
||||
)
|
||||
|
||||
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 = (
|
||||
|
|
@ -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} />
|
||||
<Popover.Button
|
||||
ref={buttonRef}
|
||||
|
|
@ -119,12 +175,18 @@ export const SiteSwitcher = () => {
|
|||
)}
|
||||
title={currentSite.domain}
|
||||
>
|
||||
<Favicon
|
||||
domain={currentSite.domain}
|
||||
className="block h-4 w-4 mx-1"
|
||||
/>
|
||||
{currentSite.isConsolidatedView ? (
|
||||
<GlobeIcon className="size-4 block mx-1 h-4 w-4 text-indigo-600 dark:text-white" />
|
||||
) : (
|
||||
<Favicon
|
||||
domain={currentSite.domain}
|
||||
className="block h-4 w-4 mx-1"
|
||||
/>
|
||||
)}
|
||||
<span className={'truncate hidden sm:block sm:mr-1 lg:mr-0'}>
|
||||
{currentSite.domain}
|
||||
{currentSite.isConsolidatedView
|
||||
? 'All sites'
|
||||
: currentSite.domain}
|
||||
</span>
|
||||
<ChevronDownIcon className="hidden lg:block h-5 w-5 ml-2 dark:text-gray-100" />
|
||||
</Popover.Button>
|
||||
|
|
@ -140,18 +202,24 @@ export const SiteSwitcher = () => {
|
|||
data-testid="sitemenu"
|
||||
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
|
||||
className={menuItemClassName}
|
||||
className={buttonLinkClassName}
|
||||
href={`/${encodeURIComponent(currentSite.domain)}/settings/general`}
|
||||
>
|
||||
<Cog8ToothIcon className="h-4 w-4 block mr-2" />
|
||||
<span className="mr-auto">Site settings</span>
|
||||
<Cog8ToothIcon className="size-4 mr-1.5" />
|
||||
Site settings
|
||||
</a>
|
||||
<MenuSeparator />
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{(canSeeSiteSettings || canSeeViewAllSites) && <MenuSeparator />}
|
||||
{sitesQuery.isLoading && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="loading sm">
|
||||
|
|
@ -167,6 +235,22 @@ export const SiteSwitcher = () => {
|
|||
/>
|
||||
</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.map(({ domain }, index) => (
|
||||
<a
|
||||
|
|
@ -187,14 +271,6 @@ export const SiteSwitcher = () => {
|
|||
)}
|
||||
</a>
|
||||
))}
|
||||
{canSeeViewAllSites && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<a className={menuItemClassName} href={`/sites`}>
|
||||
View all
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -172,8 +172,6 @@ export function IntervalPicker({
|
|||
popover.items.classNames.navigationLink,
|
||||
popover.items.classNames.selectedOption,
|
||||
popover.items.classNames.hoverLink,
|
||||
popover.items.classNames.roundedStart,
|
||||
popover.items.classNames.roundedEnd,
|
||||
'w-full text-left'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,24 @@ export enum Role {
|
|||
const userContextDefaultValue = {
|
||||
loggedIn: false,
|
||||
id: null,
|
||||
role: Role.public
|
||||
role: Role.public,
|
||||
team: {
|
||||
identifier: null,
|
||||
hasConsolidatedView: false
|
||||
}
|
||||
} 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
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ export const DEFAULT_SITE: PlausibleSite = {
|
|||
isDbip: false,
|
||||
flags: {},
|
||||
validIntervalsByPeriod: {},
|
||||
shared: false
|
||||
shared: false,
|
||||
isConsolidatedView: false
|
||||
}
|
||||
|
||||
export const TestContextProviders = ({
|
||||
|
|
@ -68,7 +69,14 @@ export const TestContextProviders = ({
|
|||
// <ThemeContextProvider> not interactive component, default value is suitable
|
||||
<SiteContextProvider site={site}>
|
||||
<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 ?? []}>
|
||||
<MemoryRouter
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ defmodule Plausible.ConsolidatedView do
|
|||
|
||||
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
|
||||
def reset_if_enabled(%Team{} = team) do
|
||||
case get(team) do
|
||||
|
|
|
|||
|
|
@ -61,8 +61,15 @@ defmodule PlausibleWeb.StatsController do
|
|||
demo = site.domain == "plausible.io"
|
||||
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? =
|
||||
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)
|
||||
|
||||
|
|
@ -87,7 +94,10 @@ defmodule PlausibleWeb.StatsController do
|
|||
is_dbip: is_dbip(),
|
||||
segments: segments,
|
||||
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? ->
|
||||
|
|
@ -392,6 +402,16 @@ defmodule PlausibleWeb.StatsController do
|
|||
|
||||
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
|
||||
|> put_resp_header("x-robots-tag", "noindex, nofollow")
|
||||
|> delete_resp_header("x-frame-options")
|
||||
|
|
@ -414,7 +434,10 @@ defmodule PlausibleWeb.StatsController do
|
|||
is_dbip: is_dbip(),
|
||||
segments: segments,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<!-- Insert upgrade_card here -->
|
||||
<.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?}
|
||||
consolidated_view={@consolidated_view}
|
||||
consolidated_stats={@consolidated_stats}
|
||||
|
|
@ -272,7 +272,7 @@ defmodule PlausibleWeb.Live.Sites do
|
|||
<.consolidated_view_stat
|
||||
value={@consolidated_stats.views_per_visit}
|
||||
label="Views per visit"
|
||||
change={1}
|
||||
change={@consolidated_stats.views_per_visit_change}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -951,6 +951,10 @@ defmodule PlausibleWeb.Live.Sites do
|
|||
on_ee do
|
||||
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, team) do
|
||||
|
|
@ -975,6 +979,7 @@ defmodule PlausibleWeb.Live.Sites do
|
|||
end
|
||||
end
|
||||
else
|
||||
defp consolidated_view_ok_to_display?(_team, _user), do: false
|
||||
defp init_consolidated_view_assigns(_user, _team), do: @no_consolidated_view
|
||||
defp load_consolidated_stats(_consolidated_view), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@
|
|||
data-valid-intervals-by-period={
|
||||
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 id="modal_root"></div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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-id") == "null"
|
||||
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" ==
|
||||
resp
|
||||
|
|
|
|||
Loading…
Reference in New Issue