Compare commits

...

50 Commits

Author SHA1 Message Date
dependabot[bot] 69d8d22ac2
Bump actions/cache from 4 to 5 (#5949)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 12:58:01 +00:00
dependabot[bot] 9978f9be0f
Bump actions/download-artifact from 6 to 7 (#5951)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 12:45:26 +00:00
Sanne de Vries dfeda94e06
Add report percentages to dashboard and details view (#5923)
* Update report percentages on dashboard and details view

* Add percentages to Countries, Regions, and Cities reports

* Add percentages to Channels, Sources, and UTM reports

* Add percentages to top pages, entry pages, and exit pages reports

* Update tests to include percentages

* Change dashboard copy from title case to sentence case

* Update details modal style

* Make animations snappier

* Introduce max height to modal and make inner content scrollable

* Improve modal mobile design

- Enable horizontal scroll for details modal on mobile
- Add responsive spacing and positioning to modal

* Added mobile tap behavior to external link in list report

* Show tooltips only when in comparison mode or when the number is abbreviated

* remove previously added showTooltip prop

- This isn't needed anymore since we now handle the tooltip logic in the MetricValue component

* Show long format upon hovering detailed view metrics

* Added mobile tapping behaviour to detailed view

* Added percentages to all detailed views

* Add mobile swipe-to-close behavior for modal

* Adjust sensitivity of modal drag to close

* Use hammerjs for swipe-to-close modal behaviour

* Prevent dragging if gesture starts inside table

* Show 2 decimal places for percentages < 0.1% across dashboard

* Adjust dark mode styles

* Add hover effect to external link icon

* Update tests to expect two-decimal percentages

* Undo hammer install and revert to old modal styling

* Remove CR and % columns from goals and custom props reports on dashboard, and show on hover in detailed view

* Remove unused constants

* Undo conversion rate on hover behaviour

- Unlike percentages, CR should show permanently.

* Show percentages permanently in custom props detailed view

* Adjust width of conversion metrics column

* Updated metric-value test

* Update top-bar test

* Added changelog entry

* Fix test expectations for percentages with imported data

- Update tests to expect correct percentages (≤100%) when imported data is included. These tests will fail until the percentage calculation bug is fixed, documenting the expected behavior.

* Add imported_visitors to tests to ensure correct total_visitors calculation

* Correct imported_visitors count in test
2025-12-16 12:43:16 +00:00
dependabot[bot] 6446e15871
Bump actions/upload-artifact from 5 to 6 (#5948)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 12:37:54 +00:00
dependabot[bot] 810b956269
Bump tj-actions/changed-files from 47.0.0 to 47.0.1 (#5950)
Bumps [tj-actions/changed-files](https://github.com/tj-actions/changed-files) from 47.0.0 to 47.0.1.
- [Release notes](https://github.com/tj-actions/changed-files/releases)
- [Changelog](https://github.com/tj-actions/changed-files/blob/main/HISTORY.md)
- [Commits](24d32ffd49...e002140703)

---
updated-dependencies:
- dependency-name: tj-actions/changed-files
  dependency-version: 47.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 12:37:41 +00:00
Adam Rutkowski aed90f7ffc
Make CI vigilant about uncaptured logs during test runs (#5676)
* Let's see

* test error

* Revert test changes

* Bump

* schedulers

* tmp

* Bump timex

* ignore libcluster warning

* fixup

* fix typo

* Set shell: bash
2025-12-15 13:28:04 +00:00
Artur Pata f07dc8dd49
Display segment filters to anyone that can see the dashboard being filtered by the segment (#5935)
* Remove segment filters secrecy

* Update changelog

* Update CHANGELOG.md

Co-authored-by: Adam Rutkowski <hq@mtod.org>

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
2025-12-15 10:21:29 +00:00
Adam Rutkowski 38381195f8
Reapply+bugfix: goals with custom props (#5936) (#5944)
* Reapply "Goals with custom props (Stats API queries, funnels) (#5936)" (#5943)

This reverts commit 45116bda7b.

* Gracefully handle `nil` for existing goal.custom_props

* Revert "Gracefully handle `nil` for existing goal.custom_props"

This reverts commit 8e38748775.

* Migration: make `goals.custom_props` non-null

* Adjust test
2025-12-11 13:09:34 +00:00
Adam Rutkowski c78ddf6ba4
Update migration that failed to run (#5946)
* Update migration that failed to run

* !fixup
2025-12-11 12:49:36 +00:00
Adam Rutkowski 40f0d4bfbf
Migration: make `goals.custom_props` non-null (#5945) 2025-12-11 12:22:36 +00:00
Artur Pata e4b282a610
Fix broken v1 filters redirect (#5941) 2025-12-11 11:51:38 +00:00
Adam Rutkowski 45116bda7b
Revert "Goals with custom props (Stats API queries, funnels) (#5936)" (#5943)
This reverts commit b6b9c2c0bf.
2025-12-11 09:17:33 +00:00
Adam Rutkowski b6b9c2c0bf
Goals with custom props (Stats API queries, funnels) (#5936)
* Migration: add custom propos to goals + revisit unique constraints

* Update constraints in goal schema (and move module)

* Add a comment, not really related but useful?

* Implement querying for goals with custom props

* Optimize goal_join_data (down to one iteration) + include goal custom props

* Test goal custom propos addition + new constraints

* Test querying for goals with custom propos attached

* Test funnels made of goals with custom props

* Format

* Fixup test name

* Fixup migration

* Unified goal join macro

* Remove dupe test

* Clean up user_id usage

* Fixup test to match the description

* Revert "Temporary: make room for pre/post migration constraint names (#5942)"

This reverts commit e4bc6b8715.

---------

Co-authored-by: Uku Taht <uku.taht@gmail.com>
2025-12-11 08:39:46 +00:00
Adam Rutkowski b299aa352a
Migration: goal custom props + revamped unique constraints (#5940)
* Migration: add custom propos to goals + revisit unique constraints

* Fixup migration
2025-12-11 08:26:44 +00:00
Adam Rutkowski e4bc6b8715
Temporary: make room for pre/post migration constraint names (#5942) 2025-12-11 07:56:37 +00:00
Adrian Gruntkowski ee906f4033
Improvements to LV dashboard scaffolding (#5937)
* Remove redundant data-tile attribute

* Remove unused component

* Set  always to ignore for optimistic loading

* Use `patch` instead of `href` in `dashboard_link`

* Replace "widgets" with first-class hooks

* Fix `useEffect` React dependency
2025-12-10 12:18:29 +00:00
Artur Pata c4ea07d8bc
Fix /change-domain page permissions (#5939)
* Add test case

* Fix change domain permissions

* Update changelog

* Add more comprehensive tests for other roles
2025-12-10 09:44:23 +00:00
RobertJoonas d6673fbbd5
DashboardQueryParser & DashboardQuerySerializer (#5938)
* rename query_parser_test to api_query_parser_test

* allow metrics to be nil in ParsedQueryParams

* swap now with relative_date in ParsedQueryParams

* add DashboardQueryParser

* stop defining defaults in ParsedQueryParams

* add DashboardQuerySerializer

* make sure parse -> serialize is a reversible transformation

* fix codespell

* fix test and silence credo

* fix another test

* parse and serialize with_imported

* cleaner decode_filters

* precompile do_not_url_encode_map and simplify uri_encode_permissive

* remove prepending ? logic
2025-12-09 13:07:36 +00:00
Adam Rutkowski d9456d7308
Give voice to humans against robotic oppression (#5934)
* Give voice to humans against robotic oppression

* Add claude folder to gitignore

---------

Co-authored-by: Uku Taht <uku.taht@gmail.com>
2025-12-08 16:40:42 +00:00
Adrian Gruntkowski 16f1eb3075
Add necessary scaffolding for enabling LV on dashboard (#5930)
* Use forked version of

* Add necessary scaffolding for enabling LV on dashboard

* Implement basics for LV pages breakdown

* Make tile and tabs latency friendly

* Bring back eslint-disable pragma in live_socket.js

* Document the code somewhat

* Fix live navigation callback in React

* Make dashboard components inside portals testable

* Add very rudimentary basic tests

* Fix typo

* Fix eslint pragma in `live_socket.js`
2025-12-08 11:46:56 +00:00
Artur Pata 007155ba60
Validate password cookie for password-protected shared links on internal stats API requests (#5932)
* Works

* Move shared link password check to AuthorizeSiteAccess plug

* Write changelog, cleanup

* Handle cookies already fetched in AuthorizeSiteAccess

* Unify shared link kind with plugins API entity
2025-12-08 07:05:31 +00:00
Artur Pata 1ff2b52cbb
Add :segment_id field to shared_links schema (#5924)
* Add :limited_to_segment_id field to shared_links schema

* Refactor column name, add FK, index, and on delete cascade

* Format
2025-12-04 10:50:07 +00:00
Sanne de Vries 98e0f7276b
Bring autoconfigure notification a step forwards in custom events cre… (#5912)
* Bring autoconfigure notification a step forwards in custom events creation flow

- Rather than showing a notification that custom events have been detected at the bottom of the form, we now show a modal prior to the form, that allows the user to add them instantly or set them up manually.

* Added tests for autoconfigure modal

* Clean it up a little

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
2025-12-03 16:24:43 +00:00
RobertJoonas 12d818af8a
Refactoring preparation for DashboardQueryParser (#5929)
* rename QueryParser to ApiQueryParser

* move utc_time_range construction to querybuilder

* input_date_range format

* rename 30m atom to realtime_30m

* move build_comparison_date_range into do_build
2025-12-03 15:09:17 +00:00
Adrian Gruntkowski 98632aee74
Don't multiply average revenue metric by sampling rate in the query (#5931)
* Don't multiply average revenue metric by sampling rate in the query

* Fix formatting
2025-12-03 14:49:11 +00:00
Adam Rutkowski fa09b73ff1
Fix postgrex disconnection during async tests (#5926) 2025-12-02 12:29:10 +00:00
dependabot[bot] 85d9e59bf3
Bump actions/checkout from 5 to 6 (#5903)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 12:19:59 +00:00
Adam Rutkowski b1f21a2736
Upgrade dependencies (#5922)
* Bump deps

* Update mjml

* Update credo

* Update dialyzer

* Minor updates:

cloak cloak_ecto ex_money fun_with_flags_ui
heroicons joken locus mox
phoenix_ecto phoenix_live_reload
sweet_xml zstream

* Format
2025-12-02 11:05:32 +00:00
RobertJoonas 5b69061885
Consolidated views <> revenue goals: add comment + fix typespec (#5921)
* add comment + fix typespec

* Update lib/plausible/goals/goals.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

---------

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
2025-12-01 13:17:13 +00:00
Adam Rutkowski b64a2355a0
Platform upgrade: elixir 1.19.4 and otp 27.3.4.6 (#5920)
* Platform upgrade: elixir 1.19.4 and otp 27.3.4.6

* !fixup

* credo

* credo

* Bump cache

* fix docker image tag

* hum

* hum

* Match docker images

* Define ALPINE_VERSION once

* fixup
2025-12-01 12:50:49 +00:00
Adam Rutkowski 8a7d681c43
CRM: allow searching sites by `domain_changed_from` (#5918) 2025-12-01 08:37:41 +00:00
Uku Taht 3c9ba41cb6
Use prima modal in ip_rules settings (#5910)
* Use prima modal in ip_rules settings

* Remove unused alias

* Do not render portal in test environment - fixes tests

* Simplify invitation modal tests

* Bump CI cache version to rebuild with prima 0.2.1

The CI was using cached dependencies with prima 0.1.9, which doesn't
support the portal parameter needed for tests. Bumping the cache
version forces a rebuild with the correct prima 0.2.1 from mix.lock.

* CI debugging

* Use correct mix env

* Resolve mix.env() at compile-time
2025-11-27 13:22:05 +00:00
Adam Rutkowski 111a8b9462
Enforce max limit for goals per site (#5917)
* Limit preloading goals

* Enforce max limit for goals per site

* typo

* credo

* Remove logger call

* Integrate #5916

* Add a test

* Add test

* Unignore opts
2025-11-27 10:19:13 +00:00
Sanne de Vries 8082b695d5
Remove background color from demo CTA (#5911) 2025-11-26 13:16:56 +00:00
Adam Rutkowski 0eea55d1c1
Slurp common test modules into exunit templates (#5909)
* Slurp common test stuff into exunit templates

* !fixup

* !fixup

* !fixup

* !fixup
2025-11-24 13:30:06 +00:00
Adam Rutkowski 5fe2be8dc8
Remove :consolidated_view feature flag (#5908) 2025-11-24 11:42:15 +00:00
Sanne de Vries 2c00acc89b
Update goal settings design (#5886)
* Update goal settings design

- Replace the `Add goal` button in goal settings with a dropdown button to directly select the goal type. This way, a modal opens with the correct form for the selected goal type. The tabs in the modal have been removed.
- Add a new `pill` component to show the goal type in the table in a more distinct way. The `settings_badge` component is replaced with the `pill` component. The `pill` component that was used in `plan_box.ex` is renamed to `highlight_pill`.
- Replaced `Belongs to funnel` text with a funnel icon in the goal settings list.
- Some small tweaks like increasing the search bar width, the padding of the table cells, and adding a header to the goal settings list.

* Update tests to use the new dropdown component instead of tabs

* Replace custom `pending invitation` pill with new pill component

* Temporary: bump prima to exercise prima dropdown LV re-render fix

* Temporary: Bump prima again

* Revert "Temporary: Bump prima again"

This reverts commit 024b34a6e9.

* Revert "Temporary: bump prima to exercise prima dropdown LV re-render fix"

This reverts commit a6eabb73d0.

* Update prima

* Replace `Add goal` button with dropdown button in goal settings empty state

* Update test to check both empty and non-empty states of the add goal dropdown

* Remove pb-14 from feature gate

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
2025-11-24 11:30:55 +00:00
Adam Rutkowski f2bc96debe
Consolidated view CTA variant for insufficient custom plans (#5907)
* Consolidated view CTA variant for insufficient custom plans

* Remove unused binding
2025-11-24 11:22:56 +00:00
Adam Rutkowski 26e5c41ef7
CRM: enable team search by identifier via `team:{UUID}` (#5904) 2025-11-24 10:21:02 +00:00
RobertJoonas 7a11f5ec40
Refactor building the Query struct (#5893)
* rename Query.build -> Query.parse_and_build

* rename two test files and move 4 %Query{} building functions into subfolder

* rename StatsAPIFilterParser to LegacyStatsAPIFilterParser

* rename Filters.QueryParser to QueryParser

* turn QueryParserTest into QueryParseAndBuildTest

* move query_parser.ex out of filters directory

* separate build from parse

* disable sample_threshold in the new intermediate build function, for now

* remove now redundant test util functions

* remove unused import

* address todo from earlier

* credo

* Make module names in sync with paths in tests

---------

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
2025-11-24 09:16:05 +00:00
Adam Rutkowski 6d5951fffd
Consolidated Views: flip CTA flow, ask to upgrade first (#5905)
* Consolidated Views: flip CTA flow, ask to upgrade first

Avoiding accidental dark pattern here: we'll first ask the
user to upgrade, instead of letting them create a team
they might not need, uninformed.

* Test

* !fixup
2025-11-24 08:43:31 +00:00
Sanne de Vries b8d64e2eff
Updated empty states across settings (#5874)
* Updated empty states across settings

* Fix funnels and props functionality not hiding when toggled off

- Add show_content? attribute to generic tile component
- Ensure content is hidden when toggled off
- Avoid rendering border and empty space when toggled off
- Fix formatting

* Update personal sites empty state

* Make `tile` component lv-embeddable (#5891)

* Use new tile component for funnels, goals, imports and custom properties

- Update the settings live views to use the new tile component
- Ensure tile component is updated when feature visibility is toggled
- Extract `no_search_results` and `empty_state` components for better readability
- Extract `highlighted` component
- Update tests

* Add empty states for team sites and simplify empty state logic

- Hide top bar on `/sites` when empty state is shown
- Extract empty state logic to a separate function
- Show the same empty state for both personal and team sites, with different copy
- extract search logic to a separate function
- add tests for various empty states cases

* Clean up:
  - remove HTTP feature visibility routes now that
    we're doing it 100% via LV
  - add tests for feature toggling
  - move "site_role" to where it's used (upgrade CTA),
    since there were already some feature-related function calls
    there
  - fix random test failures left

* Fixup

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
2025-11-24 07:50:14 +00:00
Sanne de Vries 25d40155e9
Fix Safari bug where `+ Add another step` button wasn't hidden properly (#5897)
* Fix Safari bug where `+ Add another step` button wasn't hidden properly

* Increase spacing between add step button and conversion rate when both are shown
2025-11-20 16:18:15 +00:00
Sanne de Vries a9050abdb4
Add hover state for location bars (#5896)
- As part of https://github.com/plausible/analytics/pull/5890, hover states were added to all bars on the dashboard, but the countries/regions/cities bars were missed.
2025-11-20 14:24:29 +00:00
Sanne de Vries a29f0a30ba
Fix login link being invisible on hover in dark mode (#5898) 2025-11-20 14:15:24 +00:00
Sanne de Vries 1df08a25b4
Change header dropdown copy to sentence case (#5887) 2025-11-18 13:31:15 +00:00
Sanne de Vries dec382ccd5
Make various UI improvements (#5890)
- Fix invite modal z-index issues
- Improve invite modal design
- Add hover state to stats bars on dashboard
- Improve feature gate design
- Improve trial upgrade CTA design
2025-11-18 13:28:09 +00:00
Adrian Gruntkowski a2ba1256d2
Show revenue data in all breakdowns (#5767)
* Include revenue data for all detailed API responses except entry/exit pages

* Expose revenue data in all breakdown modals except entry/exit pages

* Add revenue metrics to breakdown response only on EE

* Change query builder to enable querying event metrics \w session dimension

* Add revenue metrics to entry and exit pages breakdowns

* Expose revenue data in entry and exit pages breakdowns

* Use `argMax` for `exit_page` and `exit_page_hostname` dimensions (h/t @ukutaht)

* Don't handle event-only dimensions with session-only metrics for now

* Add tests for all breakdowns

* Add clarifying comments in code

* Mark revenue tests as EE-only
2025-11-18 11:24:54 +00:00
Adam Rutkowski 35f1cea344
Migration: add consolidated views feature to enterprise plans (#5878) 2025-11-17 09:07:30 +00:00
RobertJoonas 024e6bb9ef
Prevent email reports when consolidated view ineligible (#5882)
* do not send email reports if consolidated view not ok to display

* fix CE

* more expressive condition in ok_to_send?

* Map.get -> Map.fetch
2025-11-13 10:25:44 +00:00
376 changed files with 12880 additions and 6874 deletions

View File

@ -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, []},

View File

@ -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-*

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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-*

2
.gitignore vendored
View File

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

View File

@ -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

View File

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

View File

@ -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"]

View File

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

View File

@ -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 {

View File

@ -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;

View File

@ -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
)

View File

@ -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}

View File

@ -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'
'transition-all duration-100'
)}
>

View File

@ -21,7 +21,7 @@ export type ColumnConfiguraton<T extends Record<string, unknown>> = {
/**
* Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
*/
renderValue?: (item: T) => 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}
/>
))
)}

View File

@ -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>

View File

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

View File

@ -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) {

View File

@ -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}>
{site.flags.live_dashboard ? (
<LiveViewPortal
id="pages-breakdown-live"
className="w-full h-full border-0 overflow-hidden"
/>
) : (
<Pages />
)}
</div>
</div>

View File

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

View File

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

View File

@ -63,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]

View File

@ -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,

View File

@ -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)(

View File

@ -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,11 +535,12 @@ 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>
{canExpandSegment({ segment: data, site, user }) && (
<AppNavigationLink
className={primaryNeutralButtonClassName}
path={rootRoute.path}
@ -561,6 +555,7 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => {
>
Edit segment
</AppNavigationLink>
)}
<AppNavigationLink
className={removeFilterButtonClassname}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -137,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}
/>
)

View File

@ -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>

View File

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

View File

@ -37,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">

View File

@ -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 />}

View File

@ -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,14 +149,23 @@ export default function BreakdownModal<TListItem extends { name: string }>({
/>
)
},
...metrics.map(
...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) => m.renderValue(item, meta),
renderValue: (item, isRowHovered) =>
m.renderValue(
showPercentageColumn && m.key === 'visitors'
? { ...item, percentage: null }
: item,
meta,
{ detailedView: true, isRowHovered }
),
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
sortDirection: orderByDictionary[m.key]
})
@ -151,14 +173,15 @@ export default function BreakdownModal<TListItem extends { name: string }>({
],
[
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 }) =>

View File

@ -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,16 +36,21 @@ 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 />}
</div>
{!!onSearch && (
<SearchInput
searchRef={searchRef}
@ -54,8 +61,17 @@ export const BreakdownTable = <TListItem extends { name: string }>({
/>
)}
</div>
<div className="my-4 border-b border-gray-300 dark:border-gray-700"></div>
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
<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-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>

View File

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

View File

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

View File

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

View File

@ -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()
]

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -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()

View File

@ -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">
<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"

View File

@ -7,26 +7,26 @@ import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { addFilter } 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'
})
]
}

View File

@ -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,10 +68,10 @@ class Modal extends React.Component {
/>
<div className="modal is-open" onClick={this.props.onClick}>
<div className="modal__overlay">
<button className="modal__close"></button>
<div className="[--gap:1rem] sm:[--gap:2rem] md:[--gap:4rem] flex h-full w-full items-center md:items-start justify-center p-[var(--gap)] box-border">
<div
ref={this.node}
className="modal__container dark:bg-gray-900 focus:outline-hidden"
className="max-h-[calc(100dvh_-_var(--gap)*2)] min-h-[66vh] md:min-h-120 w-full flex flex-col bg-white p-3 md:px-6 md:py-4 overflow-hidden box-border transition-[height] duration-200 ease-in shadow-2xl rounded-lg dark:bg-gray-900 focus:outline-hidden"
style={this.getStyle()}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
@ -91,6 +81,7 @@ class Modal extends React.Component {
</div>
</div>
</div>
</div>
</>,
document.getElementById('modal_root')
)

View File

@ -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'
})
]
}

View File

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

View File

@ -9,7 +9,7 @@ import {
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
import { addFilter } 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'
})
]
}

View File

@ -7,7 +7,7 @@ import {
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
import { addFilter } 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'
})
]
}

View File

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

View File

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

View File

@ -15,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 = <>&#12336;</>
}
const formattedChange = hideNumber
? null
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
return (
<span className={className} data-testid="change-arrow">
{icon}

View File

@ -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,12 +240,10 @@ export default function ListReport<
</FlipMove>
</div>
{!!detailsLinkProps &&
!state.loading &&
!(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && (
{!!detailsLinkProps && !state.loading && (
<MoreLink
onClick={undefined}
className={'mt-2'}
className={'mt-3'}
linkProps={detailsLinkProps}
list={state.list}
/>
@ -223,7 +255,9 @@ export default function ListReport<
}
function renderReportHeader() {
const metricLabels = getAvailableMetrics().map((metric) => {
const metricLabels = getAvailableMetrics()
.filter((metric) => !metric.meta.showOnHover)
.map((metric) => {
return (
<div
key={metric.key}
@ -236,7 +270,7 @@ export default function ListReport<
})
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) => {
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)}`}
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 dark:text-gray-200 text-right">
{metric.renderValue(listItem, state.meta)}
<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() {

View File

@ -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('')

View File

@ -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,34 +36,66 @@ 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>
}
return (
<Tooltip
info={
<ComparisonTooltipContent
value={value}
comparison={comparison}
metricLabel={metricLabel}
{...props}
/>
}
const valueContent = (
<span
className={showTooltip ? 'cursor-default' : ''}
data-testid="metric-value"
>
<span className="cursor-default" data-testid="metric-value">
{shortFormatter(value)}
{percentageDisplay && (
<span className="mr-3 text-gray-500 dark:text-gray-400">
{percentageDisplay}
</span>
)}
{displayFormatter(value)}
{comparison ? (
<ChangeArrow
change={comparison.change}
@ -73,6 +105,25 @@ export default function MetricValue(props: {
/>
) : null}
</span>
)
if (!showTooltip) {
return valueContent
}
return (
<Tooltip
containerRef={portalRef as React.RefObject<HTMLElement>}
info={
<ComparisonTooltipContent
value={value}
comparison={comparison}
metricLabel={metricLabel}
{...props}
/>
}
>
{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">
<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>
}
}

View File

@ -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,

View File

@ -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>

View File

@ -27,26 +27,26 @@ import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
const UTM_TAGS = {
utm_medium: {
title: 'UTM Mediums',
title: 'UTM mediums',
label: 'Medium',
endpoint: '/utm_mediums'
},
utm_source: {
title: 'UTM Sources',
title: 'UTM sources',
label: 'Source',
endpoint: '/utm_sources'
},
utm_campaign: {
title: 'UTM Campaigns',
title: 'UTM campaigns',
label: 'Campaign',
endpoint: '/utm_campaigns'
},
utm_content: {
title: 'UTM Contents',
title: 'UTM contents',
label: 'Content',
endpoint: '/utm_contents'
},
utm_term: { title: 'UTM Terms', label: 'Term', endpoint: '/utm_terms' }
utm_term: { title: 'UTM terms', label: 'Term', endpoint: '/utm_terms' }
}
function AllSources({ afterFetchData }) {
@ -70,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">

View File

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

View File

@ -69,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 '-'
}

View File

@ -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) {

View File

@ -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
}

View File

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

View File

@ -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. */

View File

@ -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
})
}
})
}
})

View File

@ -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)
}
}
})
}
})

View File

@ -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
}
}
}
}

View File

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

View File

@ -8,7 +8,7 @@ config :bcrypt_elixir, :log_rounds, 4
config :plausible, Plausible.Repo,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 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

View File

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

View File

@ -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),

View File

@ -16,7 +16,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do
limit = Keyword.fetch!(opts, :limit)
q =
from t in Plausible.Teams.Team,
from(t in Plausible.Teams.Team,
as: :team,
inner_join: o in assoc(t, :owners),
limit: ^limit,
@ -25,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,11 +34,20 @@ defmodule Plausible.CustomerSupport.Resource.Team do
limit = Keyword.fetch!(opts, :limit)
q =
from t in Plausible.Teams.Team,
if opts[:uuid_provided?] do
from(t in Plausible.Teams.Team,
as: :team,
inner_join: o in assoc(t, :owners),
where: t.identifier == ^input,
preload: [owners: o]
)
else
from(t in Plausible.Teams.Team,
as: :team,
inner_join: o in assoc(t, :owners),
where:
ilike(t.name, ^"%#{input}%") or ilike(o.name, ^"%#{input}%") or
ilike(t.name, ^"%#{input}%") or
ilike(o.name, ^"%#{input}%") or
ilike(o.email, ^"%#{input}%"),
limit: ^limit,
order_by: [
@ -47,28 +57,33 @@ defmodule Plausible.CustomerSupport.Resource.Team do
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

View File

@ -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,
%{

View File

@ -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

View File

@ -67,7 +67,7 @@ defmodule PlausibleWeb.CustomerSupport.Components.Layout do
</p>
<strong>team:</strong>input<br />
<p class="font-sans pl-2 mb-1">
Search for teams exclusively. Input will be checked against user'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>

View File

@ -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

View File

@ -50,6 +50,21 @@ defmodule PlausibleWeb.Live.FunnelSettings do
<div id="funnel-settings-main">
<.flash_messages flash={@flash} />
<.tile
docs="funnel-analysis"
feature_mod={Plausible.Billing.Feature.Funnels}
feature_toggle?={true}
show_content?={!Plausible.Billing.Feature.Funnels.opted_out?(@site)}
site={@site}
current_user={@current_user}
current_team={@current_team}
>
<:title>
Funnels
</:title>
<:subtitle :if={Enum.count(@all_funnels) > 0}>
Compose goals into funnels to track user flows and conversion rates.
</:subtitle>
<%= if @setup_funnel? do %>
{live_render(
@socket,
@ -70,20 +85,26 @@ defmodule PlausibleWeb.Live.FunnelSettings do
/>
</div>
<div :if={@goal_count < Funnel.min_steps()} class="flex flex-col items-center">
<h1 class="mt-4 text-center">
<div
:if={@goal_count < Funnel.min_steps()}
class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto"
>
<h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7">
Ready to dig into user flows?
</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!
</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="mb-2"
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

View File

@ -64,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
phx-target="#funnel-form"
phx-click-away="cancel-add-funnel"
onkeydown="return event.key != 'Enter';"
class="bg-white dark:bg-gray-900 shadow-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>
<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)
} />
<div class="mt-6">
<p id="funnel-eval" class="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
<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,
%{

View File

@ -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>
<%= 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

View File

@ -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: %{

View File

@ -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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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