* Revert "Revert "UI groundwork: Conversions to Behaviors (#3005)" (#3024)"

This reverts commit dc0853bac7.

* Move ref back to LazyLoader container
This commit is contained in:
hq1 2023-06-13 12:26:33 +02:00 committed by GitHub
parent dc0853bac7
commit c7b8a8ef27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 631 additions and 45 deletions

View File

@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
## Unreleased
### Added
- Call to action for tracking Goal Conversions and an option to hide the section from the dashboard
- Add support for `with_imported=true` in Stats API aggregate endpoint
- Ability to use '--' instead of '=' sign in the `tagged-events` classnames
- 'Last updated X seconds ago' info to 'current visitors' tooltips

View File

@ -71,3 +71,11 @@ export function get(url, query={}, ...extraQuery) {
return response.json()
})
}
export function put(url, body) {
return fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
})
}

View File

@ -0,0 +1,56 @@
import React from "react"
import { sectionTitles } from "../stats/behaviours"
import * as api from '../api'
export function FeatureSetupNotice({ site, feature, shortFeatureName, title, info, settingsLink, onHideAction }) {
const sectionTitle = sectionTitles[feature]
const requestHideSection = () => {
if (window.confirm(`Are you sure you want to hide ${sectionTitle}? You can make it visible again in your site settings later.`)) {
api.put(`/api/${encodeURIComponent(site.domain)}/disable-feature`, { feature: feature })
.then(response => {
if (response.ok) { onHideAction() }
})
}
}
function setupButton() {
return (
<a href={settingsLink} className="ml-2 sm:ml-4 button px-2 sm:px-4">
<p className="flex flex-col justify-center text-xs sm:text-sm">Set up {shortFeatureName}</p>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="ml-2 w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
</svg>
</a>
)
}
function hideButton() {
return (
<button
onClick={requestHideSection}
className="inline-block px-2 sm:px-4 py-2 border border-gray-300 dark:border-gray-500 leading-5 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition ease-in-out duration-150">
Hide this report
</button>
)
}
return (
<div className="sm:mx-32 mt-6 mb-3" >
<div className="py-3">
<div className="text-center mt-2 text-gray-800 dark:text-gray-200">
{title}
</div>
<div className="text-justify mt-4 font-small text-sm text-gray-500 dark:text-gray-200">
{info}
</div>
<div className="text-xs sm:text-sm flex my-6 justify-center">
{hideButton()}
{setupButton()}
</div>
</div>
</div>
)
}

View File

