Compare commits

..

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

399 changed files with 8144 additions and 14823 deletions

View File

@ -117,11 +117,6 @@
{Credo.Check.Refactor.Apply, []}, {Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []}, {Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, false}, {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.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.MatchInCondition, []},
@ -138,7 +133,6 @@
# #
## Warnings ## Warnings
# #
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []}, {Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},

View File

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

View File

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

View File

@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
CACHE_VERSION: v17 CACHE_VERSION: v15
PERSISTENT_CACHE_DIR: cached PERSISTENT_CACHE_DIR: cached
jobs: jobs:
@ -63,7 +63,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
@ -74,7 +74,7 @@ jobs:
elixir-version: ${{ steps.versions.outputs.elixir }} elixir-version: ${{ steps.versions.outputs.elixir }}
otp-version: ${{ steps.versions.outputs.erlang }} otp-version: ${{ steps.versions.outputs.erlang }}
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: | path: |
deps deps
@ -114,26 +114,13 @@ jobs:
- run: make minio - run: make minio
if: env.MIX_ENV == 'test' if: env.MIX_ENV == 'test'
- run: | - run: mix test --include slow --include minio --include migrations --include kaffy_quirks --max-failures 1 --warnings-as-errors --partitions 6
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
if: env.MIX_ENV == 'test' if: env.MIX_ENV == 'test'
env: env:
MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1" MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1"
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }} MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
- run: mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 4
- 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
if: env.MIX_ENV == 'ce_test' if: env.MIX_ENV == 'ce_test'
env: env:
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }} MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
@ -144,7 +131,7 @@ jobs:
MIX_ENV: test MIX_ENV: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
@ -155,7 +142,7 @@ jobs:
elixir-version: ${{ steps.versions.outputs.elixir }} elixir-version: ${{ steps.versions.outputs.elixir }}
otp-version: ${{ steps.versions.outputs.erlang }} otp-version: ${{ steps.versions.outputs.erlang }}
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: | path: |
deps deps

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

@ -6,20 +6,12 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown
### Removed ### Removed
### Changed ### Changed
- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards
### Fixed ### 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 ## v3.1.0 - 2025-11-13
### Added ### Added

View File

