Compare commits
50 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
69d8d22ac2 | |
|
|
9978f9be0f | |
|
|
dfeda94e06 | |
|
|
6446e15871 | |
|
|
810b956269 | |
|
|
aed90f7ffc | |
|
|
f07dc8dd49 | |
|
|
38381195f8 | |
|
|
c78ddf6ba4 | |
|
|
40f0d4bfbf | |
|
|
e4b282a610 | |
|
|
45116bda7b | |
|
|
b6b9c2c0bf | |
|
|
b299aa352a | |
|
|
e4bc6b8715 | |
|
|
ee906f4033 | |
|
|
c4ea07d8bc | |
|
|
d6673fbbd5 | |
|
|
d9456d7308 | |
|
|
16f1eb3075 | |
|
|
007155ba60 | |
|
|
1ff2b52cbb | |
|
|
98e0f7276b | |
|
|
12d818af8a | |
|
|
98632aee74 | |
|
|
fa09b73ff1 | |
|
|
85d9e59bf3 | |
|
|
b1f21a2736 | |
|
|
5b69061885 | |
|
|
b64a2355a0 | |
|
|
8a7d681c43 | |
|
|
3c9ba41cb6 | |
|
|
111a8b9462 | |
|
|
8082b695d5 | |
|
|
0eea55d1c1 | |
|
|
5fe2be8dc8 | |
|
|
2c00acc89b | |
|
|
f2bc96debe | |
|
|
26e5c41ef7 | |
|
|
7a11f5ec40 | |
|
|
6d5951fffd | |
|
|
b8d64e2eff | |
|
|
25d40155e9 | |
|
|
a9050abdb4 | |
|
|
a29f0a30ba | |
|
|
1df08a25b4 | |
|
|
dec382ccd5 | |
|
|
a2ba1256d2 | |
|
|
35f1cea344 | |
|
|
024e6bb9ef |
|
|
@ -117,6 +117,11 @@
|
|||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, false},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, []},
|
||||
{Credo.Check.Refactor.RejectReject, []},
|
||||
{Credo.Check.Refactor.FunctionArity, []},
|
||||
{Credo.Check.Refactor.LongQuoteBlocks, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
|
|
@ -133,6 +138,7 @@
|
|||
#
|
||||
## Warnings
|
||||
#
|
||||
{Credo.Check.Warning.Dbg, []},
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues, []},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ jobs:
|
|||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
|
|
@ -91,7 +91,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ jobs:
|
|||
codespell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: codespell-project/actions-codespell@v2
|
||||
with:
|
||||
check_filenames: true
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CACHE_VERSION: v15
|
||||
CACHE_VERSION: v17
|
||||
PERSISTENT_CACHE_DIR: cached
|
||||
|
||||
jobs:
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ jobs:
|
|||
elixir-version: ${{ steps.versions.outputs.elixir }}
|
||||
otp-version: ${{ steps.versions.outputs.erlang }}
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
|
|
@ -114,13 +114,26 @@ jobs:
|
|||
|
||||
- run: make minio
|
||||
if: env.MIX_ENV == 'test'
|
||||
- run: mix test --include slow --include minio --include migrations --include kaffy_quirks --max-failures 1 --warnings-as-errors --partitions 6
|
||||
- run: |
|
||||
mix test --include slow --include minio --include migrations --max-failures 1 --warnings-as-errors --partitions 6 | tee test_output.log
|
||||
if grep -E '\.+[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3} \[[^]]+\]' test_output.log | grep -v 'libcluster'; then
|
||||
echo "The tests are producing output, this usually indicates some error"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
if: env.MIX_ENV == 'test'
|
||||
env:
|
||||
MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1"
|
||||
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
|
||||
|
||||
- run: mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 4
|
||||
|
||||
- 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'
|
||||
env:
|
||||
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
|
||||
|
|
@ -131,7 +144,7 @@ jobs:
|
|||
MIX_ENV: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
@ -142,7 +155,7 @@ jobs:
|
|||
elixir-version: ${{ steps.versions.outputs.elixir }}
|
||||
otp-version: ${{ steps.versions.outputs.erlang }}
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Read .tool-versions
|
||||
uses: marocchino/tool-versions-action@v1
|
||||
id: versions
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Read .tool-versions
|
||||
uses: marocchino/tool-versions-action@v1
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
otp-version: ${{ steps.versions.outputs.erlang}}
|
||||
|
||||
- name: Restore Elixir dependencies cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
fetch-depth: 1
|
||||
|
||||
- name: Checkout master for comparison
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
path: master-branch
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
|
||||
- name: Get changed files
|
||||
id: changelog_changed
|
||||
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891
|
||||
with:
|
||||
files: |
|
||||
tracker/npm_package/CHANGELOG.md
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 23.2.0
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm --prefix ./tracker ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
|
|
@ -49,7 +49,7 @@ jobs:
|
|||
run: npm --prefix ./tracker test -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: tracker/blob-report
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 23.2.0
|
||||
|
|
@ -70,7 +70,7 @@ jobs:
|
|||
run: npm --prefix ./tracker ci
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
|
|
|
|||
|
|
@ -97,3 +97,5 @@ plausible-report.xml
|
|||
# Docker volumes
|
||||
.clickhouse_db_vol*
|
||||
plausible_db*
|
||||
|
||||
.claude
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
erlang 27.3.1
|
||||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4.6
|
||||
elixir 1.19.4-otp-27
|
||||
nodejs 23.2.0
|
||||
|
|
|
|||
|
|
@ -6,12 +6,20 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown
|
||||
|
||||
### Removed
|
||||
|
||||
### Changed
|
||||
|
||||
- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards
|
||||
|
||||
### Fixed
|
||||
|
||||
- To make internal stats API requests for password-protected shared links, shared link auth cookie must be set in the requests
|
||||
- Fixed issue with site guests in Editor role and team members in Editor role not being able to change the domain of site
|
||||
- Fixed direct dashboard links that use legacy dashboard filters containing URL encoded special characters (e.g. character `ê` in the legacy filter `?page=%C3%AA`)
|
||||
|
||||
## v3.1.0 - 2025-11-13
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# we can not use the pre-built tar because the distribution is
|
||||
# platform specific, it makes sense to build it in the docker
|
||||
|
||||
ARG ALPINE_VERSION=3.22.2
|
||||
|
||||
#### Builder
|
||||
FROM hexpm/elixir:1.18.3-erlang-27.3.1-alpine-3.21.3 AS buildcontainer
|
||||
FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-${ALPINE_VERSION} AS buildcontainer
|
||||
|
||||
ARG MIX_ENV=ce
|
||||
|
||||
|
|
@ -20,7 +22,7 @@ RUN mkdir /app
|
|||
WORKDIR /app
|
||||
|
||||
# install build dependencies
|
||||
RUN apk add --no-cache git "nodejs-current=23.2.0-r1" yarn npm python3 ca-certificates wget gnupg make gcc libc-dev brotli
|
||||
RUN apk add --no-cache git "nodejs-current=23.11.1-r0" yarn npm python3 ca-certificates wget gnupg make gcc libc-dev brotli
|
||||
|
||||
COPY mix.exs ./
|
||||
COPY mix.lock ./
|
||||
|
|
@ -54,7 +56,7 @@ COPY rel rel
|
|||
RUN mix release plausible
|
||||
|
||||
# Main Docker Image
|
||||
FROM alpine:3.21.3
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
LABEL maintainer="plausible.io <hello@plausible.io>"
|
||||
|
||||
ARG BUILD_METADATA={}
|
||||
|
|
@ -84,3 +86,4 @@ EXPOSE 8000
|
|||
ENV DEFAULT_DATA_DIR=/var/lib/plausible
|
||||
VOLUME /var/lib/plausible
|
||||
CMD ["run"]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep:*)",
|
||||
"Read(//Users/ukutaht/plausible/analytics/lib/**)",
|
||||
"Bash(node:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@
|
|||
--color-gray-950: var(--color-zinc-950);
|
||||
|
||||
/* Custom gray shades from config (override some zinc values) */
|
||||
--color-gray-75: rgb(247 247 248);
|
||||
--color-gray-150: rgb(236 236 238);
|
||||
--color-gray-750: rgb(50 50 54);
|
||||
--color-gray-825: rgb(35 35 38);
|
||||
|
|
@ -294,16 +295,12 @@ blockquote {
|
|||
display: inline;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-child(odd) {
|
||||
background-color: var(--color-gray-100);
|
||||
.table-striped tbody tr:nth-child(odd) td {
|
||||
background-color: var(--color-gray-75);
|
||||
}
|
||||
|
||||
.dark .table-striped tbody tr:nth-child(odd) {
|
||||
background-color: var(--color-gray-800);
|
||||
}
|
||||
|
||||
.dark .table-striped tbody tr:nth-child(even) {
|
||||
background-color: var(--color-gray-900);
|
||||
.dark .table-striped tbody tr:nth-child(odd) td {
|
||||
background-color: var(--color-gray-850);
|
||||
}
|
||||
|
||||
.fade-enter {
|
||||
|
|
|
|||
|
|
@ -32,33 +32,6 @@
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal__container {
|
||||
background-color: #fff;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 4px;
|
||||
margin: 50px auto;
|
||||
box-sizing: border-box;
|
||||
min-height: 509px;
|
||||
transition: height 200ms ease-in;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
position: fixed;
|
||||
color: #b8c2cc;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
top: 12px;
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.modal__close::before {
|
||||
content: '\2715';
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@keyframes mm-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* 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
|
||||
)
|
||||
|
|
@ -66,7 +66,7 @@ export const SearchInput = ({
|
|||
type="text"
|
||||
placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
|
||||
className={classNames(
|
||||
'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',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
onChange={debouncedOnSearchInputChange}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,18 @@ export const SortButton = ({
|
|||
return (
|
||||
<button
|
||||
onClick={toggleSort}
|
||||
className={classNames('group', 'hover:underline', 'relative')}
|
||||
className={classNames(
|
||||
'group',
|
||||
'hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-100',
|
||||
'relative'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
title={next.hint}
|
||||
className={classNames(
|
||||
'absolute',
|
||||
'rounded inline-block h-4 w-4',
|
||||
'rounded inline-block size-4',
|
||||
'ml-1',
|
||||
{
|
||||
[SortDirection.asc]: 'rotate-180',
|
||||
|
|
@ -30,9 +34,8 @@ export const SortButton = ({
|
|||
}[sortDirection ?? next.direction],
|
||||
!sortDirection && 'opacity-0',
|
||||
!sortDirection && 'group-hover:opacity-100',
|
||||
sortDirection &&
|
||||
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
|
||||
'transition'
|
||||
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
|
||||
'transition-all duration-100'
|
||||
)}
|
||||
>
|
||||
↓
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export type ColumnConfiguraton<T extends Record<string, unknown>> = {
|
|||
/**
|
||||
* Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
|
||||
*/
|
||||
renderValue?: (item: T) => ReactNode
|
||||
renderValue?: (item: T, isRowHovered?: boolean) => ReactNode
|
||||
/** Function used to create richer cells */
|
||||
renderItem?: (item: T) => ReactNode
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export const TableHeaderCell = ({
|
|||
return (
|
||||
<th
|
||||
className={classNames(
|
||||
'p-2 text-xs font-bold text-gray-500 dark:text-gray-400 tracking-wide',
|
||||
'p-2 text-xs font-semibold text-gray-500 dark:text-gray-400',
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
|
|
@ -58,7 +58,13 @@ export const TableCell = ({
|
|||
align?: 'left' | 'right'
|
||||
}) => {
|
||||
return (
|
||||
<td className={classNames('p-2 font-medium', className)} align={align}>
|
||||
<td
|
||||
className={classNames(
|
||||
'p-2 font-medium first:rounded-s-sm last:rounded-e-sm',
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
|
|
@ -68,15 +74,42 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
|
|||
rowIndex,
|
||||
pageIndex,
|
||||
item,
|
||||
columns
|
||||
columns,
|
||||
tappedRowName,
|
||||
onRowTap
|
||||
}: {
|
||||
rowIndex: number
|
||||
pageIndex?: number
|
||||
item: T
|
||||
columns: ColumnConfiguraton<T>[]
|
||||
tappedRowName?: string | null
|
||||
onRowTap?: (rowName: string | null) => void
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
const rowName = (item as unknown as { name: string }).name
|
||||
const isTapped = tappedRowName === rowName
|
||||
const isRowActive = isHovered || isTapped
|
||||
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
|
||||
if (onRowTap) {
|
||||
if (isTapped) {
|
||||
onRowTap(null)
|
||||
} else {
|
||||
onRowTap(rowName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="text-sm dark:text-gray-200">
|
||||
<tr
|
||||
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 }) => (
|
||||
<TableCell
|
||||
key={`${(pageIndex ?? null) === null ? '' : `page_${pageIndex}_`}row_${rowIndex}_${String(key)}`}
|
||||
|
|
@ -86,7 +119,7 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
|
|||
{renderItem
|
||||
? renderItem(item)
|
||||
: renderValue
|
||||
? renderValue(item)
|
||||
? renderValue(item, isRowActive)
|
||||
: (item[key] ?? '')}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
@ -101,6 +134,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
columns: ColumnConfiguraton<T>[]
|
||||
data: T[] | { pages: T[][] }
|
||||
}) => {
|
||||
const [tappedRowName, setTappedRowName] = React.useState<string | null>(null)
|
||||
|
||||
const renderColumnLabel = (column: ColumnConfiguraton<T>) => {
|
||||
if (column.metricWarning) {
|
||||
return (
|
||||
|
|
@ -125,13 +160,13 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
}
|
||||
|
||||
return (
|
||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
||||
<thead>
|
||||
<tr className="text-xs font-bold text-gray-500 dark:text-gray-400">
|
||||
<table className="border-collapse table-striped table-fixed w-max min-w-full">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-900 z-10">
|
||||
<tr className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
{columns.map((column) => (
|
||||
<TableHeaderCell
|
||||
key={`header_${String(column.key)}`}
|
||||
className={classNames('p-2 tracking-wide', column.width)}
|
||||
className={classNames('p-2', column.width)}
|
||||
align={column.align}
|
||||
>
|
||||
{column.onSort ? (
|
||||
|
|
@ -156,6 +191,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
columns={columns}
|
||||
rowIndex={rowIndex}
|
||||
key={rowIndex}
|
||||
tappedRowName={tappedRowName}
|
||||
onRowTap={setTappedRowName}
|
||||
/>
|
||||
))
|
||||
: data.pages.map((page, pageIndex) =>
|
||||
|
|
@ -166,6 +203,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
rowIndex={rowIndex}
|
||||
pageIndex={pageIndex}
|
||||
key={`page_${pageIndex}_row_${rowIndex}`}
|
||||
tappedRowName={tappedRowName}
|
||||
onRowTap={setTappedRowName}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ const Items = ({
|
|||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
placeholderUnfocused="Press / to search"
|
||||
className="ml-auto w-full py-1 text-sm"
|
||||
className="ml-auto w-full py-1"
|
||||
onSearch={handleSearchInput}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
SegmentType,
|
||||
SavedSegment,
|
||||
SegmentData,
|
||||
canSeeSegmentDetails
|
||||
canExpandSegment
|
||||
} from './segments'
|
||||
import { Filter } from '../query'
|
||||
import { PlausibleSite } from '../site-context'
|
||||
|
|
@ -183,34 +183,124 @@ describe(`${resolveFilters.name}`, () => {
|
|||
)
|
||||
})
|
||||
|
||||
describe(`${canSeeSegmentDetails.name}`, () => {
|
||||
it('should return true if the user is logged in and not a public role', () => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role: Role.admin,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
describe(`${canExpandSegment.name}`, () => {
|
||||
it.each([[Role.admin], [Role.editor], [Role.owner]])(
|
||||
'allows expanding site segment if the user is logged in and in the role %p',
|
||||
(role) => {
|
||||
const site = { siteSegmentsAvailable: true }
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 1, type: SegmentType.site },
|
||||
user,
|
||||
site
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
expect(canSeeSegmentDetails({ user })).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('should return false if the user is not logged in', () => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: false,
|
||||
role: Role.editor,
|
||||
id: null,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
expect(canSeeSegmentDetails({ user })).toBe(false)
|
||||
it('forbids expanding site segment if site segments are not available', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 1, type: SegmentType.site },
|
||||
user: {
|
||||
loggedIn: true,
|
||||
role: Role.owner,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if the user has a public role', () => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role: Role.public,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
it('forbids public role from expanding site segments', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: null, type: SegmentType.site },
|
||||
user: {
|
||||
loggedIn: false,
|
||||
role: Role.public,
|
||||
id: null,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[Role.viewer],
|
||||
[Role.billing],
|
||||
[Role.editor],
|
||||
[Role.admin],
|
||||
[Role.owner]
|
||||
])(
|
||||
'allows expanding personal segment if it belongs to the user and the user is in role %p',
|
||||
(role) => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 1, type: SegmentType.personal },
|
||||
user,
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
expect(canSeeSegmentDetails({ user })).toBe(false)
|
||||
)
|
||||
|
||||
it('forbids expanding personal segment of other users', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 2, owner_id: 222, type: SegmentType.personal },
|
||||
user: {
|
||||
loggedIn: true,
|
||||
role: Role.owner,
|
||||
id: 111,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ export enum SegmentType {
|
|||
site = 'site'
|
||||
}
|
||||
|
||||
/** keep in sync with Plausible.Segments */
|
||||
const ROLES_WITH_MAYBE_SITE_SEGMENTS = [Role.admin, Role.editor, Role.owner]
|
||||
const ROLES_WITH_PERSONAL_SEGMENTS = [
|
||||
Role.billing,
|
||||
Role.viewer,
|
||||
Role.admin,
|
||||
Role.editor,
|
||||
Role.owner
|
||||
]
|
||||
|
||||
/** This type signifies that the owner can't be shown. */
|
||||
type SegmentOwnershipHidden = { owner_id: null; owner_name: null }
|
||||
|
||||
|
|
@ -148,6 +158,36 @@ export function resolveFilters(
|
|||
})
|
||||
}
|
||||
|
||||
export function canExpandSegment({
|
||||
segment,
|
||||
site,
|
||||
user
|
||||
}: {
|
||||
segment: Pick<SavedSegment, 'id' | 'owner_id' | 'type'>
|
||||
site: Pick<PlausibleSite, 'siteSegmentsAvailable'>
|
||||
user: UserContextValue
|
||||
}) {
|
||||
if (
|
||||
segment.type === SegmentType.site &&
|
||||
site.siteSegmentsAvailable &&
|
||||
user.loggedIn &&
|
||||
ROLES_WITH_MAYBE_SITE_SEGMENTS.includes(user.role)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
segment.type === SegmentType.personal &&
|
||||
user.loggedIn &&
|
||||
ROLES_WITH_PERSONAL_SEGMENTS.includes(user.role) &&
|
||||
user.id === segment.owner_id
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isListableSegment({
|
||||
segment,
|
||||
site,
|
||||
|
|
@ -173,10 +213,6 @@ export function isListableSegment({
|
|||
return false
|
||||
}
|
||||
|
||||
export function canSeeSegmentDetails({ user }: { user: UserContextValue }) {
|
||||
return user.loggedIn && user.role !== Role.public
|
||||
}
|
||||
|
||||
export function findAppliedSegmentFilter({ filters }: { filters: Filter[] }) {
|
||||
const segmentFilter = filters.find(isSegmentFilter)
|
||||
if (!segmentFilter) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { LiveViewPortal } from './components/liveview-portal'
|
||||
import VisitorGraph from './stats/graph/visitor-graph'
|
||||
import Sources from './stats/sources'
|
||||
import Pages from './stats/pages'
|
||||
|
|
@ -7,7 +8,10 @@ import Devices from './stats/devices'
|
|||
import { TopBar } from './nav-menu/top-bar'
|
||||
import Behaviours from './stats/behaviours'
|
||||
import { useQueryContext } from './query-context'
|
||||
import { useSiteContext } from './site-context'
|
||||
import { isRealTimeDashboard } from './util/filters'
|
||||
import { useAppNavigate } from './navigation/use-app-navigate'
|
||||
import { parseSearch } from './util/url-search-params'
|
||||
|
||||
function DashboardStats({
|
||||
importedDataInView,
|
||||
|
|
@ -16,6 +20,36 @@ function DashboardStats({
|
|||
importedDataInView?: boolean
|
||||
updateImportedDataInView?: (v: boolean) => void
|
||||
}) {
|
||||
const navigate = useAppNavigate()
|
||||
const site = useSiteContext()
|
||||
|
||||
// Handler for navigation events delegated from LiveView dashboard.
|
||||
// Necessary to emulate navigation events in LiveView with pushState
|
||||
// manipulation disabled.
|
||||
const onLiveNavigate = useCallback(
|
||||
(e: CustomEvent) => {
|
||||
navigate({
|
||||
path: e.detail.path,
|
||||
search: () => parseSearch(e.detail.search)
|
||||
})
|
||||
},
|
||||
[navigate]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(
|
||||
'dashboard:live-navigate',
|
||||
onLiveNavigate as EventListener
|
||||
)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'dashboard:live-navigate',
|
||||
onLiveNavigate as EventListener
|
||||
)
|
||||
}
|
||||
}, [onLiveNavigate])
|
||||
|
||||
const statsBoxClass =
|
||||
'relative min-h-[436px] w-full mt-5 p-4 flex flex-col bg-white dark:bg-gray-900 shadow-sm rounded-md md:min-h-initial md:h-27.25rem md:w-[calc(50%-10px)] md:ml-[10px] md:mr-[10px] first:ml-0 last:mr-0'
|
||||
|
||||
|
|
@ -27,7 +61,14 @@ function DashboardStats({
|
|||
<Sources />
|
||||
</div>
|
||||
<div className={statsBoxClass}>
|
||||
<Pages />
|
||||
{site.flags.live_dashboard ? (
|
||||
<LiveViewPortal
|
||||
id="pages-breakdown-live"
|
||||
className="w-full h-full border-0 overflow-hidden"
|
||||
/>
|
||||
) : (
|
||||
<Pages />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export const SearchableSegmentsSection = ({
|
|||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
placeholderUnfocused="Press / to search"
|
||||
className="ml-auto w-full py-1 text-sm"
|
||||
className="ml-auto w-full py-1"
|
||||
onSearch={handleSearchInput}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ test('user can open and close filters dropdown', async () => {
|
|||
'Location',
|
||||
'Screen size',
|
||||
'Browser',
|
||||
'Operating System',
|
||||
'Operating system',
|
||||
'Goal'
|
||||
])
|
||||
await userEvent.click(toggleFilters)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,15 @@ export const useAppNavigate = () => {
|
|||
search,
|
||||
...options
|
||||
}: AppNavigationTarget & NavigateOptions) => {
|
||||
// Event dispatched for handling by LiveView dashboard via hook.
|
||||
// Necessary to emulate navigation events in LiveView with pushState
|
||||
// manipulation disabled.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('dashboard:live-navigate-back', {
|
||||
detail: { search: window.location.search }
|
||||
})
|
||||
)
|
||||
|
||||
return _navigate(getToOptions({ path, params, search }), options)
|
||||
},
|
||||
[getToOptions, _navigate]
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
|
|||
import { dateForSite, formatDayShort } from '../util/date'
|
||||
import { useSiteContext } from '../site-context'
|
||||
|
||||
type SegmentAuthorshipProps = { className?: string } & (
|
||||
| { showOnlyPublicData: true; segment: SavedSegmentPublic }
|
||||
| { showOnlyPublicData: false; segment: SavedSegment }
|
||||
)
|
||||
type SegmentAuthorshipProps = {
|
||||
className?: string
|
||||
showOnlyPublicData: boolean
|
||||
segment: SavedSegmentPublic | SavedSegment
|
||||
}
|
||||
|
||||
export function SegmentAuthorship({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -58,45 +58,6 @@ describe('Segment details modal - errors', () => {
|
|||
},
|
||||
message: `Segment not found with with ID "202020"`,
|
||||
siteOptions: { siteSegmentsAvailable: true }
|
||||
},
|
||||
{
|
||||
case: 'site segment is in list but not listable because site segments are not available',
|
||||
segments: [anyPersonalSegment, anySiteSegment],
|
||||
segmentId: anySiteSegment.id,
|
||||
user: {
|
||||
loggedIn: true,
|
||||
id: 1,
|
||||
role: Role.owner,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
message: `Segment not found with with ID "${anySiteSegment.id}"`,
|
||||
siteOptions: { siteSegmentsAvailable: false }
|
||||
},
|
||||
{
|
||||
case: 'personal segment is in list but not listable because it is a public dashboard',
|
||||
segments: [{ ...anyPersonalSegment, owner_id: null, owner_name: null }],
|
||||
segmentId: anyPersonalSegment.id,
|
||||
user: {
|
||||
loggedIn: false,
|
||||
id: null,
|
||||
role: Role.public,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
message: `Segment not found with with ID "${anyPersonalSegment.id}"`,
|
||||
siteOptions: { siteSegmentsAvailable: true }
|
||||
},
|
||||
{
|
||||
case: 'segment is in list and listable, but detailed view is not available because user is not logged in',
|
||||
segments: [{ ...anySiteSegment, owner_id: null, owner_name: null }],
|
||||
segmentId: anySiteSegment.id,
|
||||
user: {
|
||||
loggedIn: false,
|
||||
id: null,
|
||||
role: Role.public,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
message: 'Not enough permissions to see segment details',
|
||||
siteOptions: { siteSegmentsAvailable: true }
|
||||
}
|
||||
]
|
||||
it.each(cases)(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { ReactNode, useCallback, useState } from 'react'
|
||||
import ModalWithRouting from '../stats/modals/modal'
|
||||
import {
|
||||
canSeeSegmentDetails,
|
||||
isListableSegment,
|
||||
canExpandSegment,
|
||||
isSegmentFilter,
|
||||
SavedSegment,
|
||||
SEGMENT_TYPE_LABELS,
|
||||
|
|
@ -22,9 +21,9 @@ import { MutationStatus } from '@tanstack/react-query'
|
|||
import { ApiError } from '../api'
|
||||
import { ErrorPanel } from '../components/error-panel'
|
||||
import { useSegmentsContext } from '../filtering/segments-context'
|
||||
import { useSiteContext } from '../site-context'
|
||||
import { Role, UserContextValue, useUserContext } from '../user-context'
|
||||
import { removeFilterButtonClassname } from '../components/remove-filter-button'
|
||||
import { useSiteContext } from '../site-context'
|
||||
|
||||
interface ApiRequestProps {
|
||||
status: MutationStatus
|
||||
|
|
@ -501,9 +500,7 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
|
|||
const { query } = useQueryContext()
|
||||
const { segments } = useSegmentsContext()
|
||||
|
||||
const segment = segments
|
||||
.filter((s) => isListableSegment({ segment: s, site, user }))
|
||||
.find((s) => String(s.id) === String(id))
|
||||
const segment = segments.find((s) => String(s.id) === String(id))
|
||||
|
||||
let error: ApiError | null = null
|
||||
|
||||
|
|
@ -511,10 +508,6 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
|
|||
error = new ApiError(`Segment not found with with ID "${id}"`, {
|
||||
error: `Segment not found with with ID "${id}"`
|
||||
})
|
||||
} else if (!canSeeSegmentDetails({ user })) {
|
||||
error = new ApiError('Not enough permissions to see segment details', {
|
||||
error: `Not enough permissions to see segment details`
|
||||
})
|
||||
}
|
||||
|
||||
const data = !error ? segment : null
|
||||
|
|
@ -542,25 +535,27 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
|
|||
|
||||
<SegmentAuthorship
|
||||
segment={data}
|
||||
showOnlyPublicData={false}
|
||||
showOnlyPublicData={!user.loggedIn || user.role === Role.public}
|
||||
className="mt-4 text-sm"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ButtonsRow>
|
||||
<AppNavigationLink
|
||||
className={primaryNeutralButtonClassName}
|
||||
path={rootRoute.path}
|
||||
search={(s) => ({
|
||||
...s,
|
||||
filters: data.segment_data.filters,
|
||||
labels: data.segment_data.labels
|
||||
})}
|
||||
state={{
|
||||
expandedSegment: data
|
||||
}}
|
||||
>
|
||||
Edit segment
|
||||
</AppNavigationLink>
|
||||
{canExpandSegment({ segment: data, site, user }) && (
|
||||
<AppNavigationLink
|
||||
className={primaryNeutralButtonClassName}
|
||||
path={rootRoute.path}
|
||||
search={(s) => ({
|
||||
...s,
|
||||
filters: data.segment_data.filters,
|
||||
labels: data.segment_data.labels
|
||||
})}
|
||||
state={{
|
||||
expandedSegment: data
|
||||
}}
|
||||
>
|
||||
Edit segment
|
||||
</AppNavigationLink>
|
||||
)}
|
||||
|
||||
<AppNavigationLink
|
||||
className={removeFilterButtonClassname}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
|||
}
|
||||
|
||||
// Update this object when new feature flags are added to the frontend.
|
||||
type FeatureFlags = Record<never, boolean>
|
||||
type FeatureFlags = {
|
||||
live_dashboard?: boolean
|
||||
}
|
||||
|
||||
export const siteContextDefaultValue = {
|
||||
domain: '',
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function Bar({
|
|||
return (
|
||||
<div className="w-full h-full relative" style={style}>
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full ${bg || ''}`}
|
||||
className={`absolute top-0 left-0 h-full rounded-sm ${bg || ''}`}
|
||||
style={{ width: `${width}%` }}
|
||||
></div>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
|||
path: conversionsRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
color="bg-red-50"
|
||||
color="bg-red-50 group-hover/row:bg-red-100"
|
||||
colMinWidth={90}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrlFactory()}
|
||||
maybeHideDetails={true}
|
||||
color="bg-red-50"
|
||||
colMinWidth={90}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export const PROPS = 'props'
|
|||
export const FUNNELS = 'funnels'
|
||||
|
||||
export const sectionTitles = {
|
||||
[CONVERSIONS]: 'Goal Conversions',
|
||||
[PROPS]: 'Custom Properties',
|
||||
[CONVERSIONS]: 'Goal conversions',
|
||||
[PROPS]: 'Custom properties',
|
||||
[FUNNELS]: 'Funnels'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,8 +137,7 @@ export default function Properties({ afterFetchData }) {
|
|||
params: { propKey },
|
||||
search: (search) => search
|
||||
}}
|
||||
maybeHideDetails={true}
|
||||
color="bg-red-50"
|
||||
color="bg-red-50 group-hover/row:bg-red-100"
|
||||
colMinWidth={90}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,8 +76,9 @@ function Browsers({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -121,8 +122,9 @@ function BrowserVersions({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -187,9 +189,11 @@ function OperatingSystems({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { hiddenonMobile: true } })
|
||||
metrics.createPercentage({
|
||||
meta: { showOnHover: true, hiddenOnMobile: true }
|
||||
}),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -238,8 +242,9 @@ function OperatingSystemVersions({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +286,9 @@ function ScreenSizes({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -432,7 +438,7 @@ export default function Devices() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
<div className="flex justify-between w-full">
|
||||
<div className="flex gap-x-1">
|
||||
<h3 className="font-bold dark:text-gray-100">Devices</h3>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
export const METRIC_LABELS = {
|
||||
visitors: 'Visitors',
|
||||
pageviews: 'Pageviews',
|
||||
events: 'Total Conversions',
|
||||
views_per_visit: 'Views per Visit',
|
||||
events: 'Total conversions',
|
||||
views_per_visit: 'Views per visit',
|
||||
visits: 'Visits',
|
||||
bounce_rate: 'Bounce Rate',
|
||||
visit_duration: 'Visit Duration',
|
||||
conversions: 'Converted Visitors',
|
||||
conversion_rate: 'Conversion Rate',
|
||||
average_revenue: 'Average Revenue',
|
||||
total_revenue: 'Total Revenue',
|
||||
scroll_depth: 'Scroll Depth',
|
||||
time_on_page: 'Time on Page'
|
||||
bounce_rate: 'Bounce rate',
|
||||
visit_duration: 'Visit duration',
|
||||
conversions: 'Converted visitors',
|
||||
conversion_rate: 'Conversion rate',
|
||||
average_revenue: 'Average revenue',
|
||||
total_revenue: 'Total revenue',
|
||||
scroll_depth: 'Scroll depth',
|
||||
time_on_page: 'Time on page'
|
||||
}
|
||||
|
||||
function plottable(dataArray) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -54,7 +56,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -79,6 +81,8 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -93,7 +97,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -118,6 +122,8 @@ function Cities({ query, site, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -131,7 +137,7 @@ function Cities({ query, site, afterFetchData }) {
|
|||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -247,7 +253,7 @@ class Locations extends React.Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="flex gap-x-1">
|
||||
<h3 className="font-bold dark:text-gray-100">
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ const WorldMap = ({
|
|||
path: countriesRoute.path,
|
||||
search: (search: Record<string, unknown>) => search
|
||||
}}
|
||||
className={undefined}
|
||||
className="mt-3"
|
||||
onClick={undefined}
|
||||
/>
|
||||
{site.isDbip && <GeolocationNotice />}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import {
|
|||
useRememberOrderBy
|
||||
} from '../../hooks/use-order-by'
|
||||
import { Metric } from '../reports/metrics'
|
||||
import * as metricsModule from '../reports/metrics'
|
||||
import { BreakdownResultMeta, DashboardQuery } from '../../query'
|
||||
import { ColumnConfiguraton } from '../../components/table'
|
||||
import { BreakdownTable } from './breakdown-table'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { DrilldownLink, FilterInfo } from '../../components/drilldown-link'
|
||||
import { SharedReportProps } from '../reports/list'
|
||||
import { hasConversionGoalFilter } from '../../util/filters'
|
||||
|
||||
export type ReportInfo = {
|
||||
/** Title of the report to render on the top left. */
|
||||
|
|
@ -35,6 +37,8 @@ type BreakdownModalProps = {
|
|||
/** Function that must return a new query that contains appropriate search filter for searchValue param. */
|
||||
addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery
|
||||
searchEnabled?: boolean
|
||||
/** When true, keep the percentage metric as a permanently visible, sortable column. */
|
||||
showPercentageColumn?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +66,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
renderIcon,
|
||||
getExternalLinkUrl,
|
||||
searchEnabled = true,
|
||||
showPercentageColumn = false,
|
||||
afterFetchData,
|
||||
afterFetchNextPage,
|
||||
addSearchFilter,
|
||||
|
|
@ -71,20 +76,28 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
const { query } = useQueryContext()
|
||||
const [meta, setMeta] = useState<BreakdownResultMeta | null>(null)
|
||||
|
||||
const breakdownMetrics = useMemo(() => {
|
||||
const hasPercentage = metrics.some((m) => m.key === 'percentage')
|
||||
if (!hasPercentage && !hasConversionGoalFilter(query)) {
|
||||
return [...metrics, metricsModule.createPercentage()]
|
||||
}
|
||||
return metrics
|
||||
}, [metrics, query])
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const defaultOrderBy = getStoredOrderBy({
|
||||
domain: site.domain,
|
||||
reportInfo,
|
||||
metrics,
|
||||
metrics: breakdownMetrics,
|
||||
fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
|
||||
})
|
||||
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
|
||||
metrics,
|
||||
metrics: breakdownMetrics,
|
||||
defaultOrderBy
|
||||
})
|
||||
useRememberOrderBy({
|
||||
effectiveOrderBy: orderBy,
|
||||
metrics,
|
||||
metrics: breakdownMetrics,
|
||||
reportInfo
|
||||
})
|
||||
const apiState = usePaginatedGetAPI<
|
||||
|
|
@ -125,7 +138,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
{
|
||||
label: reportInfo.dimensionLabel,
|
||||
key: 'name',
|
||||
width: 'w-48 md:w-full flex items-center break-all',
|
||||
width: 'w-40 md:w-48',
|
||||
align: 'left',
|
||||
renderItem: (item) => (
|
||||
<NameCell
|
||||
|
|
@ -136,29 +149,39 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
/>
|
||||
)
|
||||
},
|
||||
...metrics.map(
|
||||
(m): ColumnConfiguraton<TListItem> => ({
|
||||
label: m.renderLabel(query),
|
||||
key: m.key,
|
||||
width: m.width,
|
||||
align: 'right',
|
||||
metricWarning: getMetricWarning(m, meta),
|
||||
renderValue: (item) => m.renderValue(item, meta),
|
||||
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
|
||||
sortDirection: orderByDictionary[m.key]
|
||||
})
|
||||
)
|
||||
...breakdownMetrics
|
||||
.filter((m) => showPercentageColumn || m.key !== 'percentage')
|
||||
.map(
|
||||
(m): ColumnConfiguraton<TListItem> => ({
|
||||
label: m.renderLabel(query),
|
||||
key: m.key,
|
||||
width: m.width,
|
||||
align: 'right',
|
||||
metricWarning: getMetricWarning(m, meta),
|
||||
renderValue: (item, isRowHovered) =>
|
||||
m.renderValue(
|
||||
showPercentageColumn && m.key === 'visitors'
|
||||
? { ...item, percentage: null }
|
||||
: item,
|
||||
meta,
|
||||
{ detailedView: true, isRowHovered }
|
||||
),
|
||||
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
|
||||
sortDirection: orderByDictionary[m.key]
|
||||
})
|
||||
)
|
||||
],
|
||||
[
|
||||
reportInfo.dimensionLabel,
|
||||
metrics,
|
||||
breakdownMetrics,
|
||||
getFilterInfo,
|
||||
query,
|
||||
orderByDictionary,
|
||||
toggleSortByMetric,
|
||||
renderIcon,
|
||||
getExternalLinkUrl,
|
||||
meta
|
||||
meta,
|
||||
showPercentageColumn
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -190,7 +213,7 @@ const NameCell = <TListItem extends { name: string }>({
|
|||
renderIcon?: (item: TListItem) => ReactNode
|
||||
getExternalLinkUrl?: (listItem: TListItem) => string
|
||||
}) => (
|
||||
<>
|
||||
<div className="max-w-full break-all flex items-center">
|
||||
{typeof renderIcon === 'function' && renderIcon(item)}
|
||||
<DrilldownLink
|
||||
path={rootRoute.path}
|
||||
|
|
@ -203,7 +226,7 @@ const NameCell = <TListItem extends { name: string }>({
|
|||
{typeof getExternalLinkUrl === 'function' && (
|
||||
<ExternalLinkIcon url={getExternalLinkUrl(item)} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ExternalLinkIcon = ({ url }: { url?: string }) =>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { ReactNode, useRef } from 'react'
|
||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
import { SearchInput } from '../../components/search-input'
|
||||
import { ColumnConfiguraton, Table } from '../../components/table'
|
||||
import RocketIcon from './rocket-icon'
|
||||
import { QueryStatus } from '@tanstack/react-query'
|
||||
|
||||
const MIN_HEIGHT_PX = 500
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||
import { rootRoute } from '../../router'
|
||||
|
||||
export const BreakdownTable = <TListItem extends { name: string }>({
|
||||
title,
|
||||
|
|
@ -19,7 +20,8 @@ export const BreakdownTable = <TListItem extends { name: string }>({
|
|||
data,
|
||||
status,
|
||||
error,
|
||||
displayError
|
||||
displayError,
|
||||
onClose
|
||||
}: {
|
||||
title: ReactNode
|
||||
onSearch?: (input: string) => void
|
||||
|
|
@ -34,28 +36,42 @@ export const BreakdownTable = <TListItem extends { name: string }>({
|
|||
error?: Error | null
|
||||
/** Controls whether the component displays API request errors or ignores them. */
|
||||
displayError?: boolean
|
||||
onClose?: () => void
|
||||
}) => {
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useAppNavigate()
|
||||
const handleClose =
|
||||
onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s }))
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h1 className="text-xl font-bold dark:text-gray-100">{title}</h1>
|
||||
<>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<h1 className="shrink-0 mb-0.5 text-base md:text-lg font-bold dark:text-gray-100">
|
||||
{title}
|
||||
</h1>
|
||||
{!isPending && isFetching && <SmallLoadingSpinner />}
|
||||
{!!onSearch && (
|
||||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
onSearch={onSearch}
|
||||
className={
|
||||
displayError && status === 'error' ? 'pointer-events-none' : ''
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!onSearch && (
|
||||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
onSearch={onSearch}
|
||||
className={
|
||||
displayError && status === 'error' ? 'pointer-events-none' : ''
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close modal"
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="my-4 border-b border-gray-300 dark:border-gray-700"></div>
|
||||
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
|
||||
<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} />}
|
||||
{isPending && <InitialLoadingSpinner />}
|
||||
{data && <Table<TListItem> data={data} columns={columns} />}
|
||||
|
|
@ -66,15 +82,12 @@ export const BreakdownTable = <TListItem extends { name: string }>({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const InitialLoadingSpinner = () => (
|
||||
<div
|
||||
className="w-full h-full flex flex-col justify-center"
|
||||
style={{ minHeight: `${MIN_HEIGHT_PX}px` }}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col justify-center">
|
||||
<div className="mx-auto loading">
|
||||
<div />
|
||||
</div>
|
||||
|
|
@ -88,10 +101,7 @@ const SmallLoadingSpinner = () => (
|
|||
)
|
||||
|
||||
const ErrorMessage = ({ error }: { error?: unknown }) => (
|
||||
<div
|
||||
className="grid grid-rows-2 text-gray-700 dark:text-gray-300"
|
||||
style={{ height: `${MIN_HEIGHT_PX}px` }}
|
||||
>
|
||||
<div className="grid grid-rows-2 text-gray-700 dark:text-gray-300">
|
||||
<div className="text-center self-end">
|
||||
<RocketIcon />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ function ConversionsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Goal Conversions',
|
||||
title: 'Goal conversions',
|
||||
dimension: 'goal',
|
||||
endpoint: url.apiPath(site, '/conversions'),
|
||||
dimensionLabel: 'Goal'
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function BrowserVersionsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Browser Versions',
|
||||
title: 'Browser versions',
|
||||
dimension: 'browser_version',
|
||||
endpoint: url.apiPath(site, '/browser-versions'),
|
||||
dimensionLabel: 'Browser version',
|
||||
|
|
@ -52,7 +52,7 @@ function BrowserVersionsModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function BrowsersModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,31 @@ import {
|
|||
hasConversionGoalFilter,
|
||||
isRealTimeDashboard
|
||||
} from '../../../util/filters'
|
||||
import { revenueAvailable } from '../../../query'
|
||||
import * as metrics from '../../reports/metrics'
|
||||
|
||||
export default function chooseMetrics(query) {
|
||||
export default function chooseMetrics(query, site) {
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
if (hasConversionGoalFilter(query)) {
|
||||
return [
|
||||
metrics.createTotalVisitors(),
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
width: 'w-32 md:w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
}),
|
||||
metrics.createPercentage()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function OperatingSystemVersionsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Operating System Versions',
|
||||
title: 'Operating system versions',
|
||||
dimension: 'os_version',
|
||||
endpoint: url.apiPath(site, '/operating-system-versions'),
|
||||
dimensionLabel: 'Operating system version',
|
||||
|
|
@ -49,7 +49,7 @@ function OperatingSystemVersionsModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function OperatingSystemsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Operating Systems',
|
||||
title: 'Operating systems',
|
||||
dimension: 'os',
|
||||
endpoint: url.apiPath(site, '/operating-systems'),
|
||||
dimensionLabel: 'Operating system',
|
||||
|
|
@ -49,7 +49,7 @@ function OperatingSystemsModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ function ScreenSizesModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Screen Sizes',
|
||||
title: 'Screen sizes',
|
||||
dimension: 'screen',
|
||||
endpoint: url.apiPath(site, '/screen-sizes'),
|
||||
dimensionLabel: 'Screen size',
|
||||
|
|
@ -39,7 +39,7 @@ function ScreenSizesModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
searchEnabled={false}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
hasConversionGoalFilter,
|
||||
isRealTimeDashboard
|
||||
} from '../../util/filters'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
|
|
@ -16,8 +16,11 @@ function EntryPagesModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Entry Pages',
|
||||
title: 'Entry pages',
|
||||
dimension: 'entry_page',
|
||||
endpoint: url.apiPath(site, '/entry-pages'),
|
||||
dimensionLabel: 'Entry page',
|
||||
|
|
@ -54,15 +57,17 @@ function EntryPagesModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
@ -70,8 +75,8 @@ function EntryPagesModal() {
|
|||
return [
|
||||
metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }),
|
||||
metrics.createVisits({
|
||||
renderLabel: (_query) => 'Total Entrances',
|
||||
width: 'w-36'
|
||||
renderLabel: (_query) => 'Total entrances',
|
||||
width: 'w-32'
|
||||
}),
|
||||
metrics.createBounceRate(),
|
||||
metrics.createVisitDuration()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import Modal from './modal'
|
||||
import { hasConversionGoalFilter } from '../../util/filters'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
|
|
@ -13,8 +13,11 @@ function ExitPagesModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Exit Pages',
|
||||
title: 'Exit pages',
|
||||
dimension: 'exit_page',
|
||||
endpoint: url.apiPath(site, '/exit-pages'),
|
||||
dimensionLabel: 'Page url',
|
||||
|
|
@ -51,15 +54,17 @@ function ExitPagesModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (query.period === 'realtime') {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
@ -70,7 +75,8 @@ function ExitPagesModal() {
|
|||
sortable: true
|
||||
}),
|
||||
metrics.createVisits({
|
||||
renderLabel: (_query) => 'Total Exits',
|
||||
renderLabel: (_query) => 'Total exits',
|
||||
width: 'w-32',
|
||||
sortable: true
|
||||
}),
|
||||
metrics.createExitRate()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react'
|
||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import Modal from './modal'
|
||||
|
|
@ -68,6 +69,7 @@ class FilterModal extends React.Component {
|
|||
)
|
||||
|
||||
this.handleKeydown = this.handleKeydown.bind(this)
|
||||
this.closeModal = this.closeModal.bind(this)
|
||||
this.state = {
|
||||
query,
|
||||
filterState,
|
||||
|
|
@ -108,6 +110,13 @@ class FilterModal extends React.Component {
|
|||
)
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.props.navigate({
|
||||
path: rootRoute.path,
|
||||
search: (search) => search
|
||||
})
|
||||
}
|
||||
|
||||
selectFiltersAndCloseModal(filters) {
|
||||
this.props.navigate({
|
||||
path: rootRoute.path,
|
||||
|
|
@ -169,13 +178,23 @@ class FilterModal extends React.Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<Modal maxWidth="460px">
|
||||
<h1 className="text-xl font-bold dark:text-gray-100">
|
||||
Filter by {formatFilterGroup(this.props.modalType)}
|
||||
</h1>
|
||||
<Modal maxWidth="460px" onClose={this.closeModal}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-base md:text-lg font-bold dark:text-gray-100">
|
||||
Filter by {formatFilterGroup(this.props.modalType)}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.closeModal}
|
||||
aria-label="Close modal"
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-b border-gray-300 dark:border-gray-700"></div>
|
||||
<main className="modal__content">
|
||||
<div className="mt-2 md:mt-4 border-b border-gray-300 dark:border-gray-700"></div>
|
||||
<main>
|
||||
<form
|
||||
className="flex flex-col"
|
||||
onSubmit={this.handleSubmit.bind(this)}
|
||||
|
|
@ -192,7 +211,7 @@ class FilterModal extends React.Component {
|
|||
/>
|
||||
))}
|
||||
|
||||
<div className="mt-6 flex gap-x-4 items-center justify-start">
|
||||
<div className="mt-6 mb-3 flex gap-x-4 items-center justify-start">
|
||||
<button
|
||||
type="submit"
|
||||
className="button !px-3"
|
||||
|
|
|
|||
|
|
@ -7,26 +7,26 @@ import * as metrics from '../reports/metrics'
|
|||
import * as url from '../../util/url'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import { SortDirection } from '../../hooks/use-order-by'
|
||||
|
||||
const VIEWS = {
|
||||
countries: {
|
||||
title: 'Top Countries',
|
||||
title: 'Top countries',
|
||||
dimension: 'country',
|
||||
endpoint: '/countries',
|
||||
dimensionLabel: 'Country',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
},
|
||||
regions: {
|
||||
title: 'Top Regions',
|
||||
title: 'Top regions',
|
||||
dimension: 'region',
|
||||
endpoint: '/regions',
|
||||
dimensionLabel: 'Region',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
},
|
||||
cities: {
|
||||
title: 'Top Cities',
|
||||
title: 'Top cities',
|
||||
dimension: 'city',
|
||||
endpoint: '/cities',
|
||||
dimensionLabel: 'City',
|
||||
|
|
@ -38,6 +38,9 @@ function LocationsModal({ currentView }) {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
let reportInfo = VIEWS[currentView]
|
||||
reportInfo = {
|
||||
...reportInfo,
|
||||
|
|
@ -75,15 +78,17 @@ function LocationsModal({ currentView }) {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (query.period === 'realtime') {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,8 @@ import { createPortal } from 'react-dom'
|
|||
import { isModifierPressed, isTyping, Keybind } from '../../keybinding'
|
||||
import { rootRoute } from '../../router'
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||
|
||||
// This corresponds to the 'md' breakpoint on TailwindCSS.
|
||||
const MD_WIDTH = 768
|
||||
// We assume that the dashboard is by default opened on a desktop. This is also a fall-back for when, for any reason, the width is not ascertained.
|
||||
const DEFAULT_WIDTH = 1080
|
||||
|
||||
class Modal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
|
@ -27,26 +23,21 @@ class Modal extends React.Component {
|
|||
window.addEventListener('resize', this.handleResize, false)
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.body.style.overflow = null
|
||||
document.body.style.height = null
|
||||
document.removeEventListener('mousedown', this.handleClickOutside)
|
||||
window.removeEventListener('resize', this.handleResize, false)
|
||||
}
|
||||
|
||||
handleClickOutside(e) {
|
||||
if (this.node.current.contains(e.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onClose()
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.setState({ viewport: window.innerWidth })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description
|
||||
* Decide whether to set max-width, and if so, to what.
|
||||
|
|
@ -56,12 +47,11 @@ class Modal extends React.Component {
|
|||
*/
|
||||
getStyle() {
|
||||
const { maxWidth } = this.props
|
||||
const { viewport } = this.state
|
||||
const styleObject = {}
|
||||
if (maxWidth) {
|
||||
styleObject.maxWidth = maxWidth
|
||||
} else {
|
||||
styleObject.width = viewport <= MD_WIDTH ? 'min-content' : '860px'
|
||||
styleObject.maxWidth = '880px'
|
||||
}
|
||||
return styleObject
|
||||
}
|
||||
|
|
@ -78,16 +68,17 @@ class Modal extends React.Component {
|
|||
/>
|
||||
<div className="modal is-open" onClick={this.props.onClick}>
|
||||
<div className="modal__overlay">
|
||||
<button className="modal__close"></button>
|
||||
<div
|
||||
ref={this.node}
|
||||
className="modal__container dark:bg-gray-900 focus:outline-hidden"
|
||||
style={this.getStyle()}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
>
|
||||
<FocusOnMount focusableRef={this.node} />
|
||||
{this.props.children}
|
||||
<div className="[--gap:1rem] sm:[--gap:2rem] md:[--gap:4rem] flex h-full w-full items-center md:items-start justify-center p-[var(--gap)] box-border">
|
||||
<div
|
||||
ref={this.node}
|
||||
className="max-h-[calc(100dvh_-_var(--gap)*2)] min-h-[66vh] md:min-h-120 w-full flex flex-col bg-white p-3 md:px-6 md:py-4 overflow-hidden box-border transition-[height] duration-200 ease-in shadow-2xl rounded-lg dark:bg-gray-900 focus:outline-hidden"
|
||||
style={this.getStyle()}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
>
|
||||
<FocusOnMount focusableRef={this.node} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
hasConversionGoalFilter,
|
||||
isRealTimeDashboard
|
||||
} from '../../util/filters'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
|
|
@ -16,8 +16,11 @@ function PagesModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Top Pages',
|
||||
title: 'Top pages',
|
||||
dimension: 'page',
|
||||
endpoint: url.apiPath(site, '/pages'),
|
||||
dimensionLabel: 'Page url',
|
||||
|
|
@ -54,15 +57,17 @@ function PagesModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ function PropsModal() {
|
|||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
|
||||
title: specialTitleWhenGoalFilter(query, 'Custom property breakdown'),
|
||||
dimension: propKey,
|
||||
endpoint: url.apiPath(
|
||||
site,
|
||||
|
|
@ -71,6 +71,7 @@ function PropsModal() {
|
|||
metrics={chooseMetrics()}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
showPercentageColumn
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { SortDirection } from '../../hooks/use-order-by'
|
||||
|
|
@ -20,6 +20,9 @@ function ReferrerDrilldownModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Referrer Drilldown',
|
||||
dimension: 'referrer',
|
||||
|
|
@ -61,15 +64,17 @@ function ReferrerDrilldownModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { SortDirection } from '../../hooks/use-order-by'
|
||||
|
|
@ -16,7 +16,7 @@ import { SourceFavicon } from '../sources/source-favicon'
|
|||
const VIEWS = {
|
||||
sources: {
|
||||
info: {
|
||||
title: 'Top Sources',
|
||||
title: 'Top sources',
|
||||
dimension: 'source',
|
||||
endpoint: '/sources',
|
||||
dimensionLabel: 'Source',
|
||||
|
|
@ -33,7 +33,7 @@ const VIEWS = {
|
|||
},
|
||||
channels: {
|
||||
info: {
|
||||
title: 'Top Acquisition Channels',
|
||||
title: 'Top acquisition channels',
|
||||
dimension: 'channel',
|
||||
endpoint: '/channels',
|
||||
dimensionLabel: 'Channel',
|
||||
|
|
@ -42,46 +42,46 @@ const VIEWS = {
|
|||
},
|
||||
utm_mediums: {
|
||||
info: {
|
||||
title: 'Top UTM Mediums',
|
||||
title: 'Top UTM mediums',
|
||||
dimension: 'utm_medium',
|
||||
endpoint: '/utm_mediums',
|
||||
dimensionLabel: 'UTM Medium',
|
||||
dimensionLabel: 'UTM medium',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_sources: {
|
||||
info: {
|
||||
title: 'Top UTM Sources',
|
||||
title: 'Top UTM sources',
|
||||
dimension: 'utm_source',
|
||||
endpoint: '/utm_sources',
|
||||
dimensionLabel: 'UTM Source',
|
||||
dimensionLabel: 'UTM source',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_campaigns: {
|
||||
info: {
|
||||
title: 'Top UTM Campaigns',
|
||||
title: 'Top UTM campaigns',
|
||||
dimension: 'utm_campaign',
|
||||
endpoint: '/utm_campaigns',
|
||||
dimensionLabel: 'UTM Campaign',
|
||||
dimensionLabel: 'UTM campaign',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_contents: {
|
||||
info: {
|
||||
title: 'Top UTM Contents',
|
||||
title: 'Top UTM contents',
|
||||
dimension: 'utm_content',
|
||||
endpoint: '/utm_contents',
|
||||
dimensionLabel: 'UTM Content',
|
||||
dimensionLabel: 'UTM content',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_terms: {
|
||||
info: {
|
||||
title: 'Top UTM Terms',
|
||||
title: 'Top UTM terms',
|
||||
dimension: 'utm_term',
|
||||
endpoint: '/utm_terms',
|
||||
dimensionLabel: 'UTM Term',
|
||||
dimensionLabel: 'UTM term',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,9 @@ function SourcesModal({ currentView }) {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
let reportInfo = VIEWS[currentView].info
|
||||
reportInfo = {
|
||||
...reportInfo,
|
||||
|
|
@ -127,15 +130,17 @@ function SourcesModal({ currentView }) {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ function EntryPages({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
defaultLabel: 'Unique Entrances',
|
||||
defaultLabel: 'Unique entrances',
|
||||
width: 'w-36',
|
||||
meta: { plot: true }
|
||||
}),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -53,7 +55,7 @@ function EntryPages({ afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -79,10 +81,12 @@ function ExitPages({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
defaultLabel: 'Unique Exits',
|
||||
defaultLabel: 'Unique exits',
|
||||
width: 'w-36',
|
||||
meta: { plot: true }
|
||||
}),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -99,7 +103,7 @@ function ExitPages({ afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -125,6 +129,8 @@ function TopPages({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -141,15 +147,15 @@ function TopPages({ afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const labelFor = {
|
||||
pages: 'Top Pages',
|
||||
'entry-pages': 'Entry Pages',
|
||||
'exit-pages': 'Exit Pages'
|
||||
pages: 'Top pages',
|
||||
'entry-pages': 'Entry pages',
|
||||
'exit-pages': 'Exit pages'
|
||||
}
|
||||
|
||||
export default function Pages() {
|
||||
|
|
@ -187,7 +193,7 @@ export default function Pages() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
{/* Header Container */}
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="flex gap-x-1">
|
||||
|
|
@ -201,9 +207,9 @@ export default function Pages() {
|
|||
</div>
|
||||
<TabWrapper>
|
||||
{[
|
||||
{ label: 'Top Pages', value: 'pages' },
|
||||
{ label: 'Entry Pages', value: 'entry-pages' },
|
||||
{ label: 'Exit Pages', value: 'exit-pages' }
|
||||
{ label: 'Top pages', value: 'pages' },
|
||||
{ label: 'Entry pages', value: 'entry-pages' },
|
||||
{ label: 'Exit pages', value: 'exit-pages' }
|
||||
].map(({ value, label }) => (
|
||||
<TabButton
|
||||
active={mode === value}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ it('renders tilde for no change', () => {
|
|||
|
||||
const arrowElement = screen.getByTestId('change-arrow')
|
||||
|
||||
expect(arrowElement).toHaveTextContent('〰 0%')
|
||||
expect(arrowElement).toHaveTextContent('0%')
|
||||
})
|
||||
|
||||
it('inverts colors for positive bounce_rate change', () => {
|
||||
|
|
|
|||
|
|
@ -15,24 +15,22 @@ export function ChangeArrow({
|
|||
className: string
|
||||
hideNumber?: boolean
|
||||
}) {
|
||||
const formattedChange = hideNumber
|
||||
? null
|
||||
: ` ${numberShortFormatter(Math.abs(change))}%`
|
||||
|
||||
let icon = null
|
||||
const arrowClassName = classNames(
|
||||
color(change, metric),
|
||||
'inline-block h-3 w-3 stroke-[1px] stroke-current'
|
||||
'mb-0.5 inline-block size-3 stroke-[1px] stroke-current'
|
||||
)
|
||||
|
||||
if (change > 0) {
|
||||
icon = <ArrowUpRightIcon className={arrowClassName} />
|
||||
} else if (change < 0) {
|
||||
icon = <ArrowDownRightIcon className={arrowClassName} />
|
||||
} else if (change === 0 && !hideNumber) {
|
||||
icon = <>〰</>
|
||||
}
|
||||
|
||||
const formattedChange = hideNumber
|
||||
? null
|
||||
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
|
||||
|
||||
return (
|
||||
<span className={className} data-testid="change-arrow">
|
||||
{icon}
|
||||
|
|
|
|||
|
|
@ -26,27 +26,34 @@ const COL_MIN_WIDTH = 70
|
|||
|
||||
function ExternalLink<T>({
|
||||
item,
|
||||
getExternalLinkUrl
|
||||
getExternalLinkUrl,
|
||||
isTapped
|
||||
}: {
|
||||
item: T
|
||||
getExternalLinkUrl?: (item: T) => string
|
||||
isTapped?: boolean
|
||||
}) {
|
||||
const dest = getExternalLinkUrl && getExternalLinkUrl(item)
|
||||
if (dest) {
|
||||
const className = isTapped
|
||||
? 'visible md:invisible md:group-hover/row:visible'
|
||||
: 'invisible md:group-hover/row:visible'
|
||||
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={dest}
|
||||
className="w-4 h-4 invisible group-hover:visible"
|
||||
>
|
||||
<a target="_blank" rel="noreferrer" href={dest} className={className}>
|
||||
<svg
|
||||
className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline size-3.5 mb-0.5 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<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>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4M12 12l9-9-.303.303M14 3h7v7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
|
|
@ -88,11 +95,6 @@ type ListReportProps = {
|
|||
colMinWidth?: number
|
||||
/** Navigation props to be passed to "More" link, if any. */
|
||||
detailsLinkProps?: AppNavigationLinkProps
|
||||
/** Set this to `true` if the details button should be hidden on
|
||||
* the condition that there are less than MAX_ITEMS entries in the list (i.e. nothing
|
||||
* more to show).
|
||||
*/
|
||||
maybeHideDetails?: boolean
|
||||
/** Function with additional action to be taken when a list entry is clicked. */
|
||||
onClick?: () => void
|
||||
/** Color of the comparison bars in light-mode. */
|
||||
|
|
@ -114,7 +116,6 @@ export default function ListReport<
|
|||
colMinWidth = COL_MIN_WIDTH,
|
||||
afterFetchData,
|
||||
detailsLinkProps,
|
||||
maybeHideDetails,
|
||||
onClick,
|
||||
color,
|
||||
getFilterInfo,
|
||||
|
|
@ -129,6 +130,7 @@ export default function ListReport<
|
|||
meta: BreakdownResultMeta | null
|
||||
}>({ loading: true, list: null, meta: null })
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [tappedRow, setTappedRow] = useState<string | null>(null)
|
||||
|
||||
const isRealtime = isRealTimeDashboard(query)
|
||||
const goalFilterApplied = hasConversionGoalFilter(query)
|
||||
|
|
@ -194,6 +196,38 @@ export default function ListReport<
|
|||
}
|
||||
}
|
||||
|
||||
function showOnHoverClass(metric: Metric, listItemName: string) {
|
||||
if (!metric.meta.showOnHover) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// On mobile: show if row is tapped, hide otherwise
|
||||
// On desktop: slide in from right when hovering
|
||||
if (tappedRow === listItemName) {
|
||||
return 'translate-x-0 opacity-100 transition-all duration-150'
|
||||
} else {
|
||||
return 'translate-x-[100%] opacity-0 transition-all duration-150 md:group-hover/report:translate-x-0 md:group-hover/report:opacity-100'
|
||||
}
|
||||
}
|
||||
|
||||
function slideLeftClass(
|
||||
metricIndex: number,
|
||||
showOnHoverIndex: number,
|
||||
hasShowOnHoverMetric: boolean,
|
||||
listItemName: string
|
||||
) {
|
||||
// Columns before the showOnHover column should slide left when it appears
|
||||
if (!hasShowOnHoverMetric || metricIndex >= showOnHoverIndex) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (tappedRow === listItemName) {
|
||||
return 'transition-transform duration-150 translate-x-0'
|
||||
} else {
|
||||
return 'transition-transform duration-150 translate-x-[100%] md:group-hover/report:translate-x-0'
|
||||
}
|
||||
}
|
||||
|
||||
function renderReport() {
|
||||
if (state.list && state.list.length > 0) {
|
||||
return (
|
||||
|
|
@ -206,16 +240,14 @@ export default function ListReport<
|
|||
</FlipMove>
|
||||
</div>
|
||||
|
||||
{!!detailsLinkProps &&
|
||||
!state.loading &&
|
||||
!(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && (
|
||||
<MoreLink
|
||||
onClick={undefined}
|
||||
className={'mt-2'}
|
||||
linkProps={detailsLinkProps}
|
||||
list={state.list}
|
||||
/>
|
||||
)}
|
||||
{!!detailsLinkProps && !state.loading && (
|
||||
<MoreLink
|
||||
onClick={undefined}
|
||||
className={'mt-3'}
|
||||
linkProps={detailsLinkProps}
|
||||
list={state.list}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -223,20 +255,22 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderReportHeader() {
|
||||
const metricLabels = getAvailableMetrics().map((metric) => {
|
||||
return (
|
||||
<div
|
||||
key={metric.key}
|
||||
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
|
||||
style={{ minWidth: colMinWidth }}
|
||||
>
|
||||
{metric.renderLabel(query)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
const metricLabels = getAvailableMetrics()
|
||||
.filter((metric) => !metric.meta.showOnHover)
|
||||
.map((metric) => {
|
||||
return (
|
||||
<div
|
||||
key={metric.key}
|
||||
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
|
||||
style={{ minWidth: colMinWidth }}
|
||||
>
|
||||
{metric.renderLabel(query)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
|
||||
<div className="pt-3 w-full text-xs font-semibold text-gray-500 flex items-center dark:text-gray-400">
|
||||
<span className="grow truncate">{keyLabel}</span>
|
||||
{metricLabels}
|
||||
</div>
|
||||
|
|
@ -244,11 +278,22 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderRow(listItem: TListItem) {
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
|
||||
if (tappedRow === listItem.name) {
|
||||
setTappedRow(null)
|
||||
} else {
|
||||
setTappedRow(listItem.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
|
||||
<div
|
||||
className="flex w-full items-center"
|
||||
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"
|
||||
style={{ marginTop: ROW_GAP_HEIGHT }}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{renderBarFor(listItem)}
|
||||
{renderMetricValuesFor(listItem)}
|
||||
|
|
@ -258,7 +303,7 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderBarFor(listItem: TListItem) {
|
||||
const lightBackground = color || 'bg-green-50'
|
||||
const lightBackground = color || 'bg-green-50 group-hover/row:bg-green-100'
|
||||
const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key
|
||||
|
||||
return (
|
||||
|
|
@ -267,10 +312,10 @@ export default function ListReport<
|
|||
maxWidthDeduction={undefined}
|
||||
count={listItem[metricToPlot]}
|
||||
all={state.list}
|
||||
bg={`${lightBackground} dark:bg-gray-500/15`}
|
||||
bg={`${lightBackground} dark:bg-gray-500/15 dark:group-hover/row:bg-gray-500/30`}
|
||||
plot={metricToPlot}
|
||||
>
|
||||
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
|
||||
<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">
|
||||
<DrilldownLink
|
||||
filterInfo={getFilterInfo(listItem)}
|
||||
onClick={onClick}
|
||||
|
|
@ -285,6 +330,7 @@ export default function ListReport<
|
|||
<ExternalLink
|
||||
item={listItem}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
isTapped={tappedRow === listItem.name}
|
||||
/>
|
||||
</div>
|
||||
</Bar>
|
||||
|
|
@ -299,19 +345,36 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderMetricValuesFor(listItem: TListItem) {
|
||||
return getAvailableMetrics().map((metric) => {
|
||||
return (
|
||||
<div
|
||||
key={`${listItem.name}__${metric.key}`}
|
||||
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
||||
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
||||
>
|
||||
<span className="font-medium text-sm dark:text-gray-200 text-right">
|
||||
{metric.renderValue(listItem, state.meta)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
const availableMetrics = getAvailableMetrics()
|
||||
const showOnHoverIndex = availableMetrics.findIndex(
|
||||
(m) => m.meta.showOnHover
|
||||
)
|
||||
const hasShowOnHoverMetric = showOnHoverIndex !== -1
|
||||
|
||||
return (
|
||||
<>
|
||||
{availableMetrics.map((metric, index) => {
|
||||
const isShowOnHover = metric.meta.showOnHover
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${listItem.name}__${metric.key}`}
|
||||
className={`text-right ${hiddenOnMobileClass(metric)} ${showOnHoverClass(metric, listItem.name)} ${slideLeftClass(index, showOnHoverIndex, hasShowOnHoverMetric, listItem.name)}`}
|
||||
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
||||
>
|
||||
<span
|
||||
className={`font-medium text-sm text-right ${isShowOnHover ? 'text-gray-500 group-hover/row:text-gray-800 dark:group-hover/row:text-gray-200' : 'text-gray-800 dark:text-gray-200'}`}
|
||||
>
|
||||
{metric.renderValue(listItem, state.meta, {
|
||||
detailedView: false,
|
||||
isRowHovered: false
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ const REVENUE = { long: '$1,659.50', short: '$1.7K' }
|
|||
|
||||
describe('single value', () => {
|
||||
it('renders small value', async () => {
|
||||
await renderWithTooltip(<MetricValue {...valueProps('visitors', 10)} />)
|
||||
render(<MetricValue {...valueProps('visitors', 10)} />)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('10')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('10')
|
||||
})
|
||||
|
||||
it('renders large value', async () => {
|
||||
|
|
@ -25,23 +24,19 @@ describe('single value', () => {
|
|||
})
|
||||
|
||||
it('renders percentages', async () => {
|
||||
await renderWithTooltip(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
|
||||
render(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3%')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3%')
|
||||
})
|
||||
|
||||
it('renders durations', async () => {
|
||||
await renderWithTooltip(
|
||||
<MetricValue {...valueProps('visit_duration', 60)} />
|
||||
)
|
||||
render(<MetricValue {...valueProps('visit_duration', 60)} />)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s')
|
||||
})
|
||||
|
||||
it('renders with custom formatter', async () => {
|
||||
await renderWithTooltip(
|
||||
render(
|
||||
<MetricValue
|
||||
{...valueProps('test_money', 5.3)}
|
||||
formatter={(value) => `${value}$`}
|
||||
|
|
@ -49,7 +44,6 @@ describe('single value', () => {
|
|||
)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3$')
|
||||
})
|
||||
|
||||
it('renders revenue properly', async () => {
|
||||
|
|
@ -80,9 +74,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10 visitors',
|
||||
'↑ 100%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'↑ 100%',
|
||||
'5 visitors',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -98,9 +91,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'5 visitors',
|
||||
'↓ 50%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'↓ 50%',
|
||||
'10 visitors',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -116,9 +108,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10 visitors',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'0%',
|
||||
'10 visitors',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -136,9 +127,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10 conversions',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'0%',
|
||||
'10 conversions',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -154,14 +144,7 @@ describe('comparisons', () => {
|
|||
)
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10% ',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'10% ',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
['10% ', '01 Aug - 31 Aug', '0%', '10% ', '01 July - 31 July'].join('')
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -177,9 +160,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10$ test',
|
||||
'↑ 100%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'↑ 100%',
|
||||
'5$ test',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -200,9 +182,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'$1,659.50 average_revenue',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'0%',
|
||||
'$1,659.50 average_revenue',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useRef, useEffect } from 'react'
|
||||
import { Metric } from '../../../types/query-api'
|
||||
import { Tooltip } from '../../util/tooltip'
|
||||
import { ChangeArrow } from './change-arrow'
|
||||
|
|
@ -36,23 +36,84 @@ export default function MetricValue(props: {
|
|||
renderLabel: (query: DashboardQuery) => string
|
||||
formatter?: (value: ValueType) => string
|
||||
meta: BreakdownResultMeta | null
|
||||
detailedView?: boolean
|
||||
isRowHovered?: boolean
|
||||
}) {
|
||||
const { query } = useQueryContext()
|
||||
const portalRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const { metric, listItem } = props
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
portalRef.current = document.body
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { metric, listItem, detailedView = false, isRowHovered = false } = props
|
||||
const { value, comparison } = useMemo(
|
||||
() => valueRenderProps(listItem, metric),
|
||||
[listItem, metric]
|
||||
)
|
||||
const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
|
||||
const shortFormatter = props.formatter ?? MetricFormatterShort[metric]
|
||||
const longFormatter = props.formatter ?? MetricFormatterLong[metric]
|
||||
|
||||
const isAbbreviated = useMemo(() => {
|
||||
if (value === null) return false
|
||||
return shortFormatter(value) !== longFormatter(value)
|
||||
}, [value, shortFormatter, longFormatter])
|
||||
|
||||
const showTooltip = detailedView
|
||||
? !!comparison
|
||||
: !!comparison || isAbbreviated
|
||||
|
||||
const shouldShowLongFormat =
|
||||
detailedView && !comparison && isRowHovered && isAbbreviated
|
||||
const displayFormatter = shouldShowLongFormat ? longFormatter : shortFormatter
|
||||
|
||||
const percentageValue = listItem['percentage' as Metric]
|
||||
const shouldShowPercentage =
|
||||
detailedView &&
|
||||
metric === 'visitors' &&
|
||||
isRowHovered &&
|
||||
percentageValue != null
|
||||
const percentageFormatter = MetricFormatterShort['percentage']
|
||||
const percentageDisplay = shouldShowPercentage
|
||||
? percentageFormatter(percentageValue)
|
||||
: null
|
||||
|
||||
if (value === null && (!comparison || comparison.value === null)) {
|
||||
return <span data-testid="metric-value">{shortFormatter(value)}</span>
|
||||
return <span data-testid="metric-value">{displayFormatter(value)}</span>
|
||||
}
|
||||
|
||||
const valueContent = (
|
||||
<span
|
||||
className={showTooltip ? 'cursor-default' : ''}
|
||||
data-testid="metric-value"
|
||||
>
|
||||
{percentageDisplay && (
|
||||
<span className="mr-3 text-gray-500 dark:text-gray-400">
|
||||
{percentageDisplay}
|
||||
</span>
|
||||
)}
|
||||
{displayFormatter(value)}
|
||||
{comparison ? (
|
||||
<ChangeArrow
|
||||
change={comparison.change}
|
||||
metric={metric}
|
||||
className="inline-block pl-1 w-4"
|
||||
hideNumber
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!showTooltip) {
|
||||
return valueContent
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
containerRef={portalRef as React.RefObject<HTMLElement>}
|
||||
info={
|
||||
<ComparisonTooltipContent
|
||||
value={value}
|
||||
|
|
@ -62,17 +123,7 @@ export default function MetricValue(props: {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{valueContent}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -106,34 +157,34 @@ function ComparisonTooltipContent({
|
|||
return (
|
||||
<div className="text-left whitespace-nowrap py-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-base">
|
||||
{longFormatter(value)} {label}
|
||||
</span>
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm/6 text-white">
|
||||
{longFormatter(value)} {label}
|
||||
</span>
|
||||
<div className="font-normal text-xs text-white">
|
||||
{meta.date_range_label}
|
||||
</div>
|
||||
</div>
|
||||
<ChangeArrow
|
||||
metric={metric}
|
||||
change={comparison.change}
|
||||
className="pl-4 text-xs text-gray-100"
|
||||
className="text-xs/6 font-medium text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-normal text-xs">{meta.date_range_label}</div>
|
||||
</div>
|
||||
<div>vs</div>
|
||||
<div className="w-full border-t border-gray-600"></div>
|
||||
<div>
|
||||
<div className="font-bold text-base">
|
||||
<div className="font-medium text-sm/6 text-gray-300/80">
|
||||
{longFormatter(comparison.value)} {label}
|
||||
</div>
|
||||
<div className="font-normal text-xs">
|
||||
<div className="font-normal text-xs text-gray-300/80">
|
||||
{meta.comparison_date_range_label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{longFormatter(value)} {label}
|
||||
</div>
|
||||
)
|
||||
return <div className="whitespace-nowrap">{longFormatter(value)}</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ export class Metric {
|
|||
this.renderValue = this.renderValue.bind(this)
|
||||
}
|
||||
|
||||
renderValue(listItem, meta) {
|
||||
renderValue(listItem, meta, options = {}) {
|
||||
const { detailedView = false, isRowHovered = false } = options
|
||||
return (
|
||||
<MetricValue
|
||||
listItem={listItem}
|
||||
|
|
@ -51,6 +52,8 @@ export class Metric {
|
|||
renderLabel={this.renderLabel}
|
||||
meta={meta}
|
||||
formatter={this.formatter}
|
||||
detailedView={detailedView}
|
||||
isRowHovered={isRowHovered}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -85,7 +88,7 @@ export const createVisitors = (props) => {
|
|||
}
|
||||
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-36',
|
||||
sortable: true,
|
||||
...props,
|
||||
key: 'visitors',
|
||||
|
|
@ -96,7 +99,7 @@ export const createVisitors = (props) => {
|
|||
export const createConversionRate = (props) => {
|
||||
const renderLabel = (_query) => 'CR'
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'conversion_rate',
|
||||
renderLabel,
|
||||
|
|
@ -116,13 +119,13 @@ export const createPercentage = (props) => {
|
|||
}
|
||||
|
||||
export const createEvents = (props) => {
|
||||
return new Metric({ width: 'w-24', ...props, key: 'events', sortable: true })
|
||||
return new Metric({ width: 'w-28', ...props, key: 'events', sortable: true })
|
||||
}
|
||||
|
||||
export const createTotalRevenue = (props) => {
|
||||
const renderLabel = (_query) => 'Revenue'
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-32',
|
||||
...props,
|
||||
key: 'total_revenue',
|
||||
renderLabel,
|
||||
|
|
@ -133,7 +136,7 @@ export const createTotalRevenue = (props) => {
|
|||
export const createAverageRevenue = (props) => {
|
||||
const renderLabel = (_query) => 'Average'
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-28',
|
||||
...props,
|
||||
key: 'average_revenue',
|
||||
renderLabel,
|
||||
|
|
@ -142,9 +145,9 @@ export const createAverageRevenue = (props) => {
|
|||
}
|
||||
|
||||
export const createTotalVisitors = (props) => {
|
||||
const renderLabel = (_query) => 'Total Visitors'
|
||||
const renderLabel = (_query) => 'Total visitors'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-32',
|
||||
...props,
|
||||
key: 'total_visitors',
|
||||
renderLabel,
|
||||
|
|
@ -157,9 +160,9 @@ export const createVisits = (props) => {
|
|||
}
|
||||
|
||||
export const createVisitDuration = (props) => {
|
||||
const renderLabel = (_query) => 'Visit Duration'
|
||||
const renderLabel = (_query) => 'Visit duration'
|
||||
return new Metric({
|
||||
width: 'w-36',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'visit_duration',
|
||||
renderLabel,
|
||||
|
|
@ -168,9 +171,9 @@ export const createVisitDuration = (props) => {
|
|||
}
|
||||
|
||||
export const createBounceRate = (props) => {
|
||||
const renderLabel = (_query) => 'Bounce Rate'
|
||||
const renderLabel = (_query) => 'Bounce rate'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'bounce_rate',
|
||||
renderLabel,
|
||||
|
|
@ -190,9 +193,9 @@ export const createPageviews = (props) => {
|
|||
}
|
||||
|
||||
export const createTimeOnPage = (props) => {
|
||||
const renderLabel = (_query) => 'Time on Page'
|
||||
const renderLabel = (_query) => 'Time on page'
|
||||
return new Metric({
|
||||
width: 'w-32',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'time_on_page',
|
||||
renderLabel,
|
||||
|
|
@ -201,9 +204,9 @@ export const createTimeOnPage = (props) => {
|
|||
}
|
||||
|
||||
export const createExitRate = (props) => {
|
||||
const renderLabel = (_query) => 'Exit Rate'
|
||||
const renderLabel = (_query) => 'Exit rate'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'exit_rate',
|
||||
renderLabel,
|
||||
|
|
@ -212,9 +215,9 @@ export const createExitRate = (props) => {
|
|||
}
|
||||
|
||||
export const createScrollDepth = (props) => {
|
||||
const renderLabel = (_query) => 'Scroll Depth'
|
||||
const renderLabel = (_query) => 'Scroll depth'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'scroll_depth',
|
||||
renderLabel,
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function SearchTerms() {
|
|||
path: referrersGoogleRoute.path,
|
||||
search: (search: Record<string, unknown>) => search
|
||||
}}
|
||||
className="w-full mt-2"
|
||||
className="w-full mt-3"
|
||||
onClick={undefined}
|
||||
/>
|
||||
</React.Fragment>
|
||||
|
|
|
|||
|
|
@ -27,26 +27,26 @@ import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
|
|||
|
||||
const UTM_TAGS = {
|
||||
utm_medium: {
|
||||
title: 'UTM Mediums',
|
||||
title: 'UTM mediums',
|
||||
label: 'Medium',
|
||||
endpoint: '/utm_mediums'
|
||||
},
|
||||
utm_source: {
|
||||
title: 'UTM Sources',
|
||||
title: 'UTM sources',
|
||||
label: 'Source',
|
||||
endpoint: '/utm_sources'
|
||||
},
|
||||
utm_campaign: {
|
||||
title: 'UTM Campaigns',
|
||||
title: 'UTM campaigns',
|
||||
label: 'Campaign',
|
||||
endpoint: '/utm_campaigns'
|
||||
},
|
||||
utm_content: {
|
||||
title: 'UTM Contents',
|
||||
title: 'UTM contents',
|
||||
label: 'Content',
|
||||
endpoint: '/utm_contents'
|
||||
},
|
||||
utm_term: { title: 'UTM Terms', label: 'Term', endpoint: '/utm_terms' }
|
||||
utm_term: { title: 'UTM terms', label: 'Term', endpoint: '/utm_terms' }
|
||||
}
|
||||
|
||||
function AllSources({ afterFetchData }) {
|
||||
|
|
@ -70,6 +70,8 @@ function AllSources({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -83,7 +85,7 @@ function AllSources({ afterFetchData }) {
|
|||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: sourcesRoute.path, search: (search) => search }}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-blue-50"
|
||||
color="bg-blue-50 group-hover/row:bg-blue-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -106,6 +108,8 @@ function Channels({ onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -122,7 +126,7 @@ function Channels({ onClick, afterFetchData }) {
|
|||
path: channelsRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
color="bg-blue-50"
|
||||
color="bg-blue-50 group-hover/row:bg-blue-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -154,6 +158,8 @@ function UTMSources({ tab, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -166,14 +172,14 @@ function UTMSources({ tab, afterFetchData }) {
|
|||
keyLabel={utmTag.label}
|
||||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: route?.path, search: (search) => search }}
|
||||
color="bg-blue-50"
|
||||
color="bg-blue-50 group-hover/row:bg-blue-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const labelFor = {
|
||||
channels: 'Top Channels',
|
||||
all: 'Top Sources'
|
||||
channels: 'Top channels',
|
||||
all: 'Top sources'
|
||||
}
|
||||
|
||||
for (const [key, utm_tag] of Object.entries(UTM_TAGS)) {
|
||||
|
|
@ -241,7 +247,7 @@ export default function SourceList() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
{/* Header Container */}
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="flex gap-x-1">
|
||||
|
|
|
|||
|
|
@ -291,23 +291,23 @@ export const formattedFilters = {
|
|||
prop_value: 'Value',
|
||||
source: 'Source',
|
||||
channel: 'Channel',
|
||||
utm_medium: 'UTM Medium',
|
||||
utm_source: 'UTM Source',
|
||||
utm_campaign: 'UTM Campaign',
|
||||
utm_content: 'UTM Content',
|
||||
utm_term: 'UTM Term',
|
||||
utm_medium: 'UTM medium',
|
||||
utm_source: 'UTM source',
|
||||
utm_campaign: 'UTM campaign',
|
||||
utm_content: 'UTM content',
|
||||
utm_term: 'UTM term',
|
||||
referrer: 'Referrer URL',
|
||||
screen: 'Screen size',
|
||||
browser: 'Browser',
|
||||
browser_version: 'Browser Version',
|
||||
os: 'Operating System',
|
||||
os_version: 'Operating System Version',
|
||||
browser_version: 'Browser version',
|
||||
os: 'Operating system',
|
||||
os_version: 'Operating system version',
|
||||
country: 'Country',
|
||||
region: 'Region',
|
||||
city: 'City',
|
||||
page: 'Page',
|
||||
hostname: 'Hostname',
|
||||
entry_page: 'Entry Page',
|
||||
exit_page: 'Exit Page',
|
||||
entry_page: 'Entry page',
|
||||
exit_page: 'Exit page',
|
||||
segment: 'Segment'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,11 @@ export function durationFormatter(duration: number): string {
|
|||
|
||||
export function percentageFormatter(number: number | null): string {
|
||||
if (typeof number === 'number') {
|
||||
return number + '%'
|
||||
if (Math.abs(number) > 0 && Math.abs(number) < 0.1) {
|
||||
return number.toFixed(2) + '%'
|
||||
} else {
|
||||
return number.toFixed(1).replace(/\.0$/, '') + '%'
|
||||
}
|
||||
} else {
|
||||
return '-'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,14 @@ export function Tooltip({
|
|||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: 'top',
|
||||
modifiers: [
|
||||
{ name: 'arrow', options: { element: arrowElement } },
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 4]
|
||||
offset: [0, 6]
|
||||
}
|
||||
},
|
||||
...(boundary
|
||||
|
|
@ -67,8 +65,6 @@ export function Tooltip({
|
|||
popperStyle={styles.popper}
|
||||
popperAttributes={attributes.popper}
|
||||
setPopperElement={setPopperElement}
|
||||
setArrowElement={setArrowElement}
|
||||
arrowStyle={styles.arrow}
|
||||
>
|
||||
{info}
|
||||
</TooltipMessage>
|
||||
|
|
@ -82,16 +78,12 @@ function TooltipMessage({
|
|||
popperStyle,
|
||||
popperAttributes,
|
||||
setPopperElement,
|
||||
setArrowElement,
|
||||
arrowStyle,
|
||||
children
|
||||
}: {
|
||||
containerRef?: RefObject<HTMLElement>
|
||||
popperStyle: CSSProperties
|
||||
arrowStyle: CSSProperties
|
||||
popperAttributes?: Record<string, string>
|
||||
setPopperElement: (element: HTMLDivElement) => void
|
||||
setArrowElement: (element: HTMLDivElement) => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const messageElement = (
|
||||
|
|
@ -99,15 +91,10 @@ function TooltipMessage({
|
|||
ref={setPopperElement}
|
||||
style={popperStyle}
|
||||
{...popperAttributes}
|
||||
className="z-50 p-2 rounded-sm text-sm text-gray-100 font-bold bg-gray-800 dark:bg-gray-700"
|
||||
className="z-[999] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700"
|
||||
role="tooltip"
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={setArrowElement}
|
||||
style={arrowStyle}
|
||||
className="tooltip-arrow"
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
if (containerRef) {
|
||||
|
|
|
|||
|
|
@ -40,21 +40,22 @@ const LEGACY_URL_PARAMETERS = {
|
|||
exit_page: null
|
||||
}
|
||||
|
||||
function isV1(searchRecord: Record<string, unknown>): boolean {
|
||||
return Object.keys(searchRecord).some(
|
||||
(k) => k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)
|
||||
)
|
||||
function isV1(searchParams: URLSearchParams): boolean {
|
||||
for (const k of searchParams.keys()) {
|
||||
if (k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function parseSearchRecord(
|
||||
searchRecord: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const searchRecordEntries = Object.entries(searchRecord)
|
||||
function parseSearch(searchString: string): Record<string, unknown> {
|
||||
const searchParams = new URLSearchParams(searchString)
|
||||
const updatedSearchRecordEntries = []
|
||||
const filters: Filter[] = []
|
||||
let labels: DashboardQuery['labels'] = {}
|
||||
|
||||
for (const [key, value] of searchRecordEntries) {
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
|
|
@ -63,9 +64,10 @@ function parseSearchRecord(
|
|||
filters.push(filter)
|
||||
const labelsKey: string | null | undefined =
|
||||
LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
|
||||
if (labelsKey && searchRecord[labelsKey]) {
|
||||
const labelsParamValue = labelsKey ? searchParams.get(labelsKey) : null
|
||||
if (labelsParamValue) {
|
||||
const clauses = filter[2]
|
||||
const labelsValues = (searchRecord[labelsKey] as string)
|
||||
const labelsValues = labelsParamValue
|
||||
.split('|')
|
||||
.filter((label) => !!label)
|
||||
const newLabels = Object.fromEntries(
|
||||
|
|
@ -79,8 +81,9 @@ function parseSearchRecord(
|
|||
}
|
||||
}
|
||||
|
||||
if (typeof searchRecord['props'] === 'string') {
|
||||
filters.push(...(parseLegacyPropsFilter(searchRecord['props']) as Filter[]))
|
||||
const propsParamValue = searchParams.get('props')
|
||||
if (typeof propsParamValue === 'string') {
|
||||
filters.push(...(parseLegacyPropsFilter(propsParamValue) as Filter[]))
|
||||
}
|
||||
updatedSearchRecordEntries.push(['filters', filters], ['labels', labels])
|
||||
return Object.fromEntries(updatedSearchRecordEntries)
|
||||
|
|
@ -114,5 +117,5 @@ function parseLegacyPropsFilter(rawValue: string) {
|
|||
|
||||
export const v1 = {
|
||||
isV1,
|
||||
parseSearchRecord
|
||||
parseSearch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,21 +242,25 @@ describe(`${getRedirectTarget.name}`, () => {
|
|||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns updated URL for page=... style filters (v1), and running the updated value through the function again returns null (no redirect loop)', () => {
|
||||
const pathname = '/'
|
||||
const search = '?page=/docs'
|
||||
const expectedUpdatedSearch = '?f=is,page,/docs&r=v1'
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search
|
||||
} as Location)
|
||||
).toEqual(`${pathname}${expectedUpdatedSearch}`)
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search: expectedUpdatedSearch
|
||||
} as Location)
|
||||
).toBeNull()
|
||||
})
|
||||
it.each([
|
||||
['?page=/docs', '?f=is,page,/docs&r=v1'],
|
||||
['?page=%C3%AA&embed=true', '?f=is,page,%C3%AA&embed=true&r=v1']
|
||||
])(
|
||||
'returns updated URL v1 style filter %s, and running the updated value through the function again returns null (no redirect loop)',
|
||||
(searchString, expectedSearchString) => {
|
||||
const pathname = '/'
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search: searchString
|
||||
} as Location)
|
||||
).toEqual(`${pathname}${expectedSearchString}`)
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search: expectedSearchString
|
||||
} as Location)
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { v1 } from './url-search-params-v1'
|
|||
import { v2 } from './url-search-params-v2'
|
||||
|
||||
/**
|
||||
* These charcters are not URL encoded to have more readable URLs.
|
||||
* These characters are not URL encoded to have more readable URLs.
|
||||
* Browsers seem to handle this just fine.
|
||||
* `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param``
|
||||
*/
|
||||
|
|
@ -241,18 +241,17 @@ export function getRedirectTarget(windowLocation: Location): null | string {
|
|||
}
|
||||
|
||||
const isV2 = v2.isV2(searchParams)
|
||||
const isV1 = v1.isV1(searchParams)
|
||||
|
||||
if (isV2) {
|
||||
return `${windowLocation.pathname}${stringifySearch({ ...v2.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' })}`
|
||||
}
|
||||
|
||||
const searchRecord = v2.parseSearch(windowLocation.search)
|
||||
const isV1 = v1.isV1(searchRecord)
|
||||
|
||||
if (!isV1) {
|
||||
return null
|
||||
if (isV1) {
|
||||
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}`
|
||||
}
|
||||
|
||||
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearchRecord(searchRecord), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}`
|
||||
return null
|
||||
}
|
||||
|
||||
/** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* 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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,14 @@
|
|||
The modules below this comment block are resolved from '../deps' folder,
|
||||
which does not exist when running the lint command in Github CI
|
||||
*/
|
||||
|
||||
/* eslint-disable import/no-unresolved */
|
||||
import 'phoenix_html'
|
||||
import { Socket } from 'phoenix'
|
||||
import { LiveSocket } from 'phoenix_live_view'
|
||||
import { Modal, Dropdown } from 'prima'
|
||||
import DashboardRoot from './dashboard_root'
|
||||
import DashboardTabs from './dashboard_tabs.js'
|
||||
import topbar from 'topbar'
|
||||
/* eslint-enable import/no-unresolved */
|
||||
|
||||
|
|
@ -14,8 +17,12 @@ import Alpine from 'alpinejs'
|
|||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']")
|
||||
let websocketUrl = document.querySelector("meta[name='websocket-url']")
|
||||
let disablePushStateFlag = document.querySelector(
|
||||
"meta[name='live-socket-disable-push-state']"
|
||||
)
|
||||
let domain = document.querySelector("meta[name='dashboard-domain']")
|
||||
if (csrfToken && websocketUrl) {
|
||||
let Hooks = { Modal, Dropdown }
|
||||
let Hooks = { Modal, Dropdown, DashboardRoot, DashboardTabs }
|
||||
Hooks.Metrics = {
|
||||
mounted() {
|
||||
this.handleEvent('send-metrics', ({ event_name }) => {
|
||||
|
|
@ -48,9 +55,14 @@ if (csrfToken && websocketUrl) {
|
|||
let token = csrfToken.getAttribute('content')
|
||||
let url = websocketUrl.getAttribute('content')
|
||||
let liveUrl = url === '' ? '/live' : new URL('/live', url).href
|
||||
let disablePushState =
|
||||
!!disablePushStateFlag &&
|
||||
disablePushStateFlag.getAttribute('content') === 'true'
|
||||
let domainName = domain && domain.getAttribute('content')
|
||||
let liveSocket = new LiveSocket(liveUrl, Socket, {
|
||||
// For dashboard LV migration
|
||||
disablePushState: disablePushState,
|
||||
heartbeatIntervalMs: 10000,
|
||||
params: { _csrf_token: token },
|
||||
hooks: Hooks,
|
||||
uploaders: Uploaders,
|
||||
dom: {
|
||||
|
|
@ -60,6 +72,20 @@ if (csrfToken && websocketUrl) {
|
|||
Alpine.clone(from, to)
|
||||
}
|
||||
}
|
||||
},
|
||||
params: () => {
|
||||
if (domainName) {
|
||||
return {
|
||||
// The prefs are used by dashboard LiveView to persist
|
||||
// user preferences across the reloads.
|
||||
user_prefs: {
|
||||
pages_tab: localStorage.getItem(`pageTab__${domainName}`)
|
||||
},
|
||||
_csrf_token: token
|
||||
}
|
||||
} else {
|
||||
return { _csrf_token: token }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ config :bcrypt_elixir, :log_rounds, 4
|
|||
|
||||
config :plausible, Plausible.Repo,
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: System.schedulers_online() * 2
|
||||
pool_size: System.schedulers_online()
|
||||
|
||||
config :plausible, Plausible.ClickhouseRepo,
|
||||
loggers: [Ecto.LogEntry],
|
||||
|
|
@ -63,3 +63,5 @@ config :plausible, Plausible.InstallationSupport.Checks.VerifyInstallation,
|
|||
]
|
||||
|
||||
config :plausible, Plausible.Session.Salts, interval: :timer.hours(1)
|
||||
|
||||
config :plausible, max_goals_per_site: 10
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@ defmodule Plausible.ConsolidatedView do
|
|||
|
||||
import Ecto.Query
|
||||
|
||||
@spec flag_enabled?(Team.t()) :: boolean()
|
||||
def flag_enabled?(team) do
|
||||
FunWithFlags.enabled?(:consolidated_view, for: team)
|
||||
end
|
||||
|
||||
@spec cta_dismissed?(User.t(), Team.t()) :: boolean()
|
||||
def cta_dismissed?(%User{} = user, %Team{} = team) do
|
||||
{:ok, team_membership} = Teams.Memberships.get_team_membership(team, user)
|
||||
|
|
@ -51,7 +46,6 @@ defmodule Plausible.ConsolidatedView do
|
|||
@spec ok_to_display?(Team.t() | nil) :: boolean()
|
||||
def ok_to_display?(team) do
|
||||
is_struct(team, Team) and
|
||||
flag_enabled?(team) and
|
||||
view_enabled?(team) and
|
||||
has_sites_to_consolidate?(team) and
|
||||
Plausible.Billing.Feature.ConsolidatedView.check_availability(team) == :ok
|
||||
|
|
@ -84,23 +78,26 @@ defmodule Plausible.ConsolidatedView do
|
|||
end
|
||||
|
||||
@spec enable(Team.t()) ::
|
||||
{:ok, Site.t()} | {:error, :no_sites | :team_not_setup | :upgrade_required}
|
||||
{:ok, Site.t()}
|
||||
| {:error, :no_sites | :team_not_setup | :upgrade_required | :contact_us}
|
||||
def enable(%Team{} = team) do
|
||||
availability_check = Plausible.Billing.Feature.ConsolidatedView.check_availability(team)
|
||||
|
||||
cond do
|
||||
not has_sites_to_consolidate?(team) ->
|
||||
{:error, :no_sites}
|
||||
|
||||
Teams.Billing.enterprise_configured?(team) and availability_check != :ok ->
|
||||
{:error, :contact_us}
|
||||
|
||||
availability_check != :ok ->
|
||||
availability_check
|
||||
|
||||
not Teams.setup?(team) ->
|
||||
{:error, :team_not_setup}
|
||||
|
||||
not flag_enabled?(team) ->
|
||||
{:error, :unavailable}
|
||||
|
||||
true ->
|
||||
case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
|
||||
:ok -> do_enable(team)
|
||||
error -> error
|
||||
end
|
||||
do_enable(team)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@ defmodule Plausible.CustomerSupport.Resource.Site do
|
|||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
ilike(s.domain, ^"%#{input}%") or ilike(t.name, ^"%#{input}%") or
|
||||
ilike(o.name, ^"%#{input}%") or ilike(o.email, ^"%#{input}%"),
|
||||
ilike(o.name, ^"%#{input}%") or ilike(o.email, ^"%#{input}%") or
|
||||
ilike(s.domain_changed_from, ^"%#{input}%"),
|
||||
order_by: [
|
||||
desc: fragment("?.domain = ?", s, ^input),
|
||||
desc: fragment("?.domain_changed_from = ?", s, ^input),
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
desc: fragment("?.email = ?", o, ^input),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
|
|||
limit = Keyword.fetch!(opts, :limit)
|
||||
|
||||
q =
|
||||
from t in Plausible.Teams.Team,
|
||||
from(t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
limit: ^limit,
|
||||
|
|
@ -25,6 +25,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
|
|||
on: true,
|
||||
order_by: [desc: :id],
|
||||
preload: [owners: o, subscription: s]
|
||||
)
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
|
|
@ -33,42 +34,56 @@ defmodule Plausible.CustomerSupport.Resource.Team do
|
|||
limit = Keyword.fetch!(opts, :limit)
|
||||
|
||||
q =
|
||||
from t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
ilike(t.name, ^"%#{input}%") or ilike(o.name, ^"%#{input}%") or
|
||||
ilike(o.email, ^"%#{input}%"),
|
||||
limit: ^limit,
|
||||
order_by: [
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
desc: fragment("?.email = ?", o, ^input),
|
||||
asc: t.name
|
||||
],
|
||||
preload: [owners: o]
|
||||
if opts[:uuid_provided?] do
|
||||
from(t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where: t.identifier == ^input,
|
||||
preload: [owners: o]
|
||||
)
|
||||
else
|
||||
from(t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
ilike(t.name, ^"%#{input}%") or
|
||||
ilike(o.name, ^"%#{input}%") or
|
||||
ilike(o.email, ^"%#{input}%"),
|
||||
limit: ^limit,
|
||||
order_by: [
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
desc: fragment("?.email = ?", o, ^input),
|
||||
asc: t.name
|
||||
],
|
||||
preload: [owners: o]
|
||||
)
|
||||
end
|
||||
|
||||
q =
|
||||
if opts[:with_subscription_only?] do
|
||||
from t in q,
|
||||
from(t in q,
|
||||
inner_lateral_join: s in subquery(Teams.last_subscription_join_query()),
|
||||
on: true,
|
||||
preload: [subscription: s]
|
||||
)
|
||||
else
|
||||
from t in q,
|
||||
from(t in q,
|
||||
left_lateral_join: s in subquery(Teams.last_subscription_join_query()),
|
||||
on: true,
|
||||
preload: [subscription: s]
|
||||
)
|
||||
end
|
||||
|
||||
q =
|
||||
if opts[:with_sso_only?] do
|
||||
from t in q,
|
||||
from(t in q,
|
||||
inner_join: sso_integration in assoc(t, :sso_integration),
|
||||
as: :sso_integration,
|
||||
left_join: sso_domains in assoc(sso_integration, :sso_domains),
|
||||
as: :sso_domains,
|
||||
or_where: ilike(sso_domains.domain, ^"%#{input}%")
|
||||
)
|
||||
else
|
||||
q
|
||||
end
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ defmodule Plausible.Stats.ConsolidatedView do
|
|||
|> DateTime.to_iso8601()
|
||||
|
||||
stats_query =
|
||||
Stats.Query.build!(view, :internal, %{
|
||||
Stats.Query.parse_and_build!(view, :internal, %{
|
||||
"site_id" => view.domain,
|
||||
"metrics" => ["visitors", "visits", "pageviews", "views_per_visit"],
|
||||
"include" => %{"comparisons" => %{"mode" => "custom", "date_range" => [c_from, c_to]}},
|
||||
|
|
@ -91,7 +91,7 @@ defmodule Plausible.Stats.ConsolidatedView do
|
|||
|
||||
defp query_24h_intervals(view, now) do
|
||||
graph_query =
|
||||
Stats.Query.build!(
|
||||
Stats.Query.parse_and_build!(
|
||||
view,
|
||||
:internal,
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -408,6 +408,11 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
|
|||
{:missing, param} ->
|
||||
H.bad_request(conn, "Parameter `#{param}` is required to create a goal")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
message = Enum.map_join(changeset.errors, ", ", &translate_error/1)
|
||||
|
||||
H.bad_request(conn, message)
|
||||
|
||||
e ->
|
||||
H.bad_request(conn, "Something went wrong: #{inspect(e)}")
|
||||
end
|
||||
|
|
@ -605,4 +610,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
|
|||
# remap to `custom_properties`
|
||||
|> Map.put(:custom_properties, site.allowed_event_props || [])
|
||||
end
|
||||
|
||||
defp translate_error({field, {msg, opts}}) do
|
||||
Enum.reduce(opts, "#{field}: #{msg}", fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ defmodule PlausibleWeb.CustomerSupport.Components.Layout do
|
|||
</p>
|
||||
<strong>team:</strong>input<br />
|
||||
<p class="font-sans pl-2 mb-1">
|
||||
Search for teams exclusively. Input will be checked against user's name and e-mail.
|
||||
Search for teams exclusively. Input will be checked against user/team name, e-mail or team identifier. Identifier must be provided complete, as is.
|
||||
</p>
|
||||
|
||||
<strong>team:</strong>input <strong>+sub</strong>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,15 @@ defmodule PlausibleWeb.CustomerSupport.Components.Search do
|
|||
opts
|
||||
end
|
||||
|
||||
opts =
|
||||
case Ecto.UUID.cast(input) do
|
||||
{:ok, _uuid} ->
|
||||
Keyword.merge(opts, uuid_provided?: true)
|
||||
|
||||
_ ->
|
||||
opts
|
||||
end
|
||||
|
||||
{[Resource.Team], input, opts}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -50,40 +50,61 @@ defmodule PlausibleWeb.Live.FunnelSettings do
|
|||
<div id="funnel-settings-main">
|
||||
<.flash_messages flash={@flash} />
|
||||
|
||||
<%= if @setup_funnel? do %>
|
||||
{live_render(
|
||||
@socket,
|
||||
PlausibleWeb.Live.FunnelSettings.Form,
|
||||
id: "funnels-form",
|
||||
session: %{
|
||||
"domain" => @domain,
|
||||
"funnel_id" => @funnel_id
|
||||
}
|
||||
)}
|
||||
<% end %>
|
||||
<div :if={@goal_count >= Funnel.min_steps()}>
|
||||
<.live_component
|
||||
module={PlausibleWeb.Live.FunnelSettings.List}
|
||||
id="funnels-list"
|
||||
funnels={@displayed_funnels}
|
||||
filter_text={@filter_text}
|
||||
/>
|
||||
</div>
|
||||
<.tile
|
||||
docs="funnel-analysis"
|
||||
feature_mod={Plausible.Billing.Feature.Funnels}
|
||||
feature_toggle?={true}
|
||||
show_content?={!Plausible.Billing.Feature.Funnels.opted_out?(@site)}
|
||||
site={@site}
|
||||
current_user={@current_user}
|
||||
current_team={@current_team}
|
||||
>
|
||||
<:title>
|
||||
Funnels
|
||||
</:title>
|
||||
<:subtitle :if={Enum.count(@all_funnels) > 0}>
|
||||
Compose goals into funnels to track user flows and conversion rates.
|
||||
</:subtitle>
|
||||
<%= if @setup_funnel? do %>
|
||||
{live_render(
|
||||
@socket,
|
||||
PlausibleWeb.Live.FunnelSettings.Form,
|
||||
id: "funnels-form",
|
||||
session: %{
|
||||
"domain" => @domain,
|
||||
"funnel_id" => @funnel_id
|
||||
}
|
||||
)}
|
||||
<% end %>
|
||||
<div :if={@goal_count >= Funnel.min_steps()}>
|
||||
<.live_component
|
||||
module={PlausibleWeb.Live.FunnelSettings.List}
|
||||
id="funnels-list"
|
||||
funnels={@displayed_funnels}
|
||||
filter_text={@filter_text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :if={@goal_count < Funnel.min_steps()} class="flex flex-col items-center">
|
||||
<h1 class="mt-4 text-center">
|
||||
Ready to dig into user flows?
|
||||
</h1>
|
||||
<p class="mt-4 mb-6 max-w-lg text-sm text-gray-500 dark:text-gray-400 leading-5 text-center">
|
||||
Set up a few goals first (e.g. <b>"Signup"</b>, <b>"Visit /"</b>, or <b>"Scroll 50% on /blog/*"</b>) and return here to build your first funnel!
|
||||
</p>
|
||||
<.button_link
|
||||
class="mb-2"
|
||||
href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
|
||||
<div
|
||||
:if={@goal_count < Funnel.min_steps()}
|
||||
class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto"
|
||||
>
|
||||
Set up goals →
|
||||
</.button_link>
|
||||
</div>
|
||||
<h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
|
||||
Ready to dig into user flows?
|
||||
</h3>
|
||||
<p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
|
||||
Set up a few goals like <.highlighted>Signup</.highlighted>, <.highlighted>Visit /</.highlighted>, or
|
||||
<.highlighted>Scroll 50% on /blog/*</.highlighted>
|
||||
first, then return here to build your first funnel.
|
||||
</p>
|
||||
<.button_link
|
||||
class="mt-4"
|
||||
href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
|
||||
>
|
||||
Set up goals →
|
||||
</.button_link>
|
||||
</div>
|
||||
</.tile>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -147,4 +168,8 @@ defmodule PlausibleWeb.Live.FunnelSettings do
|
|||
def handle_info(:cancel_setup_funnel, socket) do
|
||||
{:noreply, assign(socket, setup_funnel?: false, funnel_id: nil)}
|
||||
end
|
||||
|
||||
def handle_info({:feature_toggled, flash_msg, updated_site}, socket) do
|
||||
{:noreply, assign(put_flash(socket, :success, flash_msg), site: updated_site)}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
phx-target="#funnel-form"
|
||||
phx-click-away="cancel-add-funnel"
|
||||
onkeydown="return event.key != 'Enter';"
|
||||
class="bg-white dark:bg-gray-900 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"
|
||||
class="bg-white dark:bg-gray-900 shadow-2xl rounded-lg px-8 pt-6 pb-8 mb-4 mt-8"
|
||||
>
|
||||
<.title class="mb-6">
|
||||
{if @funnel, do: "Edit", else: "Add"} funnel
|
||||
|
|
@ -84,7 +84,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
Funnel steps
|
||||
</.label>
|
||||
|
||||
<div :for={step_idx <- @step_ids} class="flex mb-3 mt-3">
|
||||
<div :for={step_idx <- @step_ids} class="flex my-3">
|
||||
<div class="w-2/5 flex-1">
|
||||
<.live_component
|
||||
selected={find_preselected(@funnel, @funnel_modified?, step_idx)}
|
||||
|
|
@ -117,13 +117,12 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<.add_step_button :if={
|
||||
length(@step_ids) < Funnel.max_steps() and
|
||||
map_size(@selections_made) < length(@goals)
|
||||
} />
|
||||
|
||||
<div class="mt-6">
|
||||
<p id="funnel-eval" class="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
|
||||
<div class="flex flex-col gap-y-4 mt-6">
|
||||
<.add_step_button :if={
|
||||
length(@step_ids) < Funnel.max_steps() and
|
||||
map_size(@selections_made) < length(@goals)
|
||||
} />
|
||||
<p id="funnel-eval" class="text-gray-800 dark:text-gray-200 text-sm">
|
||||
<%= if @evaluation_result do %>
|
||||
Last month conversion rate: <strong><%= List.last(@evaluation_result.steps).conversion_rate %></strong>%
|
||||
<% end %>
|
||||
|
|
@ -179,7 +178,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
|
||||
def add_step_button(assigns) do
|
||||
~H"""
|
||||
<a class="underline text-indigo-500 text-sm cursor-pointer mt-6" phx-click="add-step">
|
||||
<a class="text-indigo-500 text-sm font-medium cursor-pointer" phx-click="add-step">
|
||||
+ Add another step
|
||||
</a>
|
||||
"""
|
||||
|
|
@ -350,7 +349,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
)
|
||||
|
||||
query =
|
||||
Plausible.Stats.Query.build!(
|
||||
Plausible.Stats.Query.parse_and_build!(
|
||||
site,
|
||||
:internal,
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -10,13 +10,17 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
|
|||
use PlausibleWeb, :live_component
|
||||
|
||||
def render(assigns) do
|
||||
assigns = assign(assigns, :searching?, String.trim(assigns.filter_text) != "")
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
|
||||
<.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
|
||||
Add funnel
|
||||
</.button>
|
||||
</.filter_bar>
|
||||
<%= if @searching? or Enum.count(@funnels) > 0 do %>
|
||||
<.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
|
||||
<.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
|
||||
Add funnel
|
||||
</.button>
|
||||
</.filter_bar>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.count(@funnels) > 0 do %>
|
||||
<.table rows={@funnels}>
|
||||
|
|
@ -42,19 +46,44 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
|
|||
</:tbody>
|
||||
</.table>
|
||||
<% else %>
|
||||
<p class="mt-12 mb-8 text-sm text-center">
|
||||
<span :if={String.trim(@filter_text) != ""}>
|
||||
No funnels found for this site. Please refine or
|
||||
<.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
|
||||
reset your search.
|
||||
</.styled_link>
|
||||
</span>
|
||||
<span :if={String.trim(@filter_text) == "" && Enum.empty?(@funnels)}>
|
||||
No funnels configured for this site.
|
||||
</span>
|
||||
</p>
|
||||
<.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">
|
||||
No funnels found for this site. Please refine or
|
||||
<.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
|
||||
reset your search.
|
||||
</.styled_link>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp empty_state(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto">
|
||||
<h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
|
||||
Create your first funnel
|
||||
</h3>
|
||||
<p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
|
||||
Compose goals into funnels to track user flows and conversion rates.
|
||||
<.styled_link href="https://plausible.io/docs/funnel-analysis" target="_blank">
|
||||
Learn more
|
||||
</.styled_link>
|
||||
</p>
|
||||
<.button
|
||||
id="add-funnel-button"
|
||||
phx-click="add-funnel"
|
||||
class="mt-4"
|
||||
>
|
||||
Add funnel
|
||||
</.button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.Funnels do
|
|||
use PlausibleWeb, :plugins_api_controller
|
||||
|
||||
operation(:create,
|
||||
id: "Funnel.GetOrCreate",
|
||||
operation_id: "Funnel.GetOrCreate",
|
||||
summary: "Get or create Funnel",
|
||||
request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest},
|
||||
responses: %{
|
||||
|
|
|
|||
|
|
@ -1,15 +1,3 @@
|
|||
defimpl Bamboo.Formatter, for: Plausible.Auth.User do
|
||||
def format_email_address(user, _opts) do
|
||||
{user.name, user.email}
|
||||
end
|
||||
end
|
||||
|
||||
defimpl FunWithFlags.Actor, for: Plausible.Auth.User do
|
||||
def id(%{id: id}) do
|
||||
"user:#{id}"
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Plausible.Auth.User do
|
||||
use Plausible
|
||||
use Ecto.Schema
|
||||
|
|
@ -284,3 +272,15 @@ defmodule Plausible.Auth.User do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Bamboo.Formatter, for: Plausible.Auth.User do
|
||||
def format_email_address(user, _opts) do
|
||||
{user.name, user.email}
|
||||
end
|
||||
end
|
||||
|
||||
defimpl FunWithFlags.Actor, for: Plausible.Auth.User do
|
||||
def id(%{id: id}) do
|
||||
"user:#{id}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,18 +19,32 @@ defmodule Plausible.Goal do
|
|||
field :funnels, {:array, :map}, virtual: true, default: []
|
||||
end
|
||||
|
||||
field :custom_props, :map, default: %{}
|
||||
|
||||
belongs_to :site, Plausible.Site
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@fields [:id, :site_id, :event_name, :page_path, :scroll_threshold, :display_name] ++
|
||||
@fields [
|
||||
:id,
|
||||
:site_id,
|
||||
:event_name,
|
||||
:page_path,
|
||||
:scroll_threshold,
|
||||
:display_name,
|
||||
:custom_props
|
||||
] ++
|
||||
on_ee(do: [:currency], else: [])
|
||||
|
||||
@max_event_name_length 120
|
||||
|
||||
def max_event_name_length(), do: @max_event_name_length
|
||||
|
||||
@max_custom_props_per_goal 3
|
||||
|
||||
def max_custom_props_per_goal(), do: @max_custom_props_per_goal
|
||||
|
||||
def changeset(goal, attrs \\ %{}) do
|
||||
goal
|
||||
|> cast(attrs, @fields)
|
||||
|
|
@ -40,11 +54,18 @@ defmodule Plausible.Goal do
|
|||
|> validate_event_name_and_page_path()
|
||||
|> validate_page_path_for_scroll_goal()
|
||||
|> maybe_put_display_name()
|
||||
|> unique_constraint(:event_name, name: :goals_event_name_unique)
|
||||
|> validate_change(:custom_props, fn :custom_props, custom_props ->
|
||||
if map_size(custom_props) > @max_custom_props_per_goal do
|
||||
[custom_props: "use at most #{@max_custom_props_per_goal} properties per goal"]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|> unique_constraint(:display_name, name: :goals_display_name_unique)
|
||||
|> unique_constraint(:event_name, name: :goals_event_config_unique)
|
||||
|> unique_constraint([:page_path, :scroll_threshold],
|
||||
name: :goals_page_path_and_scroll_threshold_unique
|
||||
name: :goals_pageview_config_unique
|
||||
)
|
||||
|> unique_constraint(:display_name, name: :goals_site_id_display_name_index)
|
||||
|> validate_length(:event_name, max: @max_event_name_length)
|
||||
|> validate_number(:scroll_threshold,
|
||||
greater_than_or_equal_to: -1,
|
||||
|
|
@ -7,6 +7,21 @@ defmodule Plausible.Goals do
|
|||
|
||||
alias Plausible.Goal
|
||||
alias Ecto.Multi
|
||||
alias Ecto.Changeset
|
||||
|
||||
@max_goals_per_site 1_000
|
||||
@spec max_goals_per_site(Keyword.t()) :: pos_integer()
|
||||
def max_goals_per_site(opts \\ []) do
|
||||
override = Keyword.get(opts, :max_goals_per_site)
|
||||
|
||||
if override do
|
||||
override
|
||||
else
|
||||
# see: config/test.exs - you can steer this limit for tests
|
||||
# by providing `max_goals_per_site` option to e.g. create/3
|
||||
Application.get_env(:plausible, :max_goals_per_site, @max_goals_per_site)
|
||||
end
|
||||
end
|
||||
|
||||
@spec get(Plausible.Site.t(), pos_integer()) :: nil | Plausible.Goal.t()
|
||||
def get(site, id) when is_integer(id) do
|
||||
|
|
@ -20,18 +35,30 @@ defmodule Plausible.Goals do
|
|||
end
|
||||
|
||||
@spec create(Plausible.Site.t(), map(), Keyword.t()) ::
|
||||
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
|
||||
{:ok, Goal.t()}
|
||||
| {:error, Changeset.t()}
|
||||
| {:error, :upgrade_required}
|
||||
| {:error, :revenue_goals_unavailable}
|
||||
@doc """
|
||||
Creates a Goal for a site.
|
||||
|
||||
If the created goal is a revenue goal, it sets site.updated_at to be
|
||||
refreshed by the sites cache, as revenue goals are used during ingestion.
|
||||
|
||||
Returns `{:ok, goal}` or `{:error, changeset}` when creation fails due to
|
||||
invalid fields. It can also return:
|
||||
|
||||
* `{:error, :upgrade_required}` - Adding a revenue goal is not allowed
|
||||
for team's subscription.
|
||||
|
||||
* `{:error, :revenue_goals_unavailable}` - When the site is a consolidated
|
||||
view and the goal created is a revenue goal. Revenue goal creation is not
|
||||
allowed for consolidated views due to the inability to force a single
|
||||
currency on a goal across all consolidated sites.
|
||||
"""
|
||||
def create(site, params, opts \\ []) do
|
||||
upsert? = Keyword.get(opts, :upsert?, false)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
case insert_goal(site, params, upsert?) do
|
||||
case insert_goal(site, params, opts) do
|
||||
{:ok, :insert, goal} ->
|
||||
on_ee do
|
||||
now = Keyword.get(opts, :now, DateTime.utc_now())
|
||||
|
|
@ -53,7 +80,7 @@ defmodule Plausible.Goals do
|
|||
end
|
||||
|
||||
@spec update(Plausible.Goal.t(), map()) ::
|
||||
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
|
||||
{:ok, Goal.t()} | {:error, Changeset.t()} | {:error, :upgrade_required}
|
||||
def update(goal, params) do
|
||||
changeset = Goal.changeset(goal, params)
|
||||
|
||||
|
|
@ -69,7 +96,7 @@ defmodule Plausible.Goals do
|
|||
updated_goal
|
||||
end
|
||||
else
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:error, %Changeset{} = changeset} ->
|
||||
Repo.rollback(changeset)
|
||||
|
||||
{:error, :upgrade_required} ->
|
||||
|
|
@ -78,16 +105,19 @@ defmodule Plausible.Goals do
|
|||
end)
|
||||
end
|
||||
|
||||
def find_or_create(site, params, opts \\ [])
|
||||
|
||||
def find_or_create(
|
||||
site,
|
||||
%{
|
||||
"goal_type" => "event",
|
||||
"event_name" => event_name,
|
||||
"currency" => currency
|
||||
} = params
|
||||
} = params,
|
||||
opts
|
||||
)
|
||||
when is_binary(event_name) and is_binary(currency) do
|
||||
with {:ok, goal} <- create(site, params, upsert?: true) do
|
||||
with {:ok, goal} <- create(site, params, do_upsert(opts)) do
|
||||
if to_string(goal.currency) == currency do
|
||||
{:ok, goal}
|
||||
else
|
||||
|
|
@ -95,7 +125,7 @@ defmodule Plausible.Goals do
|
|||
changeset =
|
||||
goal
|
||||
|> Goal.changeset()
|
||||
|> Ecto.Changeset.add_error(
|
||||
|> Changeset.add_error(
|
||||
:event_name,
|
||||
"'#{goal.event_name}' (with currency: #{goal.currency}) has already been taken"
|
||||
)
|
||||
|
|
@ -105,23 +135,25 @@ defmodule Plausible.Goals do
|
|||
end
|
||||
end
|
||||
|
||||
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name} = params)
|
||||
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name} = params, opts)
|
||||
when is_binary(event_name) do
|
||||
create(site, params, upsert?: true)
|
||||
create(site, params, do_upsert(opts))
|
||||
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) do
|
||||
create(site, params, upsert?: true)
|
||||
def find_or_create(site, %{"goal_type" => "page", "page_path" => _} = params, opts) do
|
||||
create(site, params, do_upsert(opts))
|
||||
end
|
||||
|
||||
def find_or_create(_, %{"goal_type" => "page"}), do: {:missing, "page_path"}
|
||||
def find_or_create(_, %{"goal_type" => "page"}, _), do: {:missing, "page_path"}
|
||||
|
||||
def list_revenue_goals(site) do
|
||||
from(g in Plausible.Goal,
|
||||
where: g.site_id == ^site.id and not is_nil(g.currency),
|
||||
select: %{display_name: g.display_name, currency: g.currency}
|
||||
select: %{display_name: g.display_name, currency: g.currency},
|
||||
order_by: [desc: g.id],
|
||||
limit: ^max_goals_per_site()
|
||||
)
|
||||
|> Plausible.Repo.all()
|
||||
end
|
||||
|
|
@ -133,13 +165,21 @@ defmodule Plausible.Goals do
|
|||
|> Enum.map(&maybe_trim/1)
|
||||
end
|
||||
|
||||
def for_site_query(site, opts \\ []) do
|
||||
def for_site_query(site \\ nil, opts \\ []) do
|
||||
query =
|
||||
from g in Goal,
|
||||
inner_join: assoc(g, :site),
|
||||
where: g.site_id == ^site.id,
|
||||
order_by: [desc: g.id],
|
||||
preload: [:site]
|
||||
limit: ^max_goals_per_site(opts)
|
||||
|
||||
query =
|
||||
if site do
|
||||
from g in query,
|
||||
inner_join: assoc(g, :site),
|
||||
where: g.site_id == ^site.id,
|
||||
preload: [:site]
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
if ee?() and opts[:preload_funnels?] == true do
|
||||
from(g in query,
|
||||
|
|
@ -152,9 +192,9 @@ defmodule Plausible.Goals do
|
|||
end
|
||||
end
|
||||
|
||||
def batch_create_event_goals(names, site) do
|
||||
def batch_create_event_goals(names, site, opts \\ []) do
|
||||
Enum.reduce(names, [], fn name, acc ->
|
||||
case insert_goal(site, %{event_name: name}, true) do
|
||||
case insert_goal(site, %{event_name: name}, do_upsert(opts)) do
|
||||
{:ok, _, goal} -> acc ++ [goal]
|
||||
_ -> acc
|
||||
end
|
||||
|
|
@ -248,25 +288,25 @@ defmodule Plausible.Goals do
|
|||
|
||||
@spec create_outbound_links(Plausible.Site.t()) :: :ok
|
||||
def create_outbound_links(%Plausible.Site{} = site) do
|
||||
create(site, %{"event_name" => "Outbound Link: Click"}, upsert?: true)
|
||||
create(site, %{"event_name" => "Outbound Link: Click"}, do_upsert())
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec create_file_downloads(Plausible.Site.t()) :: :ok
|
||||
def create_file_downloads(%Plausible.Site{} = site) do
|
||||
create(site, %{"event_name" => "File Download"}, upsert?: true)
|
||||
create(site, %{"event_name" => "File Download"}, do_upsert())
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec create_form_submissions(Plausible.Site.t()) :: :ok
|
||||
def create_form_submissions(%Plausible.Site{} = site) do
|
||||
create(site, %{"event_name" => "Form: Submission"}, upsert?: true)
|
||||
create(site, %{"event_name" => "Form: Submission"}, do_upsert())
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec create_404(Plausible.Site.t()) :: :ok
|
||||
def create_404(%Plausible.Site{} = site) do
|
||||
create(site, %{"event_name" => "404"}, upsert?: true)
|
||||
create(site, %{"event_name" => "404"}, do_upsert())
|
||||
:ok
|
||||
end
|
||||
|
||||
|
|
@ -314,11 +354,11 @@ defmodule Plausible.Goals do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp insert_goal(site, params, upsert?) do
|
||||
defp insert_goal(site, params, opts) do
|
||||
params = Map.delete(params, "site_id")
|
||||
|
||||
insert_opts =
|
||||
if upsert? do
|
||||
if upsert?(opts) do
|
||||
[on_conflict: :nothing]
|
||||
else
|
||||
[]
|
||||
|
|
@ -328,6 +368,7 @@ defmodule Plausible.Goals do
|
|||
|
||||
with :ok <- maybe_check_feature_access(site, changeset),
|
||||
:ok <- check_no_currency_if_consolidated(site, changeset),
|
||||
:ok <- check_goals_limit(site, changeset, opts),
|
||||
{:ok, goal} <- Repo.insert(changeset, insert_opts) do
|
||||
# Upsert with `on_conflict: :nothing` strategy
|
||||
# will result in goal struct missing primary key field
|
||||
|
|
@ -346,7 +387,7 @@ defmodule Plausible.Goals do
|
|||
end
|
||||
|
||||
defp maybe_check_feature_access(site, changeset) do
|
||||
if Ecto.Changeset.get_field(changeset, :currency) do
|
||||
if Changeset.get_field(changeset, :currency) do
|
||||
site = Plausible.Repo.preload(site, :team)
|
||||
Plausible.Billing.Feature.RevenueGoals.check_availability(site.team)
|
||||
else
|
||||
|
|
@ -354,8 +395,23 @@ defmodule Plausible.Goals do
|
|||
end
|
||||
end
|
||||
|
||||
defp check_goals_limit(site, changeset, opts) do
|
||||
if upsert?(opts) and goal_exists_for_upsert?(site, changeset) do
|
||||
:ok
|
||||
else
|
||||
if count(site) >= max_goals_per_site(opts) and changeset.valid? do
|
||||
changeset
|
||||
|> Changeset.add_error(:page_path, "Maximum number of goals reached")
|
||||
|> Changeset.add_error(:event_name, "Maximum number of goals reached")
|
||||
|> Changeset.apply_action(:insert)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp check_no_currency_if_consolidated(site, changeset) do
|
||||
if Plausible.Sites.consolidated?(site) && Ecto.Changeset.get_field(changeset, :currency) do
|
||||
if Plausible.Sites.consolidated?(site) && Changeset.get_field(changeset, :currency) do
|
||||
{:error, :revenue_goals_unavailable}
|
||||
else
|
||||
:ok
|
||||
|
|
@ -377,4 +433,29 @@ defmodule Plausible.Goals do
|
|||
defp maybe_trim(other) do
|
||||
other
|
||||
end
|
||||
|
||||
defp upsert?(opts) do
|
||||
Keyword.get(opts, :upsert?, false)
|
||||
end
|
||||
|
||||
defp do_upsert(opts \\ []) do
|
||||
Keyword.put(opts, :upsert?, true)
|
||||
end
|
||||
|
||||
defp goal_exists_for_upsert?(site, changeset) do
|
||||
event_name = Changeset.get_field(changeset, :event_name)
|
||||
page_path = Changeset.get_field(changeset, :page_path)
|
||||
scroll_threshold = Changeset.get_field(changeset, :scroll_threshold)
|
||||
|
||||
query_params =
|
||||
[site_id: site.id]
|
||||
|> maybe_add_filter(:event_name, event_name)
|
||||
|> maybe_add_filter(:page_path, page_path)
|
||||
|> maybe_add_filter(:scroll_threshold, scroll_threshold)
|
||||
|
||||
Repo.exists?(from(g in Goal, where: ^query_params))
|
||||
end
|
||||
|
||||
defp maybe_add_filter(params, _key, nil), do: params
|
||||
defp maybe_add_filter(params, key, value), do: Keyword.put(params, key, value)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ defmodule Plausible.Google.GA4.API do
|
|||
end
|
||||
end
|
||||
|
||||
defp prepare_request(report_request, date_range, property, access_token) do
|
||||
defp prepare_request(%GA4.ReportRequest{} = report_request, date_range, property, access_token) do
|
||||
%GA4.ReportRequest{
|
||||
report_request
|
||||
| date_range: date_range,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule Plausible.Segments.Filters do
|
|||
This module contains functions that enable resolving segments in filters.
|
||||
"""
|
||||
alias Plausible.Segments
|
||||
alias Plausible.Stats.Filters
|
||||
alias Plausible.Stats.{Filters, ApiQueryParser}
|
||||
|
||||
@max_segment_filters_count 10
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ defmodule Plausible.Segments.Filters do
|
|||
segments,
|
||||
%{},
|
||||
fn %Segments.Segment{id: id, segment_data: segment_data} ->
|
||||
case Filters.QueryParser.parse_filters(segment_data["filters"]) do
|
||||
case ApiQueryParser.parse_filters(segment_data["filters"]) do
|
||||
{:ok, filters} -> {id, filters}
|
||||
_ -> {id, nil}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ defmodule Plausible.Segments.Segment do
|
|||
"""
|
||||
def build_naive_query_from_segment_data(%Plausible.Site{} = site, filters),
|
||||
do:
|
||||
Plausible.Stats.Query.build(
|
||||
Plausible.Stats.Query.parse_and_build(
|
||||
site,
|
||||
:internal,
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ defmodule Plausible.Shield.CountryRuleCache do
|
|||
|> where([rule, site], rule.country_code == ^country_code and site.domain == ^domain)
|
||||
|
||||
case Plausible.Repo.one(query) do
|
||||
{_, _, rule} -> %CountryRule{rule | from_cache?: false}
|
||||
{_, _, rule = %CountryRule{}} -> %CountryRule{rule | from_cache?: false}
|
||||
_any -> nil
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ defmodule Plausible.Shield.HostnameRuleCache do
|
|||
|
||||
case Plausible.Repo.all(query) do
|
||||
[_ | _] = results ->
|
||||
Enum.map(results, fn {_, _, rule} ->
|
||||
Enum.map(results, fn {_, _, rule = %HostnameRule{}} ->
|
||||
%HostnameRule{rule | from_cache?: false}
|
||||
end)
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ defmodule Plausible.Shield.IPRuleCache do
|
|||
|> where([rule, site], rule.inet == ^address and site.domain == ^domain)
|
||||
|
||||
case Plausible.Repo.one(query) do
|
||||
{_, _, rule} -> %IPRule{rule | from_cache?: false}
|
||||
{_, _, rule = %IPRule{}} -> %IPRule{rule | from_cache?: false}
|
||||
_any -> nil
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ defmodule Plausible.Shield.PageRuleCache do
|
|||
|> where([..., site], site.domain == ^domain)
|
||||
|
||||
case Plausible.Repo.one(query) do
|
||||
{_, _, rule} -> %PageRule{rule | from_cache?: false}
|
||||
{_, _, rule = %PageRule{}} -> %PageRule{rule | from_cache?: false}
|
||||
_any -> nil
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue