Compare commits

..

No commits in common. "master" and "v3.1.0" have entirely different histories.

376 changed files with 6866 additions and 12872 deletions

View File

@ -117,11 +117,6 @@
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, false},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
@ -138,7 +133,6 @@
#
## Warnings
#
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},

View File

@ -68,7 +68,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v5
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
@ -91,7 +91,7 @@ jobs:
steps:
- name: Download digests
uses: actions/download-artifact@v7
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@ -10,7 +10,7 @@ jobs:
codespell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: codespell-project/actions-codespell@v2
with:
check_filenames: true

View File

@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
env:
CACHE_VERSION: v17
CACHE_VERSION: v15
PERSISTENT_CACHE_DIR: cached
jobs:
@ -63,7 +63,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
fetch-depth: 0
@ -74,7 +74,7 @@ jobs:
elixir-version: ${{ steps.versions.outputs.elixir }}
otp-version: ${{ steps.versions.outputs.erlang }}
- uses: actions/cache@v5
- uses: actions/cache@v4
with:
path: |
deps
@ -114,26 +114,13 @@ jobs:
- run: make minio
if: env.MIX_ENV == 'test'
- run: |
mix test --include slow --include minio --include migrations --max-failures 1 --warnings-as-errors --partitions 6 | tee test_output.log
if grep -E '\.+[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3} \[[^]]+\]' test_output.log | grep -v 'libcluster'; then
echo "The tests are producing output, this usually indicates some error"
exit 1
fi
shell: bash
- run: mix test --include slow --include minio --include migrations --include kaffy_quirks --max-failures 1 --warnings-as-errors --partitions 6
if: env.MIX_ENV == 'test'
env:
MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1"
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
- run: |
mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 4 | tee test_output.log
if grep -E '\.+[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3} \[[^]]+\]' test_output.log | grep -v 'libcluster'; then
echo "The tests are producing output, this usually indicates some error"
exit 1
fi
shell: bash
- run: mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 4
if: env.MIX_ENV == 'ce_test'
env:
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
@ -144,7 +131,7 @@ jobs:
MIX_ENV: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
fetch-depth: 0
@ -155,7 +142,7 @@ jobs:
elixir-version: ${{ steps.versions.outputs.elixir }}
otp-version: ${{ steps.versions.outputs.erlang }}
- uses: actions/cache@v5
- uses: actions/cache@v4
with:
path: |
deps

View File

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Read .tool-versions
uses: marocchino/tool-versions-action@v1
id: versions

View File

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Read .tool-versions
uses: marocchino/tool-versions-action@v1
@ -35,7 +35,7 @@ jobs:
otp-version: ${{ steps.versions.outputs.erlang}}
- name: Restore Elixir dependencies cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
deps

View File

@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }}

View File

@ -15,7 +15,7 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
@ -23,7 +23,7 @@ jobs:
fetch-depth: 1
- name: Checkout master for comparison
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: master
path: master-branch
@ -122,7 +122,7 @@ jobs:
- name: Get changed files
id: changelog_changed
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62
with:
files: |
tracker/npm_package/CHANGELOG.md

View File

@ -20,7 +20,7 @@ jobs:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 23.2.0
@ -29,7 +29,7 @@ jobs:
- name: Install dependencies
run: npm --prefix ./tracker ci
- name: Cache Playwright browsers
uses: actions/cache@v5
uses: actions/cache@v4
id: playwright-cache
with:
path: |
@ -49,7 +49,7 @@ jobs:
run: npm --prefix ./tracker test -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v5
with:
name: blob-report-${{ matrix.shardIndex }}
path: tracker/blob-report
@ -60,7 +60,7 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 23.2.0
@ -70,7 +70,7 @@ jobs:
run: npm --prefix ./tracker ci
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v6
with:
path: all-blob-reports
pattern: blob-report-*

2
.gitignore vendored
View File

@ -97,5 +97,3 @@ plausible-report.xml
# Docker volumes
.clickhouse_db_vol*
plausible_db*
.claude

View File

@ -1,3 +1,3 @@
erlang 27.3.4.6
elixir 1.19.4-otp-27
erlang 27.3.1
elixir 1.18.3-otp-27
nodejs 23.2.0

View File

@ -6,20 +6,12 @@ All notable changes to this project will be documented in this file.
### Added
- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown
### Removed
### Changed
- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards
### Fixed
- To make internal stats API requests for password-protected shared links, shared link auth cookie must be set in the requests
- Fixed issue with site guests in Editor role and team members in Editor role not being able to change the domain of site
- Fixed direct dashboard links that use legacy dashboard filters containing URL encoded special characters (e.g. character `ê` in the legacy filter `?page=%C3%AA`)
## v3.1.0 - 2025-11-13
### Added

View File

@ -1,10 +1,8 @@
# we can not use the pre-built tar because the distribution is
# platform specific, it makes sense to build it in the docker
ARG ALPINE_VERSION=3.22.2
#### Builder
FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-${ALPINE_VERSION} AS buildcontainer
FROM hexpm/elixir:1.18.3-erlang-27.3.1-alpine-3.21.3 AS buildcontainer
ARG MIX_ENV=ce
@ -22,7 +20,7 @@ RUN mkdir /app
WORKDIR /app
# install build dependencies
RUN apk add --no-cache git "nodejs-current=23.11.1-r0" yarn npm python3 ca-certificates wget gnupg make gcc libc-dev brotli
RUN apk add --no-cache git "nodejs-current=23.2.0-r1" yarn npm python3 ca-certificates wget gnupg make gcc libc-dev brotli
COPY mix.exs ./
COPY mix.lock ./
@ -56,7 +54,7 @@ COPY rel rel
RUN mix release plausible
# Main Docker Image
FROM alpine:${ALPINE_VERSION}
FROM alpine:3.21.3
LABEL maintainer="plausible.io <hello@plausible.io>"
ARG BUILD_METADATA={}
@ -86,4 +84,3 @@ EXPOSE 8000
ENV DEFAULT_DATA_DIR=/var/lib/plausible
VOLUME /var/lib/plausible
CMD ["run"]

View File

@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(grep:*)",
"Read(//Users/ukutaht/plausible/analytics/lib/**)",
"Bash(node:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -90,7 +90,6 @@
--color-gray-950: var(--color-zinc-950);
/* Custom gray shades from config (override some zinc values) */
--color-gray-75: rgb(247 247 248);
--color-gray-150: rgb(236 236 238);
--color-gray-750: rgb(50 50 54);
--color-gray-825: rgb(35 35 38);
@ -295,12 +294,16 @@ blockquote {
display: inline;
}
.table-striped tbody tr:nth-child(odd) td {
background-color: var(--color-gray-75);
.table-striped tbody tr:nth-child(odd) {
background-color: var(--color-gray-100);
}
.dark .table-striped tbody tr:nth-child(odd) td {
background-color: var(--color-gray-850);
.dark .table-striped tbody tr:nth-child(odd) {
background-color: var(--color-gray-800);
}
.dark .table-striped tbody tr:nth-child(even) {
background-color: var(--color-gray-900);
}
.fade-enter {

View File

@ -32,6 +32,33 @@
overflow: auto;
}
.modal__container {
background-color: #fff;
padding: 1rem 2rem;
border-radius: 4px;
margin: 50px auto;
box-sizing: border-box;
min-height: 509px;
transition: height 200ms ease-in;
}
.modal__close {
position: fixed;
color: #b8c2cc;
font-size: 48px;
font-weight: bold;
top: 12px;
right: 24px;
}
.modal__close::before {
content: '\2715';
}
.modal__content {
margin-bottom: 2rem;
}
@keyframes mm-fade-in {
from {
opacity: 0;

View File

@ -1,38 +0,0 @@
/**
* Component used for embedding LiveView components inside React.
*
* The content of the portal is completely excluded from React re-renders with
* a hardwired `React.memo`.
*/
import React from 'react'
import classNames from 'classnames'
const MIN_HEIGHT = 380
type LiveViewPortalProps = {
id: string
className?: string
}
export const LiveViewPortal = React.memo(
function ({ id, className }: LiveViewPortalProps) {
return (
<div
id={id}
className={classNames('group', className)}
style={{ width: '100%', border: '0', minHeight: MIN_HEIGHT }}
>
<div
className="w-full flex flex-col justify-center group-has-[[data-phx-teleported]]:hidden"
style={{ minHeight: MIN_HEIGHT }}
>
<div className="mx-auto loading">
<div />
</div>
</div>
</div>
)
},
() => true
)

View File

@ -66,7 +66,7 @@ export const SearchInput = ({
type="text"
placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
className={classNames(
'text-sm dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 max-w-64 w-full dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500',
'dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 w-48 dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500',
className
)}
onChange={debouncedOnSearchInputChange}

View File

@ -15,18 +15,14 @@ export const SortButton = ({
return (
<button
onClick={toggleSort}
className={classNames(
'group',
'hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-100',
'relative'
)}
className={classNames('group', 'hover:underline', 'relative')}
>
{children}
<span
title={next.hint}
className={classNames(
'absolute',
'rounded inline-block size-4',
'rounded inline-block h-4 w-4',
'ml-1',
{
[SortDirection.asc]: 'rotate-180',
@ -34,8 +30,9 @@ export const SortButton = ({
}[sortDirection ?? next.direction],
!sortDirection && 'opacity-0',
!sortDirection && 'group-hover:opacity-100',
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
'transition-all duration-100'
sortDirection &&
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
'transition'
)}
>

View File

@ -21,7 +21,7 @@ export type ColumnConfiguraton<T extends Record<string, unknown>> = {
/**
* Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
*/
renderValue?: (item: T, isRowHovered?: boolean) => ReactNode
renderValue?: (item: T) => ReactNode
/** Function used to create richer cells */
renderItem?: (item: T) => ReactNode
}
@ -38,7 +38,7 @@ export const TableHeaderCell = ({
return (
<th
className={classNames(
'p-2 text-xs font-semibold text-gray-500 dark:text-gray-400',
'p-2 text-xs font-bold text-gray-500 dark:text-gray-400 tracking-wide',
className
)}
align={align}
@ -58,13 +58,7 @@ export const TableCell = ({
align?: 'left' | 'right'
}) => {
return (
<td
className={classNames(
'p-2 font-medium first:rounded-s-sm last:rounded-e-sm',
className
)}
align={align}
>
<td className={classNames('p-2 font-medium', className)} align={align}>
{children}
</td>
)
@ -74,42 +68,15 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
rowIndex,
pageIndex,
item,
columns,
tappedRowName,
onRowTap
columns
}: {
rowIndex: number
pageIndex?: number
item: T
columns: ColumnConfiguraton<T>[]
tappedRowName?: string | null
onRowTap?: (rowName: string | null) => void
}) => {
const [isHovered, setIsHovered] = React.useState(false)
const rowName = (item as unknown as { name: string }).name
const isTapped = tappedRowName === rowName
const isRowActive = isHovered || isTapped
const handleRowClick = (e: React.MouseEvent) => {
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
if (onRowTap) {
if (isTapped) {
onRowTap(null)
} else {
onRowTap(rowName)
}
}
}
}
return (
<tr
className="group text-sm dark:text-gray-200 md:cursor-default cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleRowClick}
>
<tr className="text-sm dark:text-gray-200">
{columns.map(({ key, width, align, renderValue, renderItem }) => (
<TableCell
key={`${(pageIndex ?? null) === null ? '' : `page_${pageIndex}_`}row_${rowIndex}_${String(key)}`}
@ -119,7 +86,7 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
{renderItem
? renderItem(item)
: renderValue
? renderValue(item, isRowActive)
? renderValue(item)
: (item[key] ?? '')}
</TableCell>
))}
@ -134,8 +101,6 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns: ColumnConfiguraton<T>[]
data: T[] | { pages: T[][] }
}) => {
const [tappedRowName, setTappedRowName] = React.useState<string | null>(null)
const renderColumnLabel = (column: ColumnConfiguraton<T>) => {
if (column.metricWarning) {
return (
@ -160,13 +125,13 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
}
return (
<table className="border-collapse table-striped table-fixed w-max min-w-full">
<thead className="sticky top-0 bg-white dark:bg-gray-900 z-10">
<tr className="text-xs font-semibold text-gray-500 dark:text-gray-400">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr className="text-xs font-bold text-gray-500 dark:text-gray-400">
{columns.map((column) => (
<TableHeaderCell
key={`header_${String(column.key)}`}
className={classNames('p-2', column.width)}
className={classNames('p-2 tracking-wide', column.width)}
align={column.align}
>
{column.onSort ? (
@ -191,8 +156,6 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns={columns}
rowIndex={rowIndex}
key={rowIndex}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/>
))
: data.pages.map((page, pageIndex) =>
@ -203,8 +166,6 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
rowIndex={rowIndex}
pageIndex={pageIndex}
key={`page_${pageIndex}_row_${rowIndex}`}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/>
))
)}

View File

@ -160,7 +160,7 @@ const Items = ({
<SearchInput
searchRef={searchRef}
placeholderUnfocused="Press / to search"
className="ml-auto w-full py-1"
className="ml-auto w-full py-1 text-sm"
onSearch={handleSearchInput}
/>
</div>

View File

@ -10,7 +10,7 @@ import {
SegmentType,
SavedSegment,
SegmentData,
canExpandSegment
canSeeSegmentDetails
} from './segments'
import { Filter } from '../query'
import { PlausibleSite } from '../site-context'
@ -183,124 +183,34 @@ describe(`${resolveFilters.name}`, () => {
)
})
describe(`${canExpandSegment.name}`, () => {
it.each([[Role.admin], [Role.editor], [Role.owner]])(
'allows expanding site segment if the user is logged in and in the role %p',
(role) => {
const site = { siteSegmentsAvailable: true }
const user: UserContextValue = {
loggedIn: true,
role,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
}
expect(
canExpandSegment({
segment: { id: 1, owner_id: 1, type: SegmentType.site },
user,
site
})
).toBe(true)
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,
team: { identifier: null, hasConsolidatedView: false }
}
)
it('allows expanding site segments defined by other users', () => {
expect(
canExpandSegment({
segment: { id: 1, owner_id: 222, type: SegmentType.site },
user: {
loggedIn: true,
role: Role.owner,
id: 111,
team: { identifier: null, hasConsolidatedView: false }
},
site: { siteSegmentsAvailable: true }
})
).toBe(true)
expect(canSeeSegmentDetails({ user })).toBe(true)
})
it('forbids expanding site segment if site segments are not available', () => {
expect(
canExpandSegment({
segment: { id: 1, owner_id: 1, type: SegmentType.site },
user: {
loggedIn: true,
role: Role.owner,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
},
site: { siteSegmentsAvailable: false }
})
).toBe(false)
})
it('forbids public role from expanding site segments', () => {
expect(
canExpandSegment({
segment: { id: 1, owner_id: null, type: SegmentType.site },
user: {
loggedIn: false,
role: Role.public,
id: null,
team: { identifier: null, hasConsolidatedView: false }
},
site: { siteSegmentsAvailable: false }
})
).toBe(false)
})
it.each([
[Role.viewer],
[Role.billing],
[Role.editor],
[Role.admin],
[Role.owner]
])(
'allows expanding personal segment if it belongs to the user and the user is in role %p',
(role) => {
const user: UserContextValue = {
loggedIn: true,
role,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
}
expect(
canExpandSegment({
segment: { id: 1, owner_id: 1, type: SegmentType.personal },
user,
site: { siteSegmentsAvailable: false }
})
).toBe(true)
it('should return false if the user is not logged in', () => {
const user: UserContextValue = {
loggedIn: false,
role: Role.editor,
id: null,
team: { identifier: null, hasConsolidatedView: false }
}
)
it('forbids expanding personal segment of other users', () => {
expect(
canExpandSegment({
segment: { id: 2, owner_id: 222, type: SegmentType.personal },
user: {
loggedIn: true,
role: Role.owner,
id: 111,
team: { identifier: null, hasConsolidatedView: false }
},
site: { siteSegmentsAvailable: false }
})
).toBe(false)
expect(canSeeSegmentDetails({ user })).toBe(false)
})
it('forbids public role from expanding personal segments', () => {
expect(
canExpandSegment({
segment: { id: 1, owner_id: 1, type: SegmentType.personal },
user: {
loggedIn: false,
role: Role.public,
id: null,
team: { identifier: null, hasConsolidatedView: false }
},
site: { siteSegmentsAvailable: false }
})
).toBe(false)
it('should return false if the user has a public role', () => {
const user: UserContextValue = {
loggedIn: true,
role: Role.public,
id: 1,
team: { identifier: null, hasConsolidatedView: false }
}
expect(canSeeSegmentDetails({ user })).toBe(false)
})
})

View File

@ -10,16 +10,6 @@ export enum SegmentType {
site = 'site'
}
/** keep in sync with Plausible.Segments */
const ROLES_WITH_MAYBE_SITE_SEGMENTS = [Role.admin, Role.editor, Role.owner]
const ROLES_WITH_PERSONAL_SEGMENTS = [
Role.billing,
Role.viewer,
Role.admin,
Role.editor,
Role.owner
]
/** This type signifies that the owner can't be shown. */
type SegmentOwnershipHidden = { owner_id: null; owner_name: null }
@ -158,36 +148,6 @@ export function resolveFilters(
})
}
export function canExpandSegment({
segment,
site,
user
}: {
segment: Pick<SavedSegment, 'id' | 'owner_id' | 'type'>
site: Pick<PlausibleSite, 'siteSegmentsAvailable'>
user: UserContextValue
}) {
if (
segment.type === SegmentType.site &&
site.siteSegmentsAvailable &&
user.loggedIn &&
ROLES_WITH_MAYBE_SITE_SEGMENTS.includes(user.role)
) {
return true
}
if (
segment.type === SegmentType.personal &&
user.loggedIn &&
ROLES_WITH_PERSONAL_SEGMENTS.includes(user.role) &&
user.id === segment.owner_id
) {
return true
}
return false
}
export function isListableSegment({
segment,
site,
@ -213,6 +173,10 @@ export function isListableSegment({
return false
}
export function canSeeSegmentDetails({ user }: { user: UserContextValue }) {
return user.loggedIn && user.role !== Role.public
}
export function findAppliedSegmentFilter({ filters }: { filters: Filter[] }) {
const segmentFilter = filters.find(isSegmentFilter)
if (!segmentFilter) {

View File

@ -1,5 +1,4 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react'
import { LiveViewPortal } from './components/liveview-portal'
import React, { useMemo, useState } from 'react'
import VisitorGraph from './stats/graph/visitor-graph'
import Sources from './stats/sources'
import Pages from './stats/pages'
@ -8,10 +7,7 @@ import Devices from './stats/devices'
import { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours'
import { useQueryContext } from './query-context'
import { useSiteContext } from './site-context'
import { isRealTimeDashboard } from './util/filters'
import { useAppNavigate } from './navigation/use-app-navigate'
import { parseSearch } from './util/url-search-params'
function DashboardStats({
importedDataInView,
@ -20,36 +16,6 @@ function DashboardStats({
importedDataInView?: boolean
updateImportedDataInView?: (v: boolean) => void
}) {
const navigate = useAppNavigate()
const site = useSiteContext()
// Handler for navigation events delegated from LiveView dashboard.
// Necessary to emulate navigation events in LiveView with pushState
// manipulation disabled.
const onLiveNavigate = useCallback(
(e: CustomEvent) => {
navigate({
path: e.detail.path,
search: () => parseSearch(e.detail.search)
})
},
[navigate]
)
useEffect(() => {
window.addEventListener(
'dashboard:live-navigate',
onLiveNavigate as EventListener
)
return () => {
window.removeEventListener(
'dashboard:live-navigate',
onLiveNavigate as EventListener
)
}
}, [onLiveNavigate])
const statsBoxClass =
'relative min-h-[436px] w-full mt-5 p-4 flex flex-col bg-white dark:bg-gray-900 shadow-sm rounded-md md:min-h-initial md:h-27.25rem md:w-[calc(50%-10px)] md:ml-[10px] md:mr-[10px] first:ml-0 last:mr-0'
@ -61,14 +27,7 @@ function DashboardStats({
<Sources />
</div>
<div className={statsBoxClass}>
{site.flags.live_dashboard ? (
<LiveViewPortal
id="pages-breakdown-live"
className="w-full h-full border-0 overflow-hidden"
/>
) : (
<Pages />
)}
<Pages />
</div>
</div>

View File

@ -84,7 +84,7 @@ export const SearchableSegmentsSection = ({
<SearchInput
searchRef={searchRef}
placeholderUnfocused="Press / to search"
className="ml-auto w-full py-1"
className="ml-auto w-full py-1 text-sm"
onSearch={handleSearchInput}
/>
)}

View File

@ -92,7 +92,7 @@ test('user can open and close filters dropdown', async () => {
'Location',
'Screen size',
'Browser',
'Operating system',
'Operating System',
'Goal'
])
await userEvent.click(toggleFilters)

View File

@ -63,15 +63,6 @@ export const useAppNavigate = () => {
search,
...options
}: AppNavigationTarget & NavigateOptions) => {
// Event dispatched for handling by LiveView dashboard via hook.
// Necessary to emulate navigation events in LiveView with pushState
// manipulation disabled.
window.dispatchEvent(
new CustomEvent('dashboard:live-navigate-back', {
detail: { search: window.location.search }
})
)
return _navigate(getToOptions({ path, params, search }), options)
},
[getToOptions, _navigate]

View File

@ -3,11 +3,10 @@ import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
import { dateForSite, formatDayShort } from '../util/date'
import { useSiteContext } from '../site-context'
type SegmentAuthorshipProps = {
className?: string
showOnlyPublicData: boolean
segment: SavedSegmentPublic | SavedSegment
}
type SegmentAuthorshipProps = { className?: string } & (
| { showOnlyPublicData: true; segment: SavedSegmentPublic }
| { showOnlyPublicData: false; segment: SavedSegment }
)
export function SegmentAuthorship({
className,

View File

@ -58,6 +58,45 @@ describe('Segment details modal - errors', () => {
},
message: `Segment not found with with ID "202020"`,
siteOptions: { siteSegmentsAvailable: true }
},
{
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,
team: { identifier: null, hasConsolidatedView: false }
},
message: `Segment not found with with ID "${anySiteSegment.id}"`,
siteOptions: { siteSegmentsAvailable: false }
},
{
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,
team: { identifier: null, hasConsolidatedView: false }
},
message: `Segment not found with with ID "${anyPersonalSegment.id}"`,
siteOptions: { siteSegmentsAvailable: true }
},
{
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,
team: { identifier: null, hasConsolidatedView: false }
},
message: 'Not enough permissions to see segment details',
siteOptions: { siteSegmentsAvailable: true }
}
]
it.each(cases)(

View File

@ -1,7 +1,8 @@
import React, { ReactNode, useCallback, useState } from 'react'
import ModalWithRouting from '../stats/modals/modal'
import {
canExpandSegment,
canSeeSegmentDetails,
isListableSegment,
isSegmentFilter,
SavedSegment,
SEGMENT_TYPE_LABELS,
@ -21,9 +22,9 @@ import { MutationStatus } from '@tanstack/react-query'
import { ApiError } from '../api'
import { ErrorPanel } from '../components/error-panel'
import { useSegmentsContext } from '../filtering/segments-context'
import { useSiteContext } from '../site-context'
import { Role, UserContextValue, useUserContext } from '../user-context'
import { removeFilterButtonClassname } from '../components/remove-filter-button'
import { useSiteContext } from '../site-context'
interface ApiRequestProps {
status: MutationStatus
@ -500,7 +501,9 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
const { query } = useQueryContext()
const { segments } = useSegmentsContext()
const segment = segments.find((s) => String(s.id) === String(id))
const segment = segments
.filter((s) => isListableSegment({ segment: s, site, user }))
.find((s) => String(s.id) === String(id))
let error: ApiError | null = null
@ -508,6 +511,10 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
error = new ApiError(`Segment not found with with ID "${id}"`, {
error: `Segment not found with with ID "${id}"`
})
} else if (!canSeeSegmentDetails({ user })) {
error = new ApiError('Not enough permissions to see segment details', {
error: `Not enough permissions to see segment details`
})
}
const data = !error ? segment : null
@ -535,27 +542,25 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
<SegmentAuthorship
segment={data}
showOnlyPublicData={!user.loggedIn || user.role === Role.public}
showOnlyPublicData={false}
className="mt-4 text-sm"
/>
<div className="mt-4">
<ButtonsRow>
{canExpandSegment({ segment: data, site, user }) && (
<AppNavigationLink
className={primaryNeutralButtonClassName}
path={rootRoute.path}
search={(s) => ({
...s,
filters: data.segment_data.filters,
labels: data.segment_data.labels
})}
state={{
expandedSegment: data
}}
>
Edit segment
</AppNavigationLink>
)}
<AppNavigationLink
className={primaryNeutralButtonClassName}
path={rootRoute.path}
search={(s) => ({
...s,
filters: data.segment_data.filters,
labels: data.segment_data.labels
})}
state={{
expandedSegment: data
}}
>
Edit segment
</AppNavigationLink>
<AppNavigationLink
className={removeFilterButtonClassname}

View File

@ -27,9 +27,7 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
}
// Update this object when new feature flags are added to the frontend.
type FeatureFlags = {
live_dashboard?: boolean
}
type FeatureFlags = Record<never, boolean>
export const siteContextDefaultValue = {
domain: '',

View File

@ -26,7 +26,7 @@ export default function Bar({
return (
<div className="w-full h-full relative" style={style}>
<div
className={`absolute top-0 left-0 h-full rounded-sm ${bg || ''}`}
className={`absolute top-0 left-0 h-full ${bg || ''}`}
style={{ width: `${width}%` }}
></div>
{children}

View File

@ -54,7 +54,7 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
path: conversionsRoute.path,
search: (search) => search
}}
color="bg-red-50 group-hover/row:bg-red-100"
color="bg-red-50"
colMinWidth={90}
/>
)

View File

@ -93,6 +93,7 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
search: (search) => search
}}
getExternalLinkUrl={getExternalLinkUrlFactory()}
maybeHideDetails={true}
color="bg-red-50"
colMinWidth={90}
/>

View File

@ -31,8 +31,8 @@ export const PROPS = 'props'
export const FUNNELS = 'funnels'
export const sectionTitles = {
[CONVERSIONS]: 'Goal conversions',
[PROPS]: 'Custom properties',
[CONVERSIONS]: 'Goal Conversions',
[PROPS]: 'Custom Properties',
[FUNNELS]: 'Funnels'
}

View File

@ -137,7 +137,8 @@ export default function Properties({ afterFetchData }) {
params: { propKey },
search: (search) => search
}}
color="bg-red-50 group-hover/row:bg-red-100"
maybeHideDetails={true}
color="bg-red-50"
colMinWidth={90}
/>
)

View File

@ -76,9 +76,8 @@ function Browsers({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter((metric) => !!metric)
}
@ -122,9 +121,8 @@ function BrowserVersions({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter((metric) => !!metric)
}
@ -189,11 +187,9 @@ function OperatingSystems({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({
meta: { showOnHover: true, hiddenOnMobile: true }
}),
hasConversionGoalFilter(query) && metrics.createConversionRate()
metrics.createPercentage({ meta: { hiddenonMobile: true } })
].filter((metric) => !!metric)
}
@ -242,9 +238,8 @@ function OperatingSystemVersions({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter((metric) => !!metric)
}
@ -286,9 +281,8 @@ function ScreenSizes({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter((metric) => !!metric)
}
@ -438,7 +432,7 @@ export default function Devices() {
}
return (
<div className="group/report overflow-x-hidden">
<div>
<div className="flex justify-between w-full">
<div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">Devices</h3>

View File

@ -1,17 +1,17 @@
export const METRIC_LABELS = {
visitors: 'Visitors',
pageviews: 'Pageviews',
events: 'Total conversions',
views_per_visit: 'Views per visit',
events: 'Total Conversions',
views_per_visit: 'Views per Visit',
visits: 'Visits',
bounce_rate: 'Bounce rate',
visit_duration: 'Visit duration',
conversions: 'Converted visitors',
conversion_rate: 'Conversion rate',
average_revenue: 'Average revenue',
total_revenue: 'Total revenue',
scroll_depth: 'Scroll depth',
time_on_page: 'Time on page'
bounce_rate: 'Bounce Rate',
visit_duration: 'Visit Duration',
conversions: 'Converted Visitors',
conversion_rate: 'Conversion Rate',
average_revenue: 'Average Revenue',
total_revenue: 'Total Revenue',
scroll_depth: 'Scroll Depth',
time_on_page: 'Time on Page'
}
function plottable(dataArray) {

View File

@ -37,8 +37,6 @@ function Countries({ query, site, onClick, afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -56,7 +54,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
search: (search) => search
}}
renderIcon={renderIcon}
color="bg-orange-50 group-hover/row:bg-orange-100"
color="bg-orange-50"
/>
)
}
@ -81,8 +79,6 @@ function Regions({ query, site, onClick, afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -97,7 +93,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
metrics={chooseMetrics()}
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
renderIcon={renderIcon}
color="bg-orange-50 group-hover/row:bg-orange-100"
color="bg-orange-50"
/>
)
}
@ -122,8 +118,6 @@ function Cities({ query, site, afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -137,7 +131,7 @@ function Cities({ query, site, afterFetchData }) {
metrics={chooseMetrics()}
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
renderIcon={renderIcon}
color="bg-orange-50 group-hover/row:bg-orange-100"
color="bg-orange-50"
/>
)
}
@ -253,7 +247,7 @@ class Locations extends React.Component {
render() {
return (
<div className="group/report overflow-x-hidden">
<div>
<div className="w-full flex justify-between">
<div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">

View File

@ -185,7 +185,7 @@ const WorldMap = ({
path: countriesRoute.path,
search: (search: Record<string, unknown>) => search
}}
className="mt-3"
className={undefined}
onClick={undefined}
/>
{site.isDbip && <GeolocationNotice />}

View File

@ -11,14 +11,12 @@ import {
useRememberOrderBy
} from '../../hooks/use-order-by'
import { Metric } from '../reports/metrics'
import * as metricsModule from '../reports/metrics'
import { BreakdownResultMeta, DashboardQuery } from '../../query'
import { ColumnConfiguraton } from '../../components/table'
import { BreakdownTable } from './breakdown-table'
import { useSiteContext } from '../../site-context'
import { DrilldownLink, FilterInfo } from '../../components/drilldown-link'
import { SharedReportProps } from '../reports/list'
import { hasConversionGoalFilter } from '../../util/filters'
export type ReportInfo = {
/** Title of the report to render on the top left. */
@ -37,8 +35,6 @@ type BreakdownModalProps = {
/** Function that must return a new query that contains appropriate search filter for searchValue param. */
addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery
searchEnabled?: boolean
/** When true, keep the percentage metric as a permanently visible, sortable column. */
showPercentageColumn?: boolean
}
/**
@ -66,7 +62,6 @@ export default function BreakdownModal<TListItem extends { name: string }>({
renderIcon,
getExternalLinkUrl,
searchEnabled = true,
showPercentageColumn = false,
afterFetchData,
afterFetchNextPage,
addSearchFilter,
@ -76,28 +71,20 @@ export default function BreakdownModal<TListItem extends { name: string }>({
const { query } = useQueryContext()
const [meta, setMeta] = useState<BreakdownResultMeta | null>(null)
const breakdownMetrics = useMemo(() => {
const hasPercentage = metrics.some((m) => m.key === 'percentage')
if (!hasPercentage && !hasConversionGoalFilter(query)) {
return [...metrics, metricsModule.createPercentage()]
}
return metrics
}, [metrics, query])
const [search, setSearch] = useState('')
const defaultOrderBy = getStoredOrderBy({
domain: site.domain,
reportInfo,
metrics: breakdownMetrics,
metrics,
fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
})
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
metrics: breakdownMetrics,
metrics,
defaultOrderBy
})
useRememberOrderBy({
effectiveOrderBy: orderBy,
metrics: breakdownMetrics,
metrics,
reportInfo
})
const apiState = usePaginatedGetAPI<
@ -138,7 +125,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
{
label: reportInfo.dimensionLabel,
key: 'name',
width: 'w-40 md:w-48',
width: 'w-48 md:w-full flex items-center break-all',
align: 'left',
renderItem: (item) => (
<NameCell
@ -149,39 +136,29 @@ export default function BreakdownModal<TListItem extends { name: string }>({
/>
)
},
...breakdownMetrics
.filter((m) => showPercentageColumn || m.key !== 'percentage')
.map(
(m): ColumnConfiguraton<TListItem> => ({
label: m.renderLabel(query),
key: m.key,
width: m.width,
align: 'right',
metricWarning: getMetricWarning(m, meta),
renderValue: (item, isRowHovered) =>
m.renderValue(
showPercentageColumn && m.key === 'visitors'
? { ...item, percentage: null }
: item,
meta,
{ detailedView: true, isRowHovered }
),
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
sortDirection: orderByDictionary[m.key]
})
)
...metrics.map(
(m): ColumnConfiguraton<TListItem> => ({
label: m.renderLabel(query),
key: m.key,
width: m.width,
align: 'right',
metricWarning: getMetricWarning(m, meta),
renderValue: (item) => m.renderValue(item, meta),
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
sortDirection: orderByDictionary[m.key]
})
)
],
[
reportInfo.dimensionLabel,
breakdownMetrics,
metrics,
getFilterInfo,
query,
orderByDictionary,
toggleSortByMetric,
renderIcon,
getExternalLinkUrl,
meta,
showPercentageColumn
meta
]
)
@ -213,7 +190,7 @@ const NameCell = <TListItem extends { name: string }>({
renderIcon?: (item: TListItem) => ReactNode
getExternalLinkUrl?: (listItem: TListItem) => string
}) => (
<div className="max-w-full break-all flex items-center">
<>
{typeof renderIcon === 'function' && renderIcon(item)}
<DrilldownLink
path={rootRoute.path}
@ -226,7 +203,7 @@ const NameCell = <TListItem extends { name: string }>({
{typeof getExternalLinkUrl === 'function' && (
<ExternalLinkIcon url={getExternalLinkUrl(item)} />
)}
</div>
</>
)
const ExternalLinkIcon = ({ url }: { url?: string }) =>

View File

@ -1,12 +1,11 @@
import React, { ReactNode, useRef } from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { SearchInput } from '../../components/search-input'
import { ColumnConfiguraton, Table } from '../../components/table'
import RocketIcon from './rocket-icon'
import { QueryStatus } from '@tanstack/react-query'
import { useAppNavigate } from '../../navigation/use-app-navigate'
import { rootRoute } from '../../router'
const MIN_HEIGHT_PX = 500
export const BreakdownTable = <TListItem extends { name: string }>({
title,
@ -20,8 +19,7 @@ export const BreakdownTable = <TListItem extends { name: string }>({
data,
status,
error,
displayError,
onClose
displayError
}: {
title: ReactNode
onSearch?: (input: string) => void
@ -36,42 +34,28 @@ export const BreakdownTable = <TListItem extends { name: string }>({
error?: Error | null
/** Controls whether the component displays API request errors or ignores them. */
displayError?: boolean
onClose?: () => void
}) => {
const searchRef = useRef<HTMLInputElement>(null)
const navigate = useAppNavigate()
const handleClose =
onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s }))
return (
<>
<div className="flex justify-between items-center gap-4">
<div className="flex items-center gap-4 w-full">
<h1 className="shrink-0 mb-0.5 text-base md:text-lg font-bold dark:text-gray-100">
{title}
</h1>
<div className="w-full h-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-x-2">
<h1 className="text-xl font-bold dark:text-gray-100">{title}</h1>
{!isPending && isFetching && <SmallLoadingSpinner />}
{!!onSearch && (
<SearchInput
searchRef={searchRef}
onSearch={onSearch}
className={
displayError && status === 'error' ? 'pointer-events-none' : ''
}
/>
)}
</div>
<button
type="button"
onClick={handleClose}
aria-label="Close modal"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<XMarkIcon className="size-5" />
</button>
{!!onSearch && (
<SearchInput
searchRef={searchRef}
onSearch={onSearch}
className={
displayError && status === 'error' ? 'pointer-events-none' : ''
}
/>
)}
</div>
<div className="my-3 md:my-4 border-b border-gray-250 dark:border-gray-750"></div>
<div className="flex-1 overflow-auto pr-4 -mr-4">
<div className="my-4 border-b border-gray-300 dark:border-gray-700"></div>
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
{displayError && status === 'error' && <ErrorMessage error={error} />}
{isPending && <InitialLoadingSpinner />}
{data && <Table<TListItem> data={data} columns={columns} />}
@ -82,12 +66,15 @@ export const BreakdownTable = <TListItem extends { name: string }>({
/>
)}
</div>
</>
</div>
)
}
const InitialLoadingSpinner = () => (
<div className="w-full h-full flex flex-col justify-center">
<div
className="w-full h-full flex flex-col justify-center"
style={{ minHeight: `${MIN_HEIGHT_PX}px` }}
>
<div className="mx-auto loading">
<div />
</div>
@ -101,7 +88,10 @@ const SmallLoadingSpinner = () => (
)
const ErrorMessage = ({ error }: { error?: unknown }) => (
<div className="grid grid-rows-2 text-gray-700 dark:text-gray-300">
<div
className="grid grid-rows-2 text-gray-700 dark:text-gray-300"
style={{ height: `${MIN_HEIGHT_PX}px` }}
>
<div className="text-center self-end">
<RocketIcon />
</div>

View File

@ -13,7 +13,7 @@ function ConversionsModal() {
const site = useSiteContext()
const reportInfo = {
title: 'Goal conversions',
title: 'Goal Conversions',
dimension: 'goal',
endpoint: url.apiPath(site, '/conversions'),
dimensionLabel: 'Goal'

View File

@ -14,7 +14,7 @@ function BrowserVersionsModal() {
const site = useSiteContext()
const reportInfo = {
title: 'Browser versions',
title: 'Browser Versions',
dimension: 'browser_version',
endpoint: url.apiPath(site, '/browser-versions'),
dimensionLabel: 'Browser version',
@ -52,7 +52,7 @@ function BrowserVersionsModal() {
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query, site)}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}

View File

@ -52,7 +52,7 @@ function BrowsersModal() {
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query, site)}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}

View File

@ -2,31 +2,25 @@ import {
hasConversionGoalFilter,
isRealTimeDashboard
} from '../../../util/filters'
import { revenueAvailable } from '../../../query'
import * as metrics from '../../reports/metrics'
export default function chooseMetrics(query, site) {
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
export default function chooseMetrics(query) {
if (hasConversionGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({
renderLabel: (_query) => 'Conversions',
width: 'w-32 md:w-28'
width: 'w-28'
}),
metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(),
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({
renderLabel: (_query) => 'Current visitors',
width: 'w-32'
width: 'w-36'
}),
metrics.createPercentage()
]

View File

@ -14,7 +14,7 @@ function OperatingSystemVersionsModal() {
const site = useSiteContext()
const reportInfo = {
title: 'Operating system versions',
title: 'Operating System Versions',
dimension: 'os_version',
endpoint: url.apiPath(site, '/operating-system-versions'),
dimensionLabel: 'Operating system version',
@ -49,7 +49,7 @@ function OperatingSystemVersionsModal() {
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query, site)}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}

View File

@ -14,7 +14,7 @@ function OperatingSystemsModal() {
const site = useSiteContext()
const reportInfo = {
title: 'Operating systems',
title: 'Operating Systems',
dimension: 'os',
endpoint: url.apiPath(site, '/operating-systems'),
dimensionLabel: 'Operating system',
@ -49,7 +49,7 @@ function OperatingSystemsModal() {
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query, site)}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}

View File

@ -13,7 +13,7 @@ function ScreenSizesModal() {
const site = useSiteContext()
const reportInfo = {
title: 'Screen sizes',
title: 'Screen Sizes',
dimension: 'screen',
endpoint: url.apiPath(site, '/screen-sizes'),
dimensionLabel: 'Screen size',
@ -39,7 +39,7 @@ function ScreenSizesModal() {
<Modal>
<BreakdownModal
reportInfo={reportInfo}
metrics={chooseMetrics(query, site)}
metrics={chooseMetrics(query)}
getFilterInfo={getFilterInfo}
searchEnabled={false}
renderIcon={renderIcon}

View File

@ -4,7 +4,7 @@ import {
hasConversionGoalFilter,
isRealTimeDashboard
} from '../../util/filters'
import { addFilter, revenueAvailable } from '../../query'
import { addFilter } from '../../query'
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
@ -16,11 +16,8 @@ function EntryPagesModal() {
const { query } = useQueryContext()
const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = {
title: 'Entry pages',
title: 'Entry Pages',
dimension: 'entry_page',
endpoint: url.apiPath(site, '/entry-pages'),
dimensionLabel: 'Entry page',
@ -57,17 +54,15 @@ function EntryPagesModal() {
renderLabel: (_query) => 'Conversions',
width: 'w-28'
}),
metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(),
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({
renderLabel: (_query) => 'Current visitors',
width: 'w-32'
width: 'w-36'
})
]
}
@ -75,8 +70,8 @@ function EntryPagesModal() {
return [
metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }),
metrics.createVisits({
renderLabel: (_query) => 'Total entrances',
width: 'w-32'
renderLabel: (_query) => 'Total Entrances',
width: 'w-36'
}),
metrics.createBounceRate(),
metrics.createVisitDuration()

View File

@ -1,7 +1,7 @@
import React, { useCallback } from 'react'
import Modal from './modal'
import { hasConversionGoalFilter } from '../../util/filters'
import { addFilter, revenueAvailable } from '../../query'
import { addFilter } from '../../query'
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
@ -13,11 +13,8 @@ function ExitPagesModal() {
const { query } = useQueryContext()
const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = {
title: 'Exit pages',
title: 'Exit Pages',
dimension: 'exit_page',
endpoint: url.apiPath(site, '/exit-pages'),
dimensionLabel: 'Page url',
@ -54,17 +51,15 @@ function ExitPagesModal() {
renderLabel: (_query) => 'Conversions',
width: 'w-28'
}),
metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(),
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
metrics.createConversionRate()
]
}
if (query.period === 'realtime') {
return [
metrics.createVisitors({
renderLabel: (_query) => 'Current visitors',
width: 'w-32'
width: 'w-36'
})
]
}
@ -75,8 +70,7 @@ function ExitPagesModal() {
sortable: true
}),
metrics.createVisits({
renderLabel: (_query) => 'Total exits',
width: 'w-32',
renderLabel: (_query) => 'Total Exits',
sortable: true
}),
metrics.createExitRate()

View File

@ -1,5 +1,4 @@
import React from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { useParams } from 'react-router-dom'
import Modal from './modal'
@ -69,7 +68,6 @@ class FilterModal extends React.Component {
)
this.handleKeydown = this.handleKeydown.bind(this)
this.closeModal = this.closeModal.bind(this)
this.state = {
query,
filterState,
@ -110,13 +108,6 @@ class FilterModal extends React.Component {
)
}
closeModal() {
this.props.navigate({
path: rootRoute.path,
search: (search) => search
})
}
selectFiltersAndCloseModal(filters) {
this.props.navigate({
path: rootRoute.path,
@ -178,23 +169,13 @@ class FilterModal extends React.Component {
render() {
return (
<Modal maxWidth="460px" onClose={this.closeModal}>
<div className="flex items-center justify-between gap-3">
<h1 className="text-base md:text-lg font-bold dark:text-gray-100">
Filter by {formatFilterGroup(this.props.modalType)}
</h1>
<button
type="button"
onClick={this.closeModal}
aria-label="Close modal"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<XMarkIcon className="size-5" />
</button>
</div>
<Modal maxWidth="460px">
<h1 className="text-xl font-bold dark:text-gray-100">
Filter by {formatFilterGroup(this.props.modalType)}
</h1>
<div className="mt-2 md:mt-4 border-b border-gray-300 dark:border-gray-700"></div>
<main>
<div className="mt-4 border-b border-gray-300 dark:border-gray-700"></div>
<main className="modal__content">
<form
className="flex flex-col"
onSubmit={this.handleSubmit.bind(this)}
@ -211,7 +192,7 @@ class FilterModal extends React.Component {
/>
))}
<div className="mt-6 mb-3 flex gap-x-4 items-center justify-start">
<div className="mt-6 flex gap-x-4 items-center justify-start">
<button
type="submit"
className="button !px-3"

View File

@ -7,26 +7,26 @@ import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { addFilter, revenueAvailable } from '../../query'
import { addFilter } from '../../query'
import { SortDirection } from '../../hooks/use-order-by'
const VIEWS = {
countries: {
title: 'Top countries',
title: 'Top Countries',
dimension: 'country',
endpoint: '/countries',
dimensionLabel: 'Country',
defaultOrder: ['visitors', SortDirection.desc]
},
regions: {
title: 'Top regions',
title: 'Top Regions',
dimension: 'region',
endpoint: '/regions',
dimensionLabel: 'Region',
defaultOrder: ['visitors', SortDirection.desc]
},
cities: {
title: 'Top cities',
title: 'Top Cities',
dimension: 'city',
endpoint: '/cities',
dimensionLabel: 'City',
@ -38,9 +38,6 @@ function LocationsModal({ currentView }) {
const { query } = useQueryContext()
const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
let reportInfo = VIEWS[currentView]
reportInfo = {
...reportInfo,
@ -78,17 +75,15 @@ function LocationsModal({ currentView }) {
renderLabel: (_query) => 'Conversions',
width: 'w-28'
}),
metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(),
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
metrics.createConversionRate()
]
}
if (query.period === 'realtime') {
return [
metrics.createVisitors({
renderLabel: (_query) => 'Current visitors',
width: 'w-32'
width: 'w-36'
})
]
}

View File

@ -3,8 +3,12 @@ import { createPortal } from 'react-dom'
import { isModifierPressed, isTyping, Keybind } from '../../keybinding'
import { rootRoute } from '../../router'
import { useAppNavigate } from '../../navigation/use-app-navigate'
// This corresponds to the 'md' breakpoint on TailwindCSS.
const MD_WIDTH = 768
// We assume that the dashboard is by default opened on a desktop. This is also a fall-back for when, for any reason, the width is not ascertained.
const DEFAULT_WIDTH = 1080
class Modal extends React.Component {
constructor(props) {
super(props)
@ -23,21 +27,26 @@ class Modal extends React.Component {
window.addEventListener('resize', this.handleResize, false)
this.handleResize()
}
componentWillUnmount() {
document.body.style.overflow = null
document.body.style.height = null
document.removeEventListener('mousedown', this.handleClickOutside)
window.removeEventListener('resize', this.handleResize, false)
}
handleClickOutside(e) {
if (this.node.current.contains(e.target)) {
return
}
this.props.onClose()
}
handleResize() {
this.setState({ viewport: window.innerWidth })
}
/**
* @description
* Decide whether to set max-width, and if so, to what.
@ -47,11 +56,12 @@ class Modal extends React.Component {
*/
getStyle() {
const { maxWidth } = this.props
const { viewport } = this.state
const styleObject = {}
if (maxWidth) {
styleObject.maxWidth = maxWidth
} else {
styleObject.maxWidth = '880px'
styleObject.width = viewport <= MD_WIDTH ? 'min-content' : '860px'
}
return styleObject
}
@ -68,17 +78,16 @@ class Modal extends React.Component {
/>
<div className="modal is-open" onClick={this.props.onClick}>
<div className="modal__overlay">
<div className="[--gap:1rem] sm:[--gap:2rem] md:[--gap:4rem] flex h-full w-full items-center md:items-start justify-center p-[var(--gap)] box-border">
<div
ref={this.node}
className="max-h-[calc(100dvh_-_var(--gap)*2)] min-h-[66vh] md:min-h-120 w-full flex flex-col bg-white p-3 md:px-6 md:py-4 overflow-hidden box-border transition-[height] duration-200 ease-in shadow-2xl rounded-lg dark:bg-gray-900 focus:outline-hidden"
style={this.getStyle()}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
>
<FocusOnMount focusableRef={this.node} />
{this.props.children}
</div>
<button className="modal__close"></button>
<div
ref={this.node}
className="modal__container dark:bg-gray-900 focus:outline-hidden"
style={this.getStyle()}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
>
<FocusOnMount focusableRef={this.node} />
{this.props.children}
</div>
</div>
</div>

View File

@ -4,7 +4,7 @@ import {
hasConversionGoalFilter,
isRealTimeDashboard
} from '../../util/filters'
import { addFilter, revenueAvailable } from '../../query'
import { addFilter } from '../../query'
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
@ -16,11 +16,8 @@ function PagesModal() {
const { query } = useQueryContext()
const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = {
title: 'Top pages',
title: 'Top Pages',
dimension: 'page',
endpoint: url.apiPath(site, '/pages'),
dimensionLabel: 'Page url',
@ -57,17 +54,15 @@ function PagesModal() {
renderLabel: (_query) => 'Conversions',
width: 'w-28'
}),
metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(),
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({
renderLabel: (_query) => 'Current visitors',
width: 'w-32'
width: 'w-36'
})
]
}

View File

@ -21,7 +21,7 @@ function PropsModal() {
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = {
title: specialTitleWhenGoalFilter(query, 'Custom property breakdown'),
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
dimension: propKey,
endpoint: url.apiPath(
site,
@ -71,7 +71,6 @@ function PropsModal() {
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
showPercentageColumn
/>
</Modal>
)

View File

@ -9,7 +9,7 @@ import {
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
import { addFilter, revenueAvailable } from '../../query'
import { addFilter } from '../../query'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { SortDirection } from '../../hooks/use-order-by'
@ -20,9 +20,6 @@ function ReferrerDrilldownModal() {
const { query } = useQueryContext()
const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = {
title: 'Referrer Drilldown',
dimension: 'referrer',
@ -64,17 +61,15 @@ function ReferrerDrilldownModal() {
renderLabel: (_query) => 'Conversions',
width: 'w-28'
}),
metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(),
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({
renderLabel: (_query) => 'Current visitors',
width: 'w-32'
width: 'w-36'
})
]
}

View File

@ -7,7 +7,7 @@ import {
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
import { addFilter, revenueAvailable } from '../../query'
import { addFilter } from '../../query'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { SortDirection } from '../../hooks/use-order-by'
@ -16,7 +16,7 @@ import { SourceFavicon } from '../sources/source-favicon'
const VIEWS = {
sources: {
info: {
title: 'Top sources',
title: 'Top Sources',
dimension: 'source',
endpoint: '/sources',
dimensionLabel: 'Source',
@ -33,7 +33,7 @@ const VIEWS = {
},
channels: {
info: {
title: 'Top acquisition channels',
title: 'Top Acquisition Channels',
dimension: 'channel',
endpoint: '/channels',
dimensionLabel: 'Channel',
@ -42,46 +42,46 @@ const VIEWS = {
},
utm_mediums: {
info: {
title: 'Top UTM mediums',
title: 'Top UTM Mediums',
dimension: 'utm_medium',
endpoint: '/utm_mediums',
dimensionLabel: 'UTM medium',
dimensionLabel: 'UTM Medium',
defaultOrder: ['visitors', SortDirection.desc]
}
},
utm_sources: {
info: {
title: 'Top UTM sources',
title: 'Top UTM Sources',
dimension: 'utm_source',
endpoint: '/utm_sources',
dimensionLabel: 'UTM source',
dimensionLabel: 'UTM Source',
defaultOrder: ['visitors', SortDirection.desc]
}
},
utm_campaigns: {
info: {
title: 'Top UTM campaigns',
title: 'Top UTM Campaigns',
dimension: 'utm_campaign',
endpoint: '/utm_campaigns',
dimensionLabel: 'UTM campaign',
dimensionLabel: 'UTM Campaign',
defaultOrder: ['visitors', SortDirection.desc]
}
},
utm_contents: {
info: {
title: 'Top UTM contents',
title: 'Top UTM Contents',
dimension: 'utm_content',
endpoint: '/utm_contents',
dimensionLabel: 'UTM content',
dimensionLabel: 'UTM Content',
defaultOrder: ['visitors', SortDirection.desc]
}
},
utm_terms: {
info: {
title: 'Top UTM terms',
title: 'Top UTM Terms',
dimension: 'utm_term',
endpoint: '/utm_terms',
dimensionLabel: 'UTM term',
dimensionLabel: 'UTM Term',
defaultOrder: ['visitors', SortDirection.desc]
}
}
@ -91,9 +91,6 @@ function SourcesModal({ currentView }) {
const { query } = useQueryContext()
const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
let reportInfo = VIEWS[currentView].info
reportInfo = {
...reportInfo,
@ -130,17 +127,15 @@ function SourcesModal({ currentView }) {
renderLabel: (_query) => 'Conversions',
width: 'w-28'
}),
metrics.createConversionRate(),
showRevenueMetrics && metrics.createTotalRevenue(),
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({
renderLabel: (_query) => 'Current visitors',
width: 'w-32'
width: 'w-36'
})
]
}

View File

@ -33,12 +33,10 @@ function EntryPages({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({
defaultLabel: 'Unique entrances',
defaultLabel: 'Unique Entrances',
width: 'w-36',
meta: { plot: true }
}),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -55,7 +53,7 @@ function EntryPages({ afterFetchData }) {
search: (search) => search
}}
getExternalLinkUrl={getExternalLinkUrl}
color="bg-orange-50 group-hover/row:bg-orange-100"
color="bg-orange-50"
/>
)
}
@ -81,12 +79,10 @@ function ExitPages({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({
defaultLabel: 'Unique exits',
defaultLabel: 'Unique Exits',
width: 'w-36',
meta: { plot: true }
}),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -103,7 +99,7 @@ function ExitPages({ afterFetchData }) {
search: (search) => search
}}
getExternalLinkUrl={getExternalLinkUrl}
color="bg-orange-50 group-hover/row:bg-orange-100"
color="bg-orange-50"
/>
)
}
@ -129,8 +125,6 @@ function TopPages({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -147,15 +141,15 @@ function TopPages({ afterFetchData }) {
search: (search) => search
}}
getExternalLinkUrl={getExternalLinkUrl}
color="bg-orange-50 group-hover/row:bg-orange-100"
color="bg-orange-50"
/>
)
}
const labelFor = {
pages: 'Top pages',
'entry-pages': 'Entry pages',
'exit-pages': 'Exit pages'
pages: 'Top Pages',
'entry-pages': 'Entry Pages',
'exit-pages': 'Exit Pages'
}
export default function Pages() {
@ -193,7 +187,7 @@ export default function Pages() {
}
return (
<div className="group/report overflow-x-hidden">
<div>
{/* Header Container */}
<div className="w-full flex justify-between">
<div className="flex gap-x-1">
@ -207,9 +201,9 @@ export default function Pages() {
</div>
<TabWrapper>
{[
{ label: 'Top pages', value: 'pages' },
{ label: 'Entry pages', value: 'entry-pages' },
{ label: 'Exit pages', value: 'exit-pages' }
{ label: 'Top Pages', value: 'pages' },
{ label: 'Entry Pages', value: 'entry-pages' },
{ label: 'Exit Pages', value: 'exit-pages' }
].map(({ value, label }) => (
<TabButton
active={mode === value}

View File

@ -34,7 +34,7 @@ it('renders tilde for no change', () => {
const arrowElement = screen.getByTestId('change-arrow')
expect(arrowElement).toHaveTextContent('0%')
expect(arrowElement).toHaveTextContent('0%')
})
it('inverts colors for positive bounce_rate change', () => {

View File

@ -15,22 +15,24 @@ export function ChangeArrow({
className: string
hideNumber?: boolean
}) {
const formattedChange = hideNumber
? null
: ` ${numberShortFormatter(Math.abs(change))}%`
let icon = null
const arrowClassName = classNames(
color(change, metric),
'mb-0.5 inline-block size-3 stroke-[1px] stroke-current'
'inline-block h-3 w-3 stroke-[1px] stroke-current'
)
if (change > 0) {
icon = <ArrowUpRightIcon className={arrowClassName} />
} else if (change < 0) {
icon = <ArrowDownRightIcon className={arrowClassName} />
} else if (change === 0 && !hideNumber) {
icon = <>&#12336;</>
}
const formattedChange = hideNumber
? null
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
return (
<span className={className} data-testid="change-arrow">
{icon}

View File

@ -26,34 +26,27 @@ const COL_MIN_WIDTH = 70
function ExternalLink<T>({
item,
getExternalLinkUrl,
isTapped
getExternalLinkUrl
}: {
item: T
getExternalLinkUrl?: (item: T) => string
isTapped?: boolean
}) {
const dest = getExternalLinkUrl && getExternalLinkUrl(item)
if (dest) {
const className = isTapped
? 'visible md:invisible md:group-hover/row:visible'
: 'invisible md:group-hover/row:visible'
return (
<a target="_blank" rel="noreferrer" href={dest} className={className}>
<a
target="_blank"
rel="noreferrer"
href={dest}
className="w-4 h-4 invisible group-hover:visible"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline size-3.5 mb-0.5 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4M12 12l9-9-.303.303M14 3h7v7"
/>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path>
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path>
</svg>
</a>
)
@ -95,6 +88,11 @@ type ListReportProps = {
colMinWidth?: number
/** Navigation props to be passed to "More" link, if any. */
detailsLinkProps?: AppNavigationLinkProps
/** Set this to `true` if the details button should be hidden on
* the condition that there are less than MAX_ITEMS entries in the list (i.e. nothing
* more to show).
*/
maybeHideDetails?: boolean
/** Function with additional action to be taken when a list entry is clicked. */
onClick?: () => void
/** Color of the comparison bars in light-mode. */
@ -116,6 +114,7 @@ export default function ListReport<
colMinWidth = COL_MIN_WIDTH,
afterFetchData,
detailsLinkProps,
maybeHideDetails,
onClick,
color,
getFilterInfo,
@ -130,7 +129,6 @@ export default function ListReport<
meta: BreakdownResultMeta | null
}>({ loading: true, list: null, meta: null })
const [visible, setVisible] = useState(false)
const [tappedRow, setTappedRow] = useState<string | null>(null)
const isRealtime = isRealTimeDashboard(query)
const goalFilterApplied = hasConversionGoalFilter(query)
@ -196,38 +194,6 @@ export default function ListReport<
}
}
function showOnHoverClass(metric: Metric, listItemName: string) {
if (!metric.meta.showOnHover) {
return ''
}
// On mobile: show if row is tapped, hide otherwise
// On desktop: slide in from right when hovering
if (tappedRow === listItemName) {
return 'translate-x-0 opacity-100 transition-all duration-150'
} else {
return 'translate-x-[100%] opacity-0 transition-all duration-150 md:group-hover/report:translate-x-0 md:group-hover/report:opacity-100'
}
}
function slideLeftClass(
metricIndex: number,
showOnHoverIndex: number,
hasShowOnHoverMetric: boolean,
listItemName: string
) {
// Columns before the showOnHover column should slide left when it appears
if (!hasShowOnHoverMetric || metricIndex >= showOnHoverIndex) {
return ''
}
if (tappedRow === listItemName) {
return 'transition-transform duration-150 translate-x-0'
} else {
return 'transition-transform duration-150 translate-x-[100%] md:group-hover/report:translate-x-0'
}
}
function renderReport() {
if (state.list && state.list.length > 0) {
return (
@ -240,14 +206,16 @@ export default function ListReport<
</FlipMove>
</div>
{!!detailsLinkProps && !state.loading && (
<MoreLink
onClick={undefined}
className={'mt-3'}
linkProps={detailsLinkProps}
list={state.list}
/>
)}
{!!detailsLinkProps &&
!state.loading &&
!(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && (
<MoreLink
onClick={undefined}
className={'mt-2'}
linkProps={detailsLinkProps}
list={state.list}
/>
)}
</div>
)
}
@ -255,22 +223,20 @@ export default function ListReport<
}
function renderReportHeader() {
const metricLabels = getAvailableMetrics()
.filter((metric) => !metric.meta.showOnHover)
.map((metric) => {
return (
<div
key={metric.key}
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
style={{ minWidth: colMinWidth }}
>
{metric.renderLabel(query)}
</div>
)
})
const metricLabels = getAvailableMetrics().map((metric) => {
return (
<div
key={metric.key}
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
style={{ minWidth: colMinWidth }}
>
{metric.renderLabel(query)}
</div>
)
})
return (
<div className="pt-3 w-full text-xs font-semibold text-gray-500 flex items-center dark:text-gray-400">
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
<span className="grow truncate">{keyLabel}</span>
{metricLabels}
</div>
@ -278,22 +244,11 @@ export default function ListReport<
}
function renderRow(listItem: TListItem) {
const handleRowClick = (e: React.MouseEvent) => {
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
if (tappedRow === listItem.name) {
setTappedRow(null)
} else {
setTappedRow(listItem.name)
}
}
}
return (
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
<div
className="group/row flex w-full items-center hover:bg-gray-100/60 dark:hover:bg-gray-850 rounded-sm md:cursor-default cursor-pointer"
className="flex w-full items-center"
style={{ marginTop: ROW_GAP_HEIGHT }}
onClick={handleRowClick}
>
{renderBarFor(listItem)}
{renderMetricValuesFor(listItem)}
@ -303,7 +258,7 @@ export default function ListReport<
}
function renderBarFor(listItem: TListItem) {
const lightBackground = color || 'bg-green-50 group-hover/row:bg-green-100'
const lightBackground = color || 'bg-green-50'
const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key
return (
@ -312,10 +267,10 @@ export default function ListReport<
maxWidthDeduction={undefined}
count={listItem[metricToPlot]}
all={state.list}
bg={`${lightBackground} dark:bg-gray-500/15 dark:group-hover/row:bg-gray-500/30`}
bg={`${lightBackground} dark:bg-gray-500/15`}
plot={metricToPlot}
>
<div className="flex justify-start items-center gap-x-1.5 px-2 py-1.5 text-sm dark:text-gray-300 relative z-9 break-all w-full">
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
<DrilldownLink
filterInfo={getFilterInfo(listItem)}
onClick={onClick}
@ -330,7 +285,6 @@ export default function ListReport<
<ExternalLink
item={listItem}
getExternalLinkUrl={getExternalLinkUrl}
isTapped={tappedRow === listItem.name}
/>
</div>
</Bar>
@ -345,36 +299,19 @@ export default function ListReport<
}
function renderMetricValuesFor(listItem: TListItem) {
const availableMetrics = getAvailableMetrics()
const showOnHoverIndex = availableMetrics.findIndex(
(m) => m.meta.showOnHover
)
const hasShowOnHoverMetric = showOnHoverIndex !== -1
return (
<>
{availableMetrics.map((metric, index) => {
const isShowOnHover = metric.meta.showOnHover
return (
<div
key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)} ${showOnHoverClass(metric, listItem.name)} ${slideLeftClass(index, showOnHoverIndex, hasShowOnHoverMetric, listItem.name)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }}
>
<span
className={`font-medium text-sm text-right ${isShowOnHover ? 'text-gray-500 group-hover/row:text-gray-800 dark:group-hover/row:text-gray-200' : 'text-gray-800 dark:text-gray-200'}`}
>
{metric.renderValue(listItem, state.meta, {
detailedView: false,
isRowHovered: false
})}
</span>
</div>
)
})}
</>
)
return getAvailableMetrics().map((metric) => {
return (
<div
key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }}
>
<span className="font-medium text-sm dark:text-gray-200 text-right">
{metric.renderValue(listItem, state.meta)}
</span>
</div>
)
})
}
function renderLoading() {

View File

@ -11,9 +11,10 @@ const REVENUE = { long: '$1,659.50', short: '$1.7K' }
describe('single value', () => {
it('renders small value', async () => {
render(<MetricValue {...valueProps('visitors', 10)} />)
await renderWithTooltip(<MetricValue {...valueProps('visitors', 10)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('10')
expect(screen.getByRole('tooltip')).toHaveTextContent('10')
})
it('renders large value', async () => {
@ -24,19 +25,23 @@ describe('single value', () => {
})
it('renders percentages', async () => {
render(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
await renderWithTooltip(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3%')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3%')
})
it('renders durations', async () => {
render(<MetricValue {...valueProps('visit_duration', 60)} />)
await renderWithTooltip(
<MetricValue {...valueProps('visit_duration', 60)} />
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s')
expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s')
})
it('renders with custom formatter', async () => {
render(
await renderWithTooltip(
<MetricValue
{...valueProps('test_money', 5.3)}
formatter={(value) => `${value}$`}
@ -44,6 +49,7 @@ describe('single value', () => {
)
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3$')
})
it('renders revenue properly', async () => {
@ -74,8 +80,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10 visitors',
'01 Aug - 31 Aug',
'↑ 100%',
'01 Aug - 31 Aug',
'vs',
'5 visitors',
'01 July - 31 July'
].join('')
@ -91,8 +98,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'5 visitors',
'01 Aug - 31 Aug',
'↓ 50%',
'01 Aug - 31 Aug',
'vs',
'10 visitors',
'01 July - 31 July'
].join('')
@ -108,8 +116,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10 visitors',
'〰 0%',
'01 Aug - 31 Aug',
'0%',
'vs',
'10 visitors',
'01 July - 31 July'
].join('')
@ -127,8 +136,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10 conversions',
'〰 0%',
'01 Aug - 31 Aug',
'0%',
'vs',
'10 conversions',
'01 July - 31 July'
].join('')
@ -144,7 +154,14 @@ describe('comparisons', () => {
)
expect(screen.getByRole('tooltip')).toHaveTextContent(
['10% ', '01 Aug - 31 Aug', '0%', '10% ', '01 July - 31 July'].join('')
[
'10% ',
'〰 0%',
'01 Aug - 31 Aug',
'vs',
'10% ',
'01 July - 31 July'
].join('')
)
})
@ -160,8 +177,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'10$ test',
'01 Aug - 31 Aug',
'↑ 100%',
'01 Aug - 31 Aug',
'vs',
'5$ test',
'01 July - 31 July'
].join('')
@ -182,8 +200,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
[
'$1,659.50 average_revenue',
'〰 0%',
'01 Aug - 31 Aug',
'0%',
'vs',
'$1,659.50 average_revenue',
'01 July - 31 July'
].join('')

View File

@ -1,4 +1,4 @@
import React, { useMemo, useRef, useEffect } from 'react'
import React, { useMemo } from 'react'
import { Metric } from '../../../types/query-api'
import { Tooltip } from '../../util/tooltip'
import { ChangeArrow } from './change-arrow'
@ -36,84 +36,23 @@ export default function MetricValue(props: {
renderLabel: (query: DashboardQuery) => string
formatter?: (value: ValueType) => string
meta: BreakdownResultMeta | null
detailedView?: boolean
isRowHovered?: boolean
}) {
const { query } = useQueryContext()
const portalRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (typeof document !== 'undefined') {
portalRef.current = document.body
}
}, [])
const { metric, listItem, detailedView = false, isRowHovered = false } = props
const { metric, listItem } = props
const { value, comparison } = useMemo(
() => valueRenderProps(listItem, metric),
[listItem, metric]
)
const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
const shortFormatter = props.formatter ?? MetricFormatterShort[metric]
const longFormatter = props.formatter ?? MetricFormatterLong[metric]
const isAbbreviated = useMemo(() => {
if (value === null) return false
return shortFormatter(value) !== longFormatter(value)
}, [value, shortFormatter, longFormatter])
const showTooltip = detailedView
? !!comparison
: !!comparison || isAbbreviated
const shouldShowLongFormat =
detailedView && !comparison && isRowHovered && isAbbreviated
const displayFormatter = shouldShowLongFormat ? longFormatter : shortFormatter
const percentageValue = listItem['percentage' as Metric]
const shouldShowPercentage =
detailedView &&
metric === 'visitors' &&
isRowHovered &&
percentageValue != null
const percentageFormatter = MetricFormatterShort['percentage']
const percentageDisplay = shouldShowPercentage
? percentageFormatter(percentageValue)
: null
if (value === null && (!comparison || comparison.value === null)) {
return <span data-testid="metric-value">{displayFormatter(value)}</span>
}
const valueContent = (
<span
className={showTooltip ? 'cursor-default' : ''}
data-testid="metric-value"
>
{percentageDisplay && (
<span className="mr-3 text-gray-500 dark:text-gray-400">
{percentageDisplay}
</span>
)}
{displayFormatter(value)}
{comparison ? (
<ChangeArrow
change={comparison.change}
metric={metric}
className="inline-block pl-1 w-4"
hideNumber
/>
) : null}
</span>
)
if (!showTooltip) {
return valueContent
return <span data-testid="metric-value">{shortFormatter(value)}</span>
}
return (
<Tooltip
containerRef={portalRef as React.RefObject<HTMLElement>}
info={
<ComparisonTooltipContent
value={value}
@ -123,7 +62,17 @@ export default function MetricValue(props: {
/>
}
>
{valueContent}
<span className="cursor-default" data-testid="metric-value">
{shortFormatter(value)}
{comparison ? (
<ChangeArrow
change={comparison.change}
metric={metric}
className="inline-block pl-1 w-4"
hideNumber
/>
) : null}
</span>
</Tooltip>
)
}
@ -157,34 +106,34 @@ function ComparisonTooltipContent({
return (
<div className="text-left whitespace-nowrap py-1 space-y-2">
<div>
<div className="flex gap-x-4">
<div className="flex flex-col">
<span className="font-medium text-sm/6 text-white">
{longFormatter(value)} {label}
</span>
<div className="font-normal text-xs text-white">
{meta.date_range_label}
</div>
</div>
<div className="flex items-center">
<span className="font-bold text-base">
{longFormatter(value)} {label}
</span>
<ChangeArrow
metric={metric}
change={comparison.change}
className="text-xs/6 font-medium text-white"
className="pl-4 text-xs text-gray-100"
/>
</div>
<div className="font-normal text-xs">{meta.date_range_label}</div>
</div>
<div className="w-full border-t border-gray-600"></div>
<div>vs</div>
<div>
<div className="font-medium text-sm/6 text-gray-300/80">
<div className="font-bold text-base">
{longFormatter(comparison.value)} {label}
</div>
<div className="font-normal text-xs text-gray-300/80">
<div className="font-normal text-xs">
{meta.comparison_date_range_label}
</div>
</div>
</div>
)
} else {
return <div className="whitespace-nowrap">{longFormatter(value)}</div>
return (
<div className="whitespace-nowrap">
{longFormatter(value)} {label}
</div>
)
}
}

View File

@ -43,8 +43,7 @@ export class Metric {
this.renderValue = this.renderValue.bind(this)
}
renderValue(listItem, meta, options = {}) {
const { detailedView = false, isRowHovered = false } = options
renderValue(listItem, meta) {
return (
<MetricValue
listItem={listItem}
@ -52,8 +51,6 @@ export class Metric {
renderLabel={this.renderLabel}
meta={meta}
formatter={this.formatter}
detailedView={detailedView}
isRowHovered={isRowHovered}
/>
)
}
@ -88,7 +85,7 @@ export const createVisitors = (props) => {
}
return new Metric({
width: 'w-36',
width: 'w-24',
sortable: true,
...props,
key: 'visitors',
@ -99,7 +96,7 @@ export const createVisitors = (props) => {
export const createConversionRate = (props) => {
const renderLabel = (_query) => 'CR'
return new Metric({
width: 'w-28 md:w-24',
width: 'w-24',
...props,
key: 'conversion_rate',
renderLabel,
@ -119,13 +116,13 @@ export const createPercentage = (props) => {
}
export const createEvents = (props) => {
return new Metric({ width: 'w-28', ...props, key: 'events', sortable: true })
return new Metric({ width: 'w-24', ...props, key: 'events', sortable: true })
}
export const createTotalRevenue = (props) => {
const renderLabel = (_query) => 'Revenue'
return new Metric({
width: 'w-32',
width: 'w-24',
...props,
key: 'total_revenue',
renderLabel,
@ -136,7 +133,7 @@ export const createTotalRevenue = (props) => {
export const createAverageRevenue = (props) => {
const renderLabel = (_query) => 'Average'
return new Metric({
width: 'w-28',
width: 'w-24',
...props,
key: 'average_revenue',
renderLabel,
@ -145,9 +142,9 @@ export const createAverageRevenue = (props) => {
}
export const createTotalVisitors = (props) => {
const renderLabel = (_query) => 'Total visitors'
const renderLabel = (_query) => 'Total Visitors'
return new Metric({
width: 'w-32',
width: 'w-28',
...props,
key: 'total_visitors',
renderLabel,
@ -160,9 +157,9 @@ export const createVisits = (props) => {
}
export const createVisitDuration = (props) => {
const renderLabel = (_query) => 'Visit duration'
const renderLabel = (_query) => 'Visit Duration'
return new Metric({
width: 'w-28 md:w-24',
width: 'w-36',
...props,
key: 'visit_duration',
renderLabel,
@ -171,9 +168,9 @@ export const createVisitDuration = (props) => {
}
export const createBounceRate = (props) => {
const renderLabel = (_query) => 'Bounce rate'
const renderLabel = (_query) => 'Bounce Rate'
return new Metric({
width: 'w-28 md:w-24',
width: 'w-28',
...props,
key: 'bounce_rate',
renderLabel,
@ -193,9 +190,9 @@ export const createPageviews = (props) => {
}
export const createTimeOnPage = (props) => {
const renderLabel = (_query) => 'Time on page'
const renderLabel = (_query) => 'Time on Page'
return new Metric({
width: 'w-28 md:w-24',
width: 'w-32',
...props,
key: 'time_on_page',
renderLabel,
@ -204,9 +201,9 @@ export const createTimeOnPage = (props) => {
}
export const createExitRate = (props) => {
const renderLabel = (_query) => 'Exit rate'
const renderLabel = (_query) => 'Exit Rate'
return new Metric({
width: 'w-28 md:w-24',
width: 'w-28',
...props,
key: 'exit_rate',
renderLabel,
@ -215,9 +212,9 @@ export const createExitRate = (props) => {
}
export const createScrollDepth = (props) => {
const renderLabel = (_query) => 'Scroll depth'
const renderLabel = (_query) => 'Scroll Depth'
return new Metric({
width: 'w-28 md:w-24',
width: 'w-28',
...props,
key: 'scroll_depth',
renderLabel,

View File

@ -149,7 +149,7 @@ export function SearchTerms() {
path: referrersGoogleRoute.path,
search: (search: Record<string, unknown>) => search
}}
className="w-full mt-3"
className="w-full mt-2"
onClick={undefined}
/>
</React.Fragment>

View File

@ -27,26 +27,26 @@ import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
const UTM_TAGS = {
utm_medium: {
title: 'UTM mediums',
title: 'UTM Mediums',
label: 'Medium',
endpoint: '/utm_mediums'
},
utm_source: {
title: 'UTM sources',
title: 'UTM Sources',
label: 'Source',
endpoint: '/utm_sources'
},
utm_campaign: {
title: 'UTM campaigns',
title: 'UTM Campaigns',
label: 'Campaign',
endpoint: '/utm_campaigns'
},
utm_content: {
title: 'UTM contents',
title: 'UTM Contents',
label: 'Content',
endpoint: '/utm_contents'
},
utm_term: { title: 'UTM terms', label: 'Term', endpoint: '/utm_terms' }
utm_term: { title: 'UTM Terms', label: 'Term', endpoint: '/utm_terms' }
}
function AllSources({ afterFetchData }) {
@ -70,8 +70,6 @@ function AllSources({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -85,7 +83,7 @@ function AllSources({ afterFetchData }) {
metrics={chooseMetrics()}
detailsLinkProps={{ path: sourcesRoute.path, search: (search) => search }}
renderIcon={renderIcon}
color="bg-blue-50 group-hover/row:bg-blue-100"
color="bg-blue-50"
/>
)
}
@ -108,8 +106,6 @@ function Channels({ onClick, afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -126,7 +122,7 @@ function Channels({ onClick, afterFetchData }) {
path: channelsRoute.path,
search: (search) => search
}}
color="bg-blue-50 group-hover/row:bg-blue-100"
color="bg-blue-50"
/>
)
}
@ -158,8 +154,6 @@ function UTMSources({ tab, afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric)
}
@ -172,14 +166,14 @@ function UTMSources({ tab, afterFetchData }) {
keyLabel={utmTag.label}
metrics={chooseMetrics()}
detailsLinkProps={{ path: route?.path, search: (search) => search }}
color="bg-blue-50 group-hover/row:bg-blue-100"
color="bg-blue-50"
/>
)
}
const labelFor = {
channels: 'Top channels',
all: 'Top sources'
channels: 'Top Channels',
all: 'Top Sources'
}
for (const [key, utm_tag] of Object.entries(UTM_TAGS)) {
@ -247,7 +241,7 @@ export default function SourceList() {
}
return (
<div className="group/report overflow-x-hidden">
<div>
{/* Header Container */}
<div className="w-full flex justify-between">
<div className="flex gap-x-1">

View File

@ -291,23 +291,23 @@ export const formattedFilters = {
prop_value: 'Value',
source: 'Source',
channel: 'Channel',
utm_medium: 'UTM medium',
utm_source: 'UTM source',
utm_campaign: 'UTM campaign',
utm_content: 'UTM content',
utm_term: 'UTM term',
utm_medium: 'UTM Medium',
utm_source: 'UTM Source',
utm_campaign: 'UTM Campaign',
utm_content: 'UTM Content',
utm_term: 'UTM Term',
referrer: 'Referrer URL',
screen: 'Screen size',
browser: 'Browser',
browser_version: 'Browser version',
os: 'Operating system',
os_version: 'Operating system version',
browser_version: 'Browser Version',
os: 'Operating System',
os_version: 'Operating System Version',
country: 'Country',
region: 'Region',
city: 'City',
page: 'Page',
hostname: 'Hostname',
entry_page: 'Entry page',
exit_page: 'Exit page',
entry_page: 'Entry Page',
exit_page: 'Exit Page',
segment: 'Segment'
}

View File

@ -69,11 +69,7 @@ export function durationFormatter(duration: number): string {
export function percentageFormatter(number: number | null): string {
if (typeof number === 'number') {
if (Math.abs(number) > 0 && Math.abs(number) < 0.1) {
return number.toFixed(2) + '%'
} else {
return number.toFixed(1).replace(/\.0$/, '') + '%'
}
return number + '%'
} else {
return '-'
}

View File

@ -26,14 +26,16 @@ export function Tooltip({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
)
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top',
modifiers: [
{ name: 'arrow', options: { element: arrowElement } },
{
name: 'offset',
options: {
offset: [0, 6]
offset: [0, 4]
}
},
...(boundary
@ -65,6 +67,8 @@ export function Tooltip({
popperStyle={styles.popper}
popperAttributes={attributes.popper}
setPopperElement={setPopperElement}
setArrowElement={setArrowElement}
arrowStyle={styles.arrow}
>
{info}
</TooltipMessage>
@ -78,12 +82,16 @@ function TooltipMessage({
popperStyle,
popperAttributes,
setPopperElement,
setArrowElement,
arrowStyle,
children
}: {
containerRef?: RefObject<HTMLElement>
popperStyle: CSSProperties
arrowStyle: CSSProperties
popperAttributes?: Record<string, string>
setPopperElement: (element: HTMLDivElement) => void
setArrowElement: (element: HTMLDivElement) => void
children: ReactNode
}) {
const messageElement = (
@ -91,10 +99,15 @@ function TooltipMessage({
ref={setPopperElement}
style={popperStyle}
{...popperAttributes}
className="z-[999] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700"
className="z-50 p-2 rounded-sm text-sm text-gray-100 font-bold bg-gray-800 dark:bg-gray-700"
role="tooltip"
>
{children}
<div
ref={setArrowElement}
style={arrowStyle}
className="tooltip-arrow"
></div>
</div>
)
if (containerRef) {

View File

@ -40,22 +40,21 @@ const LEGACY_URL_PARAMETERS = {
exit_page: null
}
function isV1(searchParams: URLSearchParams): boolean {
for (const k of searchParams.keys()) {
if (k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)) {
return true
}
}
return false
function isV1(searchRecord: Record<string, unknown>): boolean {
return Object.keys(searchRecord).some(
(k) => k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)
)
}
function parseSearch(searchString: string): Record<string, unknown> {
const searchParams = new URLSearchParams(searchString)
function parseSearchRecord(
searchRecord: Record<string, unknown>
): Record<string, unknown> {
const searchRecordEntries = Object.entries(searchRecord)
const updatedSearchRecordEntries = []
const filters: Filter[] = []
let labels: DashboardQuery['labels'] = {}
for (const [key, value] of searchParams.entries()) {
for (const [key, value] of searchRecordEntries) {
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
if (typeof value !== 'string') {
continue
@ -64,10 +63,9 @@ function parseSearch(searchString: string): Record<string, unknown> {
filters.push(filter)
const labelsKey: string | null | undefined =
LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
const labelsParamValue = labelsKey ? searchParams.get(labelsKey) : null
if (labelsParamValue) {
if (labelsKey && searchRecord[labelsKey]) {
const clauses = filter[2]
const labelsValues = labelsParamValue
const labelsValues = (searchRecord[labelsKey] as string)
.split('|')
.filter((label) => !!label)
const newLabels = Object.fromEntries(
@ -81,9 +79,8 @@ function parseSearch(searchString: string): Record<string, unknown> {
}
}
const propsParamValue = searchParams.get('props')
if (typeof propsParamValue === 'string') {
filters.push(...(parseLegacyPropsFilter(propsParamValue) as Filter[]))
if (typeof searchRecord['props'] === 'string') {
filters.push(...(parseLegacyPropsFilter(searchRecord['props']) as Filter[]))
}
updatedSearchRecordEntries.push(['filters', filters], ['labels', labels])
return Object.fromEntries(updatedSearchRecordEntries)
@ -117,5 +114,5 @@ function parseLegacyPropsFilter(rawValue: string) {
export const v1 = {
isV1,
parseSearch
parseSearchRecord
}

View File

@ -242,25 +242,21 @@ describe(`${getRedirectTarget.name}`, () => {
).toBeNull()
})
it.each([
['?page=/docs', '?f=is,page,/docs&r=v1'],
['?page=%C3%AA&embed=true', '?f=is,page,%C3%AA&embed=true&r=v1']
])(
'returns updated URL v1 style filter %s, and running the updated value through the function again returns null (no redirect loop)',
(searchString, expectedSearchString) => {
const pathname = '/'
expect(
getRedirectTarget({
pathname,
search: searchString
} as Location)
).toEqual(`${pathname}${expectedSearchString}`)
expect(
getRedirectTarget({
pathname,
search: expectedSearchString
} as Location)
).toBeNull()
}
)
it('returns updated URL for page=... style filters (v1), and running the updated value through the function again returns null (no redirect loop)', () => {
const pathname = '/'
const search = '?page=/docs'
const expectedUpdatedSearch = '?f=is,page,/docs&r=v1'
expect(
getRedirectTarget({
pathname,
search
} as Location)
).toEqual(`${pathname}${expectedUpdatedSearch}`)
expect(
getRedirectTarget({
pathname,
search: expectedUpdatedSearch
} as Location)
).toBeNull()
})
})

View File

@ -3,7 +3,7 @@ import { v1 } from './url-search-params-v1'
import { v2 } from './url-search-params-v2'
/**
* These characters are not URL encoded to have more readable URLs.
* These charcters are not URL encoded to have more readable URLs.
* Browsers seem to handle this just fine.
* `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param``
*/
@ -241,17 +241,18 @@ export function getRedirectTarget(windowLocation: Location): null | string {
}
const isV2 = v2.isV2(searchParams)
const isV1 = v1.isV1(searchParams)
if (isV2) {
return `${windowLocation.pathname}${stringifySearch({ ...v2.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' })}`
}
if (isV1) {
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}`
const searchRecord = v2.parseSearch(windowLocation.search)
const isV1 = v1.isV1(searchRecord)
if (!isV1) {
return null
}
return null
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearchRecord(searchRecord), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}`
}
/** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */

View File

@ -1,57 +0,0 @@
/**
* Hook widget delegating navigation events to and from React.
* Necessary to emulate navigation events in LiveView with pushState
* manipulation disabled.
*/
import { buildHook } from './hook_builder'
export default buildHook({
initialize() {
this.url = window.location.href
this.addListener('click', document.body, (e) => {
const type = e.target.dataset.type || null
if (type === 'dashboard-link') {
this.url = e.target.href
const uri = new URL(this.url)
// Domain is dropped from URL prefix, because that's what react-dom-router
// expects.
const path = '/' + uri.pathname.split('/').slice(2).join('/')
this.el.dispatchEvent(
new CustomEvent('dashboard:live-navigate', {
bubbles: true,
detail: { path: path, search: uri.search }
})
)
this.pushEvent('handle_dashboard_params', { url: this.url })
e.preventDefault()
}
})
// Browser back and forward navigation triggers that event.
this.addListener('popstate', window, () => {
if (this.url !== window.location.href) {
this.pushEvent('handle_dashboard_params', {
url: window.location.href
})
}
})
// Navigation events triggered from liveview are propagated via this
// handler.
this.addListener('dashboard:live-navigate-back', window, (e) => {
if (
typeof e.detail.search === 'string' &&
this.url !== window.location.href
) {
this.pushEvent('handle_dashboard_params', {
url: window.location.href
})
}
})
}
})

View File

@ -1,44 +0,0 @@
/**
* Hook widget for optimistic loading of tabs and
* client-side persistence of selection using localStorage.
*/
import { buildHook } from './hook_builder'
function getDomain(url) {
const uri = typeof url === 'object' ? url : new URL(url)
return uri.pathname.split('/')[1]
}
export default buildHook({
initialize() {
const domain = getDomain(window.location.href)
this.addListener('click', this.el, (e) => {
const button = e.target.closest('button')
const tab = button && button.dataset.tab
if (tab) {
const label = button.dataset.label
const storageKey = button.dataset.storageKey
const activeClasses = button.dataset.activeClasses
const inactiveClasses = button.dataset.inactiveClasses
const title = this.el
.closest('[data-tile]')
.querySelector('[data-title]')
title.innerText = label
this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => {
s.className = inactiveClasses
})
button.querySelector('span').className = activeClasses
if (storageKey) {
localStorage.setItem(`${storageKey}__${domain}`, tab)
}
}
})
}
})

View File

@ -1,53 +0,0 @@
export function buildHook({ initialize, cleanup }) {
cleanup = cleanup || function () {}
return {
mounted() {
this.initialize()
},
updated() {
this.initialize()
},
reconnected() {
this.initialize()
},
destroyed() {
this.cleanup()
},
initialize() {
this.cleanup()
initialize.bind(this)()
},
cleanup() {
this.removeListeners()
cleanup.bind(this)()
},
addListener(eventName, listener, callback) {
this.listeners = this.listeners || []
listener.addEventListener(eventName, callback)
this.listeners.push({
element: listener,
event: eventName,
callback: callback
})
},
removeListeners() {
if (this.listeners) {
this.listeners.forEach((l) => {
l.element.removeEventListener(l.event, l.callback)
})
this.listeners = null
}
}
}
}

View File

@ -2,14 +2,11 @@
The modules below this comment block are resolved from '../deps' folder,
which does not exist when running the lint command in Github CI
*/
/* eslint-disable import/no-unresolved */
import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import { Modal, Dropdown } from 'prima'
import DashboardRoot from './dashboard_root'
import DashboardTabs from './dashboard_tabs.js'
import topbar from 'topbar'
/* eslint-enable import/no-unresolved */
@ -17,12 +14,8 @@ import Alpine from 'alpinejs'
let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']")
let disablePushStateFlag = document.querySelector(
"meta[name='live-socket-disable-push-state']"
)
let domain = document.querySelector("meta[name='dashboard-domain']")
if (csrfToken && websocketUrl) {
let Hooks = { Modal, Dropdown, DashboardRoot, DashboardTabs }
let Hooks = { Modal, Dropdown }
Hooks.Metrics = {
mounted() {
this.handleEvent('send-metrics', ({ event_name }) => {
@ -55,14 +48,9 @@ if (csrfToken && websocketUrl) {
let token = csrfToken.getAttribute('content')
let url = websocketUrl.getAttribute('content')
let liveUrl = url === '' ? '/live' : new URL('/live', url).href
let disablePushState =
!!disablePushStateFlag &&
disablePushStateFlag.getAttribute('content') === 'true'
let domainName = domain && domain.getAttribute('content')
let liveSocket = new LiveSocket(liveUrl, Socket, {
// For dashboard LV migration
disablePushState: disablePushState,
heartbeatIntervalMs: 10000,
params: { _csrf_token: token },
hooks: Hooks,
uploaders: Uploaders,
dom: {
@ -72,20 +60,6 @@ if (csrfToken && websocketUrl) {
Alpine.clone(from, to)
}
}
},
params: () => {
if (domainName) {
return {
// The prefs are used by dashboard LiveView to persist
// user preferences across the reloads.
user_prefs: {
pages_tab: localStorage.getItem(`pageTab__${domainName}`)
},
_csrf_token: token
}
} else {
return { _csrf_token: token }
}
}
})

View File

@ -8,7 +8,7 @@ config :bcrypt_elixir, :log_rounds, 4
config :plausible, Plausible.Repo,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online()
pool_size: System.schedulers_online() * 2
config :plausible, Plausible.ClickhouseRepo,
loggers: [Ecto.LogEntry],
@ -63,5 +63,3 @@ config :plausible, Plausible.InstallationSupport.Checks.VerifyInstallation,
]
config :plausible, Plausible.Session.Salts, interval: :timer.hours(1)
config :plausible, max_goals_per_site: 10

View File

@ -16,6 +16,11 @@ defmodule Plausible.ConsolidatedView do
import Ecto.Query
@spec flag_enabled?(Team.t()) :: boolean()
def flag_enabled?(team) do
FunWithFlags.enabled?(:consolidated_view, for: team)
end
@spec cta_dismissed?(User.t(), Team.t()) :: boolean()
def cta_dismissed?(%User{} = user, %Team{} = team) do
{:ok, team_membership} = Teams.Memberships.get_team_membership(team, user)
@ -46,6 +51,7 @@ defmodule Plausible.ConsolidatedView do
@spec ok_to_display?(Team.t() | nil) :: boolean()
def ok_to_display?(team) do
is_struct(team, Team) and
flag_enabled?(team) and
view_enabled?(team) and
has_sites_to_consolidate?(team) and
Plausible.Billing.Feature.ConsolidatedView.check_availability(team) == :ok
@ -78,26 +84,23 @@ defmodule Plausible.ConsolidatedView do
end
@spec enable(Team.t()) ::
{:ok, Site.t()}
| {:error, :no_sites | :team_not_setup | :upgrade_required | :contact_us}
{:ok, Site.t()} | {:error, :no_sites | :team_not_setup | :upgrade_required}
def enable(%Team{} = team) do
availability_check = Plausible.Billing.Feature.ConsolidatedView.check_availability(team)
cond do
not has_sites_to_consolidate?(team) ->
{:error, :no_sites}
Teams.Billing.enterprise_configured?(team) and availability_check != :ok ->
{:error, :contact_us}
availability_check != :ok ->
availability_check
not Teams.setup?(team) ->
{:error, :team_not_setup}
not flag_enabled?(team) ->
{:error, :unavailable}
true ->
do_enable(team)
case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
:ok -> do_enable(team)
error -> error
end
end
end

View File

@ -36,11 +36,9 @@ defmodule Plausible.CustomerSupport.Resource.Site do
inner_join: o in assoc(t, :owners),
where:
ilike(s.domain, ^"%#{input}%") or ilike(t.name, ^"%#{input}%") or
ilike(o.name, ^"%#{input}%") or ilike(o.email, ^"%#{input}%") or
ilike(s.domain_changed_from, ^"%#{input}%"),
ilike(o.name, ^"%#{input}%") or ilike(o.email, ^"%#{input}%"),
order_by: [
desc: fragment("?.domain = ?", s, ^input),
desc: fragment("?.domain_changed_from = ?", s, ^input),
desc: fragment("?.name = ?", t, ^input),
desc: fragment("?.name = ?", o, ^input),
desc: fragment("?.email = ?", o, ^input),

View File

@ -16,7 +16,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
limit = Keyword.fetch!(opts, :limit)
q =
from(t in Plausible.Teams.Team,
from t in Plausible.Teams.Team,
as: :team,
inner_join: o in assoc(t, :owners),
limit: ^limit,
@ -25,7 +25,6 @@ defmodule Plausible.CustomerSupport.Resource.Team do
on: true,
order_by: [desc: :id],
preload: [owners: o, subscription: s]
)
Plausible.Repo.all(q)
end
@ -34,56 +33,42 @@ defmodule Plausible.CustomerSupport.Resource.Team do
limit = Keyword.fetch!(opts, :limit)
q =
if opts[:uuid_provided?] do
from(t in Plausible.Teams.Team,
as: :team,
inner_join: o in assoc(t, :owners),
where: t.identifier == ^input,
preload: [owners: o]
)
else
from(t in Plausible.Teams.Team,
as: :team,
inner_join: o in assoc(t, :owners),
where:
ilike(t.name, ^"%#{input}%") or
ilike(o.name, ^"%#{input}%") or
ilike(o.email, ^"%#{input}%"),
limit: ^limit,
order_by: [
desc: fragment("?.name = ?", t, ^input),
desc: fragment("?.name = ?", o, ^input),
desc: fragment("?.email = ?", o, ^input),
asc: t.name
],
preload: [owners: o]
)
end
from t in Plausible.Teams.Team,
as: :team,
inner_join: o in assoc(t, :owners),
where:
ilike(t.name, ^"%#{input}%") or ilike(o.name, ^"%#{input}%") or
ilike(o.email, ^"%#{input}%"),
limit: ^limit,
order_by: [
desc: fragment("?.name = ?", t, ^input),
desc: fragment("?.name = ?", o, ^input),
desc: fragment("?.email = ?", o, ^input),
asc: t.name
],
preload: [owners: o]
q =
if opts[:with_subscription_only?] do
from(t in q,
from t in q,
inner_lateral_join: s in subquery(Teams.last_subscription_join_query()),
on: true,
preload: [subscription: s]
)
else
from(t in q,
from t in q,
left_lateral_join: s in subquery(Teams.last_subscription_join_query()),
on: true,
preload: [subscription: s]
)
end
q =
if opts[:with_sso_only?] do
from(t in q,
from t in q,
inner_join: sso_integration in assoc(t, :sso_integration),
as: :sso_integration,
left_join: sso_domains in assoc(sso_integration, :sso_domains),
as: :sso_domains,
or_where: ilike(sso_domains.domain, ^"%#{input}%")
)
else
q
end

View File

@ -56,7 +56,7 @@ defmodule Plausible.Stats.ConsolidatedView do
|> DateTime.to_iso8601()
stats_query =
Stats.Query.parse_and_build!(view, :internal, %{
Stats.Query.build!(view, :internal, %{
"site_id" => view.domain,
"metrics" => ["visitors", "visits", "pageviews", "views_per_visit"],
"include" => %{"comparisons" => %{"mode" => "custom", "date_range" => [c_from, c_to]}},
@ -91,7 +91,7 @@ defmodule Plausible.Stats.ConsolidatedView do
defp query_24h_intervals(view, now) do
graph_query =
Stats.Query.parse_and_build!(
Stats.Query.build!(
view,
:internal,
%{

View File

@ -408,11 +408,6 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
{:missing, param} ->
H.bad_request(conn, "Parameter `#{param}` is required to create a goal")
{:error, %Ecto.Changeset{} = changeset} ->
message = Enum.map_join(changeset.errors, ", ", &translate_error/1)
H.bad_request(conn, message)
e ->
H.bad_request(conn, "Something went wrong: #{inspect(e)}")
end
@ -610,10 +605,4 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
# remap to `custom_properties`
|> Map.put(:custom_properties, site.allowed_event_props || [])
end
defp translate_error({field, {msg, opts}}) do
Enum.reduce(opts, "#{field}: #{msg}", fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

View File

@ -67,7 +67,7 @@ defmodule PlausibleWeb.CustomerSupport.Components.Layout do
</p>
<strong>team:</strong>input<br />
<p class="font-sans pl-2 mb-1">
Search for teams exclusively. Input will be checked against user/team name, e-mail or team identifier. Identifier must be provided complete, as is.
Search for teams exclusively. Input will be checked against user's name and e-mail.
</p>
<strong>team:</strong>input <strong>+sub</strong>

View File

@ -103,15 +103,6 @@ defmodule PlausibleWeb.CustomerSupport.Components.Search do
opts
end
opts =
case Ecto.UUID.cast(input) do
{:ok, _uuid} ->
Keyword.merge(opts, uuid_provided?: true)
_ ->
opts
end
{[Resource.Team], input, opts}
end

View File

@ -50,61 +50,40 @@ defmodule PlausibleWeb.Live.FunnelSettings do
<div id="funnel-settings-main">
<.flash_messages flash={@flash} />
<.tile
docs="funnel-analysis"
feature_mod={Plausible.Billing.Feature.Funnels}
feature_toggle?={true}
show_content?={!Plausible.Billing.Feature.Funnels.opted_out?(@site)}
site={@site}
current_user={@current_user}
current_team={@current_team}
>
<:title>
Funnels
</:title>
<:subtitle :if={Enum.count(@all_funnels) > 0}>
Compose goals into funnels to track user flows and conversion rates.
</:subtitle>
<%= if @setup_funnel? do %>
{live_render(
@socket,
PlausibleWeb.Live.FunnelSettings.Form,
id: "funnels-form",
session: %{
"domain" => @domain,
"funnel_id" => @funnel_id
}
)}
<% end %>
<div :if={@goal_count >= Funnel.min_steps()}>
<.live_component
module={PlausibleWeb.Live.FunnelSettings.List}
id="funnels-list"
funnels={@displayed_funnels}
filter_text={@filter_text}
/>
</div>
<%= if @setup_funnel? do %>
{live_render(
@socket,
PlausibleWeb.Live.FunnelSettings.Form,
id: "funnels-form",
session: %{
"domain" => @domain,
"funnel_id" => @funnel_id
}
)}
<% end %>
<div :if={@goal_count >= Funnel.min_steps()}>
<.live_component
module={PlausibleWeb.Live.FunnelSettings.List}
id="funnels-list"
funnels={@displayed_funnels}
filter_text={@filter_text}
/>
</div>
<div
:if={@goal_count < Funnel.min_steps()}
class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto"
<div :if={@goal_count < Funnel.min_steps()} class="flex flex-col items-center">
<h1 class="mt-4 text-center">
Ready to dig into user flows?
</h1>
<p class="mt-4 mb-6 max-w-lg text-sm text-gray-500 dark:text-gray-400 leading-5 text-center">
Set up a few goals first (e.g. <b>"Signup"</b>, <b>"Visit /"</b>, or <b>"Scroll 50% on /blog/*"</b>) and return here to build your first funnel!
</p>
<.button_link
class="mb-2"
href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
>
<h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
Ready to dig into user flows?
</h3>
<p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
Set up a few goals like <.highlighted>Signup</.highlighted>, <.highlighted>Visit /</.highlighted>, or
<.highlighted>Scroll 50% on /blog/*</.highlighted>
first, then return here to build your first funnel.
</p>
<.button_link
class="mt-4"
href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
>
Set up goals
</.button_link>
</div>
</.tile>
Set up goals
</.button_link>
</div>
</div>
"""
end
@ -168,8 +147,4 @@ defmodule PlausibleWeb.Live.FunnelSettings do
def handle_info(:cancel_setup_funnel, socket) do
{:noreply, assign(socket, setup_funnel?: false, funnel_id: nil)}
end
def handle_info({:feature_toggled, flash_msg, updated_site}, socket) do
{:noreply, assign(put_flash(socket, :success, flash_msg), site: updated_site)}
end
end

View File

@ -64,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
phx-target="#funnel-form"
phx-click-away="cancel-add-funnel"
onkeydown="return event.key != 'Enter';"
class="bg-white dark:bg-gray-900 shadow-2xl rounded-lg px-8 pt-6 pb-8 mb-4 mt-8"
class="bg-white dark:bg-gray-900 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"
>
<.title class="mb-6">
{if @funnel, do: "Edit", else: "Add"} funnel
@ -84,7 +84,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
Funnel steps
</.label>
<div :for={step_idx <- @step_ids} class="flex my-3">
<div :for={step_idx <- @step_ids} class="flex mb-3 mt-3">
<div class="w-2/5 flex-1">
<.live_component
selected={find_preselected(@funnel, @funnel_modified?, step_idx)}
@ -117,12 +117,13 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
</div>
</div>
<div class="flex flex-col gap-y-4 mt-6">
<.add_step_button :if={
length(@step_ids) < Funnel.max_steps() and
map_size(@selections_made) < length(@goals)
} />
<p id="funnel-eval" class="text-gray-800 dark:text-gray-200 text-sm">
<.add_step_button :if={
length(@step_ids) < Funnel.max_steps() and
map_size(@selections_made) < length(@goals)
} />
<div class="mt-6">
<p id="funnel-eval" class="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
<%= if @evaluation_result do %>
Last month conversion rate: <strong><%= List.last(@evaluation_result.steps).conversion_rate %></strong>%
<% end %>
@ -178,7 +179,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def add_step_button(assigns) do
~H"""
<a class="text-indigo-500 text-sm font-medium cursor-pointer" phx-click="add-step">
<a class="underline text-indigo-500 text-sm cursor-pointer mt-6" phx-click="add-step">
+ Add another step
</a>
"""
@ -349,7 +350,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
)
query =
Plausible.Stats.Query.parse_and_build!(
Plausible.Stats.Query.build!(
site,
:internal,
%{

View File

@ -10,17 +10,13 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
use PlausibleWeb, :live_component
def render(assigns) do
assigns = assign(assigns, :searching?, String.trim(assigns.filter_text) != "")
~H"""
<div>
<%= if @searching? or Enum.count(@funnels) > 0 do %>
<.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
<.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
Add funnel
</.button>
</.filter_bar>
<% end %>
<.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
<.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
Add funnel
</.button>
</.filter_bar>
<%= if Enum.count(@funnels) > 0 do %>
<.table rows={@funnels}>
@ -46,44 +42,19 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
</:tbody>
</.table>
<% else %>
<.no_search_results :if={@searching?} />
<.empty_state :if={not @searching?} />
<p class="mt-12 mb-8 text-sm text-center">
<span :if={String.trim(@filter_text) != ""}>
No funnels found for this site. Please refine or
<.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
reset your search.
</.styled_link>
</span>
<span :if={String.trim(@filter_text) == "" && Enum.empty?(@funnels)}>
No funnels configured for this site.
</span>
</p>
<% end %>
</div>
"""
end
defp no_search_results(assigns) do
~H"""
<p class="mt-12 mb-8 text-sm text-center">
No funnels found for this site. Please refine or
<.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
reset your search.
</.styled_link>
</p>
"""
end
defp empty_state(assigns) do
~H"""
<div class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto">
<h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
Create your first funnel
</h3>
<p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
Compose goals into funnels to track user flows and conversion rates.
<.styled_link href="https://plausible.io/docs/funnel-analysis" target="_blank">
Learn more
</.styled_link>
</p>
<.button
id="add-funnel-button"
phx-click="add-funnel"
class="mt-4"
>
Add funnel
</.button>
</div>
"""
end
end

View File

@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.Funnels do
use PlausibleWeb, :plugins_api_controller
operation(:create,
operation_id: "Funnel.GetOrCreate",
id: "Funnel.GetOrCreate",
summary: "Get or create Funnel",
request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest},
responses: %{

View File

@ -1,3 +1,15 @@
defimpl Bamboo.Formatter, for: Plausible.Auth.User do
def format_email_address(user, _opts) do
{user.name, user.email}
end
end
defimpl FunWithFlags.Actor, for: Plausible.Auth.User do
def id(%{id: id}) do
"user:#{id}"
end
end
defmodule Plausible.Auth.User do
use Plausible
use Ecto.Schema
@ -272,15 +284,3 @@ defmodule Plausible.Auth.User do
end
end
end
defimpl Bamboo.Formatter, for: Plausible.Auth.User do
def format_email_address(user, _opts) do
{user.name, user.email}
end
end
defimpl FunWithFlags.Actor, for: Plausible.Auth.User do
def id(%{id: id}) do
"user:#{id}"
end
end

View File

@ -19,32 +19,18 @@ defmodule Plausible.Goal do
field :funnels, {:array, :map}, virtual: true, default: []
end
field :custom_props, :map, default: %{}
belongs_to :site, Plausible.Site
timestamps()
end
@fields [
:id,
:site_id,
:event_name,
:page_path,
:scroll_threshold,
:display_name,
:custom_props
] ++
@fields [:id, :site_id, :event_name, :page_path, :scroll_threshold, :display_name] ++
on_ee(do: [:currency], else: [])
@max_event_name_length 120
def max_event_name_length(), do: @max_event_name_length
@max_custom_props_per_goal 3
def max_custom_props_per_goal(), do: @max_custom_props_per_goal
def changeset(goal, attrs \\ %{}) do
goal
|> cast(attrs, @fields)
@ -54,18 +40,11 @@ defmodule Plausible.Goal do
|> validate_event_name_and_page_path()
|> validate_page_path_for_scroll_goal()
|> maybe_put_display_name()
|> validate_change(:custom_props, fn :custom_props, custom_props ->
if map_size(custom_props) > @max_custom_props_per_goal do
[custom_props: "use at most #{@max_custom_props_per_goal} properties per goal"]
else
[]
end
end)
|> unique_constraint(:display_name, name: :goals_display_name_unique)
|> unique_constraint(:event_name, name: :goals_event_config_unique)
|> unique_constraint(:event_name, name: :goals_event_name_unique)
|> unique_constraint([:page_path, :scroll_threshold],
name: :goals_pageview_config_unique
name: :goals_page_path_and_scroll_threshold_unique
)
|> unique_constraint(:display_name, name: :goals_site_id_display_name_index)
|> validate_length(:event_name, max: @max_event_name_length)
|> validate_number(:scroll_threshold,
greater_than_or_equal_to: -1,

View File

@ -7,21 +7,6 @@ defmodule Plausible.Goals do
alias Plausible.Goal
alias Ecto.Multi
alias Ecto.Changeset
@max_goals_per_site 1_000
@spec max_goals_per_site(Keyword.t()) :: pos_integer()
def max_goals_per_site(opts \\ []) do
override = Keyword.get(opts, :max_goals_per_site)
if override do
override
else
# see: config/test.exs - you can steer this limit for tests
# by providing `max_goals_per_site` option to e.g. create/3
Application.get_env(:plausible, :max_goals_per_site, @max_goals_per_site)
end
end
@spec get(Plausible.Site.t(), pos_integer()) :: nil | Plausible.Goal.t()
def get(site, id) when is_integer(id) do
@ -35,30 +20,18 @@ defmodule Plausible.Goals do
end
@spec create(Plausible.Site.t(), map(), Keyword.t()) ::
{:ok, Goal.t()}
| {:error, Changeset.t()}
| {:error, :upgrade_required}
| {:error, :revenue_goals_unavailable}
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
@doc """
Creates a Goal for a site.
If the created goal is a revenue goal, it sets site.updated_at to be
refreshed by the sites cache, as revenue goals are used during ingestion.
Returns `{:ok, goal}` or `{:error, changeset}` when creation fails due to
invalid fields. It can also return:
* `{:error, :upgrade_required}` - Adding a revenue goal is not allowed
for team's subscription.
* `{:error, :revenue_goals_unavailable}` - When the site is a consolidated
view and the goal created is a revenue goal. Revenue goal creation is not
allowed for consolidated views due to the inability to force a single
currency on a goal across all consolidated sites.
"""
def create(site, params, opts \\ []) do
upsert? = Keyword.get(opts, :upsert?, false)
Repo.transaction(fn ->
case insert_goal(site, params, opts) do
case insert_goal(site, params, upsert?) do
{:ok, :insert, goal} ->
on_ee do
now = Keyword.get(opts, :now, DateTime.utc_now())
@ -80,7 +53,7 @@ defmodule Plausible.Goals do
end
@spec update(Plausible.Goal.t(), map()) ::
{:ok, Goal.t()} | {:error, Changeset.t()} | {:error, :upgrade_required}
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
def update(goal, params) do
changeset = Goal.changeset(goal, params)
@ -96,7 +69,7 @@ defmodule Plausible.Goals do
updated_goal
end
else
{:error, %Changeset{} = changeset} ->
{:error, %Ecto.Changeset{} = changeset} ->
Repo.rollback(changeset)
{:error, :upgrade_required} ->
@ -105,19 +78,16 @@ defmodule Plausible.Goals do
end)
end
def find_or_create(site, params, opts \\ [])
def find_or_create(
site,
%{
"goal_type" => "event",
"event_name" => event_name,
"currency" => currency
} = params,
opts
} = params
)
when is_binary(event_name) and is_binary(currency) do
with {:ok, goal} <- create(site, params, do_upsert(opts)) do
with {:ok, goal} <- create(site, params, upsert?: true) do
if to_string(goal.currency) == currency do
{:ok, goal}
else
@ -125,7 +95,7 @@ defmodule Plausible.Goals do
changeset =
goal
|> Goal.changeset()
|> Changeset.add_error(
|> Ecto.Changeset.add_error(
:event_name,
"'#{goal.event_name}' (with currency: #{goal.currency}) has already been taken"
)
@ -135,25 +105,23 @@ defmodule Plausible.Goals do
end
end
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name} = params, opts)
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name} = params)
when is_binary(event_name) do
create(site, params, do_upsert(opts))
create(site, params, upsert?: true)
end
def find_or_create(_, %{"goal_type" => "event"}, _), do: {:missing, "event_name"}
def find_or_create(_, %{"goal_type" => "event"}), do: {:missing, "event_name"}
def find_or_create(site, %{"goal_type" => "page", "page_path" => _} = params, opts) do
create(site, params, do_upsert(opts))
def find_or_create(site, %{"goal_type" => "page", "page_path" => _} = params) do
create(site, params, upsert?: true)
end
def find_or_create(_, %{"goal_type" => "page"}, _), do: {:missing, "page_path"}
def find_or_create(_, %{"goal_type" => "page"}), do: {:missing, "page_path"}
def list_revenue_goals(site) do
from(g in Plausible.Goal,
where: g.site_id == ^site.id and not is_nil(g.currency),
select: %{display_name: g.display_name, currency: g.currency},
order_by: [desc: g.id],
limit: ^max_goals_per_site()
select: %{display_name: g.display_name, currency: g.currency}
)
|> Plausible.Repo.all()
end
@ -165,21 +133,13 @@ defmodule Plausible.Goals do
|> Enum.map(&maybe_trim/1)
end
def for_site_query(site \\ nil, opts \\ []) do
def for_site_query(site, opts \\ []) do
query =
from g in Goal,
inner_join: assoc(g, :site),
where: g.site_id == ^site.id,
order_by: [desc: g.id],
limit: ^max_goals_per_site(opts)
query =
if site do
from g in query,
inner_join: assoc(g, :site),
where: g.site_id == ^site.id,
preload: [:site]
else
query
end
preload: [:site]
if ee?() and opts[:preload_funnels?] == true do
from(g in query,
@ -192,9 +152,9 @@ defmodule Plausible.Goals do
end
end
def batch_create_event_goals(names, site, opts \\ []) do
def batch_create_event_goals(names, site) do
Enum.reduce(names, [], fn name, acc ->
case insert_goal(site, %{event_name: name}, do_upsert(opts)) do
case insert_goal(site, %{event_name: name}, true) do
{:ok, _, goal} -> acc ++ [goal]
_ -> acc
end
@ -288,25 +248,25 @@ defmodule Plausible.Goals do
@spec create_outbound_links(Plausible.Site.t()) :: :ok
def create_outbound_links(%Plausible.Site{} = site) do
create(site, %{"event_name" => "Outbound Link: Click"}, do_upsert())
create(site, %{"event_name" => "Outbound Link: Click"}, upsert?: true)
:ok
end
@spec create_file_downloads(Plausible.Site.t()) :: :ok
def create_file_downloads(%Plausible.Site{} = site) do
create(site, %{"event_name" => "File Download"}, do_upsert())
create(site, %{"event_name" => "File Download"}, upsert?: true)
:ok
end
@spec create_form_submissions(Plausible.Site.t()) :: :ok
def create_form_submissions(%Plausible.Site{} = site) do
create(site, %{"event_name" => "Form: Submission"}, do_upsert())
create(site, %{"event_name" => "Form: Submission"}, upsert?: true)
:ok
end
@spec create_404(Plausible.Site.t()) :: :ok
def create_404(%Plausible.Site{} = site) do
create(site, %{"event_name" => "404"}, do_upsert())
create(site, %{"event_name" => "404"}, upsert?: true)
:ok
end
@ -354,11 +314,11 @@ defmodule Plausible.Goals do
:ok
end
defp insert_goal(site, params, opts) do
defp insert_goal(site, params, upsert?) do
params = Map.delete(params, "site_id")
insert_opts =
if upsert?(opts) do
if upsert? do
[on_conflict: :nothing]
else
[]
@ -368,7 +328,6 @@ defmodule Plausible.Goals do
with :ok <- maybe_check_feature_access(site, changeset),
:ok <- check_no_currency_if_consolidated(site, changeset),
:ok <- check_goals_limit(site, changeset, opts),
{:ok, goal} <- Repo.insert(changeset, insert_opts) do
# Upsert with `on_conflict: :nothing` strategy
# will result in goal struct missing primary key field
@ -387,7 +346,7 @@ defmodule Plausible.Goals do
end
defp maybe_check_feature_access(site, changeset) do
if Changeset.get_field(changeset, :currency) do
if Ecto.Changeset.get_field(changeset, :currency) do
site = Plausible.Repo.preload(site, :team)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.team)
else
@ -395,23 +354,8 @@ defmodule Plausible.Goals do
end
end
defp check_goals_limit(site, changeset, opts) do
if upsert?(opts) and goal_exists_for_upsert?(site, changeset) do
:ok
else
if count(site) >= max_goals_per_site(opts) and changeset.valid? do
changeset
|> Changeset.add_error(:page_path, "Maximum number of goals reached")
|> Changeset.add_error(:event_name, "Maximum number of goals reached")
|> Changeset.apply_action(:insert)
else
:ok
end
end
end
defp check_no_currency_if_consolidated(site, changeset) do
if Plausible.Sites.consolidated?(site) && Changeset.get_field(changeset, :currency) do
if Plausible.Sites.consolidated?(site) && Ecto.Changeset.get_field(changeset, :currency) do
{:error, :revenue_goals_unavailable}
else
:ok
@ -433,29 +377,4 @@ defmodule Plausible.Goals do
defp maybe_trim(other) do
other
end
defp upsert?(opts) do
Keyword.get(opts, :upsert?, false)
end
defp do_upsert(opts \\ []) do
Keyword.put(opts, :upsert?, true)
end
defp goal_exists_for_upsert?(site, changeset) do
event_name = Changeset.get_field(changeset, :event_name)
page_path = Changeset.get_field(changeset, :page_path)
scroll_threshold = Changeset.get_field(changeset, :scroll_threshold)
query_params =
[site_id: site.id]
|> maybe_add_filter(:event_name, event_name)
|> maybe_add_filter(:page_path, page_path)
|> maybe_add_filter(:scroll_threshold, scroll_threshold)
Repo.exists?(from(g in Goal, where: ^query_params))
end
defp maybe_add_filter(params, _key, nil), do: params
defp maybe_add_filter(params, key, value), do: Keyword.put(params, key, value)
end

View File

@ -202,7 +202,7 @@ defmodule Plausible.Google.GA4.API do
end
end
defp prepare_request(%GA4.ReportRequest{} = report_request, date_range, property, access_token) do
defp prepare_request(report_request, date_range, property, access_token) do
%GA4.ReportRequest{
report_request
| date_range: date_range,

View File

@ -3,7 +3,7 @@ defmodule Plausible.Segments.Filters do
This module contains functions that enable resolving segments in filters.
"""
alias Plausible.Segments
alias Plausible.Stats.{Filters, ApiQueryParser}
alias Plausible.Stats.Filters
@max_segment_filters_count 10
@ -48,7 +48,7 @@ defmodule Plausible.Segments.Filters do
segments,
%{},
fn %Segments.Segment{id: id, segment_data: segment_data} ->
case ApiQueryParser.parse_filters(segment_data["filters"]) do
case Filters.QueryParser.parse_filters(segment_data["filters"]) do
{:ok, filters} -> {id, filters}
_ -> {id, nil}
end

View File

@ -131,7 +131,7 @@ defmodule Plausible.Segments.Segment do
"""
def build_naive_query_from_segment_data(%Plausible.Site{} = site, filters),
do:
Plausible.Stats.Query.parse_and_build(
Plausible.Stats.Query.build(
site,
:internal,
%{

View File

@ -44,7 +44,7 @@ defmodule Plausible.Shield.CountryRuleCache do
|> where([rule, site], rule.country_code == ^country_code and site.domain == ^domain)
case Plausible.Repo.one(query) do
{_, _, rule = %CountryRule{}} -> %CountryRule{rule | from_cache?: false}
{_, _, rule} -> %CountryRule{rule | from_cache?: false}
_any -> nil
end
end

View File

@ -45,7 +45,7 @@ defmodule Plausible.Shield.HostnameRuleCache do
case Plausible.Repo.all(query) do
[_ | _] = results ->
Enum.map(results, fn {_, _, rule = %HostnameRule{}} ->
Enum.map(results, fn {_, _, rule} ->
%HostnameRule{rule | from_cache?: false}
end)

View File

@ -44,7 +44,7 @@ defmodule Plausible.Shield.IPRuleCache do
|> where([rule, site], rule.inet == ^address and site.domain == ^domain)
case Plausible.Repo.one(query) do
{_, _, rule = %IPRule{}} -> %IPRule{rule | from_cache?: false}
{_, _, rule} -> %IPRule{rule | from_cache?: false}
_any -> nil
end
end

View File

@ -44,7 +44,7 @@ defmodule Plausible.Shield.PageRuleCache do
|> where([..., site], site.domain == ^domain)
case Plausible.Repo.one(query) do
{_, _, rule = %PageRule{}} -> %PageRule{rule | from_cache?: false}
{_, _, rule} -> %PageRule{rule | from_cache?: false}
_any -> nil
end
end

Some files were not shown because too many files have changed in this diff Show More