mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
* Revert "Revert "UI groundwork: Conversions to Behaviors (#3005)" (#3024)"
This reverts commit dc0853bac7
.
* Move ref back to LazyLoader container
This commit is contained in:
parent
dc0853bac7
commit
c7b8a8ef27
@ -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
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
56
assets/js/dashboard/components/notice.js
Normal file
56
assets/js/dashboard/components/notice.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
4
assets/js/dashboard/stats/behaviours/funnel.js
Normal file
4
assets/js/dashboard/stats/behaviours/funnel.js
Normal file
@ -0,0 +1,4 @@
|
||||
export default function Funnel(_props) {
|
||||
// TODO
|
||||
return null
|
||||
}
|
250
assets/js/dashboard/stats/behaviours/index.js
Normal file
250
assets/js/dashboard/stats/behaviours/index.js
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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" %>
|
||||
|
@ -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>
|
||||
|
@ -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 %>"
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user