@ -9,25 +9,13 @@ import Sources from './stats/sources'
import Pages from './stats/pages'
import Locations from './stats/locations';
import Devices from './stats/devices'
import Conversions from './stats/conversions'
import Behaviours from './stats/behaviours'
import ComparisonInput from './comparison-input'
import { withPinnedHeader } from './pinned-header-hoc';
function Historical(props) {
const tooltipBoundary = React.useRef(null)
function renderConversions() {
if (props.site.hasGoals) {
return (
<div className="items-start justify-between block w-full mt-6 md:flex">
<Conversions site={props.site} query={props.query} />
</div>
)
}
return null
}
return (
<div className="mb-12">
<div id="stats-container-top"></div>
@ -51,7 +39,7 @@ function Historical(props) {
<Locations site={props.site} query={props.query} />
<Devices site={props.site} query={props.query} />
</div>
{ renderConversions() }
<Behaviours site={props.site} query={props.query} currentUserRole={props.currentUserRole} />
</div>
)
}

View File

@ -16,6 +16,10 @@ if (container) {
domain: container.dataset.domain,
offset: container.dataset.offset,
hasGoals: container.dataset.hasGoals === 'true',
conversionsEnabled: container.dataset.conversionsEnabled === 'true',
funnelsEnabled: container.dataset.funnelsEnabled === 'true',
propsEnabled: container.dataset.propsEnabled === 'true',
funnels: JSON.parse(container.dataset.funnels),
statsBegin: container.dataset.statsBegin,
nativeStatsBegin: container.dataset.nativeStatsBegin,
embedded: container.dataset.embedded,

View File

@ -8,22 +8,10 @@ import Sources from './stats/sources'
import Pages from './stats/pages'
import Locations from './stats/locations'
import Devices from './stats/devices'
import Conversions from './stats/conversions'
import Behaviours from './stats/behaviours'
import { withPinnedHeader } from './pinned-header-hoc';
class Realtime extends React.Component {
renderConversions() {
if (this.props.site.hasGoals) {
return (
<div className="items-start justify-between block w-full mt-6 md:flex">
<Conversions site={this.props.site} query={this.props.query} title="Goal Conversions (last 30 min)" />
</div>
)
}
return null
}
render() {
const navClass = this.props.site.embedded ? 'relative' : 'sticky'
@ -48,8 +36,7 @@ class Realtime extends React.Component {
<Locations site={this.props.site} query={this.props.query} />
<Devices site={this.props.site} query={this.props.query} />
</div>
{ this.renderConversions() }
<Behaviours site={this.props.site} query={this.props.query} currentUserRole={this.props.currentUserRole} />
</div>
)
}

View File

@ -2,7 +2,6 @@ import React from 'react';
import { Link } from 'react-router-dom'
import FlipMove from 'react-flip-move'
import Bar from '../bar'
import PropBreakdown from './prop-breakdown'
import numberFormatter from '../../util/number-formatter'
@ -51,7 +50,7 @@ export default class Conversions extends React.Component {
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
const height = this.htmlNode.current.element.offsetHeight
this.setState({loading: true, goals: null, prevHeight: height})
this.setState({ loading: true, goals: null, prevHeight: height })
this.fetchConversions()
}
}
@ -63,7 +62,7 @@ export default class Conversions extends React.Component {
fetchConversions() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/conversions`, this.props.query)
.then((res) => this.setState({loading: false, goals: res, prevHeight: null}))
.then((res) => this.setState({ loading: false, goals: res, prevHeight: null }))
}
renderGoal(goal) {
@ -88,7 +87,7 @@ export default class Conversions extends React.Component {
<span className="inline-block w-20 font-medium text-right">{goal.conversion_rate}%</span>
</div>
</div>
{ renderProps && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} /> }
{renderProps && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} />}
</div>
)
}
@ -100,7 +99,6 @@ export default class Conversions extends React.Component {
} else if (this.state.goals) {
return (
<React.Fragment>
<h3 className="font-bold dark:text-gray-100">{this.props.title || "Goal Conversions"}</h3>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>Goal</span>
<div className="text-right">
@ -110,18 +108,26 @@ export default class Conversions extends React.Component {
</div>
</div>
<FlipMove>
{ this.state.goals.map(this.renderGoal.bind(this)) }
{this.state.goals.map(this.renderGoal.bind(this))}
</FlipMove>
</React.Fragment>
)
}
}
render() {
renderConversions() {
return (
<LazyLoader className="w-full p-4 bg-white rounded shadow-xl dark:bg-gray-825" style={{minHeight: '132px', height: this.state.prevHeight ?? 'auto'}} onVisible={this.onVisible} ref={this.htmlNode}>
{ this.renderInner() }
<LazyLoader ref={this.htmlNode} style={{ minHeight: '132px', height: this.state.prevHeight ?? 'auto' }} onVisible={this.onVisible}>
{this.renderInner()}
</LazyLoader>
)
}
render() {
return (
<div>
{this.renderConversions()}
</div>
)
}
}

View File

@ -0,0 +1,4 @@
export default function Funnel(_props) {
// TODO
return null
}

View File

@ -0,0 +1,250 @@
import React, { Fragment, useState, useEffect } from 'react'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from '../../util/storage'
import Conversions from './conversions'
import Funnel from './funnel'
import { FeatureSetupNotice } from '../../components/notice'
const ACTIVE_CLASS = 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
export const CONVERSIONS = 'conversions'
export const FUNNELS = 'funnels'
export const PROPS = 'props'
export const sectionTitles = {
[CONVERSIONS]: 'Goal Conversions',
[FUNNELS]: 'Funnels',
[PROPS]: 'Custom Properties'
}
export default function Behaviours(props) {
const site = props.site
const adminAccess = ['owner', 'admin', 'super_admin'].includes(props.currentUserRole)
const tabKey = `behavioursTab__${site.domain}`
const funnelKey = `behavioursTabFunnel__${site.domain}`
const [enabledModes, setEnabledModes] = useState(getEnabledModes())
const [mode, setMode] = useState(defaultMode())
const [funnelNames, _setFunnelNames] = useState(site.funnels.map(({ name }) => name))
const [selectedFunnel, setSelectedFunnel] = useState(storage.getItem(funnelKey))
useEffect(() => {
setMode(defaultMode())
}, [enabledModes])
function disableMode(mode) {
setEnabledModes(enabledModes.filter((m) => { return m !== mode }))
}
function setFunnel(selectedFunnel) {
return () => {
storage.setItem(tabKey, FUNNELS)
storage.setItem(funnelKey, selectedFunnel)
setMode(FUNNELS)
setSelectedFunnel(selectedFunnel)
}
}
function hasFunnels() {
return site.funnels.length > 0
}
function tabFunnelPicker() {
return <Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="inline-flex justify-between focus:outline-none">
<span className={(mode == FUNNELS) ? ACTIVE_CLASS : DEFAULT_CLASS}>Funnels</span>
<ChevronDownIcon className="-mr-1 ml-1 h-4 w-4" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="py-1">
{funnelNames.map((funnelName) => {
return (
<Menu.Item key={funnelName}>
{({ active }) => (
<span
onClick={setFunnel(funnelName)}
className={classNames(
active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer' : 'text-gray-700 dark:text-gray-200',
'block px-4 py-2 text-sm',
mode === funnelName ? 'font-bold' : ''
)}
>
{funnelName}
</span>
)}
</Menu.Item>
)
})}
</div>
</Menu.Items>
</Transition>
</Menu>
}
function tabSwitcher(toMode, displayName) {
const className = classNames({ [ACTIVE_CLASS]: mode == toMode, [DEFAULT_CLASS]: mode !== toMode })
const setTab = () => {
storage.setItem(tabKey, toMode)
setMode(toMode)
}
return (
<div className={className} onClick={setTab}>
{displayName}
</div>
)
}
function tabs() {
return (
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
{isEnabled(FUNNELS) && (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
</div>
)
}
function renderConversions() {
if (site.hasGoals) { return <Conversions site={site} query={props.query} /> }
else if (adminAccess) {
return (
<FeatureSetupNotice
site={site}
feature={CONVERSIONS}
shortFeatureName={'goals'}
title={'Measure how often visitors complete specific actions'}
info={'Goals allow you to track registrations, button clicks, form completions, external link clicks, file downloads, 404 error pages and more.'}
settingsLink={`/${encodeURIComponent(site.domain)}/settings/goals`}
onHideAction={onHideAction(CONVERSIONS)}
/>
)
}
else { return noDataYet() }
}
function renderFunnels() {
if (selectedFunnel) { return <Funnel site={site} query={props.query} funnelName={selectedFunnel} /> }
else if (adminAccess) {
return (
<FeatureSetupNotice
site={site}
feature={FUNNELS}
shortFeatureName={'funnels'}
title={'Follow the visitor journey from entry to conversion'}
info={'Funnels allow you to analyze the user flow through your website, uncover possible issues, optimize your site and increase the conversion rate.'}
settingsLink={`/${encodeURIComponent(site.domain)}/settings/funnels`}
onHideAction={onHideAction(FUNNELS)}
/>
)
}
else { return noDataYet() }
}
function renderProps() {
if (adminAccess) {
return (
<FeatureSetupNotice
site={site}
feature={PROPS}
shortFeatureName={'props'}
title={'No custom properties found'}
info={'You can attach custom properties when sending a pageview or event. This allows you to create custom metrics and analyze stats we don\'t track automatically.'}
settingsLink={`/${encodeURIComponent(site.domain)}/settings/props`}
onHideAction={onHideAction(PROPS)}
/>
)
} else { return noDataYet() }
}
function noDataYet() {
return (
<div className="font-medium text-gray-500 dark:text-gray-400 py-12 text-center">
No data yet
</div>
)
}
function onHideAction(mode) {
return () => { disableMode(mode) }
}
function renderContent() {
switch (mode) {
case CONVERSIONS:
return renderConversions()
case FUNNELS:
return renderFunnels()
case PROPS:
return renderProps()
}
}
function defaultMode() {
if (enabledModes.length === 0) { return null }
const storedMode = storage.getItem(tabKey)
if (storedMode && enabledModes.includes(storedMode)) { return storedMode }
if (enabledModes.includes(CONVERSIONS)) { return CONVERSIONS }
if (enabledModes.includes(FUNNELS)) { return FUNNELS }
return PROPS
}
function getEnabledModes() {
let enabledModes = []
if (site.conversionsEnabled) {
enabledModes.push(CONVERSIONS)
}
if (site.funnelsEnabled && !isRealtime() && site.flags.funnels) {
enabledModes.push(FUNNELS)
}
if (site.propsEnabled && !isRealtime() && site.flags.props) {
enabledModes.push(PROPS)
}
return enabledModes
}
function isEnabled(mode) {
return enabledModes.includes(mode)
}
function isRealtime() {
return props.query.period === 'realtime'
}
if (mode) {
return (
<div className="items-start justify-between block w-full mt-6 md:flex">
<div className="w-full p-4 bg-white rounded shadow-xl dark:bg-gray-825">
<div className="flex justify-between w-full">
<h3 className="font-bold dark:text-gray-100">
{sectionTitles[mode] + (isRealtime() ? ' (last 30min)' : '')}
</h3>
{tabs()}
</div>
{renderContent()}
</div>
</div>
)
} else {
return null
}
}

View File

@ -18,6 +18,9 @@ defmodule Plausible.Site do
field :stats_start_date, :date
field :native_stats_start_at, :naive_datetime
field :allowed_event_props, {:array, :string}
field :conversions_enabled, :boolean
field :props_enabled, :boolean
field :funnels_enabled, :boolean
field :ingest_rate_limit_scale_seconds, :integer, default: 60
# default is set via changeset/2
@ -166,6 +169,14 @@ defmodule Plausible.Site do
change(site, allowed_event_props: list)
end
def disable_feature(site, "conversions"), do: change(site, conversions_enabled: false)
def disable_feature(site, "funnels"), do: change(site, funnels_enabled: false)
def disable_feature(site, "props"), do: change(site, props_enabled: false)
def enable_feature(site, "conversions"), do: change(site, conversions_enabled: true)
def enable_feature(site, "funnels"), do: change(site, funnels_enabled: true)
def enable_feature(site, "props"), do: change(site, props_enabled: true)
def remove_imported_data(site) do
change(site, imported_data: nil)
end

View File

@ -2,9 +2,11 @@ defmodule PlausibleWeb.Api.InternalController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Stats.Clickhouse, as: Stats
alias Plausible.{Sites, Site, Auth}
alias Plausible.Auth.User
def domain_status(conn, %{"domain" => domain}) do
site = Plausible.Sites.get_by_domain(domain)
site = Sites.get_by_domain(domain)
if Stats.has_pageviews?(site) do
json(conn, "READY")
@ -30,11 +32,28 @@ defmodule PlausibleWeb.Api.InternalController do
end
end
def disable_feature(conn, %{"domain" => domain, "feature" => feature}) do
with %User{id: user_id} <- conn.assigns[:current_user],
site <- Sites.get_by_domain(domain),
true <- Sites.has_admin_access?(user_id, site) || Auth.is_super_admin?(user_id) do
Site.disable_feature(site, feature)
|> Repo.update()
json(conn, "ok")
else
_ ->
PlausibleWeb.Api.Helpers.unauthorized(
conn,
"You need to be logged in as the owner or admin account of this site"
)
end
end
defp sites_for(user, params) do
Repo.paginate(
from(
s in Plausible.Site,
join: sm in Plausible.Site.Membership,
s in Site,
join: sm in Site.Membership,
on: sm.site_id == s.id,
where: sm.user_id == ^user.id,
order_by: s.domain

View File

@ -171,6 +171,34 @@ defmodule PlausibleWeb.SiteController do
end
end
def set_feature_status(conn, %{"action" => action, "feature" => feature}) do
site = conn.assigns[:site]
report_title =
case feature do
"conversions" -> "Goals"
"funnels" -> "Funnels"
"props" -> "Properties"
end
{change, flash_msg} =
case action do
"enable" ->
{Plausible.Site.enable_feature(site, feature),
"#{report_title} are now visible again on your dashboard"}
"disable" ->
{Plausible.Site.disable_feature(site, feature),
"#{report_title} are now hidden from your dashboard"}
end
Repo.update(change)
conn
|> put_flash(:success, flash_msg)
|> redirect(to: Routes.site_path(conn, :settings_goals, conn.assigns[:site].domain))
end
def settings(conn, %{"website" => website}) do
redirect(conn, to: Routes.site_path(conn, :settings_general, website))
end

View File

@ -64,6 +64,7 @@ defmodule PlausibleWeb.StatsController do
|> render("stats.html",
site: site,
has_goals: Plausible.Sites.has_goals?(site),
funnels: [],
stats_start_date: stats_start_date,
native_stats_start_date: NaiveDateTime.to_date(site.native_stats_start_at),
title: "Plausible · " <> site.domain,
@ -291,6 +292,7 @@ defmodule PlausibleWeb.StatsController do
|> render("stats.html",
site: shared_link.site,
has_goals: Sites.has_goals?(shared_link.site),
funnels: [],
stats_start_date: shared_link.site.stats_start_date,
native_stats_start_date: NaiveDateTime.to_date(shared_link.site.native_stats_start_at),
title: "Plausible · " <> shared_link.site.domain,
@ -324,8 +326,11 @@ defmodule PlausibleWeb.StatsController do
defp shared_link_cookie_name(slug), do: "shared-link-" <> slug
defp get_flags(_user) do
%{}
defp get_flags(user) do
%{
funnels: FunWithFlags.enabled?(:funnels, for: user),
props: FunWithFlags.enabled?(:props, for: user)
}
end
defp is_dbip() do

View File

@ -117,6 +117,8 @@ defmodule PlausibleWeb.Router do
post "/paddle/webhook", Api.PaddleController, :webhook
get "/:domain/status", Api.InternalController, :domain_status
put "/:domain/disable-feature", Api.InternalController, :disable_feature
get "/sites", Api.InternalController, :sites
end
@ -251,6 +253,7 @@ defmodule PlausibleWeb.Router do
get "/:website/goals/new", SiteController, :new_goal
post "/:website/goals", SiteController, :create_goal
delete "/:website/goals/:id", SiteController, :delete_goal
put "/:website/settings/features/:action/:feature", SiteController, :set_feature_status
put "/:website/settings", SiteController, :update_settings
put "/:website/settings/google", SiteController, :update_google_auth
delete "/:website/settings/google-search", SiteController, :delete_google_auth

View File

@ -7,6 +7,11 @@
</div>
<div class="my-6">
<div id="event-fields">
<div class="pb-6 text-xs text-gray-700 dark:text-gray-200 text-justify rounded-md bg">
Custom events are not tracked by default - you have to configure them on your site to be sent to Plausible. See examples and learn more in
<a class="text-indigo-500 hover:underline" target="_blank" rel="noreferrer" href="https://plausible.io/docs/custom-event-goals"> our docs</a>.
</div>
<div>
<%= label f, :event_name, class: "block text-sm font-bold dark:text-gray-100" %>
<%= text_input f, :event_name, class: "transition mt-3 bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500", placeholder: "Signup" %>

View File

@ -2,11 +2,26 @@
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Goals</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Define actions that you want your users to take like visiting a certain page, submitting a form, etc.</p>
<%= link(to: "https://plausible.io/docs/goal-conversions", target: "_blank", rel: "noreferrer") do %>
<svg class="w-6 h-6 absolute top-0 right-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
<% end %>
<div class="mt-4 mb-8 flex items-center">
<%= if @site.conversions_enabled do %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/settings/features/disable/conversions", method: :put, class: "bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-5 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200"></span>
<% end %>
<% else %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/settings/features/enable/conversions", method: :put, class: "bg-gray-200 dark:bg-gray-700 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-0 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200"></span>
<% end %>
<% end %>
<span class="ml-2 text-sm font-medium text-gray-900 leading-5 dark:text-gray-100">Show goals in the dashboard</span>
</div>
</header>
<%= if @site.conversions_enabled do %>
<%= if Enum.count(@goals) > 0 do %>
<div class="mt-4">
<%= for goal <- @goals do %>
@ -19,8 +34,9 @@
<% end %>
</div>
<% else %>
<div class="mt-4 dark:text-gray-100">No goals configured for this site yet</div>
<div class="my-4 text-center text-md text-gray-500 dark:text-gray-400">No goals configured for this site yet</div>
<% end %>
<%= link("+ Add goal", to: "/#{URI.encode_www_form(@site.domain)}/goals/new", class: "button mt-6") %>
<%= link("+ Add goal", to: "/#{URI.encode_www_form(@site.domain)}/goals/new", class: "button mt-6") %>
<% end %>
</div>

View File

@ -18,6 +18,10 @@
data-domain="<%= @site.domain %>"
data-offset="<%= Plausible.Site.tz_offset(@site) %>"
data-has-goals="<%= @has_goals %>"
data-conversions-enabled="<%= @site.conversions_enabled %>"
data-funnels-enabled="<%= @site.funnels_enabled %>"
data-props-enabled="<%= @site.props_enabled %>"
data-funnels="<%= Jason.encode!(@funnels) %>"
data-logged-in="<%= !!@conn.assigns[:current_user] %>"
data-stats-begin="<%= @stats_start_date %>"
data-native-stats-begin="<%= @native_stats_start_date %>"

View File

@ -45,4 +45,92 @@ defmodule PlausibleWeb.Api.InternalControllerTest do
}
end
end
describe "PUT /api/:domain/disable-feature" do
setup [:create_user, :log_in]
test "when the logged-in user is an admin of the site", %{conn: conn, user: user} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :admin)
conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"})
assert json_response(conn, 200) == "ok"
assert %{conversions_enabled: false} = Plausible.Sites.get_by_domain(site.domain)
end
test "can disable conversions, funnels, and props with admin access", %{
conn: conn,
user: user
} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :admin)
put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"})
put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "funnels"})
put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "props"})
assert %{conversions_enabled: false, funnels_enabled: false, props_enabled: false} =
Plausible.Sites.get_by_domain(site.domain)
end
test "when the logged-in user is an owner of the site", %{conn: conn, user: user} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :owner)
conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"})
assert json_response(conn, 200) == "ok"
assert %{conversions_enabled: false} = Plausible.Sites.get_by_domain(site.domain)
end
test "when the logged-in user is an super-admin", %{conn: conn, user: user} do
site = insert(:site)
patch_env(:super_admin_user_ids, [user.id])
conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"})
assert json_response(conn, 200) == "ok"
assert %{conversions_enabled: false} = Plausible.Sites.get_by_domain(site.domain)
end
test "returns 401 when the logged-in user is a viewer of the site", %{conn: conn, user: user} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :viewer)
conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"})
assert json_response(conn, 401) == %{
"error" => "You need to be logged in as the owner or admin account of this site"
}
assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
end
test "returns 401 when the logged-in user doesn't have site access at all", %{conn: conn} do
site = insert(:site)
conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"})
assert json_response(conn, 401) == %{
"error" => "You need to be logged in as the owner or admin account of this site"
}
assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
end
end
describe "PUT /api/:domain/disable-feature - user not logged in" do
test "returns 401 unauthorized", %{conn: conn} do
site = insert(:site)
conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"})
assert json_response(conn, 401) == %{
"error" => "You need to be logged in as the owner or admin account of this site"
}
assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
end
end
end

View File

@ -739,6 +739,109 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "PUT /:website/settings/features/:action/:feature" do
setup [:create_user, :log_in]
test "can disable conversions, funnels, and props with admin access", %{
conn: conn,
user: user
} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :admin)
conn1 = put(conn, "/#{site.domain}/settings/features/disable/conversions")
conn2 = put(conn, "/#{site.domain}/settings/features/disable/funnels")
conn3 = put(conn, "/#{site.domain}/settings/features/disable/props")
assert %{conversions_enabled: false, funnels_enabled: false, props_enabled: false} =
Plausible.Sites.get_by_domain(site.domain)
assert Phoenix.Flash.get(conn1.assigns.flash, :success) ==
"Goals are now hidden from your dashboard"
assert Phoenix.Flash.get(conn2.assigns.flash, :success) ==
"Funnels are now hidden from your dashboard"
assert Phoenix.Flash.get(conn3.assigns.flash, :success) ==
"Properties are now hidden from your dashboard"
assert redirected_to(conn1, 302) =~ "/#{site.domain}/settings/goals"
end
test "can enable conversions, funnels, and props with admin access", %{
conn: conn,
user: user
} do
site =
insert(:site, conversions_enabled: false, funnels_enabled: false, props_enabled: false)
insert(:site_membership, user: user, site: site, role: :owner)
conn1 = put(conn, "/#{site.domain}/settings/features/enable/conversions")
conn2 = put(conn, "/#{site.domain}/settings/features/enable/funnels")
conn3 = put(conn, "/#{site.domain}/settings/features/enable/props")
assert %{conversions_enabled: true, funnels_enabled: true, props_enabled: true} =
Plausible.Sites.get_by_domain(site.domain)
assert Phoenix.Flash.get(conn1.assigns.flash, :success) ==
"Goals are now visible again on your dashboard"
assert Phoenix.Flash.get(conn2.assigns.flash, :success) ==
"Funnels are now visible again on your dashboard"
assert Phoenix.Flash.get(conn3.assigns.flash, :success) ==
"Properties are now visible again on your dashboard"
assert redirected_to(conn3, 302) =~ "/#{site.domain}/settings/goals"
end
test "can enable and disable with super-admin access", %{
conn: conn,
user: user
} do
site = insert(:site)
patch_env(:super_admin_user_ids, [user.id])
conn1 = put(conn, "/#{site.domain}/settings/features/disable/funnels")
assert %{funnels_enabled: false} = Plausible.Sites.get_by_domain(site.domain)
assert Phoenix.Flash.get(conn1.assigns.flash, :success) ==
"Funnels are now hidden from your dashboard"
conn2 = put(conn, "/#{site.domain}/settings/features/enable/funnels")
assert %{funnels_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
assert Phoenix.Flash.get(conn2.assigns.flash, :success) ==
"Funnels are now visible again on your dashboard"
assert redirected_to(conn1, 302) =~ "/#{site.domain}/settings/goals"
assert redirected_to(conn2, 302) =~ "/#{site.domain}/settings/goals"
end
test "fails to set feature status with viewer access", %{
conn: conn,
user: user
} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :viewer)
conn = put(conn, "/#{site.domain}/settings/features/disable/conversions")
assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
assert conn.status == 404
end
test "fails to set feature status for a foreign site", %{conn: conn} do
site = insert(:site)
conn = put(conn, "/#{site.domain}/settings/features/disable/conversions")
assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
assert conn.status == 404
end
end
describe "POST /sites/:website/weekly-report/enable" do
setup [:create_user, :log_in, :create_site]