Compare commits
68 Commits
v3.1.0-rc.
...
master
| 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 | |
|
|
b624f39d17 | |
|
|
08c7d2e948 | |
|
|
dfbf0a9f4e | |
|
|
b0e8c8bdd0 | |
|
|
f24aa4f305 | |
|
|
0274f25a9e | |
|
|
62e9ec5f05 | |
|
|
49c9cabaed | |
|
|
839aebd782 | |
|
|
1a5eba85e7 | |
|
|
af7dd46458 | |
|
|
2ca24e77cc | |
|
|
040fb349f7 | |
|
|
16cbc07f3d | |
|
|
a07aaa67eb | |
|
|
592dc8ed97 | |
|
|
d0ba8f7bd0 | |
|
|
7de8526b6a |
|
|
@ -117,6 +117,11 @@
|
|||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, false},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, []},
|
||||
{Credo.Check.Refactor.RejectReject, []},
|
||||
{Credo.Check.Refactor.FunctionArity, []},
|
||||
{Credo.Check.Refactor.LongQuoteBlocks, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
|
|
@ -133,6 +138,7 @@
|
|||
#
|
||||
## Warnings
|
||||
#
|
||||
{Credo.Check.Warning.Dbg, []},
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues, []},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ jobs:
|
|||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
|
|
@ -91,7 +91,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ jobs:
|
|||
codespell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: codespell-project/actions-codespell@v2
|
||||
with:
|
||||
check_filenames: true
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CACHE_VERSION: v15
|
||||
CACHE_VERSION: v17
|
||||
PERSISTENT_CACHE_DIR: cached
|
||||
|
||||
jobs:
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ jobs:
|
|||
elixir-version: ${{ steps.versions.outputs.elixir }}
|
||||
otp-version: ${{ steps.versions.outputs.erlang }}
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
|
|
@ -114,13 +114,26 @@ jobs:
|
|||
|
||||
- run: make minio
|
||||
if: env.MIX_ENV == 'test'
|
||||
- run: mix test --include slow --include minio --include migrations --include kaffy_quirks --max-failures 1 --warnings-as-errors --partitions 6
|
||||
- run: |
|
||||
mix test --include slow --include minio --include migrations --max-failures 1 --warnings-as-errors --partitions 6 | tee test_output.log
|
||||
if grep -E '\.+[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3} \[[^]]+\]' test_output.log | grep -v 'libcluster'; then
|
||||
echo "The tests are producing output, this usually indicates some error"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
if: env.MIX_ENV == 'test'
|
||||
env:
|
||||
MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1"
|
||||
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
|
||||
|
||||
- run: mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 4
|
||||
|
||||
- run: |
|
||||
mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 4 | tee test_output.log
|
||||
if grep -E '\.+[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3} \[[^]]+\]' test_output.log | grep -v 'libcluster'; then
|
||||
echo "The tests are producing output, this usually indicates some error"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
if: env.MIX_ENV == 'ce_test'
|
||||
env:
|
||||
MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }}
|
||||
|
|
@ -131,7 +144,7 @@ jobs:
|
|||
MIX_ENV: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
@ -142,7 +155,7 @@ jobs:
|
|||
elixir-version: ${{ steps.versions.outputs.elixir }}
|
||||
otp-version: ${{ steps.versions.outputs.erlang }}
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Read .tool-versions
|
||||
uses: marocchino/tool-versions-action@v1
|
||||
id: versions
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Read .tool-versions
|
||||
uses: marocchino/tool-versions-action@v1
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
otp-version: ${{ steps.versions.outputs.erlang}}
|
||||
|
||||
- name: Restore Elixir dependencies cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
deps
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
fetch-depth: 1
|
||||
|
||||
- name: Checkout master for comparison
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
path: master-branch
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
|
||||
- name: Get changed files
|
||||
id: changelog_changed
|
||||
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891
|
||||
with:
|
||||
files: |
|
||||
tracker/npm_package/CHANGELOG.md
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 23.2.0
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm --prefix ./tracker ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
|
|
@ -49,7 +49,7 @@ jobs:
|
|||
run: npm --prefix ./tracker test -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: tracker/blob-report
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 23.2.0
|
||||
|
|
@ -70,7 +70,7 @@ jobs:
|
|||
run: npm --prefix ./tracker ci
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
|
|
|
|||
|
|
@ -97,3 +97,5 @@ plausible-report.xml
|
|||
# Docker volumes
|
||||
.clickhouse_db_vol*
|
||||
plausible_db*
|
||||
|
||||
.claude
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
erlang 27.3.1
|
||||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4.6
|
||||
elixir 1.19.4-otp-27
|
||||
nodejs 23.2.0
|
||||
|
|
|
|||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -6,6 +6,24 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown
|
||||
|
||||
### Removed
|
||||
|
||||
### Changed
|
||||
|
||||
- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards
|
||||
|
||||
### Fixed
|
||||
|
||||
- To make internal stats API requests for password-protected shared links, shared link auth cookie must be set in the requests
|
||||
- Fixed issue with site guests in Editor role and team members in Editor role not being able to change the domain of site
|
||||
- Fixed direct dashboard links that use legacy dashboard filters containing URL encoded special characters (e.g. character `ê` in the legacy filter `?page=%C3%AA`)
|
||||
|
||||
## v3.1.0 - 2025-11-13
|
||||
|
||||
### Added
|
||||
|
||||
- Custom events can now be marked as non-interactive in events API and tracker script: events marked as non-interactive are not counted towards bounce rate
|
||||
- Ability to leave team via Team Settings > Leave Team
|
||||
- Stats APIv2 now supports `include.trim_relative_date_range` - this option allows trimming empty values after current time for `day`, `month` and `year` date_range values
|
||||
|
|
@ -21,6 +39,7 @@ All notable changes to this project will be documented in this file.
|
|||
- The new tracker script automatically updates to respect the following configuration options available in "New site" flows and "Review installation" flows: whether to track outbound links, file downloads, form submissions
|
||||
- The new tracker script allows overriding almost all options by changing the snippet on the website, with the function `plausible.init({ ...your overrides... })` - this can be unique page-by-page
|
||||
- A new `@plausible-analytics/tracker` ESM module is available on NPM - it has near-identical configuration API and identical tracking logic as the script and it receives bugfixes and updates concurrently with the new tracker script
|
||||
- Ability to enforce enabling 2FA by all team members
|
||||
|
||||
### Removed
|
||||
|
||||
|
|
@ -32,6 +51,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Main graph no longer shows empty values after current time for `day`, `month` and `year` periods
|
||||
- Include `bounce_rate` metric in Entry Pages breakdown
|
||||
- Dark mode theme has been refined with darker color scheme and better visual hierarchy
|
||||
- Creating shared links now happens in a modal
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
@ -44,6 +64,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Fixed unhandled tracker-related exceptions on link clicks within svgs
|
||||
- Remove Subscription and Invoices menu from CE
|
||||
- Fix email sending error "Mua.SMTPError" 503 Bad sequence of commands
|
||||
- Make button to include / exclude imported data visible on Safari
|
||||
|
||||
## v3.0.0 - 2025-04-11
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# we can not use the pre-built tar because the distribution is
|
||||
# platform specific, it makes sense to build it in the docker
|
||||
|
||||
ARG ALPINE_VERSION=3.22.2
|
||||
|
||||
#### Builder
|
||||
FROM hexpm/elixir:1.18.3-erlang-27.3.1-alpine-3.21.3 AS buildcontainer
|
||||
FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-${ALPINE_VERSION} AS buildcontainer
|
||||
|
||||
ARG MIX_ENV=ce
|
||||
|
||||
|
|
@ -20,7 +22,7 @@ RUN mkdir /app
|
|||
WORKDIR /app
|
||||
|
||||
# install build dependencies
|
||||
RUN apk add --no-cache git "nodejs-current=23.2.0-r1" yarn npm python3 ca-certificates wget gnupg make gcc libc-dev brotli
|
||||
RUN apk add --no-cache git "nodejs-current=23.11.1-r0" yarn npm python3 ca-certificates wget gnupg make gcc libc-dev brotli
|
||||
|
||||
COPY mix.exs ./
|
||||
COPY mix.lock ./
|
||||
|
|
@ -54,7 +56,7 @@ COPY rel rel
|
|||
RUN mix release plausible
|
||||
|
||||
# Main Docker Image
|
||||
FROM alpine:3.21.3
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
LABEL maintainer="plausible.io <hello@plausible.io>"
|
||||
|
||||
ARG BUILD_METADATA={}
|
||||
|
|
@ -84,3 +86,4 @@ EXPOSE 8000
|
|||
ENV DEFAULT_DATA_DIR=/var/lib/plausible
|
||||
VOLUME /var/lib/plausible
|
||||
CMD ["run"]
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,10 @@ Here's what makes Plausible a great Google Analytics alternative and why we're t
|
|||
- **Lightweight**: Plausible Analytics works by loading a script on your website, like Google Analytics. Our script is [small](https://plausible.io/lightweight-web-analytics), making your website quicker to load. You can also send events directly to our [events API](https://plausible.io/docs/events-api).
|
||||
- **Email or Slack reports**: Keep an eye on your traffic with weekly and/or monthly email or Slack reports. You can also get traffic spike notifications.
|
||||
- **Invite team members and share stats**: You have the option to be transparent and open your web analytics to everyone. Your website stats are private by default but you can choose to make them public so anyone with your custom link can view them. You can [invite team members](https://plausible.io/docs/users-roles) and assign user roles too.
|
||||
- **Define key goals and track conversions**: Create custom events with custom dimensions to track conversions and attribution to understand and identify the trends that matter. Track ecommerce revenue, outbound link clicks, file downloads and 404 error pages. Increase conversions using funnel analysis.
|
||||
- **Define key goals and track conversions**: Create custom events with custom dimensions to track conversions and attribution to understand and identify the trends that matter. Track ecommerce revenue, outbound link clicks, form completions, file downloads and 404 error pages. Increase conversions using funnel analysis.
|
||||
- **Search keywords**: Integrate your dashboard with Google Search Console to get the most accurate reporting on your search keywords.
|
||||
- **SPA support**: Plausible is built with modern web frameworks in mind and it works automatically with any pushState based router on the frontend. We also support frameworks that use the URL hash for routing. See [our documentation](https://plausible.io/docs/hash-based-routing).
|
||||
- **Smooth transition from Google Analytics**: There's a realtime dashboard, entry pages report and integration with Search Console. You can track your paid campaigns and conversions. You can invite team members. You can even [import your historical Google Analytics stats](https://plausible.io/docs/google-analytics-import). Learn how to [get the most out of your Plausible experience](https://plausible.io/docs/your-plausible-experience) and join thousands who have already migrated from Google Analytics.
|
||||
- **Smooth transition from Google Analytics**: There's a realtime dashboard, entry pages report and integration with Search Console. You can track your paid campaigns and conversions. You can invite team members. You can even [import your historical Google Analytics stats](https://plausible.io/docs/google-analytics-import) and there's [a Google Tag Manager template](https://plausible.io/gtm-template) too. Learn how to [get the most out of your Plausible experience](https://plausible.io/docs/your-plausible-experience) and join thousands who have already migrated from Google Analytics.
|
||||
|
||||
Interested to learn more? [Read more on our website](https://plausible.io), learn more about the team and the goals of the project on [our about page](https://plausible.io/about) or explore [the documentation](https://plausible.io/docs).
|
||||
|
||||
|
|
@ -63,10 +63,10 @@ Plausible is [open source web analytics](https://plausible.io/open-source-websit
|
|||
| ------------- | ------------- | ------------- |
|
||||
| **Infrastructure management** | Easy and convenient. It takes 2 minutes to start counting your stats with a worldwide CDN, high availability, backups, security and maintenance all done for you by us. We manage everything so you don’t have to worry about anything and can focus on your stats. | You do it all yourself. You need to get a server and you need to manage your infrastructure. You are responsible for installation, maintenance, upgrades, server capacity, uptime, backup, security, stability, consistency, loading time and so on.|
|
||||
| **Release schedule** | Continuously developed and improved with new features and updates multiple times per week. | [It's a long term release](https://plausible.io/blog/building-open-source) published twice per year so latest features and improvements won't be immediately available.|
|
||||
| **Premium features** | All features available as listed in [our pricing plans](https://plausible.io/#pricing). | Premium features (marketing funnels, ecommerce revenue goals and sites API) are not available in order to help support [the project's long-term sustainability](https://plausible.io/blog/community-edition).|
|
||||
| **Premium features** | All features available as listed in [our pricing plans](https://plausible.io/#pricing). | Premium features (marketing funnels, ecommerce revenue goals, SSO and sites API) are not available in order to help support [the project's long-term sustainability](https://plausible.io/blog/community-edition).|
|
||||
| **Bot filtering** | Advanced bot filtering for more accurate stats. Our algorithm detects and excludes non-human traffic patterns. We also exclude known bots by the User-Agent header and filter out traffic from data centers and referrer spam domains. We exclude ~32K data center IP ranges (i.e. a lot of bot IP addresses) by default. | Basic bot filtering that targets the most common non-human traffic based on the User-Agent header and referrer spam domains.|
|
||||
| **Server location** | All visitor data is exclusively processed on EU-owned cloud infrastructure. We keep your site data on a secure, encrypted and green energy powered server in Germany. This ensures that your site data is protected by the strict European Union data privacy laws and ensures compliance with GDPR. Your website data never leaves the EU. | You have full control and can host your instance on any server in any country that you wish. Host it on a server in your basement or host it with any cloud provider wherever you want, even those that are not GDPR compliant.|
|
||||
| **Data portability** | You see all your site stats and metrics on our modern-looking, simple to use and fast loading dashboard. You can only see the stats aggregated in the dashboard. You can download the stats using the [CSV export](https://plausible.io/docs/export-stats), [stats API](https://plausible.io/docs/stats-api) or the [Looker Studio Connector](https://plausible.io/docs/looker-studio). | Do you want access to the raw data? Self-hosting gives you that option. You can take the data directly from the ClickHouse database. |
|
||||
| **Data portability** | You see all your site stats and metrics on our modern-looking, simple to use and fast loading dashboard. You can only see the stats aggregated in the dashboard. You can download the stats using the [CSV export](https://plausible.io/docs/export-stats), [stats API](https://plausible.io/docs/stats-api) or the [Looker Studio Connector](https://plausible.io/docs/looker-studio). | Do you want access to the raw data? Self-hosting gives you that option. You can take the data directly from the ClickHouse database. The Looker Studio Connector is not available. |
|
||||
| **Premium support** | Real support delivered by real human beings who build and maintain Plausible. | Premium support is not included. CE is community supported only.|
|
||||
| **Costs** | There's a cost associated with providing an analytics service so we charge a subscription fee. We choose the subscription business model rather than the business model of surveillance capitalism. Your money funds further development of Plausible. | You need to pay for your server, CDN, backups and whatever other cost there is associated with running the infrastructure. You never have to pay any fees to us. Your money goes to 3rd party companies with no connection to us.|
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep:*)",
|
||||
"Read(//Users/ukutaht/plausible/analytics/lib/**)",
|
||||
"Bash(node:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@
|
|||
--color-gray-950: var(--color-zinc-950);
|
||||
|
||||
/* Custom gray shades from config (override some zinc values) */
|
||||
--color-gray-75: rgb(247 247 248);
|
||||
--color-gray-150: rgb(236 236 238);
|
||||
--color-gray-750: rgb(50 50 54);
|
||||
--color-gray-825: rgb(35 35 38);
|
||||
|
|
@ -294,16 +295,12 @@ blockquote {
|
|||
display: inline;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-child(odd) {
|
||||
background-color: var(--color-gray-100);
|
||||
.table-striped tbody tr:nth-child(odd) td {
|
||||
background-color: var(--color-gray-75);
|
||||
}
|
||||
|
||||
.dark .table-striped tbody tr:nth-child(odd) {
|
||||
background-color: var(--color-gray-800);
|
||||
}
|
||||
|
||||
.dark .table-striped tbody tr:nth-child(even) {
|
||||
background-color: var(--color-gray-900);
|
||||
.dark .table-striped tbody tr:nth-child(odd) td {
|
||||
background-color: var(--color-gray-850);
|
||||
}
|
||||
|
||||
.fade-enter {
|
||||
|
|
|
|||
|
|
@ -32,33 +32,6 @@
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal__container {
|
||||
background-color: #fff;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 4px;
|
||||
margin: 50px auto;
|
||||
box-sizing: border-box;
|
||||
min-height: 509px;
|
||||
transition: height 200ms ease-in;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
position: fixed;
|
||||
color: #b8c2cc;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
top: 12px;
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.modal__close::before {
|
||||
content: '\2715';
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@keyframes mm-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ if (container && container.dataset) {
|
|||
team: {
|
||||
identifier: container.dataset.teamIdentifier ?? null,
|
||||
hasConsolidatedView:
|
||||
container.dataset.teamHasConsolidatedView === 'true'
|
||||
container.dataset.consolidatedViewAvailable === 'true'
|
||||
}
|
||||
}
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Component used for embedding LiveView components inside React.
|
||||
*
|
||||
* The content of the portal is completely excluded from React re-renders with
|
||||
* a hardwired `React.memo`.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const MIN_HEIGHT = 380
|
||||
|
||||
type LiveViewPortalProps = {
|
||||
id: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const LiveViewPortal = React.memo(
|
||||
function ({ id, className }: LiveViewPortalProps) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={classNames('group', className)}
|
||||
style={{ width: '100%', border: '0', minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="w-full flex flex-col justify-center group-has-[[data-phx-teleported]]:hidden"
|
||||
style={{ minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
<div className="mx-auto loading">
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
() => true
|
||||
)
|
||||
|
|
@ -66,7 +66,7 @@ export const SearchInput = ({
|
|||
type="text"
|
||||
placeholder={isFocused ? placeholderFocused : placeholderUnfocused}
|
||||
className={classNames(
|
||||
'dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 w-48 dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500',
|
||||
'text-sm dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 max-w-64 w-full dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500',
|
||||
className
|
||||
)}
|
||||
onChange={debouncedOnSearchInputChange}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,18 @@ export const SortButton = ({
|
|||
return (
|
||||
<button
|
||||
onClick={toggleSort}
|
||||
className={classNames('group', 'hover:underline', 'relative')}
|
||||
className={classNames(
|
||||
'group',
|
||||
'hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-100',
|
||||
'relative'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
title={next.hint}
|
||||
className={classNames(
|
||||
'absolute',
|
||||
'rounded inline-block h-4 w-4',
|
||||
'rounded inline-block size-4',
|
||||
'ml-1',
|
||||
{
|
||||
[SortDirection.asc]: 'rotate-180',
|
||||
|
|
@ -30,9 +34,8 @@ export const SortButton = ({
|
|||
}[sortDirection ?? next.direction],
|
||||
!sortDirection && 'opacity-0',
|
||||
!sortDirection && 'group-hover:opacity-100',
|
||||
sortDirection &&
|
||||
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
|
||||
'transition'
|
||||
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
|
||||
'transition-all duration-100'
|
||||
)}
|
||||
>
|
||||
↓
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export type ColumnConfiguraton<T extends Record<string, unknown>> = {
|
|||
/**
|
||||
* Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
|
||||
*/
|
||||
renderValue?: (item: T) => ReactNode
|
||||
renderValue?: (item: T, isRowHovered?: boolean) => ReactNode
|
||||
/** Function used to create richer cells */
|
||||
renderItem?: (item: T) => ReactNode
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export const TableHeaderCell = ({
|
|||
return (
|
||||
<th
|
||||
className={classNames(
|
||||
'p-2 text-xs font-bold text-gray-500 dark:text-gray-400 tracking-wide',
|
||||
'p-2 text-xs font-semibold text-gray-500 dark:text-gray-400',
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
|
|
@ -58,7 +58,13 @@ export const TableCell = ({
|
|||
align?: 'left' | 'right'
|
||||
}) => {
|
||||
return (
|
||||
<td className={classNames('p-2 font-medium', className)} align={align}>
|
||||
<td
|
||||
className={classNames(
|
||||
'p-2 font-medium first:rounded-s-sm last:rounded-e-sm',
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
|
|
@ -68,15 +74,42 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
|
|||
rowIndex,
|
||||
pageIndex,
|
||||
item,
|
||||
columns
|
||||
columns,
|
||||
tappedRowName,
|
||||
onRowTap
|
||||
}: {
|
||||
rowIndex: number
|
||||
pageIndex?: number
|
||||
item: T
|
||||
columns: ColumnConfiguraton<T>[]
|
||||
tappedRowName?: string | null
|
||||
onRowTap?: (rowName: string | null) => void
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
const rowName = (item as unknown as { name: string }).name
|
||||
const isTapped = tappedRowName === rowName
|
||||
const isRowActive = isHovered || isTapped
|
||||
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
|
||||
if (onRowTap) {
|
||||
if (isTapped) {
|
||||
onRowTap(null)
|
||||
} else {
|
||||
onRowTap(rowName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="text-sm dark:text-gray-200">
|
||||
<tr
|
||||
className="group text-sm dark:text-gray-200 md:cursor-default cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{columns.map(({ key, width, align, renderValue, renderItem }) => (
|
||||
<TableCell
|
||||
key={`${(pageIndex ?? null) === null ? '' : `page_${pageIndex}_`}row_${rowIndex}_${String(key)}`}
|
||||
|
|
@ -86,7 +119,7 @@ export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
|
|||
{renderItem
|
||||
? renderItem(item)
|
||||
: renderValue
|
||||
? renderValue(item)
|
||||
? renderValue(item, isRowActive)
|
||||
: (item[key] ?? '')}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
@ -101,6 +134,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
columns: ColumnConfiguraton<T>[]
|
||||
data: T[] | { pages: T[][] }
|
||||
}) => {
|
||||
const [tappedRowName, setTappedRowName] = React.useState<string | null>(null)
|
||||
|
||||
const renderColumnLabel = (column: ColumnConfiguraton<T>) => {
|
||||
if (column.metricWarning) {
|
||||
return (
|
||||
|
|
@ -125,13 +160,13 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
}
|
||||
|
||||
return (
|
||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
||||
<thead>
|
||||
<tr className="text-xs font-bold text-gray-500 dark:text-gray-400">
|
||||
<table className="border-collapse table-striped table-fixed w-max min-w-full">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-900 z-10">
|
||||
<tr className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
{columns.map((column) => (
|
||||
<TableHeaderCell
|
||||
key={`header_${String(column.key)}`}
|
||||
className={classNames('p-2 tracking-wide', column.width)}
|
||||
className={classNames('p-2', column.width)}
|
||||
align={column.align}
|
||||
>
|
||||
{column.onSort ? (
|
||||
|
|
@ -156,6 +191,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
columns={columns}
|
||||
rowIndex={rowIndex}
|
||||
key={rowIndex}
|
||||
tappedRowName={tappedRowName}
|
||||
onRowTap={setTappedRowName}
|
||||
/>
|
||||
))
|
||||
: data.pages.map((page, pageIndex) =>
|
||||
|
|
@ -166,6 +203,8 @@ export const Table = <T extends Record<string, string | number | ReactNode>>({
|
|||
rowIndex={rowIndex}
|
||||
pageIndex={pageIndex}
|
||||
key={`page_${pageIndex}_row_${rowIndex}`}
|
||||
tappedRowName={tappedRowName}
|
||||
onRowTap={setTappedRowName}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ const Items = ({
|
|||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
placeholderUnfocused="Press / to search"
|
||||
className="ml-auto w-full py-1 text-sm"
|
||||
className="ml-auto w-full py-1"
|
||||
onSearch={handleSearchInput}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
SegmentType,
|
||||
SavedSegment,
|
||||
SegmentData,
|
||||
canSeeSegmentDetails
|
||||
canExpandSegment
|
||||
} from './segments'
|
||||
import { Filter } from '../query'
|
||||
import { PlausibleSite } from '../site-context'
|
||||
|
|
@ -183,34 +183,124 @@ describe(`${resolveFilters.name}`, () => {
|
|||
)
|
||||
})
|
||||
|
||||
describe(`${canSeeSegmentDetails.name}`, () => {
|
||||
it('should return true if the user is logged in and not a public role', () => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role: Role.admin,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
describe(`${canExpandSegment.name}`, () => {
|
||||
it.each([[Role.admin], [Role.editor], [Role.owner]])(
|
||||
'allows expanding site segment if the user is logged in and in the role %p',
|
||||
(role) => {
|
||||
const site = { siteSegmentsAvailable: true }
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 1, type: SegmentType.site },
|
||||
user,
|
||||
site
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
expect(canSeeSegmentDetails({ user })).toBe(true)
|
||||
)
|
||||
|
||||
it('allows expanding site segments defined by other users', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 222, type: SegmentType.site },
|
||||
user: {
|
||||
loggedIn: true,
|
||||
role: Role.owner,
|
||||
id: 111,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: true }
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if the user is not logged in', () => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: false,
|
||||
role: Role.editor,
|
||||
id: null,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
expect(canSeeSegmentDetails({ user })).toBe(false)
|
||||
it('forbids expanding site segment if site segments are not available', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 1, type: SegmentType.site },
|
||||
user: {
|
||||
loggedIn: true,
|
||||
role: Role.owner,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if the user has a public role', () => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role: Role.public,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
it('forbids public role from expanding site segments', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: null, type: SegmentType.site },
|
||||
user: {
|
||||
loggedIn: false,
|
||||
role: Role.public,
|
||||
id: null,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[Role.viewer],
|
||||
[Role.billing],
|
||||
[Role.editor],
|
||||
[Role.admin],
|
||||
[Role.owner]
|
||||
])(
|
||||
'allows expanding personal segment if it belongs to the user and the user is in role %p',
|
||||
(role) => {
|
||||
const user: UserContextValue = {
|
||||
loggedIn: true,
|
||||
role,
|
||||
id: 1,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
}
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 1, type: SegmentType.personal },
|
||||
user,
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
expect(canSeeSegmentDetails({ user })).toBe(false)
|
||||
)
|
||||
|
||||
it('forbids expanding personal segment of other users', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 2, owner_id: 222, type: SegmentType.personal },
|
||||
user: {
|
||||
loggedIn: true,
|
||||
role: Role.owner,
|
||||
id: 111,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('forbids public role from expanding personal segments', () => {
|
||||
expect(
|
||||
canExpandSegment({
|
||||
segment: { id: 1, owner_id: 1, type: SegmentType.personal },
|
||||
user: {
|
||||
loggedIn: false,
|
||||
role: Role.public,
|
||||
id: null,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
site: { siteSegmentsAvailable: false }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ export enum SegmentType {
|
|||
site = 'site'
|
||||
}
|
||||
|
||||
/** keep in sync with Plausible.Segments */
|
||||
const ROLES_WITH_MAYBE_SITE_SEGMENTS = [Role.admin, Role.editor, Role.owner]
|
||||
const ROLES_WITH_PERSONAL_SEGMENTS = [
|
||||
Role.billing,
|
||||
Role.viewer,
|
||||
Role.admin,
|
||||
Role.editor,
|
||||
Role.owner
|
||||
]
|
||||
|
||||
/** This type signifies that the owner can't be shown. */
|
||||
type SegmentOwnershipHidden = { owner_id: null; owner_name: null }
|
||||
|
||||
|
|
@ -148,6 +158,36 @@ export function resolveFilters(
|
|||
})
|
||||
}
|
||||
|
||||
export function canExpandSegment({
|
||||
segment,
|
||||
site,
|
||||
user
|
||||
}: {
|
||||
segment: Pick<SavedSegment, 'id' | 'owner_id' | 'type'>
|
||||
site: Pick<PlausibleSite, 'siteSegmentsAvailable'>
|
||||
user: UserContextValue
|
||||
}) {
|
||||
if (
|
||||
segment.type === SegmentType.site &&
|
||||
site.siteSegmentsAvailable &&
|
||||
user.loggedIn &&
|
||||
ROLES_WITH_MAYBE_SITE_SEGMENTS.includes(user.role)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
segment.type === SegmentType.personal &&
|
||||
user.loggedIn &&
|
||||
ROLES_WITH_PERSONAL_SEGMENTS.includes(user.role) &&
|
||||
user.id === segment.owner_id
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isListableSegment({
|
||||
segment,
|
||||
site,
|
||||
|
|
@ -173,10 +213,6 @@ export function isListableSegment({
|
|||
return false
|
||||
}
|
||||
|
||||
export function canSeeSegmentDetails({ user }: { user: UserContextValue }) {
|
||||
return user.loggedIn && user.role !== Role.public
|
||||
}
|
||||
|
||||
export function findAppliedSegmentFilter({ filters }: { filters: Filter[] }) {
|
||||
const segmentFilter = filters.find(isSegmentFilter)
|
||||
if (!segmentFilter) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { LiveViewPortal } from './components/liveview-portal'
|
||||
import VisitorGraph from './stats/graph/visitor-graph'
|
||||
import Sources from './stats/sources'
|
||||
import Pages from './stats/pages'
|
||||
|
|
@ -7,7 +8,10 @@ import Devices from './stats/devices'
|
|||
import { TopBar } from './nav-menu/top-bar'
|
||||
import Behaviours from './stats/behaviours'
|
||||
import { useQueryContext } from './query-context'
|
||||
import { useSiteContext } from './site-context'
|
||||
import { isRealTimeDashboard } from './util/filters'
|
||||
import { useAppNavigate } from './navigation/use-app-navigate'
|
||||
import { parseSearch } from './util/url-search-params'
|
||||
|
||||
function DashboardStats({
|
||||
importedDataInView,
|
||||
|
|
@ -16,6 +20,36 @@ function DashboardStats({
|
|||
importedDataInView?: boolean
|
||||
updateImportedDataInView?: (v: boolean) => void
|
||||
}) {
|
||||
const navigate = useAppNavigate()
|
||||
const site = useSiteContext()
|
||||
|
||||
// Handler for navigation events delegated from LiveView dashboard.
|
||||
// Necessary to emulate navigation events in LiveView with pushState
|
||||
// manipulation disabled.
|
||||
const onLiveNavigate = useCallback(
|
||||
(e: CustomEvent) => {
|
||||
navigate({
|
||||
path: e.detail.path,
|
||||
search: () => parseSearch(e.detail.search)
|
||||
})
|
||||
},
|
||||
[navigate]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(
|
||||
'dashboard:live-navigate',
|
||||
onLiveNavigate as EventListener
|
||||
)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'dashboard:live-navigate',
|
||||
onLiveNavigate as EventListener
|
||||
)
|
||||
}
|
||||
}, [onLiveNavigate])
|
||||
|
||||
const statsBoxClass =
|
||||
'relative min-h-[436px] w-full mt-5 p-4 flex flex-col bg-white dark:bg-gray-900 shadow-sm rounded-md md:min-h-initial md:h-27.25rem md:w-[calc(50%-10px)] md:ml-[10px] md:mr-[10px] first:ml-0 last:mr-0'
|
||||
|
||||
|
|
@ -27,7 +61,14 @@ function DashboardStats({
|
|||
<Sources />
|
||||
</div>
|
||||
<div className={statsBoxClass}>
|
||||
<Pages />
|
||||
{site.flags.live_dashboard ? (
|
||||
<LiveViewPortal
|
||||
id="pages-breakdown-live"
|
||||
className="w-full h-full border-0 overflow-hidden"
|
||||
/>
|
||||
) : (
|
||||
<Pages />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export const SearchableSegmentsSection = ({
|
|||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
placeholderUnfocused="Press / to search"
|
||||
className="ml-auto w-full py-1 text-sm"
|
||||
className="ml-auto w-full py-1"
|
||||
onSearch={handleSearchInput}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ test('user can open and close filters dropdown', async () => {
|
|||
'Location',
|
||||
'Screen size',
|
||||
'Browser',
|
||||
'Operating System',
|
||||
'Operating system',
|
||||
'Goal'
|
||||
])
|
||||
await userEvent.click(toggleFilters)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,15 @@ export const useAppNavigate = () => {
|
|||
search,
|
||||
...options
|
||||
}: AppNavigationTarget & NavigateOptions) => {
|
||||
// Event dispatched for handling by LiveView dashboard via hook.
|
||||
// Necessary to emulate navigation events in LiveView with pushState
|
||||
// manipulation disabled.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('dashboard:live-navigate-back', {
|
||||
detail: { search: window.location.search }
|
||||
})
|
||||
)
|
||||
|
||||
return _navigate(getToOptions({ path, params, search }), options)
|
||||
},
|
||||
[getToOptions, _navigate]
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
|
|||
import { dateForSite, formatDayShort } from '../util/date'
|
||||
import { useSiteContext } from '../site-context'
|
||||
|
||||
type SegmentAuthorshipProps = { className?: string } & (
|
||||
| { showOnlyPublicData: true; segment: SavedSegmentPublic }
|
||||
| { showOnlyPublicData: false; segment: SavedSegment }
|
||||
)
|
||||
type SegmentAuthorshipProps = {
|
||||
className?: string
|
||||
showOnlyPublicData: boolean
|
||||
segment: SavedSegmentPublic | SavedSegment
|
||||
}
|
||||
|
||||
export function SegmentAuthorship({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -58,45 +58,6 @@ describe('Segment details modal - errors', () => {
|
|||
},
|
||||
message: `Segment not found with with ID "202020"`,
|
||||
siteOptions: { siteSegmentsAvailable: true }
|
||||
},
|
||||
{
|
||||
case: 'site segment is in list but not listable because site segments are not available',
|
||||
segments: [anyPersonalSegment, anySiteSegment],
|
||||
segmentId: anySiteSegment.id,
|
||||
user: {
|
||||
loggedIn: true,
|
||||
id: 1,
|
||||
role: Role.owner,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
message: `Segment not found with with ID "${anySiteSegment.id}"`,
|
||||
siteOptions: { siteSegmentsAvailable: false }
|
||||
},
|
||||
{
|
||||
case: 'personal segment is in list but not listable because it is a public dashboard',
|
||||
segments: [{ ...anyPersonalSegment, owner_id: null, owner_name: null }],
|
||||
segmentId: anyPersonalSegment.id,
|
||||
user: {
|
||||
loggedIn: false,
|
||||
id: null,
|
||||
role: Role.public,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
message: `Segment not found with with ID "${anyPersonalSegment.id}"`,
|
||||
siteOptions: { siteSegmentsAvailable: true }
|
||||
},
|
||||
{
|
||||
case: 'segment is in list and listable, but detailed view is not available because user is not logged in',
|
||||
segments: [{ ...anySiteSegment, owner_id: null, owner_name: null }],
|
||||
segmentId: anySiteSegment.id,
|
||||
user: {
|
||||
loggedIn: false,
|
||||
id: null,
|
||||
role: Role.public,
|
||||
team: { identifier: null, hasConsolidatedView: false }
|
||||
},
|
||||
message: 'Not enough permissions to see segment details',
|
||||
siteOptions: { siteSegmentsAvailable: true }
|
||||
}
|
||||
]
|
||||
it.each(cases)(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { ReactNode, useCallback, useState } from 'react'
|
||||
import ModalWithRouting from '../stats/modals/modal'
|
||||
import {
|
||||
canSeeSegmentDetails,
|
||||
isListableSegment,
|
||||
canExpandSegment,
|
||||
isSegmentFilter,
|
||||
SavedSegment,
|
||||
SEGMENT_TYPE_LABELS,
|
||||
|
|
@ -22,9 +21,9 @@ import { MutationStatus } from '@tanstack/react-query'
|
|||
import { ApiError } from '../api'
|
||||
import { ErrorPanel } from '../components/error-panel'
|
||||
import { useSegmentsContext } from '../filtering/segments-context'
|
||||
import { useSiteContext } from '../site-context'
|
||||
import { Role, UserContextValue, useUserContext } from '../user-context'
|
||||
import { removeFilterButtonClassname } from '../components/remove-filter-button'
|
||||
import { useSiteContext } from '../site-context'
|
||||
|
||||
interface ApiRequestProps {
|
||||
status: MutationStatus
|
||||
|
|
@ -501,9 +500,7 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
|
|||
const { query } = useQueryContext()
|
||||
const { segments } = useSegmentsContext()
|
||||
|
||||
const segment = segments
|
||||
.filter((s) => isListableSegment({ segment: s, site, user }))
|
||||
.find((s) => String(s.id) === String(id))
|
||||
const segment = segments.find((s) => String(s.id) === String(id))
|
||||
|
||||
let error: ApiError | null = null
|
||||
|
||||
|
|
@ -511,10 +508,6 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
|
|||
error = new ApiError(`Segment not found with with ID "${id}"`, {
|
||||
error: `Segment not found with with ID "${id}"`
|
||||
})
|
||||
} else if (!canSeeSegmentDetails({ user })) {
|
||||
error = new ApiError('Not enough permissions to see segment details', {
|
||||
error: `Not enough permissions to see segment details`
|
||||
})
|
||||
}
|
||||
|
||||
const data = !error ? segment : null
|
||||
|
|
@ -542,25 +535,27 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
|
|||
|
||||
<SegmentAuthorship
|
||||
segment={data}
|
||||
showOnlyPublicData={false}
|
||||
showOnlyPublicData={!user.loggedIn || user.role === Role.public}
|
||||
className="mt-4 text-sm"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ButtonsRow>
|
||||
<AppNavigationLink
|
||||
className={primaryNeutralButtonClassName}
|
||||
path={rootRoute.path}
|
||||
search={(s) => ({
|
||||
...s,
|
||||
filters: data.segment_data.filters,
|
||||
labels: data.segment_data.labels
|
||||
})}
|
||||
state={{
|
||||
expandedSegment: data
|
||||
}}
|
||||
>
|
||||
Edit segment
|
||||
</AppNavigationLink>
|
||||
{canExpandSegment({ segment: data, site, user }) && (
|
||||
<AppNavigationLink
|
||||
className={primaryNeutralButtonClassName}
|
||||
path={rootRoute.path}
|
||||
search={(s) => ({
|
||||
...s,
|
||||
filters: data.segment_data.filters,
|
||||
labels: data.segment_data.labels
|
||||
})}
|
||||
state={{
|
||||
expandedSegment: data
|
||||
}}
|
||||
>
|
||||
Edit segment
|
||||
</AppNavigationLink>
|
||||
)}
|
||||
|
||||
<AppNavigationLink
|
||||
className={removeFilterButtonClassname}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
|||
}
|
||||
|
||||
// Update this object when new feature flags are added to the frontend.
|
||||
type FeatureFlags = Record<never, boolean>
|
||||
type FeatureFlags = {
|
||||
live_dashboard?: boolean
|
||||
}
|
||||
|
||||
const siteContextDefaultValue = {
|
||||
export const siteContextDefaultValue = {
|
||||
domain: '',
|
||||
/** offset in seconds from UTC at site load time, @example 7200 */
|
||||
offset: 0,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function Bar({
|
|||
return (
|
||||
<div className="w-full h-full relative" style={style}>
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full ${bg || ''}`}
|
||||
className={`absolute top-0 left-0 h-full rounded-sm ${bg || ''}`}
|
||||
style={{ width: `${width}%` }}
|
||||
></div>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
|||
path: conversionsRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
color="bg-red-50"
|
||||
color="bg-red-50 group-hover/row:bg-red-100"
|
||||
colMinWidth={90}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
|
|||
|
||||
function getExternalLinkUrlFactory() {
|
||||
if (prop === 'path') {
|
||||
return (listItem) => url.externalLinkForPage(site.domain, listItem.name)
|
||||
return (listItem) => url.externalLinkForPage(site, listItem.name)
|
||||
} else if (prop === 'search_query') {
|
||||
return null // WP Search Queries should not become external links
|
||||
} else {
|
||||
|
|
@ -93,7 +93,6 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrlFactory()}
|
||||
maybeHideDetails={true}
|
||||
color="bg-red-50"
|
||||
colMinWidth={90}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export const PROPS = 'props'
|
|||
export const FUNNELS = 'funnels'
|
||||
|
||||
export const sectionTitles = {
|
||||
[CONVERSIONS]: 'Goal Conversions',
|
||||
[PROPS]: 'Custom Properties',
|
||||
[CONVERSIONS]: 'Goal conversions',
|
||||
[PROPS]: 'Custom properties',
|
||||
[FUNNELS]: 'Funnels'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,8 +137,7 @@ export default function Properties({ afterFetchData }) {
|
|||
params: { propKey },
|
||||
search: (search) => search
|
||||
}}
|
||||
maybeHideDetails={true}
|
||||
color="bg-red-50"
|
||||
color="bg-red-50 group-hover/row:bg-red-100"
|
||||
colMinWidth={90}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,8 +76,9 @@ function Browsers({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -121,8 +122,9 @@ function BrowserVersions({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -187,9 +189,11 @@ function OperatingSystems({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { hiddenonMobile: true } })
|
||||
metrics.createPercentage({
|
||||
meta: { showOnHover: true, hiddenOnMobile: true }
|
||||
}),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -238,8 +242,9 @@ function OperatingSystemVersions({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +286,9 @@ function ScreenSizes({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
|
|
@ -432,7 +438,7 @@ export default function Devices() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
<div className="flex justify-between w-full">
|
||||
<div className="flex gap-x-1">
|
||||
<h3 className="font-bold dark:text-gray-100">Devices</h3>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
export const METRIC_LABELS = {
|
||||
visitors: 'Visitors',
|
||||
pageviews: 'Pageviews',
|
||||
events: 'Total Conversions',
|
||||
views_per_visit: 'Views per Visit',
|
||||
events: 'Total conversions',
|
||||
views_per_visit: 'Views per visit',
|
||||
visits: 'Visits',
|
||||
bounce_rate: 'Bounce Rate',
|
||||
visit_duration: 'Visit Duration',
|
||||
conversions: 'Converted Visitors',
|
||||
conversion_rate: 'Conversion Rate',
|
||||
average_revenue: 'Average Revenue',
|
||||
total_revenue: 'Total Revenue',
|
||||
scroll_depth: 'Scroll Depth',
|
||||
time_on_page: 'Time on Page'
|
||||
bounce_rate: 'Bounce rate',
|
||||
visit_duration: 'Visit duration',
|
||||
conversions: 'Converted visitors',
|
||||
conversion_rate: 'Conversion rate',
|
||||
average_revenue: 'Average revenue',
|
||||
total_revenue: 'Total revenue',
|
||||
scroll_depth: 'Scroll depth',
|
||||
time_on_page: 'Time on page'
|
||||
}
|
||||
|
||||
function plottable(dataArray) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default function WithImportedSwitch({
|
|||
const { query } = useQueryContext()
|
||||
const importsSwitchedOn = query.with_imported
|
||||
|
||||
const iconClass = classNames({
|
||||
const iconClass = classNames('size-4', {
|
||||
'dark:text-gray-300 text-gray-700': importsSwitchedOn,
|
||||
'dark:text-gray-500 text-gray-400': !importsSwitchedOn
|
||||
})
|
||||
|
|
@ -23,7 +23,7 @@ export default function WithImportedSwitch({
|
|||
return (
|
||||
<Tooltip
|
||||
info={<div className="font-normal truncate">{tooltipMessage}</div>}
|
||||
className="w-4 h-4"
|
||||
className="size-4"
|
||||
>
|
||||
<AppNavigationLink
|
||||
search={
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -54,7 +56,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -79,6 +81,8 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -93,7 +97,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -118,6 +122,8 @@ function Cities({ query, site, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -131,7 +137,7 @@ function Cities({ query, site, afterFetchData }) {
|
|||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -247,7 +253,7 @@ class Locations extends React.Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="flex gap-x-1">
|
||||
<h3 className="font-bold dark:text-gray-100">
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ const WorldMap = ({
|
|||
path: countriesRoute.path,
|
||||
search: (search: Record<string, unknown>) => search
|
||||
}}
|
||||
className={undefined}
|
||||
className="mt-3"
|
||||
onClick={undefined}
|
||||
/>
|
||||
{site.isDbip && <GeolocationNotice />}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import {
|
|||
useRememberOrderBy
|
||||
} from '../../hooks/use-order-by'
|
||||
import { Metric } from '../reports/metrics'
|
||||
import * as metricsModule from '../reports/metrics'
|
||||
import { BreakdownResultMeta, DashboardQuery } from '../../query'
|
||||
import { ColumnConfiguraton } from '../../components/table'
|
||||
import { BreakdownTable } from './breakdown-table'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { DrilldownLink, FilterInfo } from '../../components/drilldown-link'
|
||||
import { SharedReportProps } from '../reports/list'
|
||||
import { hasConversionGoalFilter } from '../../util/filters'
|
||||
|
||||
export type ReportInfo = {
|
||||
/** Title of the report to render on the top left. */
|
||||
|
|
@ -35,6 +37,8 @@ type BreakdownModalProps = {
|
|||
/** Function that must return a new query that contains appropriate search filter for searchValue param. */
|
||||
addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery
|
||||
searchEnabled?: boolean
|
||||
/** When true, keep the percentage metric as a permanently visible, sortable column. */
|
||||
showPercentageColumn?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +66,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
renderIcon,
|
||||
getExternalLinkUrl,
|
||||
searchEnabled = true,
|
||||
showPercentageColumn = false,
|
||||
afterFetchData,
|
||||
afterFetchNextPage,
|
||||
addSearchFilter,
|
||||
|
|
@ -71,20 +76,28 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
const { query } = useQueryContext()
|
||||
const [meta, setMeta] = useState<BreakdownResultMeta | null>(null)
|
||||
|
||||
const breakdownMetrics = useMemo(() => {
|
||||
const hasPercentage = metrics.some((m) => m.key === 'percentage')
|
||||
if (!hasPercentage && !hasConversionGoalFilter(query)) {
|
||||
return [...metrics, metricsModule.createPercentage()]
|
||||
}
|
||||
return metrics
|
||||
}, [metrics, query])
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const defaultOrderBy = getStoredOrderBy({
|
||||
domain: site.domain,
|
||||
reportInfo,
|
||||
metrics,
|
||||
metrics: breakdownMetrics,
|
||||
fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
|
||||
})
|
||||
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
|
||||
metrics,
|
||||
metrics: breakdownMetrics,
|
||||
defaultOrderBy
|
||||
})
|
||||
useRememberOrderBy({
|
||||
effectiveOrderBy: orderBy,
|
||||
metrics,
|
||||
metrics: breakdownMetrics,
|
||||
reportInfo
|
||||
})
|
||||
const apiState = usePaginatedGetAPI<
|
||||
|
|
@ -125,7 +138,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
{
|
||||
label: reportInfo.dimensionLabel,
|
||||
key: 'name',
|
||||
width: 'w-48 md:w-full flex items-center break-all',
|
||||
width: 'w-40 md:w-48',
|
||||
align: 'left',
|
||||
renderItem: (item) => (
|
||||
<NameCell
|
||||
|
|
@ -136,29 +149,39 @@ export default function BreakdownModal<TListItem extends { name: string }>({
|
|||
/>
|
||||
)
|
||||
},
|
||||
...metrics.map(
|
||||
(m): ColumnConfiguraton<TListItem> => ({
|
||||
label: m.renderLabel(query),
|
||||
key: m.key,
|
||||
width: m.width,
|
||||
align: 'right',
|
||||
metricWarning: getMetricWarning(m, meta),
|
||||
renderValue: (item) => m.renderValue(item, meta),
|
||||
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
|
||||
sortDirection: orderByDictionary[m.key]
|
||||
})
|
||||
)
|
||||
...breakdownMetrics
|
||||
.filter((m) => showPercentageColumn || m.key !== 'percentage')
|
||||
.map(
|
||||
(m): ColumnConfiguraton<TListItem> => ({
|
||||
label: m.renderLabel(query),
|
||||
key: m.key,
|
||||
width: m.width,
|
||||
align: 'right',
|
||||
metricWarning: getMetricWarning(m, meta),
|
||||
renderValue: (item, isRowHovered) =>
|
||||
m.renderValue(
|
||||
showPercentageColumn && m.key === 'visitors'
|
||||
? { ...item, percentage: null }
|
||||
: item,
|
||||
meta,
|
||||
{ detailedView: true, isRowHovered }
|
||||
),
|
||||
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
|
||||
sortDirection: orderByDictionary[m.key]
|
||||
})
|
||||
)
|
||||
],
|
||||
[
|
||||
reportInfo.dimensionLabel,
|
||||
metrics,
|
||||
breakdownMetrics,
|
||||
getFilterInfo,
|
||||
query,
|
||||
orderByDictionary,
|
||||
toggleSortByMetric,
|
||||
renderIcon,
|
||||
getExternalLinkUrl,
|
||||
meta
|
||||
meta,
|
||||
showPercentageColumn
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -190,7 +213,7 @@ const NameCell = <TListItem extends { name: string }>({
|
|||
renderIcon?: (item: TListItem) => ReactNode
|
||||
getExternalLinkUrl?: (listItem: TListItem) => string
|
||||
}) => (
|
||||
<>
|
||||
<div className="max-w-full break-all flex items-center">
|
||||
{typeof renderIcon === 'function' && renderIcon(item)}
|
||||
<DrilldownLink
|
||||
path={rootRoute.path}
|
||||
|
|
@ -203,7 +226,7 @@ const NameCell = <TListItem extends { name: string }>({
|
|||
{typeof getExternalLinkUrl === 'function' && (
|
||||
<ExternalLinkIcon url={getExternalLinkUrl(item)} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ExternalLinkIcon = ({ url }: { url?: string }) =>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { ReactNode, useRef } from 'react'
|
||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
import { SearchInput } from '../../components/search-input'
|
||||
import { ColumnConfiguraton, Table } from '../../components/table'
|
||||
import RocketIcon from './rocket-icon'
|
||||
import { QueryStatus } from '@tanstack/react-query'
|
||||
|
||||
const MIN_HEIGHT_PX = 500
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||
import { rootRoute } from '../../router'
|
||||
|
||||
export const BreakdownTable = <TListItem extends { name: string }>({
|
||||
title,
|
||||
|
|
@ -19,7 +20,8 @@ export const BreakdownTable = <TListItem extends { name: string }>({
|
|||
data,
|
||||
status,
|
||||
error,
|
||||
displayError
|
||||
displayError,
|
||||
onClose
|
||||
}: {
|
||||
title: ReactNode
|
||||
onSearch?: (input: string) => void
|
||||
|
|
@ -34,28 +36,42 @@ export const BreakdownTable = <TListItem extends { name: string }>({
|
|||
error?: Error | null
|
||||
/** Controls whether the component displays API request errors or ignores them. */
|
||||
displayError?: boolean
|
||||
onClose?: () => void
|
||||
}) => {
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useAppNavigate()
|
||||
const handleClose =
|
||||
onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s }))
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h1 className="text-xl font-bold dark:text-gray-100">{title}</h1>
|
||||
<>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<h1 className="shrink-0 mb-0.5 text-base md:text-lg font-bold dark:text-gray-100">
|
||||
{title}
|
||||
</h1>
|
||||
{!isPending && isFetching && <SmallLoadingSpinner />}
|
||||
{!!onSearch && (
|
||||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
onSearch={onSearch}
|
||||
className={
|
||||
displayError && status === 'error' ? 'pointer-events-none' : ''
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!onSearch && (
|
||||
<SearchInput
|
||||
searchRef={searchRef}
|
||||
onSearch={onSearch}
|
||||
className={
|
||||
displayError && status === 'error' ? 'pointer-events-none' : ''
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close modal"
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="my-4 border-b border-gray-300 dark:border-gray-700"></div>
|
||||
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
|
||||
<div className="my-3 md:my-4 border-b border-gray-250 dark:border-gray-750"></div>
|
||||
<div className="flex-1 overflow-auto pr-4 -mr-4">
|
||||
{displayError && status === 'error' && <ErrorMessage error={error} />}
|
||||
{isPending && <InitialLoadingSpinner />}
|
||||
{data && <Table<TListItem> data={data} columns={columns} />}
|
||||
|
|
@ -66,15 +82,12 @@ export const BreakdownTable = <TListItem extends { name: string }>({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const InitialLoadingSpinner = () => (
|
||||
<div
|
||||
className="w-full h-full flex flex-col justify-center"
|
||||
style={{ minHeight: `${MIN_HEIGHT_PX}px` }}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col justify-center">
|
||||
<div className="mx-auto loading">
|
||||
<div />
|
||||
</div>
|
||||
|
|
@ -88,10 +101,7 @@ const SmallLoadingSpinner = () => (
|
|||
)
|
||||
|
||||
const ErrorMessage = ({ error }: { error?: unknown }) => (
|
||||
<div
|
||||
className="grid grid-rows-2 text-gray-700 dark:text-gray-300"
|
||||
style={{ height: `${MIN_HEIGHT_PX}px` }}
|
||||
>
|
||||
<div className="grid grid-rows-2 text-gray-700 dark:text-gray-300">
|
||||
<div className="text-center self-end">
|
||||
<RocketIcon />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ function ConversionsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Goal Conversions',
|
||||
title: 'Goal conversions',
|
||||
dimension: 'goal',
|
||||
endpoint: url.apiPath(site, '/conversions'),
|
||||
dimensionLabel: 'Goal'
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function BrowserVersionsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Browser Versions',
|
||||
title: 'Browser versions',
|
||||
dimension: 'browser_version',
|
||||
endpoint: url.apiPath(site, '/browser-versions'),
|
||||
dimensionLabel: 'Browser version',
|
||||
|
|
@ -52,7 +52,7 @@ function BrowserVersionsModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function BrowsersModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,31 @@ import {
|
|||
hasConversionGoalFilter,
|
||||
isRealTimeDashboard
|
||||
} from '../../../util/filters'
|
||||
import { revenueAvailable } from '../../../query'
|
||||
import * as metrics from '../../reports/metrics'
|
||||
|
||||
export default function chooseMetrics(query) {
|
||||
export default function chooseMetrics(query, site) {
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
if (hasConversionGoalFilter(query)) {
|
||||
return [
|
||||
metrics.createTotalVisitors(),
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
width: 'w-32 md:w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
}),
|
||||
metrics.createPercentage()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function OperatingSystemVersionsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Operating System Versions',
|
||||
title: 'Operating system versions',
|
||||
dimension: 'os_version',
|
||||
endpoint: url.apiPath(site, '/operating-system-versions'),
|
||||
dimensionLabel: 'Operating system version',
|
||||
|
|
@ -49,7 +49,7 @@ function OperatingSystemVersionsModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function OperatingSystemsModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Operating Systems',
|
||||
title: 'Operating systems',
|
||||
dimension: 'os',
|
||||
endpoint: url.apiPath(site, '/operating-systems'),
|
||||
dimensionLabel: 'Operating system',
|
||||
|
|
@ -49,7 +49,7 @@ function OperatingSystemsModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ function ScreenSizesModal() {
|
|||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Screen Sizes',
|
||||
title: 'Screen sizes',
|
||||
dimension: 'screen',
|
||||
endpoint: url.apiPath(site, '/screen-sizes'),
|
||||
dimensionLabel: 'Screen size',
|
||||
|
|
@ -39,7 +39,7 @@ function ScreenSizesModal() {
|
|||
<Modal>
|
||||
<BreakdownModal
|
||||
reportInfo={reportInfo}
|
||||
metrics={chooseMetrics(query)}
|
||||
metrics={chooseMetrics(query, site)}
|
||||
getFilterInfo={getFilterInfo}
|
||||
searchEnabled={false}
|
||||
renderIcon={renderIcon}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
hasConversionGoalFilter,
|
||||
isRealTimeDashboard
|
||||
} from '../../util/filters'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
|
|
@ -16,8 +16,11 @@ function EntryPagesModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Entry Pages',
|
||||
title: 'Entry pages',
|
||||
dimension: 'entry_page',
|
||||
endpoint: url.apiPath(site, '/entry-pages'),
|
||||
dimensionLabel: 'Entry page',
|
||||
|
|
@ -54,15 +57,17 @@ function EntryPagesModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
@ -70,8 +75,8 @@ function EntryPagesModal() {
|
|||
return [
|
||||
metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }),
|
||||
metrics.createVisits({
|
||||
renderLabel: (_query) => 'Total Entrances',
|
||||
width: 'w-36'
|
||||
renderLabel: (_query) => 'Total entrances',
|
||||
width: 'w-32'
|
||||
}),
|
||||
metrics.createBounceRate(),
|
||||
metrics.createVisitDuration()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import Modal from './modal'
|
||||
import { hasConversionGoalFilter } from '../../util/filters'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
|
|
@ -13,8 +13,11 @@ function ExitPagesModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Exit Pages',
|
||||
title: 'Exit pages',
|
||||
dimension: 'exit_page',
|
||||
endpoint: url.apiPath(site, '/exit-pages'),
|
||||
dimensionLabel: 'Page url',
|
||||
|
|
@ -51,15 +54,17 @@ function ExitPagesModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (query.period === 'realtime') {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
@ -70,7 +75,8 @@ function ExitPagesModal() {
|
|||
sortable: true
|
||||
}),
|
||||
metrics.createVisits({
|
||||
renderLabel: (_query) => 'Total Exits',
|
||||
renderLabel: (_query) => 'Total exits',
|
||||
width: 'w-32',
|
||||
sortable: true
|
||||
}),
|
||||
metrics.createExitRate()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react'
|
||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import Modal from './modal'
|
||||
|
|
@ -68,6 +69,7 @@ class FilterModal extends React.Component {
|
|||
)
|
||||
|
||||
this.handleKeydown = this.handleKeydown.bind(this)
|
||||
this.closeModal = this.closeModal.bind(this)
|
||||
this.state = {
|
||||
query,
|
||||
filterState,
|
||||
|
|
@ -108,6 +110,13 @@ class FilterModal extends React.Component {
|
|||
)
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.props.navigate({
|
||||
path: rootRoute.path,
|
||||
search: (search) => search
|
||||
})
|
||||
}
|
||||
|
||||
selectFiltersAndCloseModal(filters) {
|
||||
this.props.navigate({
|
||||
path: rootRoute.path,
|
||||
|
|
@ -169,13 +178,23 @@ class FilterModal extends React.Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<Modal maxWidth="460px">
|
||||
<h1 className="text-xl font-bold dark:text-gray-100">
|
||||
Filter by {formatFilterGroup(this.props.modalType)}
|
||||
</h1>
|
||||
<Modal maxWidth="460px" onClose={this.closeModal}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-base md:text-lg font-bold dark:text-gray-100">
|
||||
Filter by {formatFilterGroup(this.props.modalType)}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.closeModal}
|
||||
aria-label="Close modal"
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-b border-gray-300 dark:border-gray-700"></div>
|
||||
<main className="modal__content">
|
||||
<div className="mt-2 md:mt-4 border-b border-gray-300 dark:border-gray-700"></div>
|
||||
<main>
|
||||
<form
|
||||
className="flex flex-col"
|
||||
onSubmit={this.handleSubmit.bind(this)}
|
||||
|
|
@ -192,7 +211,7 @@ class FilterModal extends React.Component {
|
|||
/>
|
||||
))}
|
||||
|
||||
<div className="mt-6 flex gap-x-4 items-center justify-start">
|
||||
<div className="mt-6 mb-3 flex gap-x-4 items-center justify-start">
|
||||
<button
|
||||
type="submit"
|
||||
className="button !px-3"
|
||||
|
|
|
|||
|
|
@ -7,26 +7,26 @@ import * as metrics from '../reports/metrics'
|
|||
import * as url from '../../util/url'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import { SortDirection } from '../../hooks/use-order-by'
|
||||
|
||||
const VIEWS = {
|
||||
countries: {
|
||||
title: 'Top Countries',
|
||||
title: 'Top countries',
|
||||
dimension: 'country',
|
||||
endpoint: '/countries',
|
||||
dimensionLabel: 'Country',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
},
|
||||
regions: {
|
||||
title: 'Top Regions',
|
||||
title: 'Top regions',
|
||||
dimension: 'region',
|
||||
endpoint: '/regions',
|
||||
dimensionLabel: 'Region',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
},
|
||||
cities: {
|
||||
title: 'Top Cities',
|
||||
title: 'Top cities',
|
||||
dimension: 'city',
|
||||
endpoint: '/cities',
|
||||
dimensionLabel: 'City',
|
||||
|
|
@ -38,6 +38,9 @@ function LocationsModal({ currentView }) {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
let reportInfo = VIEWS[currentView]
|
||||
reportInfo = {
|
||||
...reportInfo,
|
||||
|
|
@ -75,15 +78,17 @@ function LocationsModal({ currentView }) {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (query.period === 'realtime') {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,8 @@ import { createPortal } from 'react-dom'
|
|||
import { isModifierPressed, isTyping, Keybind } from '../../keybinding'
|
||||
import { rootRoute } from '../../router'
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||
|
||||
// This corresponds to the 'md' breakpoint on TailwindCSS.
|
||||
const MD_WIDTH = 768
|
||||
// We assume that the dashboard is by default opened on a desktop. This is also a fall-back for when, for any reason, the width is not ascertained.
|
||||
const DEFAULT_WIDTH = 1080
|
||||
|
||||
class Modal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
|
@ -27,26 +23,21 @@ class Modal extends React.Component {
|
|||
window.addEventListener('resize', this.handleResize, false)
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.body.style.overflow = null
|
||||
document.body.style.height = null
|
||||
document.removeEventListener('mousedown', this.handleClickOutside)
|
||||
window.removeEventListener('resize', this.handleResize, false)
|
||||
}
|
||||
|
||||
handleClickOutside(e) {
|
||||
if (this.node.current.contains(e.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onClose()
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.setState({ viewport: window.innerWidth })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description
|
||||
* Decide whether to set max-width, and if so, to what.
|
||||
|
|
@ -56,12 +47,11 @@ class Modal extends React.Component {
|
|||
*/
|
||||
getStyle() {
|
||||
const { maxWidth } = this.props
|
||||
const { viewport } = this.state
|
||||
const styleObject = {}
|
||||
if (maxWidth) {
|
||||
styleObject.maxWidth = maxWidth
|
||||
} else {
|
||||
styleObject.width = viewport <= MD_WIDTH ? 'min-content' : '860px'
|
||||
styleObject.maxWidth = '880px'
|
||||
}
|
||||
return styleObject
|
||||
}
|
||||
|
|
@ -78,16 +68,17 @@ class Modal extends React.Component {
|
|||
/>
|
||||
<div className="modal is-open" onClick={this.props.onClick}>
|
||||
<div className="modal__overlay">
|
||||
<button className="modal__close"></button>
|
||||
<div
|
||||
ref={this.node}
|
||||
className="modal__container dark:bg-gray-900 focus:outline-hidden"
|
||||
style={this.getStyle()}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
>
|
||||
<FocusOnMount focusableRef={this.node} />
|
||||
{this.props.children}
|
||||
<div className="[--gap:1rem] sm:[--gap:2rem] md:[--gap:4rem] flex h-full w-full items-center md:items-start justify-center p-[var(--gap)] box-border">
|
||||
<div
|
||||
ref={this.node}
|
||||
className="max-h-[calc(100dvh_-_var(--gap)*2)] min-h-[66vh] md:min-h-120 w-full flex flex-col bg-white p-3 md:px-6 md:py-4 overflow-hidden box-border transition-[height] duration-200 ease-in shadow-2xl rounded-lg dark:bg-gray-900 focus:outline-hidden"
|
||||
style={this.getStyle()}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
>
|
||||
<FocusOnMount focusableRef={this.node} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
hasConversionGoalFilter,
|
||||
isRealTimeDashboard
|
||||
} from '../../util/filters'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
|
|
@ -16,8 +16,11 @@ function PagesModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Top Pages',
|
||||
title: 'Top pages',
|
||||
dimension: 'page',
|
||||
endpoint: url.apiPath(site, '/pages'),
|
||||
dimensionLabel: 'Page url',
|
||||
|
|
@ -54,15 +57,17 @@ function PagesModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ function PropsModal() {
|
|||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
|
||||
title: specialTitleWhenGoalFilter(query, 'Custom property breakdown'),
|
||||
dimension: propKey,
|
||||
endpoint: url.apiPath(
|
||||
site,
|
||||
|
|
@ -71,6 +71,7 @@ function PropsModal() {
|
|||
metrics={chooseMetrics()}
|
||||
getFilterInfo={getFilterInfo}
|
||||
addSearchFilter={addSearchFilter}
|
||||
showPercentageColumn
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { SortDirection } from '../../hooks/use-order-by'
|
||||
|
|
@ -20,6 +20,9 @@ function ReferrerDrilldownModal() {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Referrer Drilldown',
|
||||
dimension: 'referrer',
|
||||
|
|
@ -61,15 +64,17 @@ function ReferrerDrilldownModal() {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
import { addFilter } from '../../query'
|
||||
import { addFilter, revenueAvailable } from '../../query'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { SortDirection } from '../../hooks/use-order-by'
|
||||
|
|
@ -16,7 +16,7 @@ import { SourceFavicon } from '../sources/source-favicon'
|
|||
const VIEWS = {
|
||||
sources: {
|
||||
info: {
|
||||
title: 'Top Sources',
|
||||
title: 'Top sources',
|
||||
dimension: 'source',
|
||||
endpoint: '/sources',
|
||||
dimensionLabel: 'Source',
|
||||
|
|
@ -33,7 +33,7 @@ const VIEWS = {
|
|||
},
|
||||
channels: {
|
||||
info: {
|
||||
title: 'Top Acquisition Channels',
|
||||
title: 'Top acquisition channels',
|
||||
dimension: 'channel',
|
||||
endpoint: '/channels',
|
||||
dimensionLabel: 'Channel',
|
||||
|
|
@ -42,46 +42,46 @@ const VIEWS = {
|
|||
},
|
||||
utm_mediums: {
|
||||
info: {
|
||||
title: 'Top UTM Mediums',
|
||||
title: 'Top UTM mediums',
|
||||
dimension: 'utm_medium',
|
||||
endpoint: '/utm_mediums',
|
||||
dimensionLabel: 'UTM Medium',
|
||||
dimensionLabel: 'UTM medium',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_sources: {
|
||||
info: {
|
||||
title: 'Top UTM Sources',
|
||||
title: 'Top UTM sources',
|
||||
dimension: 'utm_source',
|
||||
endpoint: '/utm_sources',
|
||||
dimensionLabel: 'UTM Source',
|
||||
dimensionLabel: 'UTM source',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_campaigns: {
|
||||
info: {
|
||||
title: 'Top UTM Campaigns',
|
||||
title: 'Top UTM campaigns',
|
||||
dimension: 'utm_campaign',
|
||||
endpoint: '/utm_campaigns',
|
||||
dimensionLabel: 'UTM Campaign',
|
||||
dimensionLabel: 'UTM campaign',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_contents: {
|
||||
info: {
|
||||
title: 'Top UTM Contents',
|
||||
title: 'Top UTM contents',
|
||||
dimension: 'utm_content',
|
||||
endpoint: '/utm_contents',
|
||||
dimensionLabel: 'UTM Content',
|
||||
dimensionLabel: 'UTM content',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
},
|
||||
utm_terms: {
|
||||
info: {
|
||||
title: 'Top UTM Terms',
|
||||
title: 'Top UTM terms',
|
||||
dimension: 'utm_term',
|
||||
endpoint: '/utm_terms',
|
||||
dimensionLabel: 'UTM Term',
|
||||
dimensionLabel: 'UTM term',
|
||||
defaultOrder: ['visitors', SortDirection.desc]
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,9 @@ function SourcesModal({ currentView }) {
|
|||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||
|
||||
let reportInfo = VIEWS[currentView].info
|
||||
reportInfo = {
|
||||
...reportInfo,
|
||||
|
|
@ -127,15 +130,17 @@ function SourcesModal({ currentView }) {
|
|||
renderLabel: (_query) => 'Conversions',
|
||||
width: 'w-28'
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
]
|
||||
metrics.createConversionRate(),
|
||||
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||
showRevenueMetrics && metrics.createAverageRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
if (isRealTimeDashboard(query)) {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Current visitors',
|
||||
width: 'w-36'
|
||||
width: 'w-32'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ function EntryPages({ afterFetchData }) {
|
|||
}
|
||||
|
||||
function getExternalLinkUrl(page) {
|
||||
return url.externalLinkForPage(site.domain, page.name)
|
||||
return url.externalLinkForPage(site, page.name)
|
||||
}
|
||||
|
||||
function getFilterInfo(listItem) {
|
||||
|
|
@ -33,10 +33,12 @@ function EntryPages({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
defaultLabel: 'Unique Entrances',
|
||||
defaultLabel: 'Unique entrances',
|
||||
width: 'w-36',
|
||||
meta: { plot: true }
|
||||
}),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -53,7 +55,7 @@ function EntryPages({ afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -66,7 +68,7 @@ function ExitPages({ afterFetchData }) {
|
|||
}
|
||||
|
||||
function getExternalLinkUrl(page) {
|
||||
return url.externalLinkForPage(site.domain, page.name)
|
||||
return url.externalLinkForPage(site, page.name)
|
||||
}
|
||||
|
||||
function getFilterInfo(listItem) {
|
||||
|
|
@ -79,10 +81,12 @@ function ExitPages({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({
|
||||
defaultLabel: 'Unique Exits',
|
||||
defaultLabel: 'Unique exits',
|
||||
width: 'w-36',
|
||||
meta: { plot: true }
|
||||
}),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -99,7 +103,7 @@ function ExitPages({ afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -112,7 +116,7 @@ function TopPages({ afterFetchData }) {
|
|||
}
|
||||
|
||||
function getExternalLinkUrl(page) {
|
||||
return url.externalLinkForPage(site.domain, page.name)
|
||||
return url.externalLinkForPage(site, page.name)
|
||||
}
|
||||
|
||||
function getFilterInfo(listItem) {
|
||||
|
|
@ -125,6 +129,8 @@ function TopPages({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -141,15 +147,15 @@ function TopPages({ afterFetchData }) {
|
|||
search: (search) => search
|
||||
}}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
color="bg-orange-50"
|
||||
color="bg-orange-50 group-hover/row:bg-orange-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const labelFor = {
|
||||
pages: 'Top Pages',
|
||||
'entry-pages': 'Entry Pages',
|
||||
'exit-pages': 'Exit Pages'
|
||||
pages: 'Top pages',
|
||||
'entry-pages': 'Entry pages',
|
||||
'exit-pages': 'Exit pages'
|
||||
}
|
||||
|
||||
export default function Pages() {
|
||||
|
|
@ -187,7 +193,7 @@ export default function Pages() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
{/* Header Container */}
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="flex gap-x-1">
|
||||
|
|
@ -201,9 +207,9 @@ export default function Pages() {
|
|||
</div>
|
||||
<TabWrapper>
|
||||
{[
|
||||
{ label: 'Top Pages', value: 'pages' },
|
||||
{ label: 'Entry Pages', value: 'entry-pages' },
|
||||
{ label: 'Exit Pages', value: 'exit-pages' }
|
||||
{ label: 'Top pages', value: 'pages' },
|
||||
{ label: 'Entry pages', value: 'entry-pages' },
|
||||
{ label: 'Exit pages', value: 'exit-pages' }
|
||||
].map(({ value, label }) => (
|
||||
<TabButton
|
||||
active={mode === value}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ it('renders tilde for no change', () => {
|
|||
|
||||
const arrowElement = screen.getByTestId('change-arrow')
|
||||
|
||||
expect(arrowElement).toHaveTextContent('〰 0%')
|
||||
expect(arrowElement).toHaveTextContent('0%')
|
||||
})
|
||||
|
||||
it('inverts colors for positive bounce_rate change', () => {
|
||||
|
|
|
|||
|
|
@ -15,24 +15,22 @@ export function ChangeArrow({
|
|||
className: string
|
||||
hideNumber?: boolean
|
||||
}) {
|
||||
const formattedChange = hideNumber
|
||||
? null
|
||||
: ` ${numberShortFormatter(Math.abs(change))}%`
|
||||
|
||||
let icon = null
|
||||
const arrowClassName = classNames(
|
||||
color(change, metric),
|
||||
'inline-block h-3 w-3 stroke-[1px] stroke-current'
|
||||
'mb-0.5 inline-block size-3 stroke-[1px] stroke-current'
|
||||
)
|
||||
|
||||
if (change > 0) {
|
||||
icon = <ArrowUpRightIcon className={arrowClassName} />
|
||||
} else if (change < 0) {
|
||||
icon = <ArrowDownRightIcon className={arrowClassName} />
|
||||
} else if (change === 0 && !hideNumber) {
|
||||
icon = <>〰</>
|
||||
}
|
||||
|
||||
const formattedChange = hideNumber
|
||||
? null
|
||||
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
|
||||
|
||||
return (
|
||||
<span className={className} data-testid="change-arrow">
|
||||
{icon}
|
||||
|
|
|
|||
|
|
@ -26,27 +26,34 @@ const COL_MIN_WIDTH = 70
|
|||
|
||||
function ExternalLink<T>({
|
||||
item,
|
||||
getExternalLinkUrl
|
||||
getExternalLinkUrl,
|
||||
isTapped
|
||||
}: {
|
||||
item: T
|
||||
getExternalLinkUrl?: (item: T) => string
|
||||
isTapped?: boolean
|
||||
}) {
|
||||
const dest = getExternalLinkUrl && getExternalLinkUrl(item)
|
||||
if (dest) {
|
||||
const className = isTapped
|
||||
? 'visible md:invisible md:group-hover/row:visible'
|
||||
: 'invisible md:group-hover/row:visible'
|
||||
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={dest}
|
||||
className="w-4 h-4 invisible group-hover:visible"
|
||||
>
|
||||
<a target="_blank" rel="noreferrer" href={dest} className={className}>
|
||||
<svg
|
||||
className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline size-3.5 mb-0.5 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path>
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4M12 12l9-9-.303.303M14 3h7v7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
|
|
@ -88,11 +95,6 @@ type ListReportProps = {
|
|||
colMinWidth?: number
|
||||
/** Navigation props to be passed to "More" link, if any. */
|
||||
detailsLinkProps?: AppNavigationLinkProps
|
||||
/** Set this to `true` if the details button should be hidden on
|
||||
* the condition that there are less than MAX_ITEMS entries in the list (i.e. nothing
|
||||
* more to show).
|
||||
*/
|
||||
maybeHideDetails?: boolean
|
||||
/** Function with additional action to be taken when a list entry is clicked. */
|
||||
onClick?: () => void
|
||||
/** Color of the comparison bars in light-mode. */
|
||||
|
|
@ -114,7 +116,6 @@ export default function ListReport<
|
|||
colMinWidth = COL_MIN_WIDTH,
|
||||
afterFetchData,
|
||||
detailsLinkProps,
|
||||
maybeHideDetails,
|
||||
onClick,
|
||||
color,
|
||||
getFilterInfo,
|
||||
|
|
@ -129,6 +130,7 @@ export default function ListReport<
|
|||
meta: BreakdownResultMeta | null
|
||||
}>({ loading: true, list: null, meta: null })
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [tappedRow, setTappedRow] = useState<string | null>(null)
|
||||
|
||||
const isRealtime = isRealTimeDashboard(query)
|
||||
const goalFilterApplied = hasConversionGoalFilter(query)
|
||||
|
|
@ -194,6 +196,38 @@ export default function ListReport<
|
|||
}
|
||||
}
|
||||
|
||||
function showOnHoverClass(metric: Metric, listItemName: string) {
|
||||
if (!metric.meta.showOnHover) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// On mobile: show if row is tapped, hide otherwise
|
||||
// On desktop: slide in from right when hovering
|
||||
if (tappedRow === listItemName) {
|
||||
return 'translate-x-0 opacity-100 transition-all duration-150'
|
||||
} else {
|
||||
return 'translate-x-[100%] opacity-0 transition-all duration-150 md:group-hover/report:translate-x-0 md:group-hover/report:opacity-100'
|
||||
}
|
||||
}
|
||||
|
||||
function slideLeftClass(
|
||||
metricIndex: number,
|
||||
showOnHoverIndex: number,
|
||||
hasShowOnHoverMetric: boolean,
|
||||
listItemName: string
|
||||
) {
|
||||
// Columns before the showOnHover column should slide left when it appears
|
||||
if (!hasShowOnHoverMetric || metricIndex >= showOnHoverIndex) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (tappedRow === listItemName) {
|
||||
return 'transition-transform duration-150 translate-x-0'
|
||||
} else {
|
||||
return 'transition-transform duration-150 translate-x-[100%] md:group-hover/report:translate-x-0'
|
||||
}
|
||||
}
|
||||
|
||||
function renderReport() {
|
||||
if (state.list && state.list.length > 0) {
|
||||
return (
|
||||
|
|
@ -206,16 +240,14 @@ export default function ListReport<
|
|||
</FlipMove>
|
||||
</div>
|
||||
|
||||
{!!detailsLinkProps &&
|
||||
!state.loading &&
|
||||
!(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && (
|
||||
<MoreLink
|
||||
onClick={undefined}
|
||||
className={'mt-2'}
|
||||
linkProps={detailsLinkProps}
|
||||
list={state.list}
|
||||
/>
|
||||
)}
|
||||
{!!detailsLinkProps && !state.loading && (
|
||||
<MoreLink
|
||||
onClick={undefined}
|
||||
className={'mt-3'}
|
||||
linkProps={detailsLinkProps}
|
||||
list={state.list}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -223,20 +255,22 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderReportHeader() {
|
||||
const metricLabels = getAvailableMetrics().map((metric) => {
|
||||
return (
|
||||
<div
|
||||
key={metric.key}
|
||||
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
|
||||
style={{ minWidth: colMinWidth }}
|
||||
>
|
||||
{metric.renderLabel(query)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
const metricLabels = getAvailableMetrics()
|
||||
.filter((metric) => !metric.meta.showOnHover)
|
||||
.map((metric) => {
|
||||
return (
|
||||
<div
|
||||
key={metric.key}
|
||||
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
|
||||
style={{ minWidth: colMinWidth }}
|
||||
>
|
||||
{metric.renderLabel(query)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
|
||||
<div className="pt-3 w-full text-xs font-semibold text-gray-500 flex items-center dark:text-gray-400">
|
||||
<span className="grow truncate">{keyLabel}</span>
|
||||
{metricLabels}
|
||||
</div>
|
||||
|
|
@ -244,11 +278,22 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderRow(listItem: TListItem) {
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
|
||||
if (tappedRow === listItem.name) {
|
||||
setTappedRow(null)
|
||||
} else {
|
||||
setTappedRow(listItem.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
|
||||
<div
|
||||
className="flex w-full items-center"
|
||||
className="group/row flex w-full items-center hover:bg-gray-100/60 dark:hover:bg-gray-850 rounded-sm md:cursor-default cursor-pointer"
|
||||
style={{ marginTop: ROW_GAP_HEIGHT }}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{renderBarFor(listItem)}
|
||||
{renderMetricValuesFor(listItem)}
|
||||
|
|
@ -258,7 +303,7 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderBarFor(listItem: TListItem) {
|
||||
const lightBackground = color || 'bg-green-50'
|
||||
const lightBackground = color || 'bg-green-50 group-hover/row:bg-green-100'
|
||||
const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key
|
||||
|
||||
return (
|
||||
|
|
@ -267,10 +312,10 @@ export default function ListReport<
|
|||
maxWidthDeduction={undefined}
|
||||
count={listItem[metricToPlot]}
|
||||
all={state.list}
|
||||
bg={`${lightBackground} dark:bg-gray-500/15`}
|
||||
bg={`${lightBackground} dark:bg-gray-500/15 dark:group-hover/row:bg-gray-500/30`}
|
||||
plot={metricToPlot}
|
||||
>
|
||||
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
|
||||
<div className="flex justify-start items-center gap-x-1.5 px-2 py-1.5 text-sm dark:text-gray-300 relative z-9 break-all w-full">
|
||||
<DrilldownLink
|
||||
filterInfo={getFilterInfo(listItem)}
|
||||
onClick={onClick}
|
||||
|
|
@ -285,6 +330,7 @@ export default function ListReport<
|
|||
<ExternalLink
|
||||
item={listItem}
|
||||
getExternalLinkUrl={getExternalLinkUrl}
|
||||
isTapped={tappedRow === listItem.name}
|
||||
/>
|
||||
</div>
|
||||
</Bar>
|
||||
|
|
@ -299,19 +345,36 @@ export default function ListReport<
|
|||
}
|
||||
|
||||
function renderMetricValuesFor(listItem: TListItem) {
|
||||
return getAvailableMetrics().map((metric) => {
|
||||
return (
|
||||
<div
|
||||
key={`${listItem.name}__${metric.key}`}
|
||||
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
||||
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
||||
>
|
||||
<span className="font-medium text-sm dark:text-gray-200 text-right">
|
||||
{metric.renderValue(listItem, state.meta)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
const availableMetrics = getAvailableMetrics()
|
||||
const showOnHoverIndex = availableMetrics.findIndex(
|
||||
(m) => m.meta.showOnHover
|
||||
)
|
||||
const hasShowOnHoverMetric = showOnHoverIndex !== -1
|
||||
|
||||
return (
|
||||
<>
|
||||
{availableMetrics.map((metric, index) => {
|
||||
const isShowOnHover = metric.meta.showOnHover
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${listItem.name}__${metric.key}`}
|
||||
className={`text-right ${hiddenOnMobileClass(metric)} ${showOnHoverClass(metric, listItem.name)} ${slideLeftClass(index, showOnHoverIndex, hasShowOnHoverMetric, listItem.name)}`}
|
||||
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
||||
>
|
||||
<span
|
||||
className={`font-medium text-sm text-right ${isShowOnHover ? 'text-gray-500 group-hover/row:text-gray-800 dark:group-hover/row:text-gray-200' : 'text-gray-800 dark:text-gray-200'}`}
|
||||
>
|
||||
{metric.renderValue(listItem, state.meta, {
|
||||
detailedView: false,
|
||||
isRowHovered: false
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ const REVENUE = { long: '$1,659.50', short: '$1.7K' }
|
|||
|
||||
describe('single value', () => {
|
||||
it('renders small value', async () => {
|
||||
await renderWithTooltip(<MetricValue {...valueProps('visitors', 10)} />)
|
||||
render(<MetricValue {...valueProps('visitors', 10)} />)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('10')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('10')
|
||||
})
|
||||
|
||||
it('renders large value', async () => {
|
||||
|
|
@ -25,23 +24,19 @@ describe('single value', () => {
|
|||
})
|
||||
|
||||
it('renders percentages', async () => {
|
||||
await renderWithTooltip(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
|
||||
render(<MetricValue {...valueProps('bounce_rate', 5.3)} />)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3%')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3%')
|
||||
})
|
||||
|
||||
it('renders durations', async () => {
|
||||
await renderWithTooltip(
|
||||
<MetricValue {...valueProps('visit_duration', 60)} />
|
||||
)
|
||||
render(<MetricValue {...valueProps('visit_duration', 60)} />)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s')
|
||||
})
|
||||
|
||||
it('renders with custom formatter', async () => {
|
||||
await renderWithTooltip(
|
||||
render(
|
||||
<MetricValue
|
||||
{...valueProps('test_money', 5.3)}
|
||||
formatter={(value) => `${value}$`}
|
||||
|
|
@ -49,7 +44,6 @@ describe('single value', () => {
|
|||
)
|
||||
|
||||
expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3$')
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('5.3$')
|
||||
})
|
||||
|
||||
it('renders revenue properly', async () => {
|
||||
|
|
@ -80,9 +74,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10 visitors',
|
||||
'↑ 100%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'↑ 100%',
|
||||
'5 visitors',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -98,9 +91,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'5 visitors',
|
||||
'↓ 50%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'↓ 50%',
|
||||
'10 visitors',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -116,9 +108,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10 visitors',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'0%',
|
||||
'10 visitors',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -136,9 +127,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10 conversions',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'0%',
|
||||
'10 conversions',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -154,14 +144,7 @@ describe('comparisons', () => {
|
|||
)
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10% ',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'10% ',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
['10% ', '01 Aug - 31 Aug', '0%', '10% ', '01 July - 31 July'].join('')
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -177,9 +160,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'10$ test',
|
||||
'↑ 100%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'↑ 100%',
|
||||
'5$ test',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
@ -200,9 +182,8 @@ describe('comparisons', () => {
|
|||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
[
|
||||
'$1,659.50 average_revenue',
|
||||
'〰 0%',
|
||||
'01 Aug - 31 Aug',
|
||||
'vs',
|
||||
'0%',
|
||||
'$1,659.50 average_revenue',
|
||||
'01 July - 31 July'
|
||||
].join('')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useRef, useEffect } from 'react'
|
||||
import { Metric } from '../../../types/query-api'
|
||||
import { Tooltip } from '../../util/tooltip'
|
||||
import { ChangeArrow } from './change-arrow'
|
||||
|
|
@ -36,23 +36,84 @@ export default function MetricValue(props: {
|
|||
renderLabel: (query: DashboardQuery) => string
|
||||
formatter?: (value: ValueType) => string
|
||||
meta: BreakdownResultMeta | null
|
||||
detailedView?: boolean
|
||||
isRowHovered?: boolean
|
||||
}) {
|
||||
const { query } = useQueryContext()
|
||||
const portalRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const { metric, listItem } = props
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
portalRef.current = document.body
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { metric, listItem, detailedView = false, isRowHovered = false } = props
|
||||
const { value, comparison } = useMemo(
|
||||
() => valueRenderProps(listItem, metric),
|
||||
[listItem, metric]
|
||||
)
|
||||
const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
|
||||
const shortFormatter = props.formatter ?? MetricFormatterShort[metric]
|
||||
const longFormatter = props.formatter ?? MetricFormatterLong[metric]
|
||||
|
||||
const isAbbreviated = useMemo(() => {
|
||||
if (value === null) return false
|
||||
return shortFormatter(value) !== longFormatter(value)
|
||||
}, [value, shortFormatter, longFormatter])
|
||||
|
||||
const showTooltip = detailedView
|
||||
? !!comparison
|
||||
: !!comparison || isAbbreviated
|
||||
|
||||
const shouldShowLongFormat =
|
||||
detailedView && !comparison && isRowHovered && isAbbreviated
|
||||
const displayFormatter = shouldShowLongFormat ? longFormatter : shortFormatter
|
||||
|
||||
const percentageValue = listItem['percentage' as Metric]
|
||||
const shouldShowPercentage =
|
||||
detailedView &&
|
||||
metric === 'visitors' &&
|
||||
isRowHovered &&
|
||||
percentageValue != null
|
||||
const percentageFormatter = MetricFormatterShort['percentage']
|
||||
const percentageDisplay = shouldShowPercentage
|
||||
? percentageFormatter(percentageValue)
|
||||
: null
|
||||
|
||||
if (value === null && (!comparison || comparison.value === null)) {
|
||||
return <span data-testid="metric-value">{shortFormatter(value)}</span>
|
||||
return <span data-testid="metric-value">{displayFormatter(value)}</span>
|
||||
}
|
||||
|
||||
const valueContent = (
|
||||
<span
|
||||
className={showTooltip ? 'cursor-default' : ''}
|
||||
data-testid="metric-value"
|
||||
>
|
||||
{percentageDisplay && (
|
||||
<span className="mr-3 text-gray-500 dark:text-gray-400">
|
||||
{percentageDisplay}
|
||||
</span>
|
||||
)}
|
||||
{displayFormatter(value)}
|
||||
{comparison ? (
|
||||
<ChangeArrow
|
||||
change={comparison.change}
|
||||
metric={metric}
|
||||
className="inline-block pl-1 w-4"
|
||||
hideNumber
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!showTooltip) {
|
||||
return valueContent
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
containerRef={portalRef as React.RefObject<HTMLElement>}
|
||||
info={
|
||||
<ComparisonTooltipContent
|
||||
value={value}
|
||||
|
|
@ -62,17 +123,7 @@ export default function MetricValue(props: {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<span className="cursor-default" data-testid="metric-value">
|
||||
{shortFormatter(value)}
|
||||
{comparison ? (
|
||||
<ChangeArrow
|
||||
change={comparison.change}
|
||||
metric={metric}
|
||||
className="inline-block pl-1 w-4"
|
||||
hideNumber
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{valueContent}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -106,34 +157,34 @@ function ComparisonTooltipContent({
|
|||
return (
|
||||
<div className="text-left whitespace-nowrap py-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-base">
|
||||
{longFormatter(value)} {label}
|
||||
</span>
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm/6 text-white">
|
||||
{longFormatter(value)} {label}
|
||||
</span>
|
||||
<div className="font-normal text-xs text-white">
|
||||
{meta.date_range_label}
|
||||
</div>
|
||||
</div>
|
||||
<ChangeArrow
|
||||
metric={metric}
|
||||
change={comparison.change}
|
||||
className="pl-4 text-xs text-gray-100"
|
||||
className="text-xs/6 font-medium text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-normal text-xs">{meta.date_range_label}</div>
|
||||
</div>
|
||||
<div>vs</div>
|
||||
<div className="w-full border-t border-gray-600"></div>
|
||||
<div>
|
||||
<div className="font-bold text-base">
|
||||
<div className="font-medium text-sm/6 text-gray-300/80">
|
||||
{longFormatter(comparison.value)} {label}
|
||||
</div>
|
||||
<div className="font-normal text-xs">
|
||||
<div className="font-normal text-xs text-gray-300/80">
|
||||
{meta.comparison_date_range_label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{longFormatter(value)} {label}
|
||||
</div>
|
||||
)
|
||||
return <div className="whitespace-nowrap">{longFormatter(value)}</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ export class Metric {
|
|||
this.renderValue = this.renderValue.bind(this)
|
||||
}
|
||||
|
||||
renderValue(listItem, meta) {
|
||||
renderValue(listItem, meta, options = {}) {
|
||||
const { detailedView = false, isRowHovered = false } = options
|
||||
return (
|
||||
<MetricValue
|
||||
listItem={listItem}
|
||||
|
|
@ -51,6 +52,8 @@ export class Metric {
|
|||
renderLabel={this.renderLabel}
|
||||
meta={meta}
|
||||
formatter={this.formatter}
|
||||
detailedView={detailedView}
|
||||
isRowHovered={isRowHovered}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -85,7 +88,7 @@ export const createVisitors = (props) => {
|
|||
}
|
||||
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-36',
|
||||
sortable: true,
|
||||
...props,
|
||||
key: 'visitors',
|
||||
|
|
@ -96,7 +99,7 @@ export const createVisitors = (props) => {
|
|||
export const createConversionRate = (props) => {
|
||||
const renderLabel = (_query) => 'CR'
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'conversion_rate',
|
||||
renderLabel,
|
||||
|
|
@ -116,13 +119,13 @@ export const createPercentage = (props) => {
|
|||
}
|
||||
|
||||
export const createEvents = (props) => {
|
||||
return new Metric({ width: 'w-24', ...props, key: 'events', sortable: true })
|
||||
return new Metric({ width: 'w-28', ...props, key: 'events', sortable: true })
|
||||
}
|
||||
|
||||
export const createTotalRevenue = (props) => {
|
||||
const renderLabel = (_query) => 'Revenue'
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-32',
|
||||
...props,
|
||||
key: 'total_revenue',
|
||||
renderLabel,
|
||||
|
|
@ -133,7 +136,7 @@ export const createTotalRevenue = (props) => {
|
|||
export const createAverageRevenue = (props) => {
|
||||
const renderLabel = (_query) => 'Average'
|
||||
return new Metric({
|
||||
width: 'w-24',
|
||||
width: 'w-28',
|
||||
...props,
|
||||
key: 'average_revenue',
|
||||
renderLabel,
|
||||
|
|
@ -142,9 +145,9 @@ export const createAverageRevenue = (props) => {
|
|||
}
|
||||
|
||||
export const createTotalVisitors = (props) => {
|
||||
const renderLabel = (_query) => 'Total Visitors'
|
||||
const renderLabel = (_query) => 'Total visitors'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-32',
|
||||
...props,
|
||||
key: 'total_visitors',
|
||||
renderLabel,
|
||||
|
|
@ -157,9 +160,9 @@ export const createVisits = (props) => {
|
|||
}
|
||||
|
||||
export const createVisitDuration = (props) => {
|
||||
const renderLabel = (_query) => 'Visit Duration'
|
||||
const renderLabel = (_query) => 'Visit duration'
|
||||
return new Metric({
|
||||
width: 'w-36',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'visit_duration',
|
||||
renderLabel,
|
||||
|
|
@ -168,9 +171,9 @@ export const createVisitDuration = (props) => {
|
|||
}
|
||||
|
||||
export const createBounceRate = (props) => {
|
||||
const renderLabel = (_query) => 'Bounce Rate'
|
||||
const renderLabel = (_query) => 'Bounce rate'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'bounce_rate',
|
||||
renderLabel,
|
||||
|
|
@ -190,9 +193,9 @@ export const createPageviews = (props) => {
|
|||
}
|
||||
|
||||
export const createTimeOnPage = (props) => {
|
||||
const renderLabel = (_query) => 'Time on Page'
|
||||
const renderLabel = (_query) => 'Time on page'
|
||||
return new Metric({
|
||||
width: 'w-32',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'time_on_page',
|
||||
renderLabel,
|
||||
|
|
@ -201,9 +204,9 @@ export const createTimeOnPage = (props) => {
|
|||
}
|
||||
|
||||
export const createExitRate = (props) => {
|
||||
const renderLabel = (_query) => 'Exit Rate'
|
||||
const renderLabel = (_query) => 'Exit rate'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'exit_rate',
|
||||
renderLabel,
|
||||
|
|
@ -212,9 +215,9 @@ export const createExitRate = (props) => {
|
|||
}
|
||||
|
||||
export const createScrollDepth = (props) => {
|
||||
const renderLabel = (_query) => 'Scroll Depth'
|
||||
const renderLabel = (_query) => 'Scroll depth'
|
||||
return new Metric({
|
||||
width: 'w-28',
|
||||
width: 'w-28 md:w-24',
|
||||
...props,
|
||||
key: 'scroll_depth',
|
||||
renderLabel,
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function SearchTerms() {
|
|||
path: referrersGoogleRoute.path,
|
||||
search: (search: Record<string, unknown>) => search
|
||||
}}
|
||||
className="w-full mt-2"
|
||||
className="w-full mt-3"
|
||||
onClick={undefined}
|
||||
/>
|
||||
</React.Fragment>
|
||||
|
|
|
|||
|
|
@ -27,26 +27,26 @@ import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
|
|||
|
||||
const UTM_TAGS = {
|
||||
utm_medium: {
|
||||
title: 'UTM Mediums',
|
||||
title: 'UTM mediums',
|
||||
label: 'Medium',
|
||||
endpoint: '/utm_mediums'
|
||||
},
|
||||
utm_source: {
|
||||
title: 'UTM Sources',
|
||||
title: 'UTM sources',
|
||||
label: 'Source',
|
||||
endpoint: '/utm_sources'
|
||||
},
|
||||
utm_campaign: {
|
||||
title: 'UTM Campaigns',
|
||||
title: 'UTM campaigns',
|
||||
label: 'Campaign',
|
||||
endpoint: '/utm_campaigns'
|
||||
},
|
||||
utm_content: {
|
||||
title: 'UTM Contents',
|
||||
title: 'UTM contents',
|
||||
label: 'Content',
|
||||
endpoint: '/utm_contents'
|
||||
},
|
||||
utm_term: { title: 'UTM Terms', label: 'Term', endpoint: '/utm_terms' }
|
||||
utm_term: { title: 'UTM terms', label: 'Term', endpoint: '/utm_terms' }
|
||||
}
|
||||
|
||||
function AllSources({ afterFetchData }) {
|
||||
|
|
@ -70,6 +70,8 @@ function AllSources({ afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -83,7 +85,7 @@ function AllSources({ afterFetchData }) {
|
|||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: sourcesRoute.path, search: (search) => search }}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-blue-50"
|
||||
color="bg-blue-50 group-hover/row:bg-blue-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -106,6 +108,8 @@ function Channels({ onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -122,7 +126,7 @@ function Channels({ onClick, afterFetchData }) {
|
|||
path: channelsRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
color="bg-blue-50"
|
||||
color="bg-blue-50 group-hover/row:bg-blue-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -154,6 +158,8 @@ function UTMSources({ tab, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { showOnHover: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
|
@ -166,14 +172,14 @@ function UTMSources({ tab, afterFetchData }) {
|
|||
keyLabel={utmTag.label}
|
||||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: route?.path, search: (search) => search }}
|
||||
color="bg-blue-50"
|
||||
color="bg-blue-50 group-hover/row:bg-blue-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const labelFor = {
|
||||
channels: 'Top Channels',
|
||||
all: 'Top Sources'
|
||||
channels: 'Top channels',
|
||||
all: 'Top sources'
|
||||
}
|
||||
|
||||
for (const [key, utm_tag] of Object.entries(UTM_TAGS)) {
|
||||
|
|
@ -241,7 +247,7 @@ export default function SourceList() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/report overflow-x-hidden">
|
||||
{/* Header Container */}
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="flex gap-x-1">
|
||||
|
|
|
|||
|
|
@ -291,23 +291,23 @@ export const formattedFilters = {
|
|||
prop_value: 'Value',
|
||||
source: 'Source',
|
||||
channel: 'Channel',
|
||||
utm_medium: 'UTM Medium',
|
||||
utm_source: 'UTM Source',
|
||||
utm_campaign: 'UTM Campaign',
|
||||
utm_content: 'UTM Content',
|
||||
utm_term: 'UTM Term',
|
||||
utm_medium: 'UTM medium',
|
||||
utm_source: 'UTM source',
|
||||
utm_campaign: 'UTM campaign',
|
||||
utm_content: 'UTM content',
|
||||
utm_term: 'UTM term',
|
||||
referrer: 'Referrer URL',
|
||||
screen: 'Screen size',
|
||||
browser: 'Browser',
|
||||
browser_version: 'Browser Version',
|
||||
os: 'Operating System',
|
||||
os_version: 'Operating System Version',
|
||||
browser_version: 'Browser version',
|
||||
os: 'Operating system',
|
||||
os_version: 'Operating system version',
|
||||
country: 'Country',
|
||||
region: 'Region',
|
||||
city: 'City',
|
||||
page: 'Page',
|
||||
hostname: 'Hostname',
|
||||
entry_page: 'Entry Page',
|
||||
exit_page: 'Exit Page',
|
||||
entry_page: 'Entry page',
|
||||
exit_page: 'Exit page',
|
||||
segment: 'Segment'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,11 @@ export function durationFormatter(duration: number): string {
|
|||
|
||||
export function percentageFormatter(number: number | null): string {
|
||||
if (typeof number === 'number') {
|
||||
return number + '%'
|
||||
if (Math.abs(number) > 0 && Math.abs(number) < 0.1) {
|
||||
return number.toFixed(2) + '%'
|
||||
} else {
|
||||
return number.toFixed(1).replace(/\.0$/, '') + '%'
|
||||
}
|
||||
} else {
|
||||
return '-'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,14 @@ export function Tooltip({
|
|||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: 'top',
|
||||
modifiers: [
|
||||
{ name: 'arrow', options: { element: arrowElement } },
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 4]
|
||||
offset: [0, 6]
|
||||
}
|
||||
},
|
||||
...(boundary
|
||||
|
|
@ -67,8 +65,6 @@ export function Tooltip({
|
|||
popperStyle={styles.popper}
|
||||
popperAttributes={attributes.popper}
|
||||
setPopperElement={setPopperElement}
|
||||
setArrowElement={setArrowElement}
|
||||
arrowStyle={styles.arrow}
|
||||
>
|
||||
{info}
|
||||
</TooltipMessage>
|
||||
|
|
@ -82,16 +78,12 @@ function TooltipMessage({
|
|||
popperStyle,
|
||||
popperAttributes,
|
||||
setPopperElement,
|
||||
setArrowElement,
|
||||
arrowStyle,
|
||||
children
|
||||
}: {
|
||||
containerRef?: RefObject<HTMLElement>
|
||||
popperStyle: CSSProperties
|
||||
arrowStyle: CSSProperties
|
||||
popperAttributes?: Record<string, string>
|
||||
setPopperElement: (element: HTMLDivElement) => void
|
||||
setArrowElement: (element: HTMLDivElement) => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const messageElement = (
|
||||
|
|
@ -99,15 +91,10 @@ function TooltipMessage({
|
|||
ref={setPopperElement}
|
||||
style={popperStyle}
|
||||
{...popperAttributes}
|
||||
className="z-50 p-2 rounded-sm text-sm text-gray-100 font-bold bg-gray-800 dark:bg-gray-700"
|
||||
className="z-[999] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700"
|
||||
role="tooltip"
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={setArrowElement}
|
||||
style={arrowStyle}
|
||||
className="tooltip-arrow"
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
if (containerRef) {
|
||||
|
|
|
|||
|
|
@ -40,21 +40,22 @@ const LEGACY_URL_PARAMETERS = {
|
|||
exit_page: null
|
||||
}
|
||||
|
||||
function isV1(searchRecord: Record<string, unknown>): boolean {
|
||||
return Object.keys(searchRecord).some(
|
||||
(k) => k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)
|
||||
)
|
||||
function isV1(searchParams: URLSearchParams): boolean {
|
||||
for (const k of searchParams.keys()) {
|
||||
if (k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function parseSearchRecord(
|
||||
searchRecord: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const searchRecordEntries = Object.entries(searchRecord)
|
||||
function parseSearch(searchString: string): Record<string, unknown> {
|
||||
const searchParams = new URLSearchParams(searchString)
|
||||
const updatedSearchRecordEntries = []
|
||||
const filters: Filter[] = []
|
||||
let labels: DashboardQuery['labels'] = {}
|
||||
|
||||
for (const [key, value] of searchRecordEntries) {
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
|
|
@ -63,9 +64,10 @@ function parseSearchRecord(
|
|||
filters.push(filter)
|
||||
const labelsKey: string | null | undefined =
|
||||
LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
|
||||
if (labelsKey && searchRecord[labelsKey]) {
|
||||
const labelsParamValue = labelsKey ? searchParams.get(labelsKey) : null
|
||||
if (labelsParamValue) {
|
||||
const clauses = filter[2]
|
||||
const labelsValues = (searchRecord[labelsKey] as string)
|
||||
const labelsValues = labelsParamValue
|
||||
.split('|')
|
||||
.filter((label) => !!label)
|
||||
const newLabels = Object.fromEntries(
|
||||
|
|
@ -79,8 +81,9 @@ function parseSearchRecord(
|
|||
}
|
||||
}
|
||||
|
||||
if (typeof searchRecord['props'] === 'string') {
|
||||
filters.push(...(parseLegacyPropsFilter(searchRecord['props']) as Filter[]))
|
||||
const propsParamValue = searchParams.get('props')
|
||||
if (typeof propsParamValue === 'string') {
|
||||
filters.push(...(parseLegacyPropsFilter(propsParamValue) as Filter[]))
|
||||
}
|
||||
updatedSearchRecordEntries.push(['filters', filters], ['labels', labels])
|
||||
return Object.fromEntries(updatedSearchRecordEntries)
|
||||
|
|
@ -114,5 +117,5 @@ function parseLegacyPropsFilter(rawValue: string) {
|
|||
|
||||
export const v1 = {
|
||||
isV1,
|
||||
parseSearchRecord
|
||||
parseSearch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,21 +242,25 @@ describe(`${getRedirectTarget.name}`, () => {
|
|||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns updated URL for page=... style filters (v1), and running the updated value through the function again returns null (no redirect loop)', () => {
|
||||
const pathname = '/'
|
||||
const search = '?page=/docs'
|
||||
const expectedUpdatedSearch = '?f=is,page,/docs&r=v1'
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search
|
||||
} as Location)
|
||||
).toEqual(`${pathname}${expectedUpdatedSearch}`)
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search: expectedUpdatedSearch
|
||||
} as Location)
|
||||
).toBeNull()
|
||||
})
|
||||
it.each([
|
||||
['?page=/docs', '?f=is,page,/docs&r=v1'],
|
||||
['?page=%C3%AA&embed=true', '?f=is,page,%C3%AA&embed=true&r=v1']
|
||||
])(
|
||||
'returns updated URL v1 style filter %s, and running the updated value through the function again returns null (no redirect loop)',
|
||||
(searchString, expectedSearchString) => {
|
||||
const pathname = '/'
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search: searchString
|
||||
} as Location)
|
||||
).toEqual(`${pathname}${expectedSearchString}`)
|
||||
expect(
|
||||
getRedirectTarget({
|
||||
pathname,
|
||||
search: expectedSearchString
|
||||
} as Location)
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { v1 } from './url-search-params-v1'
|
|||
import { v2 } from './url-search-params-v2'
|
||||
|
||||
/**
|
||||
* These charcters are not URL encoded to have more readable URLs.
|
||||
* These characters are not URL encoded to have more readable URLs.
|
||||
* Browsers seem to handle this just fine.
|
||||
* `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param``
|
||||
*/
|
||||
|
|
@ -241,18 +241,17 @@ export function getRedirectTarget(windowLocation: Location): null | string {
|
|||
}
|
||||
|
||||
const isV2 = v2.isV2(searchParams)
|
||||
const isV1 = v1.isV1(searchParams)
|
||||
|
||||
if (isV2) {
|
||||
return `${windowLocation.pathname}${stringifySearch({ ...v2.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' })}`
|
||||
}
|
||||
|
||||
const searchRecord = v2.parseSearch(windowLocation.search)
|
||||
const isV1 = v1.isV1(searchRecord)
|
||||
|
||||
if (!isV1) {
|
||||
return null
|
||||
if (isV1) {
|
||||
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}`
|
||||
}
|
||||
|
||||
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearchRecord(searchRecord), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}`
|
||||
return null
|
||||
}
|
||||
|
||||
/** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { apiPath, externalLinkForPage, isValidHttpUrl, trimURL } from './url'
|
||||
import { siteContextDefaultValue } from '../site-context'
|
||||
|
||||
describe('apiPath', () => {
|
||||
it.each([
|
||||
|
|
@ -32,10 +33,19 @@ describe('externalLinkForPage', () => {
|
|||
])(
|
||||
'when domain is %s and page is %s, it should return %s',
|
||||
(domain, page, expected) => {
|
||||
const result = externalLinkForPage(domain, page)
|
||||
const site = { ...siteContextDefaultValue, domain: domain }
|
||||
const result = externalLinkForPage(site, page)
|
||||
expect(result).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('returns null for consolidated view', () => {
|
||||
const consolidatedView = {
|
||||
...siteContextDefaultValue,
|
||||
isConsolidatedView: true
|
||||
}
|
||||
expect(externalLinkForPage(consolidatedView, '/some-page')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidHttpUrl', () => {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ export function apiPath(
|
|||
}
|
||||
|
||||
export function externalLinkForPage(
|
||||
domain: PlausibleSite['domain'],
|
||||
site: PlausibleSite,
|
||||
page: string
|
||||
): string | null {
|
||||
if (site.isConsolidatedView) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const domainURL = new URL(`https://${domain}`)
|
||||
const domainURL = new URL(`https://${site.domain}`)
|
||||
return `https://${domainURL.host}${page}`
|
||||
} catch (_error) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
/**
|
||||
These 3 modules 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
|
||||
*/
|
||||
|
||||
/* eslint-disable import/no-unresolved */
|
||||
import 'phoenix_html'
|
||||
import { Socket } from 'phoenix'
|
||||
import { LiveSocket } from 'phoenix_live_view'
|
||||
import { Modal, Dropdown } from 'prima'
|
||||
import DashboardRoot from './dashboard_root'
|
||||
import DashboardTabs from './dashboard_tabs.js'
|
||||
import topbar from 'topbar'
|
||||
/* eslint-enable import/no-unresolved */
|
||||
|
||||
|
|
@ -13,8 +17,12 @@ import Alpine from 'alpinejs'
|
|||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']")
|
||||
let websocketUrl = document.querySelector("meta[name='websocket-url']")
|
||||
let disablePushStateFlag = document.querySelector(
|
||||
"meta[name='live-socket-disable-push-state']"
|
||||
)
|
||||
let domain = document.querySelector("meta[name='dashboard-domain']")
|
||||
if (csrfToken && websocketUrl) {
|
||||
let Hooks = {}
|
||||
let Hooks = { Modal, Dropdown, DashboardRoot, DashboardTabs }
|
||||
Hooks.Metrics = {
|
||||
mounted() {
|
||||
this.handleEvent('send-metrics', ({ event_name }) => {
|
||||
|
|
@ -47,9 +55,14 @@ if (csrfToken && websocketUrl) {
|
|||
let token = csrfToken.getAttribute('content')
|
||||
let url = websocketUrl.getAttribute('content')
|
||||
let liveUrl = url === '' ? '/live' : new URL('/live', url).href
|
||||
let disablePushState =
|
||||
!!disablePushStateFlag &&
|
||||
disablePushStateFlag.getAttribute('content') === 'true'
|
||||
let domainName = domain && domain.getAttribute('content')
|
||||
let liveSocket = new LiveSocket(liveUrl, Socket, {
|
||||
// For dashboard LV migration
|
||||
disablePushState: disablePushState,
|
||||
heartbeatIntervalMs: 10000,
|
||||
params: { _csrf_token: token },
|
||||
hooks: Hooks,
|
||||
uploaders: Uploaders,
|
||||
dom: {
|
||||
|
|
@ -59,6 +72,20 @@ if (csrfToken && websocketUrl) {
|
|||
Alpine.clone(from, to)
|
||||
}
|
||||
}
|
||||
},
|
||||
params: () => {
|
||||
if (domainName) {
|
||||
return {
|
||||
// The prefs are used by dashboard LiveView to persist
|
||||
// user preferences across the reloads.
|
||||
user_prefs: {
|
||||
pages_tab: localStorage.getItem(`pageTab__${domainName}`)
|
||||
},
|
||||
_csrf_token: token
|
||||
}
|
||||
} else {
|
||||
return { _csrf_token: token }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ config :bcrypt_elixir, :log_rounds, 4
|
|||
|
||||
config :plausible, Plausible.Repo,
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: System.schedulers_online() * 2
|
||||
pool_size: System.schedulers_online()
|
||||
|
||||
config :plausible, Plausible.ClickhouseRepo,
|
||||
loggers: [Ecto.LogEntry],
|
||||
|
|
@ -63,3 +63,5 @@ config :plausible, Plausible.InstallationSupport.Checks.VerifyInstallation,
|
|||
]
|
||||
|
||||
config :plausible, Plausible.Session.Salts, interval: :timer.hours(1)
|
||||
|
||||
config :plausible, max_goals_per_site: 10
|
||||
|
|
|
|||
|
|
@ -16,18 +16,39 @@ defmodule Plausible.ConsolidatedView do
|
|||
|
||||
import Ecto.Query
|
||||
|
||||
@spec ok_to_display?(Team.t() | nil, User.t() | nil) :: boolean()
|
||||
def ok_to_display?(team, user) do
|
||||
with %Team{} <- team,
|
||||
%User{} <- user,
|
||||
true <- Plausible.Auth.is_super_admin?(user),
|
||||
true <- enabled?(team),
|
||||
true <- has_sites_to_consolidate?(team) do
|
||||
true
|
||||
else
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
@spec cta_dismissed?(User.t(), Team.t()) :: boolean()
|
||||
def cta_dismissed?(%User{} = user, %Team{} = team) do
|
||||
{:ok, team_membership} = Teams.Memberships.get_team_membership(team, user)
|
||||
Teams.Memberships.get_preference(team_membership, :consolidated_view_cta_dismissed)
|
||||
end
|
||||
|
||||
@spec dismiss_cta(User.t(), Team.t()) :: :ok
|
||||
def dismiss_cta(%User{} = user, %Team{} = team) do
|
||||
{:ok, team_membership} = Teams.Memberships.get_team_membership(team, user)
|
||||
Teams.Memberships.set_preference(team_membership, :consolidated_view_cta_dismissed, true)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec restore_cta(User.t(), Team.t()) :: :ok
|
||||
def restore_cta(%User{} = user, %Team{} = team) do
|
||||
{:ok, team_membership} = Teams.Memberships.get_team_membership(team, user)
|
||||
|
||||
Teams.Memberships.set_preference(
|
||||
team_membership,
|
||||
:consolidated_view_cta_dismissed,
|
||||
false
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec ok_to_display?(Team.t() | nil) :: boolean()
|
||||
def ok_to_display?(team) do
|
||||
is_struct(team, Team) and
|
||||
view_enabled?(team) and
|
||||
has_sites_to_consolidate?(team) and
|
||||
Plausible.Billing.Feature.ConsolidatedView.check_availability(team) == :ok
|
||||
end
|
||||
|
||||
@spec reset_if_enabled(Team.t()) :: :ok
|
||||
|
|
@ -56,14 +77,28 @@ defmodule Plausible.ConsolidatedView do
|
|||
from(s in q, where: s.consolidated == true)
|
||||
end
|
||||
|
||||
@spec enable(Team.t()) :: {:ok, Site.t()} | {:error, :no_sites | :team_not_setup}
|
||||
@spec enable(Team.t()) ::
|
||||
{:ok, Site.t()}
|
||||
| {:error, :no_sites | :team_not_setup | :upgrade_required | :contact_us}
|
||||
def enable(%Team{} = team) do
|
||||
with :ok <- ensure_eligible(team), do: do_enable(team)
|
||||
end
|
||||
availability_check = Plausible.Billing.Feature.ConsolidatedView.check_availability(team)
|
||||
|
||||
@spec enabled?(Team.t()) :: boolean()
|
||||
def enabled?(%Team{} = team) do
|
||||
not is_nil(get(team))
|
||||
cond do
|
||||
not has_sites_to_consolidate?(team) ->
|
||||
{:error, :no_sites}
|
||||
|
||||
Teams.Billing.enterprise_configured?(team) and availability_check != :ok ->
|
||||
{:error, :contact_us}
|
||||
|
||||
availability_check != :ok ->
|
||||
availability_check
|
||||
|
||||
not Teams.setup?(team) ->
|
||||
{:error, :team_not_setup}
|
||||
|
||||
true ->
|
||||
do_enable(team)
|
||||
end
|
||||
end
|
||||
|
||||
@spec disable(Team.t()) :: :ok
|
||||
|
|
@ -159,16 +194,6 @@ defmodule Plausible.ConsolidatedView do
|
|||
team.identifier
|
||||
end
|
||||
|
||||
# TODO: Only active trials and business subscriptions should be eligible.
|
||||
# This function should also call a new underlying feature module.
|
||||
defp ensure_eligible(%Team{} = team) do
|
||||
cond do
|
||||
not Teams.setup?(team) -> {:error, :team_not_setup}
|
||||
not has_sites_to_consolidate?(team) -> {:error, :no_sites}
|
||||
true -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp native_stats_start_at(%Team{} = team) do
|
||||
q =
|
||||
from(sr in Site.regular(),
|
||||
|
|
@ -181,7 +206,7 @@ defmodule Plausible.ConsolidatedView do
|
|||
end
|
||||
|
||||
defp has_sites_to_consolidate?(%Team{} = team) do
|
||||
Teams.owned_sites_count(team) > 0
|
||||
Teams.owned_sites_count(team) > 1
|
||||
end
|
||||
|
||||
defp majority_sites_timezone(%Team{} = team) do
|
||||
|
|
@ -200,4 +225,8 @@ defmodule Plausible.ConsolidatedView do
|
|||
nil -> "Etc/UTC"
|
||||
end
|
||||
end
|
||||
|
||||
defp view_enabled?(%Team{} = team) do
|
||||
not is_nil(get(team))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@ defmodule Plausible.CustomerSupport.Resource.Site do
|
|||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
ilike(s.domain, ^"%#{input}%") or ilike(t.name, ^"%#{input}%") or
|
||||
ilike(o.name, ^"%#{input}%") or ilike(o.email, ^"%#{input}%"),
|
||||
ilike(o.name, ^"%#{input}%") or ilike(o.email, ^"%#{input}%") or
|
||||
ilike(s.domain_changed_from, ^"%#{input}%"),
|
||||
order_by: [
|
||||
desc: fragment("?.domain = ?", s, ^input),
|
||||
desc: fragment("?.domain_changed_from = ?", s, ^input),
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
desc: fragment("?.email = ?", o, ^input),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
|
|||
limit = Keyword.fetch!(opts, :limit)
|
||||
|
||||
q =
|
||||
from t in Plausible.Teams.Team,
|
||||
from(t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
limit: ^limit,
|
||||
|
|
@ -25,6 +25,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
|
|||
on: true,
|
||||
order_by: [desc: :id],
|
||||
preload: [owners: o, subscription: s]
|
||||
)
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
|
|
@ -33,42 +34,56 @@ defmodule Plausible.CustomerSupport.Resource.Team do
|
|||
limit = Keyword.fetch!(opts, :limit)
|
||||
|
||||
q =
|
||||
from t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
ilike(t.name, ^"%#{input}%") or ilike(o.name, ^"%#{input}%") or
|
||||
ilike(o.email, ^"%#{input}%"),
|
||||
limit: ^limit,
|
||||
order_by: [
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
desc: fragment("?.email = ?", o, ^input),
|
||||
asc: t.name
|
||||
],
|
||||
preload: [owners: o]
|
||||
if opts[:uuid_provided?] do
|
||||
from(t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where: t.identifier == ^input,
|
||||
preload: [owners: o]
|
||||
)
|
||||
else
|
||||
from(t in Plausible.Teams.Team,
|
||||
as: :team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
ilike(t.name, ^"%#{input}%") or
|
||||
ilike(o.name, ^"%#{input}%") or
|
||||
ilike(o.email, ^"%#{input}%"),
|
||||
limit: ^limit,
|
||||
order_by: [
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
desc: fragment("?.email = ?", o, ^input),
|
||||
asc: t.name
|
||||
],
|
||||
preload: [owners: o]
|
||||
)
|
||||
end
|
||||
|
||||
q =
|
||||
if opts[:with_subscription_only?] do
|
||||
from t in q,
|
||||
from(t in q,
|
||||
inner_lateral_join: s in subquery(Teams.last_subscription_join_query()),
|
||||
on: true,
|
||||
preload: [subscription: s]
|
||||
)
|
||||
else
|
||||
from t in q,
|
||||
from(t in q,
|
||||
left_lateral_join: s in subquery(Teams.last_subscription_join_query()),
|
||||
on: true,
|
||||
preload: [subscription: s]
|
||||
)
|
||||
end
|
||||
|
||||
q =
|
||||
if opts[:with_sso_only?] do
|
||||
from t in q,
|
||||
from(t in q,
|
||||
inner_join: sso_integration in assoc(t, :sso_integration),
|
||||
as: :sso_integration,
|
||||
left_join: sso_domains in assoc(sso_integration, :sso_domains),
|
||||
as: :sso_domains,
|
||||
or_where: ilike(sso_domains.domain, ^"%#{input}%")
|
||||
)
|
||||
else
|
||||
q
|
||||
end
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ defmodule Plausible.Stats.ConsolidatedView do
|
|||
|> DateTime.to_iso8601()
|
||||
|
||||
stats_query =
|
||||
Stats.Query.build!(view, :internal, %{
|
||||
Stats.Query.parse_and_build!(view, :internal, %{
|
||||
"site_id" => view.domain,
|
||||
"metrics" => ["visitors", "visits", "pageviews", "views_per_visit"],
|
||||
"include" => %{"comparisons" => %{"mode" => "custom", "date_range" => [c_from, c_to]}},
|
||||
|
|
@ -91,7 +91,7 @@ defmodule Plausible.Stats.ConsolidatedView do
|
|||
|
||||
defp query_24h_intervals(view, now) do
|
||||
graph_query =
|
||||
Stats.Query.build!(
|
||||
Stats.Query.parse_and_build!(
|
||||
view,
|
||||
:internal,
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -408,6 +408,11 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
|
|||
{:missing, param} ->
|
||||
H.bad_request(conn, "Parameter `#{param}` is required to create a goal")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
message = Enum.map_join(changeset.errors, ", ", &translate_error/1)
|
||||
|
||||
H.bad_request(conn, message)
|
||||
|
||||
e ->
|
||||
H.bad_request(conn, "Something went wrong: #{inspect(e)}")
|
||||
end
|
||||
|
|
@ -605,4 +610,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
|
|||
# remap to `custom_properties`
|
||||
|> Map.put(:custom_properties, site.allowed_event_props || [])
|
||||
end
|
||||
|
||||
defp translate_error({field, {msg, opts}}) do
|
||||
Enum.reduce(opts, "#{field}: #{msg}", fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ defmodule PlausibleWeb.CustomerSupport.Components.Layout do
|
|||
</p>
|
||||
<strong>team:</strong>input<br />
|
||||
<p class="font-sans pl-2 mb-1">
|
||||
Search for teams exclusively. Input will be checked against user's name and e-mail.
|
||||
Search for teams exclusively. Input will be checked against user/team name, e-mail or team identifier. Identifier must be provided complete, as is.
|
||||
</p>
|
||||
|
||||
<strong>team:</strong>input <strong>+sub</strong>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,15 @@ defmodule PlausibleWeb.CustomerSupport.Components.Search do
|
|||
opts
|
||||
end
|
||||
|
||||
opts =
|
||||
case Ecto.UUID.cast(input) do
|
||||
{:ok, _uuid} ->
|
||||
Keyword.merge(opts, uuid_provided?: true)
|
||||
|
||||
_ ->
|
||||
opts
|
||||
end
|
||||
|
||||
{[Resource.Team], input, opts}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,17 @@ defmodule PlausibleWeb.CustomerSupport.Site.Components.Overview do
|
|||
<div class="flex justify-center items-center gap-x-8 pb-8 mb-8 border-b border-gray-200 dark:border-gray-700 w-full text-sm text-center">
|
||||
<span>Quick links:</span>
|
||||
|
||||
<.styled_link new_tab={true} href={"/#{@site.domain}"}>
|
||||
<.styled_link
|
||||
new_tab={true}
|
||||
href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, @site.domain, [])}
|
||||
>
|
||||
Dashboard
|
||||
</.styled_link>
|
||||
|
||||
<.styled_link new_tab={true} href={"/#{@site.domain}/settings/general"}>
|
||||
<.styled_link
|
||||
new_tab={true}
|
||||
href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, @site.domain, [])}
|
||||
>
|
||||
Settings
|
||||
</.styled_link>
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
|
|||
<:thead>
|
||||
<.th>Domain</.th>
|
||||
<.th>Timezone</.th>
|
||||
<.th>Available?</.th>
|
||||
<.th invisible>Dashboard</.th>
|
||||
<.th invisible>24H</.th>
|
||||
<.th invisible>Delete</.th>
|
||||
|
|
@ -53,6 +54,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
|
|||
<:tbody :let={consolidated_view}>
|
||||
<.td>{consolidated_view.domain}</.td>
|
||||
<.td>{consolidated_view.timezone}</.td>
|
||||
<.td>{availability(@team)}</.td>
|
||||
<.td>
|
||||
<.styled_link
|
||||
new_tab={true}
|
||||
|
|
@ -74,7 +76,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
|
|||
<.delete_button
|
||||
phx-click="delete-consolidated-view"
|
||||
phx-target={@myself}
|
||||
data-confirm="Are you sure you want to delete this consolidated view?"
|
||||
data-confirm="Are you sure you want to delete this consolidated view? All existing consolidated view configuration will be lost. The view itself will be recreated whenever eligible subscription/trial accesses /sites for that team."
|
||||
/>
|
||||
</.td>
|
||||
</:tbody>
|
||||
|
|
@ -90,8 +92,8 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
|
|||
success("Consolidated view created")
|
||||
{:noreply, assign(socket, consolidated_views: [consolidated_view])}
|
||||
|
||||
{:error, _} ->
|
||||
failure("Could not create consolidated view")
|
||||
{:error, reason} ->
|
||||
failure("Could not create consolidated view. Reason: #{inspect(reason)}")
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
|
@ -101,4 +103,11 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
|
|||
success("Deleted consolidated view")
|
||||
{:noreply, assign(socket, consolidated_views: [])}
|
||||
end
|
||||
|
||||
defp availability(team) do
|
||||
case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do
|
||||
:ok -> "Yes"
|
||||
{:error, :upgrade_required} -> "No - upgrade required"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -50,40 +50,61 @@ defmodule PlausibleWeb.Live.FunnelSettings do
|
|||
<div id="funnel-settings-main">
|
||||
<.flash_messages flash={@flash} />
|
||||
|
||||
<%= if @setup_funnel? do %>
|
||||
{live_render(
|
||||
@socket,
|
||||
PlausibleWeb.Live.FunnelSettings.Form,
|
||||
id: "funnels-form",
|
||||
session: %{
|
||||
"domain" => @domain,
|
||||
"funnel_id" => @funnel_id
|
||||
}
|
||||
)}
|
||||
<% end %>
|
||||
<div :if={@goal_count >= Funnel.min_steps()}>
|
||||
<.live_component
|
||||
module={PlausibleWeb.Live.FunnelSettings.List}
|
||||
id="funnels-list"
|
||||
funnels={@displayed_funnels}
|
||||
filter_text={@filter_text}
|
||||
/>
|
||||
</div>
|
||||
<.tile
|
||||
docs="funnel-analysis"
|
||||
feature_mod={Plausible.Billing.Feature.Funnels}
|
||||
feature_toggle?={true}
|
||||
show_content?={!Plausible.Billing.Feature.Funnels.opted_out?(@site)}
|
||||
site={@site}
|
||||
current_user={@current_user}
|
||||
current_team={@current_team}
|
||||
>
|
||||
<:title>
|
||||
Funnels
|
||||
</:title>
|
||||
<:subtitle :if={Enum.count(@all_funnels) > 0}>
|
||||
Compose goals into funnels to track user flows and conversion rates.
|
||||
</:subtitle>
|
||||
<%= if @setup_funnel? do %>
|
||||
{live_render(
|
||||
@socket,
|
||||
PlausibleWeb.Live.FunnelSettings.Form,
|
||||
id: "funnels-form",
|
||||
session: %{
|
||||
"domain" => @domain,
|
||||
"funnel_id" => @funnel_id
|
||||
}
|
||||
)}
|
||||
<% end %>
|
||||
<div :if={@goal_count >= Funnel.min_steps()}>
|
||||
<.live_component
|
||||
module={PlausibleWeb.Live.FunnelSettings.List}
|
||||
id="funnels-list"
|
||||
funnels={@displayed_funnels}
|
||||
filter_text={@filter_text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :if={@goal_count < Funnel.min_steps()} class="flex flex-col items-center">
|
||||
<h1 class="mt-4 text-center">
|
||||
Ready to dig into user flows?
|
||||
</h1>
|
||||
<p class="mt-4 mb-6 max-w-lg text-sm text-gray-500 dark:text-gray-400 leading-5 text-center">
|
||||
Set up a few goals first (e.g. <b>"Signup"</b>, <b>"Visit /"</b>, or <b>"Scroll 50% on /blog/*"</b>) and return here to build your first funnel!
|
||||
</p>
|
||||
<.button_link
|
||||
class="mb-2"
|
||||
href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
|
||||
<div
|
||||
:if={@goal_count < Funnel.min_steps()}
|
||||
class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto"
|
||||
>
|
||||
Set up goals →
|
||||
</.button_link>
|
||||
</div>
|
||||
<h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
|
||||
Ready to dig into user flows?
|
||||
</h3>
|
||||
<p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
|
||||
Set up a few goals like <.highlighted>Signup</.highlighted>, <.highlighted>Visit /</.highlighted>, or
|
||||
<.highlighted>Scroll 50% on /blog/*</.highlighted>
|
||||
first, then return here to build your first funnel.
|
||||
</p>
|
||||
<.button_link
|
||||
class="mt-4"
|
||||
href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)}
|
||||
>
|
||||
Set up goals →
|
||||
</.button_link>
|
||||
</div>
|
||||
</.tile>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -147,4 +168,8 @@ defmodule PlausibleWeb.Live.FunnelSettings do
|
|||
def handle_info(:cancel_setup_funnel, socket) do
|
||||
{:noreply, assign(socket, setup_funnel?: false, funnel_id: nil)}
|
||||
end
|
||||
|
||||
def handle_info({:feature_toggled, flash_msg, updated_site}, socket) do
|
||||
{:noreply, assign(put_flash(socket, :success, flash_msg), site: updated_site)}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
phx-target="#funnel-form"
|
||||
phx-click-away="cancel-add-funnel"
|
||||
onkeydown="return event.key != 'Enter';"
|
||||
class="bg-white dark:bg-gray-900 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"
|
||||
class="bg-white dark:bg-gray-900 shadow-2xl rounded-lg px-8 pt-6 pb-8 mb-4 mt-8"
|
||||
>
|
||||
<.title class="mb-6">
|
||||
{if @funnel, do: "Edit", else: "Add"} funnel
|
||||
|
|
@ -84,7 +84,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
Funnel steps
|
||||
</.label>
|
||||
|
||||
<div :for={step_idx <- @step_ids} class="flex mb-3 mt-3">
|
||||
<div :for={step_idx <- @step_ids} class="flex my-3">
|
||||
<div class="w-2/5 flex-1">
|
||||
<.live_component
|
||||
selected={find_preselected(@funnel, @funnel_modified?, step_idx)}
|
||||
|
|
@ -117,13 +117,12 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<.add_step_button :if={
|
||||
length(@step_ids) < Funnel.max_steps() and
|
||||
map_size(@selections_made) < length(@goals)
|
||||
} />
|
||||
|
||||
<div class="mt-6">
|
||||
<p id="funnel-eval" class="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
|
||||
<div class="flex flex-col gap-y-4 mt-6">
|
||||
<.add_step_button :if={
|
||||
length(@step_ids) < Funnel.max_steps() and
|
||||
map_size(@selections_made) < length(@goals)
|
||||
} />
|
||||
<p id="funnel-eval" class="text-gray-800 dark:text-gray-200 text-sm">
|
||||
<%= if @evaluation_result do %>
|
||||
Last month conversion rate: <strong><%= List.last(@evaluation_result.steps).conversion_rate %></strong>%
|
||||
<% end %>
|
||||
|
|
@ -179,7 +178,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
|
||||
def add_step_button(assigns) do
|
||||
~H"""
|
||||
<a class="underline text-indigo-500 text-sm cursor-pointer mt-6" phx-click="add-step">
|
||||
<a class="text-indigo-500 text-sm font-medium cursor-pointer" phx-click="add-step">
|
||||
+ Add another step
|
||||
</a>
|
||||
"""
|
||||
|
|
@ -350,7 +349,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||
)
|
||||
|
||||
query =
|
||||
Plausible.Stats.Query.build!(
|
||||
Plausible.Stats.Query.parse_and_build!(
|
||||
site,
|
||||
:internal,
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -10,13 +10,17 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
|
|||
use PlausibleWeb, :live_component
|
||||
|
||||
def render(assigns) do
|
||||
assigns = assign(assigns, :searching?, String.trim(assigns.filter_text) != "")
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
|
||||
<.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
|
||||
Add funnel
|
||||
</.button>
|
||||
</.filter_bar>
|
||||
<%= if @searching? or Enum.count(@funnels) > 0 do %>
|
||||
<.filter_bar filter_text={@filter_text} placeholder="Search Funnels">
|
||||
<.button id="add-funnel-button" phx-click="add-funnel" mt?={false}>
|
||||
Add funnel
|
||||
</.button>
|
||||
</.filter_bar>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.count(@funnels) > 0 do %>
|
||||
<.table rows={@funnels}>
|
||||
|
|
@ -42,19 +46,44 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
|
|||
</:tbody>
|
||||
</.table>
|
||||
<% else %>
|
||||
<p class="mt-12 mb-8 text-sm text-center">
|
||||
<span :if={String.trim(@filter_text) != ""}>
|
||||
No funnels found for this site. Please refine or
|
||||
<.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
|
||||
reset your search.
|
||||
</.styled_link>
|
||||
</span>
|
||||
<span :if={String.trim(@filter_text) == "" && Enum.empty?(@funnels)}>
|
||||
No funnels configured for this site.
|
||||
</span>
|
||||
</p>
|
||||
<.no_search_results :if={@searching?} />
|
||||
<.empty_state :if={not @searching?} />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp no_search_results(assigns) do
|
||||
~H"""
|
||||
<p class="mt-12 mb-8 text-sm text-center">
|
||||
No funnels found for this site. Please refine or
|
||||
<.styled_link phx-click="reset-filter-text" id="reset-filter-hint">
|
||||
reset your search.
|
||||
</.styled_link>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp empty_state(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto">
|
||||
<h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
|
||||
Create your first funnel
|
||||
</h3>
|
||||
<p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty">
|
||||
Compose goals into funnels to track user flows and conversion rates.
|
||||
<.styled_link href="https://plausible.io/docs/funnel-analysis" target="_blank">
|
||||
Learn more
|
||||
</.styled_link>
|
||||
</p>
|
||||
<.button
|
||||
id="add-funnel-button"
|
||||
phx-click="add-funnel"
|
||||
class="mt-4"
|
||||
>
|
||||
Add funnel
|
||||
</.button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.Funnels do
|
|||
use PlausibleWeb, :plugins_api_controller
|
||||
|
||||
operation(:create,
|
||||
id: "Funnel.GetOrCreate",
|
||||
operation_id: "Funnel.GetOrCreate",
|
||||
summary: "Get or create Funnel",
|
||||
request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest},
|
||||
responses: %{
|
||||
|
|
|
|||
|
|
@ -1,15 +1,3 @@
|
|||
defimpl Bamboo.Formatter, for: Plausible.Auth.User do
|
||||
def format_email_address(user, _opts) do
|
||||
{user.name, user.email}
|
||||
end
|
||||
end
|
||||
|
||||
defimpl FunWithFlags.Actor, for: Plausible.Auth.User do
|
||||
def id(%{id: id}) do
|
||||
"user:#{id}"
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Plausible.Auth.User do
|
||||
use Plausible
|
||||
use Ecto.Schema
|
||||
|
|
@ -284,3 +272,15 @@ defmodule Plausible.Auth.User do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Bamboo.Formatter, for: Plausible.Auth.User do
|
||||
def format_email_address(user, _opts) do
|
||||
{user.name, user.email}
|
||||
end
|
||||
end
|
||||
|
||||
defimpl FunWithFlags.Actor, for: Plausible.Auth.User do
|
||||
def id(%{id: id}) do
|
||||
"user:#{id}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ defmodule Plausible.Billing.Feature do
|
|||
Plausible.Billing.Feature.SiteSegments,
|
||||
Plausible.Billing.Feature.SitesAPI,
|
||||
Plausible.Billing.Feature.StatsAPI,
|
||||
Plausible.Billing.Feature.SSO
|
||||
Plausible.Billing.Feature.SSO,
|
||||
Plausible.Billing.Feature.ConsolidatedView
|
||||
]
|
||||
|
||||
# Generate a union type for features
|
||||
|
|
@ -229,6 +230,15 @@ defmodule Plausible.Billing.Feature.SSO do
|
|||
display_name: "Single Sign-On"
|
||||
end
|
||||
|
||||
defmodule Plausible.Billing.Feature.ConsolidatedView do
|
||||
use Plausible
|
||||
|
||||
@moduledoc false
|
||||
use Plausible.Billing.Feature,
|
||||
name: :consolidated_view,
|
||||
display_name: "Consolidated View"
|
||||
end
|
||||
|
||||
defmodule Plausible.Billing.Feature.Teams do
|
||||
@moduledoc """
|
||||
Unlike other feature modules, this one only exists to make feature gating
|
||||
|
|
|
|||
|
|
@ -19,18 +19,32 @@ defmodule Plausible.Goal do
|
|||
field :funnels, {:array, :map}, virtual: true, default: []
|
||||
end
|
||||
|
||||
field :custom_props, :map, default: %{}
|
||||
|
||||
belongs_to :site, Plausible.Site
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@fields [:id, :site_id, :event_name, :page_path, :scroll_threshold, :display_name] ++
|
||||
@fields [
|
||||
:id,
|
||||
:site_id,
|
||||
:event_name,
|
||||
:page_path,
|
||||
:scroll_threshold,
|
||||
:display_name,
|
||||
:custom_props
|
||||
] ++
|
||||
on_ee(do: [:currency], else: [])
|
||||
|
||||
@max_event_name_length 120
|
||||
|
||||
def max_event_name_length(), do: @max_event_name_length
|
||||
|
||||
@max_custom_props_per_goal 3
|
||||
|
||||
def max_custom_props_per_goal(), do: @max_custom_props_per_goal
|
||||
|
||||
def changeset(goal, attrs \\ %{}) do
|
||||
goal
|
||||
|> cast(attrs, @fields)
|
||||
|
|
@ -40,11 +54,18 @@ defmodule Plausible.Goal do
|
|||
|> validate_event_name_and_page_path()
|
||||
|> validate_page_path_for_scroll_goal()
|
||||
|> maybe_put_display_name()
|
||||
|> unique_constraint(:event_name, name: :goals_event_name_unique)
|
||||
|> validate_change(:custom_props, fn :custom_props, custom_props ->
|
||||
if map_size(custom_props) > @max_custom_props_per_goal do
|
||||
[custom_props: "use at most #{@max_custom_props_per_goal} properties per goal"]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|> unique_constraint(:display_name, name: :goals_display_name_unique)
|
||||
|> unique_constraint(:event_name, name: :goals_event_config_unique)
|
||||
|> unique_constraint([:page_path, :scroll_threshold],
|
||||
name: :goals_page_path_and_scroll_threshold_unique
|
||||
name: :goals_pageview_config_unique
|
||||
)
|
||||
|> unique_constraint(:display_name, name: :goals_site_id_display_name_index)
|
||||
|> validate_length(:event_name, max: @max_event_name_length)
|
||||
|> validate_number(:scroll_threshold,
|
||||
greater_than_or_equal_to: -1,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue