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