@ -1,10 +1,8 @@
# we can not use the pre-built tar because the distribution is # we can not use the pre-built tar because the distribution is
# platform specific, it makes sense to build it in the docker # platform specific, it makes sense to build it in the docker
ARG ALPINE_VERSION=3.22.2
#### Builder #### 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 ARG MIX_ENV=ce
@ -22,7 +20,7 @@ RUN mkdir /app
WORKDIR /app WORKDIR /app
# install build dependencies # 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.exs ./
COPY mix.lock ./ COPY mix.lock ./
@ -56,7 +54,7 @@ COPY rel rel
RUN mix release plausible RUN mix release plausible
# Main Docker Image # Main Docker Image
FROM alpine:${ALPINE_VERSION} FROM alpine:3.21.3
LABEL maintainer="plausible.io <hello@plausible.io>" LABEL maintainer="plausible.io <hello@plausible.io>"
ARG BUILD_METADATA={} ARG BUILD_METADATA={}
@ -86,4 +84,3 @@ EXPOSE 8000
ENV DEFAULT_DATA_DIR=/var/lib/plausible ENV DEFAULT_DATA_DIR=/var/lib/plausible
VOLUME /var/lib/plausible VOLUME /var/lib/plausible
CMD ["run"] 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); --color-gray-950: var(--color-zinc-950);
/* Custom gray shades from config (override some zinc values) */ /* 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-150: rgb(236 236 238);
--color-gray-750: rgb(50 50 54); --color-gray-750: rgb(50 50 54);
--color-gray-825: rgb(35 35 38); --color-gray-825: rgb(35 35 38);
@ -295,12 +294,16 @@ blockquote {
display: inline; display: inline;
} }
.table-striped tbody tr:nth-child(odd) td { .table-striped tbody tr:nth-child(odd) {
background-color: var(--color-gray-75); background-color: var(--color-gray-100);
} }
.dark .table-striped tbody tr:nth-child(odd) td { .dark .table-striped tbody tr:nth-child(odd) {
background-color: var(--color-gray-850); background-color: var(--color-gray-800);
}
.dark .table-striped tbody tr:nth-child(even) {
background-color: var(--color-gray-900);
} }
.fade-enter { .fade-enter {

View File

@ -32,6 +32,33 @@
overflow: auto; 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 { @keyframes mm-fade-in {
from { from {
opacity: 0; 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" type="text"
placeholder={isFocused ? placeholderFocused : placeholderUnfocused} placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
className={classNames( 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 className
)} )}
onChange={debouncedOnSearchInputChange} onChange={debouncedOnSearchInputChange}

View File

@ -15,18 +15,14 @@ export const SortButton = ({
return ( return (
<button <button
onClick={toggleSort} onClick={toggleSort}
className={classNames( className={classNames('group', 'hover:underline', 'relative')}
'group',
'hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-100',
'relative'
)}
> >
{children} {children}
<span <span
title={next.hint} title={next.hint}
className={classNames( className={classNames(
'absolute', 'absolute',
'rounded inline-block size-4', 'rounded inline-block h-4 w-4',
'ml-1', 'ml-1',
{ {
[SortDirection.asc]: 'rotate-180', [SortDirection.asc]: 'rotate-180',
@ -34,8 +30,9 @@ export const SortButton = ({
}[sortDirection ?? next.direction], }[sortDirection ?? next.direction],
!sortDirection && 'opacity-0', !sortDirection && 'opacity-0',
!sortDirection && 'group-hover:opacity-100', !sortDirection && 'group-hover:opacity-100',
sortDirection &&
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900', 'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
'transition-all duration-100' '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" * 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 */ /** Function used to create richer cells */
renderItem?: (item: T) => ReactNode renderItem?: (item: T) => ReactNode
} }
@ -38,7 +38,7 @@ export const TableHeaderCell = ({
return ( return (
<th <th
className={classNames( 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 className
)} )}
align={align} align={align}
@ -58,13 +58,7 @@ export const TableCell = ({
align?: 'left' | 'right' align?: 'left' | 'right'
}) => { }) => {
return ( return (
<td <td className={classNames('p-2 font-medium', className)} align={align}>
className={classNames(
'p-2 font-medium first:rounded-s-sm last:rounded-e-sm',
className
)}
align={align}
>
{children} {children}
</td> </td>
) )
@ -74,42 +68,15 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
rowIndex, rowIndex,
pageIndex, pageIndex,
item, item,
columns, columns
tappedRowName,
onRowTap
}: { }: {
rowIndex: number rowIndex: number
pageIndex?: number pageIndex?: number
item: T item: T
columns: ColumnConfiguraton<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 ( return (
<tr <tr className="text-sm dark:text-gray-200">
className="group text-sm dark:text-gray-200 md:cursor-default cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleRowClick}
>
{columns.map(({ key, width, align, renderValue, renderItem }) => ( {columns.map(({ key, width, align, renderValue, renderItem }) => (
<TableCell <TableCell
key={`${(pageIndex ?? null) === null ? '' : `page_${pageIndex}_`}row_${rowIndex}_${String(key)}`} 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
? renderItem(item) ? renderItem(item)
: renderValue : renderValue
? renderValue(item, isRowActive) ? renderValue(item)
: (item[key] ?? '')} : (item[key] ?? '')}
</TableCell> </TableCell>
))} ))}
@ -134,8 +101,6 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns: ColumnConfiguraton<T>[] columns: ColumnConfiguraton<T>[]
data: T[] | { pages: T[][] } data: T[] | { pages: T[][] }
}) => { }) => {
const [tappedRowName, setTappedRowName] = React.useState<string | null>(null)
const renderColumnLabel = (column: ColumnConfiguraton<T>) => { const renderColumnLabel = (column: ColumnConfiguraton<T>) => {
if (column.metricWarning) { if (column.metricWarning) {
return ( return (
@ -160,13 +125,13 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
} }
return ( return (
<table className="border-collapse table-striped table-fixed w-max min-w-full"> <table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead className="sticky top-0 bg-white dark:bg-gray-900 z-10"> <thead>
<tr className="text-xs font-semibold text-gray-500 dark:text-gray-400"> <tr className="text-xs font-bold text-gray-500 dark:text-gray-400">
{columns.map((column) => ( {columns.map((column) => (
<TableHeaderCell <TableHeaderCell
key={`header_${String(column.key)}`} key={`header_${String(column.key)}`}
className={classNames('p-2', column.width)} className={classNames('p-2 tracking-wide', column.width)}
align={column.align} align={column.align}
> >
{column.onSort ? ( {column.onSort ? (
@ -191,8 +156,6 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
columns={columns} columns={columns}
rowIndex={rowIndex} rowIndex={rowIndex}
key={rowIndex} key={rowIndex}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/> />
)) ))
: data.pages.map((page, pageIndex) => : data.pages.map((page, pageIndex) =>
@ -203,8 +166,6 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
rowIndex={rowIndex} rowIndex={rowIndex}
pageIndex={pageIndex} pageIndex={pageIndex}
key={`page_${pageIndex}_row_${rowIndex}`} key={`page_${pageIndex}_row_${rowIndex}`}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/> />
)) ))
)} )}

View File

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

View File

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

View File

@ -10,16 +10,6 @@ export enum SegmentType {
site = 'site' 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. */ /** This type signifies that the owner can't be shown. */
type SegmentOwnershipHidden = { owner_id: null; owner_name: null } 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({ export function isListableSegment({
segment, segment,
site, site,
@ -213,6 +173,10 @@ export function isListableSegment({
return false return false
} }
export function canSeeSegmentDetails({ user }: { user: UserContextValue }) {
return user.loggedIn && user.role !== Role.public
}
export function findAppliedSegmentFilter({ filters }: { filters: Filter[] }) { export function findAppliedSegmentFilter({ filters }: { filters: Filter[] }) {
const segmentFilter = filters.find(isSegmentFilter) const segmentFilter = filters.find(isSegmentFilter)
if (!segmentFilter) { if (!segmentFilter) {

View File

@ -1,5 +1,4 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react' import React, { useMemo, useState } from 'react'
import { LiveViewPortal } from './components/liveview-portal'
import VisitorGraph from './stats/graph/visitor-graph' import VisitorGraph from './stats/graph/visitor-graph'
import Sources from './stats/sources' import Sources from './stats/sources'
import Pages from './stats/pages' import Pages from './stats/pages'
@ -8,10 +7,7 @@ import Devices from './stats/devices'
import { TopBar } from './nav-menu/top-bar' import { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours' import Behaviours from './stats/behaviours'
import { useQueryContext } from './query-context' import { useQueryContext } from './query-context'
import { useSiteContext } from './site-context'
import { isRealTimeDashboard } from './util/filters' import { isRealTimeDashboard } from './util/filters'
import { useAppNavigate } from './navigation/use-app-navigate'
import { parseSearch } from './util/url-search-params'
function DashboardStats({ function DashboardStats({
importedDataInView, importedDataInView,
@ -20,36 +16,6 @@ function DashboardStats({
importedDataInView?: boolean importedDataInView?: boolean
updateImportedDataInView?: (v: boolean) => void 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 = 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' '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 /> <Sources />
</div> </div>
<div className={statsBoxClass}> <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>
</div> </div>

View File

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

View File

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

View File

@ -63,15 +63,6 @@ export const useAppNavigate = () => {
search, search,
...options ...options
}: AppNavigationTarget & NavigateOptions) => { }: 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) return _navigate(getToOptions({ path, params, search }), options)
}, },
[getToOptions, _navigate] [getToOptions, _navigate]

View File

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

View File

@ -58,6 +58,45 @@ describe('Segment details modal - errors', () => {
}, },
message: `Segment not found with with ID "202020"`, message: `Segment not found with with ID "202020"`,
siteOptions: { siteSegmentsAvailable: true } 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)( it.each(cases)(

View File

@ -1,7 +1,8 @@
import React, { ReactNode, useCallback, useState } from 'react' import React, { ReactNode, useCallback, useState } from 'react'
import ModalWithRouting from '../stats/modals/modal' import ModalWithRouting from '../stats/modals/modal'
import { import {
canExpandSegment, canSeeSegmentDetails,
isListableSegment,
isSegmentFilter, isSegmentFilter,
SavedSegment, SavedSegment,
SEGMENT_TYPE_LABELS, SEGMENT_TYPE_LABELS,
@ -21,9 +22,9 @@ import { MutationStatus } from '@tanstack/react-query'
import { ApiError } from '../api' import { ApiError } from '../api'
import { ErrorPanel } from '../components/error-panel' import { ErrorPanel } from '../components/error-panel'
import { useSegmentsContext } from '../filtering/segments-context' import { useSegmentsContext } from '../filtering/segments-context'
import { useSiteContext } from '../site-context'
import { Role, UserContextValue, useUserContext } from '../user-context' import { Role, UserContextValue, useUserContext } from '../user-context'
import { removeFilterButtonClassname } from '../components/remove-filter-button' import { removeFilterButtonClassname } from '../components/remove-filter-button'
import { useSiteContext } from '../site-context'
interface ApiRequestProps { interface ApiRequestProps {
status: MutationStatus status: MutationStatus
@ -500,7 +501,9 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
const { query } = useQueryContext() const { query } = useQueryContext()
const { segments } = useSegmentsContext() 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 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 = new ApiError(`Segment not found with with ID "${id}"`, {
error: `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 const data = !error ? segment : null
@ -535,12 +542,11 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
<SegmentAuthorship <SegmentAuthorship
segment={data} segment={data}
showOnlyPublicData={!user.loggedIn || user.role === Role.public} showOnlyPublicData={false}
className="mt-4 text-sm" className="mt-4 text-sm"
/> />
<div className="mt-4"> <div className="mt-4">
<ButtonsRow> <ButtonsRow>
{canExpandSegment({ segment: data, site, user }) && (
<AppNavigationLink <AppNavigationLink
className={primaryNeutralButtonClassName} className={primaryNeutralButtonClassName}
path={rootRoute.path} path={rootRoute.path}
@ -555,7 +561,6 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
> >
Edit segment Edit segment
</AppNavigationLink> </AppNavigationLink>
)}
<AppNavigationLink <AppNavigationLink
className={removeFilterButtonClassname} 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. // Update this object when new feature flags are added to the frontend.
type FeatureFlags = { type FeatureFlags = Record<never, boolean>
live_dashboard?: boolean
}
export const siteContextDefaultValue = { export const siteContextDefaultValue = {
domain: '', domain: '',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,8 +37,6 @@ function Countries({ query, site, onClick, afterFetchData }) {
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -56,7 +54,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
search: (search) => search search: (search) => search
}} }}
renderIcon={renderIcon} 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() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -97,7 +93,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }} detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
renderIcon={renderIcon} 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() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { showOnHover: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate() hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter((metric) => !!metric) ].filter((metric) => !!metric)
} }
@ -137,7 +131,7 @@ function Cities({ query, site, afterFetchData }) {
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }} detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
renderIcon={renderIcon} 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() { render() {
return ( return (
<div className="group/report overflow-x-hidden"> <div>
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100"> <h3 className="font-bold dark:text-gray-100">

View File

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

View File

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

View File

@ -1,12 +1,11 @@
import React, { ReactNode, useRef } from 'react' import React, { ReactNode, useRef } from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { SearchInput } from '../../components/search-input' import { SearchInput } from '../../components/search-input'
import { ColumnConfiguraton, Table } from '../../components/table' import { ColumnConfiguraton, Table } from '../../components/table'
import RocketIcon from './rocket-icon' import RocketIcon from './rocket-icon'
import { QueryStatus } from '@tanstack/react-query' 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 }>({ export const BreakdownTable = <TListItem extends { name: string }>({
title, title,
@ -20,8 +19,7 @@ export const BreakdownTable = <TListItem extends { name: string }>({
data, data,
status, status,
error, error,
displayError, displayError
onClose
}: { }: {
title: ReactNode title: ReactNode
onSearch?: (input: string) => void onSearch?: (input: string) => void
@ -36,21 +34,16 @@ export const BreakdownTable = <TListItem extends { name: string }>({
error?: Error | null error?: Error | null
/** Controls whether the component displays API request errors or ignores them. */ /** Controls whether the component displays API request errors or ignores them. */
displayError?: boolean displayError?: boolean
onClose?: () => void
}) => { }) => {
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null)
const navigate = useAppNavigate()
const handleClose =
onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s }))
return ( return (
<> <div className="w-full h-full">
<div className="flex justify-between items-center gap-4"> <div className="flex justify-between items-center">
<div className="flex items-center gap-4 w-full"> <div className="flex items-center gap-x-2">
<h1 className="shrink-0 mb-0.5 text-base md:text-lg font-bold dark:text-gray-100"> <h1 className="text-xl font-bold dark:text-gray-100">{title}</h1>
{title}
</h1>
{!isPending && isFetching && <SmallLoadingSpinner />} {!isPending && isFetching && <SmallLoadingSpinner />}
</div>
{!!onSearch && ( {!!onSearch && (
<SearchInput <SearchInput
searchRef={searchRef} searchRef={searchRef}
@ -61,17 +54,8 @@ export const BreakdownTable = <TListItem extends { name: string }>({
/> />
)} )}
</div> </div>
<button <div className="my-4 border-b border-gray-300 dark:border-gray-700"></div>
type="button" <div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
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>
</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">
{displayError && status === 'error' && <ErrorMessage error={error} />} {displayError && status === 'error' && <ErrorMessage error={error} />}
{isPending && <InitialLoadingSpinner />} {isPending && <InitialLoadingSpinner />}
{data && <Table<TListItem> data={data} columns={columns} />} {data && <Table<TListItem> data={data} columns={columns} />}
@ -82,12 +66,15 @@ export const BreakdownTable = <TListItem extends { name: string }>({
/> />
)} )}
</div> </div>
</> </div>
) )
} }
const InitialLoadingSpinner = () => ( 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 className="mx-auto loading">
<div /> <div />
</div> </div>
@ -101,7 +88,10 @@ const SmallLoadingSpinner = () => (
) )
const ErrorMessage = ({ error }: { error?: unknown }) => ( 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"> <div className="text-center self-end">
<RocketIcon /> <RocketIcon />
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,26 +7,26 @@ import * as metrics from '../reports/metrics'
import * as url from '../../util/url' import * as url from '../../util/url'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'
import { addFilter, revenueAvailable } from '../../query' import { addFilter } from '../../query'
import { SortDirection } from '../../hooks/use-order-by' import { SortDirection } from '../../hooks/use-order-by'
const VIEWS = { const VIEWS = {
countries: { countries: {
title: 'Top countries', title: 'Top Countries',
dimension: 'country', dimension: 'country',
endpoint: '/countries', endpoint: '/countries',
dimensionLabel: 'Country', dimensionLabel: 'Country',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
}, },
regions: { regions: {
title: 'Top regions', title: 'Top Regions',
dimension: 'region', dimension: 'region',
endpoint: '/regions', endpoint: '/regions',
dimensionLabel: 'Region', dimensionLabel: 'Region',
defaultOrder: ['visitors', SortDirection.desc] defaultOrder: ['visitors', SortDirection.desc]
}, },
cities: { cities: {
title: 'Top cities', title: 'Top Cities',
dimension: 'city', dimension: 'city',
endpoint: '/cities', endpoint: '/cities',
dimensionLabel: 'City', dimensionLabel: 'City',
@ -38,9 +38,6 @@ function LocationsModal({ currentView }) {
const { query } = useQueryContext() const { query } = useQueryContext()
const site = useSiteContext() const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
let reportInfo = VIEWS[currentView] let reportInfo = VIEWS[currentView]
reportInfo = { reportInfo = {
...reportInfo, ...reportInfo,
@ -78,17 +75,15 @@ function LocationsModal({ currentView }) {
renderLabel: (_query) => 'Conversions', renderLabel: (_query) => 'Conversions',
width: 'w-28' width: 'w-28'
}), }),
metrics.createConversionRate(), metrics.createConversionRate()
showRevenueMetrics && metrics.createTotalRevenue(), ]
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
} }
if (query.period === 'realtime') { if (query.period === 'realtime') {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', 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 { isModifierPressed, isTyping, Keybind } from '../../keybinding'
import { rootRoute } from '../../router' import { rootRoute } from '../../router'
import { useAppNavigate } from '../../navigation/use-app-navigate' 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. // 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 const DEFAULT_WIDTH = 1080
class Modal extends React.Component { class Modal extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -23,21 +27,26 @@ class Modal extends React.Component {
window.addEventListener('resize', this.handleResize, false) window.addEventListener('resize', this.handleResize, false)
this.handleResize() this.handleResize()
} }
componentWillUnmount() { componentWillUnmount() {
document.body.style.overflow = null document.body.style.overflow = null
document.body.style.height = null document.body.style.height = null
document.removeEventListener('mousedown', this.handleClickOutside) document.removeEventListener('mousedown', this.handleClickOutside)
window.removeEventListener('resize', this.handleResize, false) window.removeEventListener('resize', this.handleResize, false)
} }
handleClickOutside(e) { handleClickOutside(e) {
if (this.node.current.contains(e.target)) { if (this.node.current.contains(e.target)) {
return return
} }
this.props.onClose() this.props.onClose()
} }
handleResize() { handleResize() {
this.setState({ viewport: window.innerWidth }) this.setState({ viewport: window.innerWidth })
} }
/** /**
* @description * @description
* Decide whether to set max-width, and if so, to what. * Decide whether to set max-width, and if so, to what.
@ -47,11 +56,12 @@ class Modal extends React.Component {
*/ */
getStyle() { getStyle() {
const { maxWidth } = this.props const { maxWidth } = this.props
const { viewport } = this.state
const styleObject = {} const styleObject = {}
if (maxWidth) { if (maxWidth) {
styleObject.maxWidth = maxWidth styleObject.maxWidth = maxWidth
} else { } else {
styleObject.maxWidth = '880px' styleObject.width = viewport <= MD_WIDTH ? 'min-content' : '860px'
} }
return styleObject return styleObject
} }
@ -68,10 +78,10 @@ class Modal extends React.Component {
/> />
<div className="modal is-open" onClick={this.props.onClick}> <div className="modal is-open" onClick={this.props.onClick}>
<div className="modal__overlay"> <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"> <button className="modal__close"></button>
<div <div
ref={this.node} 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" className="modal__container dark:bg-gray-900 focus:outline-hidden"
style={this.getStyle()} style={this.getStyle()}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0} tabIndex={0}
@ -81,7 +91,6 @@ class Modal extends React.Component {
</div> </div>
</div> </div>
</div> </div>
</div>
</>, </>,
document.getElementById('modal_root') document.getElementById('modal_root')
) )

View File

@ -4,7 +4,7 @@ import {
hasConversionGoalFilter, hasConversionGoalFilter,
isRealTimeDashboard isRealTimeDashboard
} from '../../util/filters' } from '../../util/filters'
import { addFilter, revenueAvailable } from '../../query' import { addFilter } from '../../query'
import BreakdownModal from './breakdown-modal' import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics' import * as metrics from '../reports/metrics'
import * as url from '../../util/url' import * as url from '../../util/url'
@ -16,11 +16,8 @@ function PagesModal() {
const { query } = useQueryContext() const { query } = useQueryContext()
const site = useSiteContext() const site = useSiteContext()
/*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = { const reportInfo = {
title: 'Top pages', title: 'Top Pages',
dimension: 'page', dimension: 'page',
endpoint: url.apiPath(site, '/pages'), endpoint: url.apiPath(site, '/pages'),
dimensionLabel: 'Page url', dimensionLabel: 'Page url',
@ -57,17 +54,15 @@ function PagesModal() {
renderLabel: (_query) => 'Conversions', renderLabel: (_query) => 'Conversions',
width: 'w-28' width: 'w-28'
}), }),
metrics.createConversionRate(), metrics.createConversionRate()
showRevenueMetrics && metrics.createTotalRevenue(), ]
showRevenueMetrics && metrics.createAverageRevenue()
].filter((metric) => !!metric)
} }
if (isRealTimeDashboard(query)) { if (isRealTimeDashboard(query)) {
return [ return [
metrics.createVisitors({ metrics.createVisitors({
renderLabel: (_query) => 'Current visitors', 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 showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const reportInfo = { const reportInfo = {
title: specialTitleWhenGoalFilter(query, 'Custom property breakdown'), title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
dimension: propKey, dimension: propKey,
endpoint: url.apiPath( endpoint: url.apiPath(
site, site,
@ -71,7 +71,6 @@ function PropsModal() {
metrics={chooseMetrics()} metrics={chooseMetrics()}
getFilterInfo={getFilterInfo} getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter} addSearchFilter={addSearchFilter}
showPercentageColumn
/> />
</Modal> </Modal>
) )

View File

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

View File

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

View File

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

View File

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

View File

@ -15,22 +15,24 @@ export function ChangeArrow({
className: string className: string
hideNumber?: boolean hideNumber?: boolean
}) { }) {
const formattedChange = hideNumber
? null
: ` ${numberShortFormatter(Math.abs(change))}%`
let icon = null let icon = null
const arrowClassName = classNames( const arrowClassName = classNames(
color(change, metric), 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) { if (change > 0) {
icon = <ArrowUpRightIcon className={arrowClassName} /> icon = <ArrowUpRightIcon className={arrowClassName} />
} else if (change < 0) { } else if (change < 0) {
icon = <ArrowDownRightIcon className={arrowClassName} /> icon = <ArrowDownRightIcon className={arrowClassName} />
} else if (change === 0 && !hideNumber) {
icon = <>&#12336;</>
} }
const formattedChange = hideNumber
? null
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
return ( return (
<span className={className} data-testid="change-arrow"> <span className={className} data-testid="change-arrow">
{icon} {icon}

View File

@ -26,34 +26,27 @@ const COL_MIN_WIDTH = 70
function ExternalLink<T>({ function ExternalLink<T>({
item, item,
getExternalLinkUrl, getExternalLinkUrl
isTapped
}: { }: {
item: T item: T
getExternalLinkUrl?: (item: T) => string getExternalLinkUrl?: (item: T) => string
isTapped?: boolean
}) { }) {
const dest = getExternalLinkUrl && getExternalLinkUrl(item) const dest = getExternalLinkUrl && getExternalLinkUrl(item)
if (dest) { if (dest) {
const className = isTapped
? 'visible md:invisible md:group-hover/row:visible'
: 'invisible md:group-hover/row:visible'
return ( return (
<a target="_blank" rel="noreferrer" href={dest} className={className}> <a
<svg target="_blank"
xmlns="http://www.w3.org/2000/svg" rel="noreferrer"
fill="none" href={dest}
viewBox="0 0 24 24" className="w-4 h-4 invisible group-hover:visible"
className="inline size-3.5 mb-0.5 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
> >
<path <svg
stroke="currentColor" className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400"
strokeLinecap="round" fill="currentColor"
strokeLinejoin="round" viewBox="0 0 20 20"
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> </svg>
</a> </a>
) )
@ -95,6 +88,11 @@ type ListReportProps = {
colMinWidth?: number colMinWidth?: number
/** Navigation props to be passed to "More" link, if any. */ /** Navigation props to be passed to "More" link, if any. */
detailsLinkProps?: AppNavigationLinkProps 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. */ /** Function with additional action to be taken when a list entry is clicked. */
onClick?: () => void onClick?: () => void
/** Color of the comparison bars in light-mode. */ /** Color of the comparison bars in light-mode. */
@ -116,6 +114,7 @@ export default function ListReport<
colMinWidth = COL_MIN_WIDTH, colMinWidth = COL_MIN_WIDTH,
afterFetchData, afterFetchData,
detailsLinkProps, detailsLinkProps,
maybeHideDetails,
onClick, onClick,
color, color,
getFilterInfo, getFilterInfo,
@ -130,7 +129,6 @@ export default function ListReport<
meta: BreakdownResultMeta | null meta: BreakdownResultMeta | null
}>({ loading: true, list: null, meta: null }) }>({ loading: true, list: null, meta: null })
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [tappedRow, setTappedRow] = useState<string | null>(null)
const isRealtime = isRealTimeDashboard(query) const isRealtime = isRealTimeDashboard(query)
const goalFilterApplied = hasConversionGoalFilter(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() { function renderReport() {
if (state.list && state.list.length > 0) { if (state.list && state.list.length > 0) {
return ( return (
@ -240,10 +206,12 @@ export default function ListReport<
</FlipMove> </FlipMove>
</div> </div>
{!!detailsLinkProps && !state.loading && ( {!!detailsLinkProps &&
!state.loading &&
!(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && (
<MoreLink <MoreLink
onClick={undefined} onClick={undefined}
className={'mt-3'} className={'mt-2'}
linkProps={detailsLinkProps} linkProps={detailsLinkProps}
list={state.list} list={state.list}
/> />
@ -255,9 +223,7 @@ export default function ListReport<
} }
function renderReportHeader() { function renderReportHeader() {
const metricLabels = getAvailableMetrics() const metricLabels = getAvailableMetrics().map((metric) => {
.filter((metric) => !metric.meta.showOnHover)
.map((metric) => {
return ( return (
<div <div
key={metric.key} key={metric.key}
@ -270,7 +236,7 @@ export default function ListReport<
}) })
return ( 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> <span className="grow truncate">{keyLabel}</span>
{metricLabels} {metricLabels}
</div> </div>
@ -278,22 +244,11 @@ export default function ListReport<
} }
function renderRow(listItem: TListItem) { 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 ( return (
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}> <div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
<div <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 }} style={{ marginTop: ROW_GAP_HEIGHT }}
onClick={handleRowClick}
> >
{renderBarFor(listItem)} {renderBarFor(listItem)}
{renderMetricValuesFor(listItem)} {renderMetricValuesFor(listItem)}
@ -303,7 +258,7 @@ export default function ListReport<
} }
function renderBarFor(listItem: TListItem) { 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 const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key
return ( return (
@ -312,10 +267,10 @@ export default function ListReport<
maxWidthDeduction={undefined} maxWidthDeduction={undefined}
count={listItem[metricToPlot]} count={listItem[metricToPlot]}
all={state.list} 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} 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 <DrilldownLink
filterInfo={getFilterInfo(listItem)} filterInfo={getFilterInfo(listItem)}
onClick={onClick} onClick={onClick}
@ -330,7 +285,6 @@ export default function ListReport<
<ExternalLink <ExternalLink
item={listItem} item={listItem}
getExternalLinkUrl={getExternalLinkUrl} getExternalLinkUrl={getExternalLinkUrl}
isTapped={tappedRow === listItem.name}
/> />
</div> </div>
</Bar> </Bar>
@ -345,36 +299,19 @@ export default function ListReport<
} }
function renderMetricValuesFor(listItem: TListItem) { function renderMetricValuesFor(listItem: TListItem) {
const availableMetrics = getAvailableMetrics() return getAvailableMetrics().map((metric) => {
const showOnHoverIndex = availableMetrics.findIndex(
(m) => m.meta.showOnHover
)
const hasShowOnHoverMetric = showOnHoverIndex !== -1
return (
<>
{availableMetrics.map((metric, index) => {
const isShowOnHover = metric.meta.showOnHover
return ( return (
<div <div
key={`${listItem.name}__${metric.key}`} key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)} ${showOnHoverClass(metric, listItem.name)} ${slideLeftClass(index, showOnHoverIndex, hasShowOnHoverMetric, listItem.name)}`} className={`text-right ${hiddenOnMobileClass(metric)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }} style={{ width: colMinWidth, minWidth: colMinWidth }}
> >
<span <span className="font-medium text-sm dark:text-gray-200 text-right">
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)}
>
{metric.renderValue(listItem, state.meta, {
detailedView: false,
isRowHovered: false
})}
</span> </span>
</div> </div>
) )
})} })
</>
)
} }
function renderLoading() { function renderLoading() {

View File

@ -11,9 +11,10 @@ const REVENUE = { long: '$1,659.50', short: '$1.7K' }
describe('single value', () => { describe('single value', () => {
it('renders small value', async () => { 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.getByTestId('metric-value')).toHaveTextContent('10')
expect(screen.getByRole('tooltip')).toHaveTextContent('10')
}) })
it('renders large value', async () => { it('renders large value', async () => {
@ -24,19 +25,23 @@ describe('single value', () => {
}) })
it('renders percentages', async () => { 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.getByTestId('metric-value')).toHaveTextContent('5.3%')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3%')
}) })
it('renders durations', async () => { 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.getByTestId('metric-value')).toHaveTextContent('1m 00s')
expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s')
}) })
it('renders with custom formatter', async () => { it('renders with custom formatter', async () => {
render( await renderWithTooltip(
<MetricValue <MetricValue
{...valueProps('test_money', 5.3)} {...valueProps('test_money', 5.3)}
formatter={(value) => `${value}$`} formatter={(value) => `${value}$`}
@ -44,6 +49,7 @@ describe('single value', () => {
) )
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$') expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$')
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3$')
}) })
it('renders revenue properly', async () => { it('renders revenue properly', async () => {
@ -74,8 +80,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10 visitors', '10 visitors',
'01 Aug - 31 Aug',
'↑ 100%', '↑ 100%',
'01 Aug - 31 Aug',
'vs',
'5 visitors', '5 visitors',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -91,8 +98,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'5 visitors', '5 visitors',
'01 Aug - 31 Aug',
'↓ 50%', '↓ 50%',
'01 Aug - 31 Aug',
'vs',
'10 visitors', '10 visitors',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -108,8 +116,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10 visitors', '10 visitors',
'〰 0%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'0%', 'vs',
'10 visitors', '10 visitors',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -127,8 +136,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10 conversions', '10 conversions',
'〰 0%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'0%', 'vs',
'10 conversions', '10 conversions',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -144,7 +154,14 @@ describe('comparisons', () => {
) )
expect(screen.getByRole('tooltip')).toHaveTextContent( 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( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'10$ test', '10$ test',
'01 Aug - 31 Aug',
'↑ 100%', '↑ 100%',
'01 Aug - 31 Aug',
'vs',
'5$ test', '5$ test',
'01 July - 31 July' '01 July - 31 July'
].join('') ].join('')
@ -182,8 +200,9 @@ describe('comparisons', () => {
expect(screen.getByRole('tooltip')).toHaveTextContent( expect(screen.getByRole('tooltip')).toHaveTextContent(
[ [
'$1,659.50 average_revenue', '$1,659.50 average_revenue',
'〰 0%',
'01 Aug - 31 Aug', '01 Aug - 31 Aug',
'0%', 'vs',
'$1,659.50 average_revenue', '$1,659.50 average_revenue',
'01 July - 31 July' '01 July - 31 July'
].join('') ].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 { Metric } from '../../../types/query-api'
import { Tooltip } from '../../util/tooltip' import { Tooltip } from '../../util/tooltip'
import { ChangeArrow } from './change-arrow' import { ChangeArrow } from './change-arrow'
@ -36,84 +36,23 @@ export default function MetricValue(props: {
renderLabel: (query: DashboardQuery) => string renderLabel: (query: DashboardQuery) => string
formatter?: (value: ValueType) => string formatter?: (value: ValueType) => string
meta: BreakdownResultMeta | null meta: BreakdownResultMeta | null
detailedView?: boolean
isRowHovered?: boolean
}) { }) {
const { query } = useQueryContext() const { query } = useQueryContext()
const portalRef = useRef<HTMLElement | null>(null)
useEffect(() => { const { metric, listItem } = props
if (typeof document !== 'undefined') {
portalRef.current = document.body
}
}, [])
const { metric, listItem, detailedView = false, isRowHovered = false } = props
const { value, comparison } = useMemo( const { value, comparison } = useMemo(
() => valueRenderProps(listItem, metric), () => valueRenderProps(listItem, metric),
[listItem, metric] [listItem, metric]
) )
const metricLabel = useMemo(() => props.renderLabel(query), [query, props]) const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
const shortFormatter = props.formatter ?? MetricFormatterShort[metric] 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)) { if (value === null && (!comparison || comparison.value === null)) {
return <span data-testid="metric-value">{displayFormatter(value)}</span> return <span data-testid="metric-value">{shortFormatter(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 ( return (
<Tooltip <Tooltip
containerRef={portalRef as React.RefObject<HTMLElement>}
info={ info={
<ComparisonTooltipContent <ComparisonTooltipContent
value={value} 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> </Tooltip>
) )
} }
@ -157,34 +106,34 @@ function ComparisonTooltipContent({
return ( return (
<div className="text-left whitespace-nowrap py-1 space-y-2"> <div className="text-left whitespace-nowrap py-1 space-y-2">
<div> <div>
<div className="flex gap-x-4"> <div className="flex items-center">
<div className="flex flex-col"> <span className="font-bold text-base">
<span className="font-medium text-sm/6 text-white">
{longFormatter(value)} {label} {longFormatter(value)} {label}
</span> </span>
<div className="font-normal text-xs text-white">
{meta.date_range_label}
</div>
</div>
<ChangeArrow <ChangeArrow
metric={metric} metric={metric}
change={comparison.change} change={comparison.change}
className="text-xs/6 font-medium text-white" className="pl-4 text-xs text-gray-100"
/> />
</div> </div>
<div className="font-normal text-xs">{meta.date_range_label}</div>
</div> </div>
<div className="w-full border-t border-gray-600"></div> <div>vs</div>
<div> <div>
<div className="font-medium text-sm/6 text-gray-300/80"> <div className="font-bold text-base">
{longFormatter(comparison.value)} {label} {longFormatter(comparison.value)} {label}
</div> </div>
<div className="font-normal text-xs text-gray-300/80"> <div className="font-normal text-xs">
{meta.comparison_date_range_label} {meta.comparison_date_range_label}
</div> </div>
</div> </div>
</div> </div>
) )
} else { } 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) this.renderValue = this.renderValue.bind(this)
} }
renderValue(listItem, meta, options = {}) { renderValue(listItem, meta) {
const { detailedView = false, isRowHovered = false } = options
return ( return (
<MetricValue <MetricValue
listItem={listItem} listItem={listItem}
@ -52,8 +51,6 @@ export class Metric {
renderLabel={this.renderLabel} renderLabel={this.renderLabel}
meta={meta} meta={meta}
formatter={this.formatter} formatter={this.formatter}
detailedView={detailedView}
isRowHovered={isRowHovered}
/> />
) )
} }
@ -88,7 +85,7 @@ export const createVisitors = (props) => {
} }
return new Metric({ return new Metric({
width: 'w-36', width: 'w-24',
sortable: true, sortable: true,
...props, ...props,
key: 'visitors', key: 'visitors',
@ -99,7 +96,7 @@ export const createVisitors = (props) => {
export const createConversionRate = (props) => { export const createConversionRate = (props) => {
const renderLabel = (_query) => 'CR' const renderLabel = (_query) => 'CR'
return new Metric({ return new Metric({
width: 'w-28 md:w-24', width: 'w-24',
...props, ...props,
key: 'conversion_rate', key: 'conversion_rate',
renderLabel, renderLabel,
@ -119,13 +116,13 @@ export const createPercentage = (props) => {
} }
export const createEvents = (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) => { export const createTotalRevenue = (props) => {
const renderLabel = (_query) => 'Revenue' const renderLabel = (_query) => 'Revenue'
return new Metric({ return new Metric({
width: 'w-32', width: 'w-24',
...props, ...props,
key: 'total_revenue', key: 'total_revenue',
renderLabel, renderLabel,
@ -136,7 +133,7 @@ export const createTotalRevenue = (props) => {
export const createAverageRevenue = (props) => { export const createAverageRevenue = (props) => {
const renderLabel = (_query) => 'Average' const renderLabel = (_query) => 'Average'
return new Metric({ return new Metric({
width: 'w-28', width: 'w-24',
...props, ...props,
key: 'average_revenue', key: 'average_revenue',
renderLabel, renderLabel,
@ -145,9 +142,9 @@ export const createAverageRevenue = (props) => {
} }
export const createTotalVisitors = (props) => { export const createTotalVisitors = (props) => {
const renderLabel = (_query) => 'Total visitors' const renderLabel = (_query) => 'Total Visitors'
return new Metric({ return new Metric({
width: 'w-32', width: 'w-28',
...props, ...props,
key: 'total_visitors', key: 'total_visitors',
renderLabel, renderLabel,
@ -160,9 +157,9 @@ export const createVisits = (props) => {
} }
export const createVisitDuration = (props) => { export const createVisitDuration = (props) => {
const renderLabel = (_query) => 'Visit duration' const renderLabel = (_query) => 'Visit Duration'
return new Metric({ return new Metric({
width: 'w-28 md:w-24', width: 'w-36',
...props, ...props,
key: 'visit_duration', key: 'visit_duration',
renderLabel, renderLabel,
@ -171,9 +168,9 @@ export const createVisitDuration = (props) => {
} }
export const createBounceRate = (props) => { export const createBounceRate = (props) => {
const renderLabel = (_query) => 'Bounce rate' const renderLabel = (_query) => 'Bounce Rate'
return new Metric({ return new Metric({
width: 'w-28 md:w-24', width: 'w-28',
...props, ...props,
key: 'bounce_rate', key: 'bounce_rate',
renderLabel, renderLabel,
@ -193,9 +190,9 @@ export const createPageviews = (props) => {
} }
export const createTimeOnPage = (props) => { export const createTimeOnPage = (props) => {
const renderLabel = (_query) => 'Time on page' const renderLabel = (_query) => 'Time on Page'
return new Metric({ return new Metric({
width: 'w-28 md:w-24', width: 'w-32',
...props, ...props,
key: 'time_on_page', key: 'time_on_page',
renderLabel, renderLabel,
@ -204,9 +201,9 @@ export const createTimeOnPage = (props) => {
} }
export const createExitRate = (props) => { export const createExitRate = (props) => {
const renderLabel = (_query) => 'Exit rate' const renderLabel = (_query) => 'Exit Rate'
return new Metric({ return new Metric({
width: 'w-28 md:w-24', width: 'w-28',
...props, ...props,
key: 'exit_rate', key: 'exit_rate',
renderLabel, renderLabel,
@ -215,9 +212,9 @@ export const createExitRate = (props) => {
} }
export const createScrollDepth = (props) => { export const createScrollDepth = (props) => {
const renderLabel = (_query) => 'Scroll depth' const renderLabel = (_query) => 'Scroll Depth'
return new Metric({ return new Metric({
width: 'w-28 md:w-24', width: 'w-28',
...props, ...props,
key: 'scroll_depth', key: 'scroll_depth',
renderLabel, renderLabel,

View File

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

View File

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

View File

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

View File

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

View File

@ -26,14 +26,16 @@ export function Tooltip({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>( const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null null
) )
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top', placement: 'top',
modifiers: [ modifiers: [
{ name: 'arrow', options: { element: arrowElement } },
{ {
name: 'offset', name: 'offset',
options: { options: {
offset: [0, 6] offset: [0, 4]
} }
}, },
...(boundary ...(boundary
@ -65,6 +67,8 @@ export function Tooltip({
popperStyle={styles.popper} popperStyle={styles.popper}
popperAttributes={attributes.popper} popperAttributes={attributes.popper}
setPopperElement={setPopperElement} setPopperElement={setPopperElement}
setArrowElement={setArrowElement}
arrowStyle={styles.arrow}
> >
{info} {info}
</TooltipMessage> </TooltipMessage>
@ -78,12 +82,16 @@ function TooltipMessage({
popperStyle, popperStyle,
popperAttributes, popperAttributes,
setPopperElement, setPopperElement,
setArrowElement,
arrowStyle,
children children
}: { }: {
containerRef?: RefObject<HTMLElement> containerRef?: RefObject<HTMLElement>
popperStyle: CSSProperties popperStyle: CSSProperties
arrowStyle: CSSProperties
popperAttributes?: Record<string, string> popperAttributes?: Record<string, string>
setPopperElement: (element: HTMLDivElement) => void setPopperElement: (element: HTMLDivElement) => void
setArrowElement: (element: HTMLDivElement) => void
children: ReactNode children: ReactNode
}) { }) {
const messageElement = ( const messageElement = (
@ -91,10 +99,15 @@ function TooltipMessage({
ref={setPopperElement} ref={setPopperElement}
style={popperStyle} style={popperStyle}
{...popperAttributes} {...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" role="tooltip"
> >
{children} {children}
<div
ref={setArrowElement}
style={arrowStyle}
className="tooltip-arrow"
></div>
</div> </div>
) )
if (containerRef) { if (containerRef) {

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { v1 } from './url-search-params-v1'
import { v2 } from './url-search-params-v2' 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. * Browsers seem to handle this just fine.
* `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param`` * `?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 isV2 = v2.isV2(searchParams)
const isV1 = v1.isV1(searchParams)
if (isV2) { if (isV2) {
return `${windowLocation.pathname}${stringifySearch({ ...v2.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' })}` return `${windowLocation.pathname}${stringifySearch({ ...v2.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' })}`
} }
if (isV1) { const searchRecord = v2.parseSearch(windowLocation.search)
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}` 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. */ /** 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, The modules below this comment block are resolved from '../deps' folder,
which does not exist when running the lint command in Github CI which does not exist when running the lint command in Github CI
*/ */
/* eslint-disable import/no-unresolved */ /* eslint-disable import/no-unresolved */
import 'phoenix_html' import 'phoenix_html'
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view' import { LiveSocket } from 'phoenix_live_view'
import { Modal, Dropdown } from 'prima' import { Modal, Dropdown } from 'prima'
import DashboardRoot from './dashboard_root'
import DashboardTabs from './dashboard_tabs.js'
import topbar from 'topbar' import topbar from 'topbar'
/* eslint-enable import/no-unresolved */ /* eslint-enable import/no-unresolved */
@ -17,12 +14,8 @@ import Alpine from 'alpinejs'
let csrfToken = document.querySelector("meta[name='csrf-token']") let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']") 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) { if (csrfToken && websocketUrl) {
let Hooks = { Modal, Dropdown, DashboardRoot, DashboardTabs } let Hooks = { Modal, Dropdown }
Hooks.Metrics = { Hooks.Metrics = {
mounted() { mounted() {
this.handleEvent('send-metrics', ({ event_name }) => { this.handleEvent('send-metrics', ({ event_name }) => {
@ -55,14 +48,9 @@ if (csrfToken && websocketUrl) {
let token = csrfToken.getAttribute('content') let token = csrfToken.getAttribute('content')
let url = websocketUrl.getAttribute('content') let url = websocketUrl.getAttribute('content')
let liveUrl = url === '' ? '/live' : new URL('/live', url).href 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, { let liveSocket = new LiveSocket(liveUrl, Socket, {
// For dashboard LV migration
disablePushState: disablePushState,
heartbeatIntervalMs: 10000, heartbeatIntervalMs: 10000,
params: { _csrf_token: token },
hooks: Hooks, hooks: Hooks,
uploaders: Uploaders, uploaders: Uploaders,
dom: { dom: {
@ -72,20 +60,6 @@ if (csrfToken && websocketUrl) {
Alpine.clone(from, to) 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

@ -157,7 +157,6 @@ export type OrderByEntry = [
Metric | SimpleFilterDimensions | CustomPropertyFilterDimensions | TimeDimensions, Metric | SimpleFilterDimensions | CustomPropertyFilterDimensions | TimeDimensions,
"asc" | "desc" "asc" | "desc"
]; ];
export type ComparisonMode = "previous_period" | "year_over_year";
export interface QueryApiSchema { export interface QueryApiSchema {
/** /**
@ -198,14 +197,25 @@ export interface QueryApiSchema {
* If set and using `day`, `month` or `year` date_ranges, the query will be trimmed to the current date * If set and using `day`, `month` or `year` date_ranges, the query will be trimmed to the current date
*/ */
trim_relative_date_range?: boolean; trim_relative_date_range?: boolean;
comparisons?:
| {
mode: "previous_period" | "year_over_year";
/** /**
* If set, executes the same query but over a comparison date range * If set and using time:day dimensions, day-of-week of comparison query is matched
*/ */
compare?: DateRange | ComparisonMode | never; match_day_of_week?: boolean;
}
| {
mode: "custom";
/** /**
* With the `compare` option, if set and using time:day dimensions, day-of-week of comparison query is matched * If set and using time:day dimensions, day-of-week of comparison query is matched
*/ */
compare_match_day_of_week?: boolean; match_day_of_week?: boolean;
/**
* If custom period. A list of two ISO8601 dates or timestamps to compare against.
*/
date_range: DateTimeRange | DateRange;
};
}; };
pagination?: { pagination?: {
/** /**

View File

@ -8,7 +8,7 @@ config :bcrypt_elixir, :log_rounds, 4
config :plausible, Plausible.Repo, config :plausible, Plausible.Repo,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() pool_size: System.schedulers_online() * 2
config :plausible, Plausible.ClickhouseRepo, config :plausible, Plausible.ClickhouseRepo,
loggers: [Ecto.LogEntry], loggers: [Ecto.LogEntry],
@ -63,5 +63,3 @@ config :plausible, Plausible.InstallationSupport.Checks.VerifyInstallation,
] ]
config :plausible, Plausible.Session.Salts, interval: :timer.hours(1) 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 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() @spec cta_dismissed?(User.t(), Team.t()) :: boolean()
def cta_dismissed?(%User{} = user, %Team{} = team) do def cta_dismissed?(%User{} = user, %Team{} = team) do
{:ok, team_membership} = Teams.Memberships.get_team_membership(team, user) {: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() @spec ok_to_display?(Team.t() | nil) :: boolean()
def ok_to_display?(team) do def ok_to_display?(team) do
is_struct(team, Team) and is_struct(team, Team) and
flag_enabled?(team) and
view_enabled?(team) and view_enabled?(team) and
has_sites_to_consolidate?(team) and has_sites_to_consolidate?(team) and
Plausible.Billing.Feature.ConsolidatedView.check_availability(team) == :ok Plausible.Billing.Feature.ConsolidatedView.check_availability(team) == :ok
@ -78,26 +84,23 @@ defmodule Plausible.ConsolidatedView do
end end
@spec enable(Team.t()) :: @spec enable(Team.t()) ::
{:ok, Site.t()} {:ok, Site.t()} | {:error, :no_sites | :team_not_setup | :upgrade_required}
| {:error, :no_sites | :team_not_setup | :upgrade_required | :contact_us}
def enable(%Team{} = team) do def enable(%Team{} = team) do
availability_check = Plausible.Billing.Feature.ConsolidatedView.check_availability(team)
cond do cond do
not has_sites_to_consolidate?(team) -> not has_sites_to_consolidate?(team) ->
{:error, :no_sites} {:error, :no_sites}
Teams.Billing.enterprise_configured?(team) and availability_check != :ok ->
{:error, :contact_us}
availability_check != :ok ->
availability_check
not Teams.setup?(team) -> not Teams.setup?(team) ->
{:error, :team_not_setup} {:error, :team_not_setup}
not flag_enabled?(team) ->
{:error, :unavailable}
true -> true ->
do_enable(team) case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
:ok -> do_enable(team)
error -> error
end
end end
end end

View File

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

View File

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

View File

@ -1,6 +1,5 @@
defmodule Plausible.Stats.ConsolidatedView do defmodule Plausible.Stats.ConsolidatedView do
alias Plausible.{Site, Stats} alias Plausible.{Site, Stats}
alias Plausible.Stats.{ParsedQueryParams, QueryBuilder, QueryInclude}
require Logger require Logger
@spec overview_24h(Site.t(), NaiveDateTime.t()) :: map() @spec overview_24h(Site.t(), NaiveDateTime.t()) :: map()
@ -42,24 +41,29 @@ defmodule Plausible.Stats.ConsolidatedView do
from = from =
NaiveDateTime.shift(now, hour: -24) NaiveDateTime.shift(now, hour: -24)
|> DateTime.from_naive!("Etc/UTC") |> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
to = now |> DateTime.from_naive!("Etc/UTC") to = now |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601()
c_from = c_from =
NaiveDateTime.shift(now, hour: -48) NaiveDateTime.shift(now, hour: -48)
|> DateTime.from_naive!("Etc/UTC") |> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
c_to = c_to =
NaiveDateTime.shift(now, hour: -24) NaiveDateTime.shift(now, hour: -24)
|> DateTime.from_naive!("Etc/UTC") |> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
{:ok, stats_query} = stats_query =
QueryBuilder.build(view, %ParsedQueryParams{ Stats.Query.build!(view, :internal, %{
input_date_range: {:datetime_range, from, to}, "site_id" => view.domain,
metrics: [:visitors, :visits, :pageviews, :views_per_visit], "metrics" => ["visitors", "visits", "pageviews", "views_per_visit"],
include: %QueryInclude{ "include" => %{"comparisons" => %{"mode" => "custom", "date_range" => [c_from, c_to]}},
compare: {:datetime_range, c_from, c_to} "date_range" => [
} from,
to
]
}) })
%Stats.QueryResult{ %Stats.QueryResult{
@ -87,7 +91,7 @@ defmodule Plausible.Stats.ConsolidatedView do
defp query_24h_intervals(view, now) do defp query_24h_intervals(view, now) do
graph_query = graph_query =
Stats.Query.parse_and_build!( Stats.Query.build!(
view, view,
:internal, :internal,
%{ %{

View File

@ -98,8 +98,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
display_name: goal.display_name, display_name: goal.display_name,
goal_type: Goal.type(goal), goal_type: Goal.type(goal),
event_name: goal.event_name, event_name: goal.event_name,
page_path: goal.page_path, page_path: goal.page_path
custom_props: goal.custom_props
} }
end), end),
meta: pagination_meta(page.metadata) meta: pagination_meta(page.metadata)
@ -409,17 +408,6 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
{:missing, param} -> {:missing, param} ->
H.bad_request(conn, "Parameter `#{param}` is required to create a goal") 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)
{:error, :upgrade_required} ->
H.payment_required(
conn,
"Your current subscription plan does not include Custom Properties"
)
e -> e ->
H.bad_request(conn, "Something went wrong: #{inspect(e)}") H.bad_request(conn, "Something went wrong: #{inspect(e)}")
end end
@ -617,10 +605,4 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
# remap to `custom_properties` # remap to `custom_properties`
|> Map.put(:custom_properties, site.allowed_event_props || []) |> Map.put(:custom_properties, site.allowed_event_props || [])
end 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 end

View File

@ -67,7 +67,7 @@ defmodule PlausibleWeb.CustomerSupport.Components.Layout do
</p> </p>
<strong>team:</strong>input<br /> <strong>team:</strong>input<br />
<p class="font-sans pl-2 mb-1"> <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> </p>
<strong>team:</strong>input <strong>+sub</strong> <strong>team:</strong>input <strong>+sub</strong>

View File

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

View File

@ -50,21 +50,6 @@ defmodule PlausibleWeb.Live.FunnelSettings do
<div id="funnel-settings-main"> <div id="funnel-settings-main">
<.flash_messages flash={@flash} /> <.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 %> <%= if @setup_funnel? do %>
{live_render( {live_render(
@socket, @socket,
@ -85,26 +70,20 @@ defmodule PlausibleWeb.Live.FunnelSettings do
/> />
</div> </div>
<div <div :if={@goal_count < Funnel.min_steps()} class="flex flex-col items-center">
:if={@goal_count < Funnel.min_steps()} <h1 class="mt-4 text-center">
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">
Ready to dig into user flows? Ready to dig into user flows?
</h3> </h1>
<p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty"> <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 like <.highlighted>Signup</.highlighted>, <.highlighted>Visit /</.highlighted>, or 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!
<.highlighted>Scroll 50% on /blog/*</.highlighted>
first, then return here to build your first funnel.
</p> </p>
<.button_link <.button_link
class="mt-4" class="mb-2"
href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)} href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
> >
Set up goals Set up goals
</.button_link> </.button_link>
</div> </div>
</.tile>
</div> </div>
""" """
end end
@ -168,8 +147,4 @@ defmodule PlausibleWeb.Live.FunnelSettings do
def handle_info(:cancel_setup_funnel, socket) do def handle_info(:cancel_setup_funnel, socket) do
{:noreply, assign(socket, setup_funnel?: false, funnel_id: nil)} {:noreply, assign(socket, setup_funnel?: false, funnel_id: nil)}
end 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 end

View File

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

View File

@ -10,17 +10,13 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
use PlausibleWeb, :live_component use PlausibleWeb, :live_component
def render(assigns) do def render(assigns) do
assigns = assign(assigns, :searching?, String.trim(assigns.filter_text) != "")
~H""" ~H"""
<div> <div>
<%= if @searching? or Enum.count(@funnels) > 0 do %>
<.filter_bar filter_text={@filter_text} placeholder="Search Funnels"> <.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
<.button id="add-funnel-button" phx-click="add-funnel" mt?={false}> <.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
Add funnel Add funnel
</.button> </.button>
</.filter_bar> </.filter_bar>
<% end %>
<%= if Enum.count(@funnels) > 0 do %> <%= if Enum.count(@funnels) > 0 do %>
<.table rows={@funnels}> <.table rows={@funnels}>
@ -46,43 +42,18 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
</:tbody> </:tbody>
</.table> </.table>
<% else %> <% else %>
<.no_search_results :if={@searching?} />
<.empty_state :if={not @searching?} />
<% end %>
</div>
"""
end
defp no_search_results(assigns) do
~H"""
<p class="mt-12 mb-8 text-sm text-center"> <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 No funnels found for this site. Please refine or
<.styled_link phx-click="reset-filter-text" id="reset-filter-hint"> <.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
reset your search. reset your search.
</.styled_link> </.styled_link>
</span>
<span :if={String.trim(@filter_text) == "" && Enum.empty?(@funnels)}>
No funnels configured for this site.
</span>
</p> </p>
""" <% end %>
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> </div>
""" """
end end

View File

@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.Funnels do
use PlausibleWeb, :plugins_api_controller use PlausibleWeb, :plugins_api_controller
operation(:create, operation(:create,
operation_id: "Funnel.GetOrCreate", id: "Funnel.GetOrCreate",
summary: "Get or create Funnel", summary: "Get or create Funnel",
request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest}, request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest},
responses: %{ 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 defmodule Plausible.Auth.User do
use Plausible use Plausible
use Ecto.Schema use Ecto.Schema
@ -272,15 +284,3 @@ defmodule Plausible.Auth.User do
end end
end 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: [] field :funnels, {:array, :map}, virtual: true, default: []
end end
field :custom_props, :map, default: %{}
belongs_to :site, Plausible.Site belongs_to :site, Plausible.Site
timestamps() timestamps()
end end
@fields [ @fields [:id, :site_id, :event_name, :page_path, :scroll_threshold, :display_name] ++
:id,
:site_id,
:event_name,
:page_path,
:scroll_threshold,
:display_name,
:custom_props
] ++
on_ee(do: [:currency], else: []) on_ee(do: [:currency], else: [])
@max_event_name_length 120 @max_event_name_length 120
def max_event_name_length(), do: @max_event_name_length 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 def changeset(goal, attrs \\ %{}) do
goal goal
|> cast(attrs, @fields) |> cast(attrs, @fields)
@ -54,12 +40,11 @@ defmodule Plausible.Goal do
|> validate_event_name_and_page_path() |> validate_event_name_and_page_path()
|> validate_page_path_for_scroll_goal() |> validate_page_path_for_scroll_goal()
|> maybe_put_display_name() |> maybe_put_display_name()
|> validate_change(:custom_props, &validate_custom_props/2) |> unique_constraint(:event_name, name: :goals_event_name_unique)
|> unique_constraint(:display_name, name: :goals_display_name_unique)
|> unique_constraint(:event_name, name: :goals_event_config_unique)
|> unique_constraint([:page_path, :scroll_threshold], |> 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_length(:event_name, max: @max_event_name_length)
|> validate_number(:scroll_threshold, |> validate_number(:scroll_threshold,
greater_than_or_equal_to: -1, greater_than_or_equal_to: -1,
@ -177,35 +162,6 @@ defmodule Plausible.Goal do
|> update_change(:display_name, &String.trim/1) |> update_change(:display_name, &String.trim/1)
|> validate_required(:display_name) |> validate_required(:display_name)
end end
defp validate_custom_props(:custom_props, custom_props) when is_map(custom_props) do
cond do
map_size(custom_props) > @max_custom_props_per_goal ->
[custom_props: "use at most #{@max_custom_props_per_goal} properties per goal"]
not Enum.all?(custom_props, fn {k, v} ->
is_binary(k) and is_binary(v)
end) ->
[custom_props: "must be a map with string keys and string values"]
Enum.any?(custom_props, fn {k, _v} ->
String.length(k) not in 1..Plausible.Props.max_prop_key_length()
end) ->
[
custom_props: "key length is 1..#{Plausible.Props.max_prop_key_length()} characters"
]
Enum.any?(custom_props, fn {_k, v} ->
String.length(v) not in 1..Plausible.Props.max_prop_value_length()
end) ->
[
custom_props: "value length is 1..#{Plausible.Props.max_prop_value_length()} characters"
]
true ->
[]
end
end
end end
defimpl Jason.Encoder, for: Plausible.Goal do defimpl Jason.Encoder, for: Plausible.Goal do
@ -214,7 +170,7 @@ defimpl Jason.Encoder, for: Plausible.Goal do
value value
|> Map.put(:goal_type, Plausible.Goal.type(value)) |> Map.put(:goal_type, Plausible.Goal.type(value))
|> Map.take([:id, :goal_type, :event_name, :page_path, :custom_props]) |> Map.take([:id, :goal_type, :event_name, :page_path])
|> Map.put(:domain, domain) |> Map.put(:domain, domain)
|> Map.put(:display_name, value.display_name) |> Map.put(:display_name, value.display_name)
|> Jason.Encode.map(opts) |> Jason.Encode.map(opts)

View File

@ -7,21 +7,6 @@ defmodule Plausible.Goals do
alias Plausible.Goal alias Plausible.Goal
alias Ecto.Multi 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() @spec get(Plausible.Site.t(), pos_integer()) :: nil | Plausible.Goal.t()
def get(site, id) when is_integer(id) do def get(site, id) when is_integer(id) do
@ -35,30 +20,18 @@ defmodule Plausible.Goals do
end end
@spec create(Plausible.Site.t(), map(), Keyword.t()) :: @spec create(Plausible.Site.t(), map(), Keyword.t()) ::
{:ok, Goal.t()} {:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
| {:error, Changeset.t()}
| {:error, :upgrade_required}
| {:error, :revenue_goals_unavailable}
@doc """ @doc """
Creates a Goal for a site. Creates a Goal for a site.
If the created goal is a revenue goal, it sets site.updated_at to be 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. 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 def create(site, params, opts \\ []) do
upsert? = Keyword.get(opts, :upsert?, false)
Repo.transaction(fn -> Repo.transaction(fn ->
case insert_goal(site, params, opts) do case insert_goal(site, params, upsert?) do
{:ok, :insert, goal} -> {:ok, :insert, goal} ->
on_ee do on_ee do
now = Keyword.get(opts, :now, DateTime.utc_now()) now = Keyword.get(opts, :now, DateTime.utc_now())
@ -80,7 +53,7 @@ defmodule Plausible.Goals do
end end
@spec update(Plausible.Goal.t(), map()) :: @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 def update(goal, params) do
changeset = Goal.changeset(goal, params) changeset = Goal.changeset(goal, params)
@ -96,7 +69,7 @@ defmodule Plausible.Goals do
updated_goal updated_goal
end end
else else
{:error, %Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
Repo.rollback(changeset) Repo.rollback(changeset)
{:error, :upgrade_required} -> {:error, :upgrade_required} ->
@ -105,19 +78,16 @@ defmodule Plausible.Goals do
end) end)
end end
def find_or_create(site, params, opts \\ [])
def find_or_create( def find_or_create(
site, site,
%{ %{
"goal_type" => "event", "goal_type" => "event",
"event_name" => event_name, "event_name" => event_name,
"currency" => currency "currency" => currency
} = params, } = params
opts
) )
when is_binary(event_name) and is_binary(currency) do 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 if to_string(goal.currency) == currency do
{:ok, goal} {:ok, goal}
else else
@ -125,7 +95,7 @@ defmodule Plausible.Goals do
changeset = changeset =
goal goal
|> Goal.changeset() |> Goal.changeset()
|> Changeset.add_error( |> Ecto.Changeset.add_error(
:event_name, :event_name,
"'#{goal.event_name}' (with currency: #{goal.currency}) has already been taken" "'#{goal.event_name}' (with currency: #{goal.currency}) has already been taken"
) )
@ -135,25 +105,23 @@ defmodule Plausible.Goals do
end end
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 when is_binary(event_name) do
create(site, params, do_upsert(opts)) create(site, params, upsert?: true)
end 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 def find_or_create(site, %{"goal_type" => "page", "page_path" => _} = params) do
create(site, params, do_upsert(opts)) create(site, params, upsert?: true)
end 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 def list_revenue_goals(site) do
from(g in Plausible.Goal, from(g in Plausible.Goal,
where: g.site_id == ^site.id and not is_nil(g.currency), where: g.site_id == ^site.id and not is_nil(g.currency),
select: %{display_name: g.display_name, currency: g.currency}, select: %{display_name: g.display_name, currency: g.currency}
order_by: [desc: g.id],
limit: ^max_goals_per_site()
) )
|> Plausible.Repo.all() |> Plausible.Repo.all()
end end
@ -165,21 +133,13 @@ defmodule Plausible.Goals do
|> Enum.map(&maybe_trim/1) |> Enum.map(&maybe_trim/1)
end end
def for_site_query(site \\ nil, opts \\ []) do def for_site_query(site, opts \\ []) do
query = query =
from g in Goal, from g in Goal,
order_by: [desc: g.id],
limit: ^max_goals_per_site(opts)
query =
if site do
from g in query,
inner_join: assoc(g, :site), inner_join: assoc(g, :site),
where: g.site_id == ^site.id, where: g.site_id == ^site.id,
order_by: [desc: g.id],
preload: [:site] preload: [:site]
else
query
end
if ee?() and opts[:preload_funnels?] == true do if ee?() and opts[:preload_funnels?] == true do
from(g in query, from(g in query,
@ -192,9 +152,9 @@ defmodule Plausible.Goals do
end end
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 -> 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] {:ok, _, goal} -> acc ++ [goal]
_ -> acc _ -> acc
end end
@ -288,25 +248,25 @@ defmodule Plausible.Goals do
@spec create_outbound_links(Plausible.Site.t()) :: :ok @spec create_outbound_links(Plausible.Site.t()) :: :ok
def create_outbound_links(%Plausible.Site{} = site) do 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 :ok
end end
@spec create_file_downloads(Plausible.Site.t()) :: :ok @spec create_file_downloads(Plausible.Site.t()) :: :ok
def create_file_downloads(%Plausible.Site{} = site) do 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 :ok
end end
@spec create_form_submissions(Plausible.Site.t()) :: :ok @spec create_form_submissions(Plausible.Site.t()) :: :ok
def create_form_submissions(%Plausible.Site{} = site) do 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 :ok
end end
@spec create_404(Plausible.Site.t()) :: :ok @spec create_404(Plausible.Site.t()) :: :ok
def create_404(%Plausible.Site{} = site) do def create_404(%Plausible.Site{} = site) do
create(site, %{"event_name" => "404"}, do_upsert()) create(site, %{"event_name" => "404"}, upsert?: true)
:ok :ok
end end
@ -354,11 +314,11 @@ defmodule Plausible.Goals do
:ok :ok
end end
defp insert_goal(site, params, opts) do defp insert_goal(site, params, upsert?) do
params = Map.delete(params, "site_id") params = Map.delete(params, "site_id")
insert_opts = insert_opts =
if upsert?(opts) do if upsert? do
[on_conflict: :nothing] [on_conflict: :nothing]
else else
[] []
@ -368,7 +328,6 @@ defmodule Plausible.Goals do
with :ok <- maybe_check_feature_access(site, changeset), with :ok <- maybe_check_feature_access(site, changeset),
:ok <- check_no_currency_if_consolidated(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 {:ok, goal} <- Repo.insert(changeset, insert_opts) do
# Upsert with `on_conflict: :nothing` strategy # Upsert with `on_conflict: :nothing` strategy
# will result in goal struct missing primary key field # will result in goal struct missing primary key field
@ -387,13 +346,7 @@ defmodule Plausible.Goals do
end end
defp maybe_check_feature_access(site, changeset) do defp maybe_check_feature_access(site, changeset) do
with :ok <- revenue_goals_access_check(site, changeset) do if Ecto.Changeset.get_field(changeset, :currency) do
custom_props_goals_access_check(site, changeset)
end
end
defp revenue_goals_access_check(site, changeset) do
if Changeset.get_field(changeset, :currency) do
site = Plausible.Repo.preload(site, :team) site = Plausible.Repo.preload(site, :team)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.team) Plausible.Billing.Feature.RevenueGoals.check_availability(site.team)
else else
@ -401,32 +354,8 @@ defmodule Plausible.Goals do
end end
end end
defp custom_props_goals_access_check(site, changeset) do
if map_size(Changeset.get_field(changeset, :custom_props)) > 0 do
site = Plausible.Repo.preload(site, :team)
Plausible.Billing.Feature.Props.check_availability(site.team)
else
:ok
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 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} {:error, :revenue_goals_unavailable}
else else
:ok :ok
@ -448,29 +377,4 @@ defmodule Plausible.Goals do
defp maybe_trim(other) do defp maybe_trim(other) do
other other
end 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 end

View File

@ -202,7 +202,7 @@ defmodule Plausible.Google.GA4.API do
end end
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{ %GA4.ReportRequest{
report_request report_request
| date_range: date_range, | date_range: date_range,

View File

@ -51,42 +51,16 @@ defmodule Plausible.Plugins.API.Goals do
:ok :ok
end end
defp convert_to_create_params(%CreateRequest.CustomEvent{
goal: %{event_name: event_name, custom_props: custom_props}
})
when is_map(custom_props) do
%{"goal_type" => "event", "event_name" => event_name, "custom_props" => custom_props}
end
defp convert_to_create_params(%CreateRequest.CustomEvent{goal: %{event_name: event_name}}) do defp convert_to_create_params(%CreateRequest.CustomEvent{goal: %{event_name: event_name}}) do
%{"goal_type" => "event", "event_name" => event_name} %{"goal_type" => "event", "event_name" => event_name}
end end
defp convert_to_create_params(%CreateRequest.Revenue{
goal: %{event_name: event_name, currency: currency, custom_props: custom_props}
})
when is_map(custom_props) do
%{
"goal_type" => "event",
"event_name" => event_name,
"currency" => currency,
"custom_props" => custom_props
}
end
defp convert_to_create_params(%CreateRequest.Revenue{ defp convert_to_create_params(%CreateRequest.Revenue{
goal: %{event_name: event_name, currency: currency} goal: %{event_name: event_name, currency: currency}
}) do }) do
%{"goal_type" => "event", "event_name" => event_name, "currency" => currency} %{"goal_type" => "event", "event_name" => event_name, "currency" => currency}
end end
defp convert_to_create_params(%CreateRequest.Pageview{
goal: %{path: page_path, custom_props: custom_props}
})
when is_map(custom_props) do
%{"goal_type" => "page", "page_path" => page_path, "custom_props" => custom_props}
end
defp convert_to_create_params(%CreateRequest.Pageview{goal: %{path: page_path}}) do defp convert_to_create_params(%CreateRequest.Pageview{goal: %{path: page_path}}) do
%{"goal_type" => "page", "page_path" => page_path} %{"goal_type" => "page", "page_path" => page_path}
end end
@ -99,9 +73,6 @@ defmodule Plausible.Plugins.API.Goals do
{:ok, goal} -> {:ok, goal} ->
goal goal
{:error, :upgrade_required} ->
Repo.rollback(:upgrade_required)
{:error, changeset} -> {:error, changeset} ->
Repo.rollback(changeset) Repo.rollback(changeset)
end end

View File

@ -3,7 +3,7 @@ defmodule Plausible.Segments.Filters do
This module contains functions that enable resolving segments in filters. This module contains functions that enable resolving segments in filters.
""" """
alias Plausible.Segments alias Plausible.Segments
alias Plausible.Stats.{Filters, ApiQueryParser} alias Plausible.Stats.Filters
@max_segment_filters_count 10 @max_segment_filters_count 10
@ -48,7 +48,7 @@ defmodule Plausible.Segments.Filters do
segments, segments,
%{}, %{},
fn %Segments.Segment{id: id, segment_data: segment_data} -> 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} {:ok, filters} -> {id, filters}
_ -> {id, nil} _ -> {id, nil}
end end

View File

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

View File

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

View File

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

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