diff --git a/.credo.exs b/.credo.exs index f0f0f398046f..b02a03d5050b 100644 --- a/.credo.exs +++ b/.credo.exs @@ -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, []}, diff --git a/.dockerignore b/.dockerignore index 8d917ba06318..4deda942d86c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -59,6 +59,7 @@ npm-debug.log # Auto-generated tracker files /priv/tracker/js/*.js +/priv/tracker/installation_support/ # Dializer /priv/plts/*.plt diff --git a/.formatter.exs b/.formatter.exs index b5b3ce612da3..06ece05b6a92 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,6 @@ [ plugins: [Phoenix.LiveView.HTMLFormatter], - import_deps: [:ecto, :ecto_sql, :phoenix], + import_deps: [:ecto, :ecto_sql, :phoenix, :polymorphic_embed], subdirectories: ["priv/*/migrations"], inputs: [ "*.{heex,ex,exs}", diff --git a/.github/workflows/all-checks-pass.yml b/.github/workflows/all-checks-pass.yml index 7a3db246f6a1..a60e266727ed 100644 --- a/.github/workflows/all-checks-pass.yml +++ b/.github/workflows/all-checks-pass.yml @@ -11,6 +11,6 @@ jobs: checks: read steps: - name: GitHub Checks - uses: poseidon/wait-for-status-checks@v0.6.0 + uses: poseidon/wait-for-status-checks@899c768d191b56eef585c18f8558da19e1f3e707 # v0.6.0 with: - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-private-images-ghcr.yml b/.github/workflows/build-private-images-ghcr.yml index 45fae3645008..66e53bc0aea5 100644 --- a/.github/workflows/build-private-images-ghcr.yml +++ b/.github/workflows/build-private-images-ghcr.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Docker meta id: meta - uses: docker/metadata-action@v5.0.0 + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 env: DOCKER_METADATA_PR_HEAD_SHA: true with: @@ -35,10 +35,10 @@ jobs: type=sha - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -46,7 +46,7 @@ jobs: - name: Build and push id: docker_build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true tags: ${{ steps.meta.outputs.tags }} @@ -62,22 +62,38 @@ jobs: - name: Notify team on failure if: ${{ failure() && github.ref == 'refs/heads/master' }} - uses: fjogeleit/http-request-action@v1 + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 with: url: ${{ secrets.BUILD_NOTIFICATION_URL }} method: 'POST' customHeaders: '{"Content-Type": "application/json"}' data: '{"content": "Build failed"}' + - name: Get first line and Co-Authored-By lines of the commit message + if: ${{ success() && github.ref == 'refs/heads/master' }} + id: commitmsg + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + first_line=$(printf '%s\n' "$COMMIT_MSG" | head -n1 | xargs) + co_authors=$(printf '%s\n' "$COMMIT_MSG" | grep -h 'Co-authored-by:' | sort -u | cut -d: -f2- | paste -sd, - | xargs) + { + echo "first_line=$first_line" + echo "co_authors=$co_authors" + } >> $GITHUB_OUTPUT + - name: Notify team on success if: ${{ success() && github.ref == 'refs/heads/master' }} - uses: fjogeleit/http-request-action@v1 + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 with: url: ${{ secrets.BUILD_NOTIFICATION_URL }} method: 'POST' customHeaders: '{"Content-Type": "application/json"}' escapeData: 'true' - data: '{"content": "

🚀 New changes are about to be deployed to production!


đŸ‘· Author: ${{ github.actor }}


📝 Commit message: ${{ github.event.head_commit.message }}


"}' + data: | + { + "content": "

🚀 Deploying ${{ steps.commitmsg.outputs.first_line }}

Author(s): ${{ github.event.head_commit.author.name }}
${{ steps.commitmsg.outputs.co_authors}}

Commit: ${{ github.sha }}

" + } - name: Set Honeycomb marker on success if: ${{ success() && github.ref == 'refs/heads/master' }} diff --git a/.github/workflows/build-public-images-ghcr.yml b/.github/workflows/build-public-images-ghcr.yml index 9bf505cc4699..fc8ce42b287a 100644 --- a/.github/workflows/build-public-images-ghcr.yml +++ b/.github/workflows/build-public-images-ghcr.yml @@ -32,15 +32,15 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.GHCR_REPO }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -48,7 +48,7 @@ jobs: - name: Build id: docker_build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.GHCR_REPO }},push-by-digest=true,name-canonical=true,push=true @@ -68,7 +68,7 @@ jobs: touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: digests-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* @@ -77,7 +77,7 @@ jobs: - name: Notify team on failure if: ${{ failure() }} - uses: fjogeleit/http-request-action@v1 + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 with: url: ${{ secrets.BUILD_NOTIFICATION_URL }} method: "POST" @@ -91,21 +91,21 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - - uses: docker/login-action@v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - uses: docker/metadata-action@v5 + - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 id: meta with: images: ${{ env.GHCR_REPO }} diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 76980153744f..8c82c2a9b84b 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -10,8 +10,8 @@ jobs: codespell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: codespell-project/actions-codespell@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1 with: check_filenames: true ignore_words_file: .codespellignore diff --git a/.github/workflows/comment-preview-url.yml b/.github/workflows/comment-preview-url.yml index d41f0e13b5a5..d579148e7f79 100644 --- a/.github/workflows/comment-preview-url.yml +++ b/.github/workflows/comment-preview-url.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Comment with preview URL - uses: thollander/actions-comment-pull-request@v3.0.1 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 with: message: |
diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index d957fdc5c159..94187aff2809 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: true env: - CACHE_VERSION: v12 + CACHE_VERSION: v17 PERSISTENT_CACHE_DIR: cached jobs: @@ -20,12 +20,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - mix_env: ["test"] - postgres_image: ["postgres:15"] - - include: - - mix_env: "ce_test" - postgres_image: "postgres:16" + mix_env: ["test", "ce_test"] + postgres_image: ["postgres:18"] + mix_test_partition: [1, 2, 3, 4, 5, 6] env: MIX_ENV: ${{ matrix.mix_env }} @@ -42,34 +39,36 @@ jobs: --health-timeout 5s --health-retries 5 clickhouse: - image: clickhouse/clickhouse-server:24.12.2.29-alpine + image: clickhouse/clickhouse-server:25.11.5.8-alpine ports: - 8123:8123 env: + CLICKHOUSE_SKIP_USER_SETUP: 1 options: >- --health-cmd nc -zw3 localhost 8124 --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: marocchino/tool-versions-action@v1 + - uses: marocchino/tool-versions-action@18a164fa2b0db1cc1edf7305fcb17ace36d1c306 # v1.2.0 id: versions - - uses: erlef/setup-beam@v1 + - uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 with: elixir-version: ${{ steps.versions.outputs.elixir }} otp-version: ${{ steps.versions.outputs.erlang }} - - uses: actions/cache@v4 + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | deps _build tracker/node_modules priv/tracker/js + priv/tracker/installation_support ${{ env.PERSISTENT_CACHE_DIR }} key: ${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}-${{ hashFiles('**/mix.lock') }} restore-keys: | @@ -77,15 +76,15 @@ jobs: ${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-refs/heads/master- - name: Check for changes in tracker/** - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: changes with: filters: | tracker: - 'tracker/**' - - name: Check if priv/tracker/js/plausible.js exists + - name: Check if tracker and verifier are built already run: | - if [ -f priv/tracker/js/plausible.js ]; then + if [ -f priv/tracker/js/plausible-web.js ] && [ -f priv/tracker/installation_support/verifier.js ]; then echo "HAS_BUILT_TRACKER=true" >> $GITHUB_ENV else echo "HAS_BUILT_TRACKER=false" >> $GITHUB_ENV @@ -102,13 +101,161 @@ 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 + - run: mix test --include slow --include minio --include migrations --max-failures 1 --warnings-as-errors --partitions 6 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 + - run: mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 6 if: env.MIX_ENV == 'ce_test' + env: + MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }} + + e2e: + name: End-to-end tests + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + MIX_ENV: e2e_test + BASE_URL: "http://localhost:8111" + services: + postgres: + image: "postgres:18" + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + clickhouse: + image: clickhouse/clickhouse-server:25.11.5.8-alpine + ports: + - 8123:8123 + env: + CLICKHOUSE_SKIP_USER_SETUP: 1 + options: >- + --health-cmd nc -zw3 localhost 8124 + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: marocchino/tool-versions-action@18a164fa2b0db1cc1edf7305fcb17ace36d1c306 # v1.2.0 + id: versions + + - uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 + with: + elixir-version: ${{ steps.versions.outputs.elixir }} + otp-version: ${{ steps.versions.outputs.erlang }} + + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + deps + _build + assets/node_modules + tracker/node_modules + priv/tracker/js + priv/tracker/installation_support + ${{ env.PERSISTENT_CACHE_DIR }} + key: e2e-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + e2e-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}- + e2e-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-refs/heads/master- + + - name: Cache E2E dependencies and Playwright browsers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: playwright-cache + with: + path: | + e2e/node_modules + ~/.cache/ms-playwright + ~/.cache/ms-playwright-github + key: playwright-${{ runner.os }}-${{ hashFiles('e2e/package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Check for changes in tracker/** + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: changes + with: + filters: | + tracker: + - 'tracker/**' + + - run: npm install --prefix ./tracker + - run: npm run deploy --prefix ./tracker + - run: mix deps.get --only $MIX_ENV + - run: mix compile --warnings-as-errors --all-warnings + - run: npm install --prefix ./assets + - run: mix assets.deploy + - run: mix do ecto.create, ecto.migrate + - run: mix download_country_database + - run: mix run -e "Tzdata.ReleaseUpdater.poll_for_update" + + - name: Install E2E dependencies + run: npm --prefix ./e2e ci + + - name: Check format + run: npm --prefix ./e2e run check-format + + - name: Check types + run: npm --prefix ./e2e run typecheck + + - name: Install E2E Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: ./e2e + run: npx playwright install --with-deps chromium + + - name: Run E2E Playwright tests + run: npm --prefix ./e2e test -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob + + - name: Upload E2E blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: e2e-blob-report-${{ matrix.shardIndex }} + path: e2e/blob-report + retention-days: 1 + + merge-sharded-e2e-test-report: + if: ${{ !cancelled() }} + needs: [e2e] + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 23.2.0 + cache: 'npm' + cache-dependency-path: e2e/package-lock.json + - name: Install dependencies + run: npm --prefix ./e2e ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + path: all-e2e-blob-reports + pattern: e2e-blob-report-* + merge-multiple: true + + - name: Merge into list report + working-directory: ./e2e + run: npx playwright merge-reports --reporter list ../all-e2e-blob-reports static: name: Static checks (format, credo, dialyzer) @@ -116,18 +263,19 @@ jobs: MIX_ENV: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: marocchino/tool-versions-action@v1 + - uses: marocchino/tool-versions-action@18a164fa2b0db1cc1edf7305fcb17ace36d1c306 # v1.2.0 id: versions - - uses: erlef/setup-beam@v1 + + - uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 with: elixir-version: ${{ steps.versions.outputs.elixir }} otp-version: ${{ steps.versions.outputs.erlang }} - - uses: actions/cache@v4 + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | deps diff --git a/.github/workflows/migrations-validation.yml b/.github/workflows/migrations-validation.yml index 2d162f80215c..3f3681c31ee3 100644 --- a/.github/workflows/migrations-validation.yml +++ b/.github/workflows/migrations-validation.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: changes with: list-files: json diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index d2ddefe800ba..323934633e7f 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -16,12 +16,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Read .tool-versions - uses: marocchino/tool-versions-action@v1 + uses: marocchino/tool-versions-action@18a164fa2b0db1cc1edf7305fcb17ace36d1c306 # v1.2.0 id: versions - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{steps.versions.outputs.nodejs}} - run: npm install --prefix ./assets @@ -31,5 +31,6 @@ jobs: - run: npm run lint --prefix ./assets - run: npm run check-format --prefix ./assets - run: npm run test --prefix ./assets + - run: npm run lint --prefix ./tracker + - run: npm run check-format --prefix ./tracker - run: npm run deploy --prefix ./tracker - - run: npm run report-sizes --prefix ./tracker diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b68b7c06c866..9f73c53274a1 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -22,20 +22,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Read .tool-versions - uses: marocchino/tool-versions-action@v1 + uses: marocchino/tool-versions-action@18a164fa2b0db1cc1edf7305fcb17ace36d1c306 # v1.2.0 id: versions - name: Set up Elixir - uses: erlef/setup-beam@v1 + uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.23.0 with: elixir-version: ${{steps.versions.outputs.elixir}} otp-version: ${{ steps.versions.outputs.erlang}} - name: Restore Elixir dependencies cache - uses: actions/cache@v4 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | deps @@ -51,7 +51,7 @@ jobs: run: mix docs - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./doc diff --git a/.github/workflows/terraform-e2e.yml b/.github/workflows/terraform-e2e.yml index 0c834fb0b96d..717f5d6f9a14 100644 --- a/.github/workflows/terraform-e2e.yml +++ b/.github/workflows/terraform-e2e.yml @@ -27,10 +27,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: cli_config_credentials_token: ${{ secrets.TF_CLOUD_CHECKLY_API_TOKEN }} @@ -52,7 +52,7 @@ jobs: run: terraform plan -no-color continue-on-error: true - - uses: actions/github-script@v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 if: github.event_name == 'pull_request' env: PLAN: "terraform\n${{ steps.plan.outputs.stdout }}" diff --git a/.github/workflows/tracker-script-npm-release.yml b/.github/workflows/tracker-script-npm-release.yml new file mode 100644 index 000000000000..560209501441 --- /dev/null +++ b/.github/workflows/tracker-script-npm-release.yml @@ -0,0 +1,81 @@ +name: "Tracker: publish NPM release" + +on: + pull_request: + branches: [master] + types: [closed] + +jobs: + tracker-release-npm: + runs-on: ubuntu-latest + permissions: + pull-requests: read + contents: read + if: >- + ${{ github.event.pull_request.merged == true && ( + contains(github.event.pull_request.labels.*.name, 'tracker-release: patch') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: minor') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: major') ) }} + + steps: + - name: Checkout the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 23.2.0 + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install --prefix tracker + + - name: Bump the patch version and update changelog + if: "${{ contains(github.event.pull_request.labels.*.name, 'tracker-release: patch') }}" + run: npm run npm:prepare_release:patch --prefix tracker + + - name: Bump the minor version and update changelog + if: "${{ contains(github.event.pull_request.labels.*.name, 'tracker-release: minor') }}" + run: npm run npm:prepare_release:minor --prefix tracker + + - name: Bump the major version and update changelog + if: "${{ contains(github.event.pull_request.labels.*.name, 'tracker-release: major') }}" + run: npm run npm:prepare_release:major --prefix tracker + + - name: Get the package version from package.json + id: package + run: | + echo "version=$(jq -r .version tracker/npm_package/package.json)" >> $GITHUB_OUTPUT + + - name: Publish tracker script to NPM + run: npm publish + working-directory: tracker/npm_package + env: + NODE_AUTH_TOKEN: ${{ secrets.TRACKER_RELEASE_NPM_TOKEN }} + + - name: Commit and Push changes + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 + with: + message: "Released tracker script version ${{ steps.package.outputs.version }}" + github_token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + add: | + - tracker/npm_package + + - name: Notify team on success + if: ${{ success() }} + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "

🚀 New tracker script version has been released to NPM!

"}' + + - name: Notify team on failure + if: ${{ failure() }} + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "NPM release failed"}' diff --git a/.github/workflows/tracker-script-update.yml b/.github/workflows/tracker-script-update.yml new file mode 100644 index 000000000000..fc9f589cb98b --- /dev/null +++ b/.github/workflows/tracker-script-update.yml @@ -0,0 +1,137 @@ +name: "Tracker script update" + +on: + pull_request: + paths: + - 'tracker/src/**' + - 'tracker/package.json' + - 'tracker/package-lock.json' + +jobs: + tracker-script-update: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + fetch-depth: 1 + + - name: Checkout master for comparison + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: master + path: master-branch + + - name: Install jq and clickhouse-local + run: | + sudo apt-get install apt-transport-https ca-certificates dirmngr + sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 8919F6BD2B48D754 + echo "deb https://packages.clickhouse.com/deb stable main" | sudo tee \ + /etc/apt/sources.list.d/clickhouse.list + sudo apt-get update + + sudo apt-get install jq clickhouse-server -y + + - name: Compare and increment tracker_script_version + id: increment + run: | + cd tracker + # Get current version from PR branch + PR_VERSION=$(jq '.tracker_script_version' package.json) + + # Get version from master, default to 0 if not present + MASTER_VERSION=$(jq '.tracker_script_version // 0' ../master-branch/tracker/package.json) + + echo "PR tracker_script_version: $PR_VERSION" + echo "Master tracker_script_version: $MASTER_VERSION" + + # Calculate new version + NEW_VERSION=$((PR_VERSION + 1)) + + # Check version conditions + if [ $PR_VERSION -lt $MASTER_VERSION ]; then + echo "::error::PR tracker tracker_script_version ($PR_VERSION) is less than master ($MASTER_VERSION) and cannot be incremented." + echo "::error::Rebase or merge master into your PR to fix this." + exit 1 + elif [ $NEW_VERSION -eq $((MASTER_VERSION + 1)) ]; then + echo "Incrementing version from $PR_VERSION to $NEW_VERSION" + jq ".tracker_script_version = $NEW_VERSION" package.json > package.json.tmp + mv package.json.tmp package.json + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "changed=true" >> $GITHUB_OUTPUT + else + echo "Already incremented tracker_script_version in PR, skipping." + echo "version=$PR_VERSION" >> $GITHUB_OUTPUT + echo "changed=false" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 + if: steps.increment.outputs.changed == 'true' + with: + message: 'chore: Bump tracker_script_version to ${{ steps.increment.outputs.version }}' + github_token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + add: | + - tracker/package.json + + - name: Compile tracker code + run: | + cd master-branch/tracker + npm install + node compile.js --suffix master + cp ../priv/tracker/js/plausible* ../../priv/tracker/js/ + + cd ../../tracker + npm install + node compile.js --suffix pr + + - name: Run script size analyzer and set output + id: analyze + run: | + cd tracker + OUT=$(node compiler/analyze-sizes.js --baselineSuffix master --currentSuffix pr) + # Set multiline output + echo "sizes<> $GITHUB_OUTPUT + echo "$OUT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Comment script size report on PR + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: | + ${{ steps.analyze.outputs.sizes }} + comment-tag: size-report + + - name: Check PR has tracker release label set + if: >- + ${{ !( + contains(github.event.pull_request.labels.*.name, 'tracker-release: patch') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: minor') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: major') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: none') ) }} + + run: | + echo "::error::PR changes tracker script but does not have a 'tracker release:' label. Please add one." + exit 1 + + - name: Get changed files + id: changelog_changed + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 + with: + files: | + tracker/npm_package/CHANGELOG.md + + - name: Error if PR no tracker CHANGELOG.md updates + if: >- + ${{ ( + steps.changelog_changed.outputs.any_changed == 'false' && + !contains(github.event.pull_request.labels.*.name, 'tracker-release: none') ) }} + run: | + echo "::error::PR changes tracker script but does not have a tracker NPM package CHANGELOG.md update." + exit 1 diff --git a/.github/workflows/tracker-version-bump.yml b/.github/workflows/tracker-version-bump.yml deleted file mode 100644 index 35d6bbf4e1fc..000000000000 --- a/.github/workflows/tracker-version-bump.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: "Tracker: Increment Reported Version" - -on: - pull_request: - paths: - - 'tracker/src/**' - - 'tracker/package.json' - - 'tracker/package-lock.json' - -jobs: - tracker-increment-reported-version: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} - token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} - fetch-depth: 0 - - - name: Checkout master for comparison - uses: actions/checkout@v4 - with: - ref: master - path: master-branch - - - name: Install jq - run: sudo apt-get install jq -y - - - name: Compare and increment tracker_script_version - id: increment - run: | - cd tracker - # Get current version from PR branch - PR_VERSION=$(jq '.tracker_script_version' package.json) - - # Get version from master, default to 0 if not present - MASTER_VERSION=$(jq '.tracker_script_version // 0' ../master-branch/tracker/package.json) - - echo "PR tracker_script_version: $PR_VERSION" - echo "Master tracker_script_version: $MASTER_VERSION" - - # Calculate new version - NEW_VERSION=$((PR_VERSION + 1)) - - # Check version conditions - if [ $PR_VERSION -lt $MASTER_VERSION ]; then - echo "::error::PR tracker tracker_script_version ($PR_VERSION) is less than master ($MASTER_VERSION) and cannot be incremented." - echo "::error::Rebase or merge master into your PR to fix this." - exit 1 - elif [ $NEW_VERSION -eq $((MASTER_VERSION + 1)) ]; then - echo "Incrementing version from $PR_VERSION to $NEW_VERSION" - jq ".tracker_script_version = $NEW_VERSION" package.json > package.json.tmp - mv package.json.tmp package.json - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "changed=true" >> $GITHUB_OUTPUT - else - echo "Already incremented tracker_script_version in PR, skipping." - echo "version=$PR_VERSION" >> $GITHUB_OUTPUT - echo "changed=false" >> $GITHUB_OUTPUT - fi - - - name: Commit and push changes - uses: EndBug/add-and-commit@v9 - if: steps.increment.outputs.changed == 'true' - with: - message: 'chore: Bump tracker_script_version to ${{ steps.increment.outputs.version }}' - github_token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} - add: | - - tracker/package.json - # Uncomment this once they're whitelisted by CLA agent - # default_author: github_actions diff --git a/.github/workflows/tracker.yml b/.github/workflows/tracker.yml index 30848b4cfb29..af929babfddc 100644 --- a/.github/workflows/tracker.yml +++ b/.github/workflows/tracker.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: paths: - - 'tracker/**' + - "tracker/**" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -14,15 +14,68 @@ jobs: test: timeout-minutes: 15 runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 16 - - name: Install dependencies - run: npm --prefix ./tracker ci - - name: Install Playwright Browsers - working-directory: ./tracker - run: npx playwright install --with-deps - - name: Run Playwright tests - run: npm --prefix ./tracker test + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 23.2.0 + cache: 'npm' + cache-dependency-path: tracker/package-lock.json + - name: Install dependencies + run: npm --prefix ./tracker ci + - name: Cache Playwright browsers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + ~/.cache/ms-playwright-github + key: playwright-${{ runner.os }}-${{ hashFiles('tracker/package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- + - name: Install Playwright system dependencies + working-directory: ./tracker + run: npx playwright install-deps + - name: Install Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: ./tracker + run: npx playwright install + - name: Run Playwright tests + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: blob-report-${{ matrix.shardIndex }} + path: tracker/blob-report + retention-days: 1 + merge-sharded-test-report: + if: ${{ !cancelled() }} + needs: [test] + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 23.2.0 + cache: 'npm' + cache-dependency-path: tracker/package-lock.json + - name: Install dependencies + run: npm --prefix ./tracker ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge into list report + working-directory: ./tracker + run: npx playwright merge-reports --reporter list ../all-blob-reports diff --git a/.gitignore b/.gitignore index 2e11afb9084b..7d8b7ba3f1a7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,14 @@ npm-debug.log # Stored hash of source tracker files used in development environment # to detect changes in /tracker/src and avoid unnecessary compilation. -/tracker/dev-compile/last-hash.txt +/tracker/compiler/last-hash.txt +# Temporary file used by analyze-sizes.js +/tracker/compiler/.analyze-sizes.json + +# Tracker npm module files that are generated by the compiler for the NPM package +/tracker/npm_package/plausible.js* +/tracker/npm_package/plausible.cjs* +/tracker/npm_package/plausible.d.cts # test coverage directory /assets/coverage @@ -84,8 +91,11 @@ plausible-report.xml /priv/geodb/*.mmdb.gz # Auto-generated tracker files -/priv/tracker/js/*.js +/priv/tracker/js/plausible*.js* +/priv/tracker/installation_support/*.js # Docker volumes .clickhouse_db_vol* plausible_db* + +.claude diff --git a/.tool-versions b/.tool-versions index 862023bb74a2..d1ef1f421bca 100644 --- a/.tool-versions +++ b/.tool-versions @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fc43bee041..fa4292529979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,102 @@ # Changelog + All notable changes to this project will be documented in this file. ## Unreleased -### Added +### Added + +- Allow querying `views_per_visit` with a time dimension in Stats API +- Add `bounce_rate` to page-filtered Top Stats even when imports are included, but render a metric warning about imported data not included in `bounce_rate` tooltip. +- Add `time_on_page` to page-filtered Top Stats even when imports are included, unless legacy time on page is in view. +- Adds team_id to query debug metadata (saved in system.query_log log_comment column) +- Add "Unknown" option to Countries shield, for when the country code is unrecognized +- Add "Last 24 Hours" to dashboard time range picker and Stats API v2 + +### Removed + +### Changed + +- Keybind hints are hidden on smaller screens +- Site index is sortable alphanumerically and by traffic + +### Fixed + +- Fixed Stats API timeseries returning time buckets falling outside the queried range +- Fixed issue with all non-interactive events being counted as interactive +- Fixed countries map countries staying highlighted on Chrome + +## v3.2.0 - 2026-01-16 + +### Added + +- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown +- Shared links can now be limited to a particular segment of the data + +### Removed + +### Changed + +- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards +- When accessing a link to a shared password-protected dashboard subpage (e.g. `.../pages`), the viewer will be redirected to that subpage after providing the password + +### Fixed -### Removed +- 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`) +- Fix bug with tracker script config cache that made requests for certain cached scripts give error 500 -### Changed +## v3.1.0 - 2025-11-13 + +### Added + +- Custom events can now be marked as non-interactive in events API and tracker script: events marked as non-interactive are not counted towards bounce rate +- Ability to leave team via Team Settings > Leave Team +- Stats APIv2 now supports `include.trim_relative_date_range` - this option allows trimming empty values after current time for `day`, `month` and `year` date_range values +- Properties are now included in full site exports done via Site Settings > Imports & Exports +- Google Search Console integration settings: properties can be dynamically sought +- Weekly/monthly e-mail reports now contain top goal conversions +- Newly created sites are offered a new dynamic tracking script and snippet that's specific to the site +- Old sites that go to "Review installation" flow are offered the new script and snippet, along with a migration guide from legacy snippets, legacy snippets continue to function as before +- The new tracker script allows configuring `transformRequest` function to change event payloads before they're sent +- The new tracker script allows configuring `customProperties` function hook to derive custom props for events on the fly +- The new tracker script supports tracking form submissions if enabled +- The new tracker script automatically updates to respect site domain if it's changed in "Change domain" flow +- The new tracker script automatically updates to respect the following configuration options available in "New site" flows and "Review installation" flows: whether to track outbound links, file downloads, form submissions +- The new tracker script allows overriding almost all options by changing the snippet on the website, with the function `plausible.init({ ...your overrides... })` - this can be unique page-by-page +- A new `@plausible-analytics/tracker` ESM module is available on NPM - it has near-identical configuration API and identical tracking logic as the script and it receives bugfixes and updates concurrently with the new tracker script +- Ability to enforce enabling 2FA by all team members + +### Removed + +### Changed + +- A session is now marked as a bounce if it has less than 2 pageviews and no interactive custom events +- All dropmenus on dashboard are navigable with Tab (used to be a mix between tab and arrow keys), and no two dropmenus can be open at once on the dashboard +- Special path-based events like "404" don't need `event.props.path` to be explicitly defined when tracking: it is set to be the same as `event.pathname` in event ingestion; if it is explicitly defined, it is not overridden for backwards compatibility +- Main graph no longer shows empty values after current time for `day`, `month` and `year` periods +- Include `bounce_rate` metric in Entry Pages breakdown +- Dark mode theme has been refined with darker color scheme and better visual hierarchy +- Creating shared links now happens in a modal + +### Fixed + +- Make clicking Compare / Disable Comparison in period picker menu close the menu +- Do not log page views for hidden pages (prerendered pages and new tabs), until pages are viewed +- Password-authenticated shared links now carry over dashboard params properly +- Realtime and hourly graphs of visit duration, views per visit no longer overcount due to long-lasting sessions, instead showing each visit when they occurred +- Fixed realtime and hourly graphs of visits overcounting +- When reporting only `visitors` and `visits` per hour, count visits in each hour they were active in +- Fixed unhandled tracker-related exceptions on link clicks within svgs +- Remove Subscription and Invoices menu from CE +- Fix email sending error "Mua.SMTPError" 503 Bad sequence of commands +- Make button to include / exclude imported data visible on Safari ## v3.0.0 - 2025-04-11 ### Added + - Ability to sort by and compare the `exit_rate` metric in the dashboard Exit Pages > Details report - Add top 3 pages into the traffic spike email - Two new shorthand time periods `28d` and `91d` available on both dashboard and in public API @@ -60,6 +145,7 @@ All notable changes to this project will be documented in this file. - Always set site and team member limits to unlimited for Community Edition - Stats API now supports more `date_range` shorthand options like `30d`, `3mo`. - Stop showing Plausible footer when viewing stats, except when viewing a public dashboard or unembedded shared link dashboard. +- Changed Plugins API Token creation flow to only display token once it's created. ### Fixed @@ -112,6 +198,7 @@ All notable changes to this project will be documented in this file. ## v2.1.3 - 2024-09-26 ### Fixed + - Change cookie key to resolve login issue plausible/analytics#4621 - Set secure attribute on cookies when BASE_URL has HTTPS scheme plausible/analytics#4623 - Don't track custom events in CE plausible/analytics#4627 @@ -119,6 +206,7 @@ All notable changes to this project will be documented in this file. ## v2.1.2 - 2024-09-24 ### Added + - UI to edit goals along with display names - Support contains filter for goals - UI to edit funnels @@ -137,11 +225,13 @@ All notable changes to this project will be documented in this file. - Make details views on dashboard sortable ### Removed + - Deprecate `ECTO_IPV6` and `ECTO_CH_IPV6` env vars in CE plausible/analytics#4245 - Remove support for importing data from no longer available Universal Analytics - Soft-deprecate `DATABASE_SOCKET_DIR` plausible/analytics#4202 ### Changed + - Support Unix sockets in `DATABASE_URL` plausible/analytics#4202 - Realtime and hourly graphs now show visits lasting their whole duration instead when specific events occur - Increase hourly request limit for API keys in CE from 600 to 1000000 (practically removing the limit) plausible/analytics#4200 @@ -186,6 +276,7 @@ All notable changes to this project will be documented in this file. ## v2.1.0 - 2024-05-23 ### Added + - Hostname Allow List in Site Settings - Pages Block List in Site Settings - Add `conversion_rate` to Stats API Timeseries and on the main graph @@ -232,6 +323,7 @@ All notable changes to this project will be documented in this file. - Add custom events support to CSV export and import ### Removed + - Removed the nested custom event property breakdown UI when filtering by a goal in Goal Conversions - Removed the `prop_names` returned in the Stats API `event:goal` breakdown response - Removed the `prop-breakdown.csv` file from CSV export @@ -240,6 +332,7 @@ All notable changes to this project will be documented in this file. - Remove `DISABLE_AUTH` deprecation warning plausible/analytics#3904 ### Changed + - A visits `entry_page` and `exit_page` is only set and updated for pageviews, not custom events - Limit the number of Goal Conversions shown on the dashboard and render a "Details" link when there are more entries to show - Show Outbound Links / File Downloads / 404 Pages / Cloaked Links instead of Goal Conversions when filtering by the corresponding goal @@ -252,6 +345,7 @@ All notable changes to this project will be documented in this file. - default `MAILER_ADAPTER` has been changed to `Bamboo.Mua` plausible/analytics#4538 ### Fixed + - Creating many sites no longer leads to cookie overflow - Ignore sessions without pageviews for `entry_page` and `exit_page` breakdowns - Using `VersionedCollapsingMergeTree` to store visit data to avoid rare race conditions that led to wrong visit data being shown @@ -280,6 +374,7 @@ All notable changes to this project will be documented in this file. ## v2.0.0 - 2023-07-12 ### Added + - Call to action for tracking Goal Conversions and an option to hide the section from the dashboard - Add support for `with_imported=true` in Stats API aggregate endpoint - Ability to use '--' instead of '=' sign in the `tagged-events` classnames @@ -293,6 +388,7 @@ All notable changes to this project will be documented in this file. - Allow optional IPv6 for clickhouse repo plausible/analytics#2970 ### Fixed + - Fix tracker bug - call callback function even when event is ignored - Make goal-filtered CSV export return only unique_conversions timeseries in the 'visitors.csv' file - Stop treating page filter as an entry page filter @@ -313,6 +409,7 @@ All notable changes to this project will be documented in this file. - Fix a bug where the country name was not shown when [filtering through the map](https://github.com/plausible/analytics/issues/3086) ### Changed + - Treat page filter as entry page filter for `bounce_rate` - Reject events with long URIs and data URIs plausible/analytics#2536 - Always show direct traffic in sources reports plausible/analytics#2531 @@ -323,6 +420,7 @@ All notable changes to this project will be documented in this file. - Disable registration in self-hosted setups by default plausible/analytics#3014 ### Removed + - Remove Firewall plug and `IP_BLOCKLIST` environment variable - Remove the ability to collapse the main graph plausible/analytics#2627 - Remove `custom_dimension_filter` feature flag plausible/analytics#2996 @@ -330,12 +428,14 @@ All notable changes to this project will be documented in this file. ## v1.5.1 - 2022-12-06 ### Fixed + - Return empty list when breaking down by event:page without events plausible/analytics#2530 - Fallback to empty build metadata when failing to parse $BUILD_METADATA plausible/analytics#2503 ## v1.5.0 - 2022-12-02 ### Added + - Set a different interval on the top graph plausible/analytics#1574 (thanks to @Vigasaurus for this feature) - A `tagged-events` script extension for out-of-the-box custom event tracking - The ability to escape `|` characters with `\` in Stats API filter values @@ -376,6 +476,7 @@ All notable changes to this project will be documented in this file. - Fix ownership transfer invitation link in self-hosted deployments ### Fixed + - Plausible script does not prevent default if it's been prevented by an external script [plausible/analytics#1941](https://github.com/plausible/analytics/issues/1941) - Hash part of the URL can now be used when excluding pages with `script.exclusions.hash.js`. - UI fix where multi-line text in pills would not be underlined properly on small screens. @@ -394,6 +495,7 @@ All notable changes to this project will be documented in this file. - Ensure newlines from settings files are trimmed [plausible/analytics#2480](https://github.com/plausible/analytics/pull/2480) ### Changed + - `script.file-downloads.outbound-links.js` only sends an outbound link event when an outbound download link is clicked - Plausible script now uses callback navigation (instead of waiting for 150ms every time) when sending custom events - Cache the tracking script for 24 hours @@ -406,16 +508,19 @@ All notable changes to this project will be documented in this file. - Add fallback icon for when DDG favicon cannot be fetched [PR#2279](https://github.com/plausible/analytics#2279) ### Security + - Add Content-Security-Policy header to favicon path ## v1.4.1 - 2021-11-29 ### Fixed + - Fixes database error when pathname contains a question mark ## v1.4.0 - 2021-10-27 ### Added + - New parameter `metrics` for the `/api/v1/stats/timeseries` endpoint plausible/analytics#952 - CSV export now includes pageviews, bounce rate and visit duration in addition to visitors plausible/analytics#952 - Send stats to multiple dashboards by configuring a comma-separated list of domains plausible/analytics#968 @@ -433,6 +538,7 @@ All notable changes to this project will be documented in this file. - Add ability to view more than 100 custom goal properties plausible/analytics#1382 ### Fixed + - Fix weekly report time range plausible/analytics#951 - Make sure embedded dashboards can run when user has blocked third-party cookies plausible/analytics#971 - Sites listing page will paginate if the user has a lot of sites plausible/analytics#994 @@ -449,14 +555,17 @@ All notable changes to this project will be documented in this file. - Respect the `path` component of BASE_URL to allow subfolder installatons ### Removed + - Removes AppSignal monitoring package ### Changes + - Disable email verification by default. Added a configuration option `ENABLE_EMAIL_VERIFICATION=true` if you want to keep the old behaviour ## [1.3] - 2021-04-14 ### Added + - Stats API [currently in beta] plausible/analytics#679 - Ability to view and filter by entry and exit pages, in addition to regular page hits plausible/analytics#712 - 30 day and 6 month keybindings (`T` and `S`, respectively) plausible/analytics#709 @@ -467,6 +576,7 @@ All notable changes to this project will be documented in this file. - Add name/label to shared links plausible/analytics#910 ### Fixed + - Capitalized date/time selection keybinds not working plausible/analytics#709 - Invisible text on Google Search Console settings page in dark mode plausible/analytics#759 - Disable analytics tracking when running Cypress tests @@ -478,8 +588,9 @@ All notable changes to this project will be documented in this file. ## [1.2] - 2021-01-26 ### Added + - Ability to add event metadata plausible/analytics#381 -- Add tracker module to automatically track outbound links plausible/analytics#389 +- Add tracker module to automatically track outbound links plausible/analytics#389 - Display weekday on the visitor graph plausible/analytics#175 - Collect and display browser & OS versions plausible/analytics#397 - Simple notifications around traffic spikes plausible/analytics#453 @@ -492,6 +603,7 @@ All notable changes to this project will be documented in this file. - Keybindings for selecting dates/ranges plausible/analytics#630 ### Changed + - Use alpine as base image to decrease Docker image size plausible/analytics#353 - Ignore automated browsers (Phantom, Selenium, Headless Chrome, etc) - Display domain's favicon on the home page @@ -508,6 +620,7 @@ All notable changes to this project will be documented in this file. - Changed caret/chevron color in datepicker and filters dropdown ### Fixed + - Do not error when activating an already activated account plausible/analytics#370 - Ignore arrow keys when modifier keys are pressed plausible/analytics#363 - Show correct stats when goal filter is combined with source plausible/analytics#374 @@ -521,20 +634,24 @@ All notable changes to this project will be documented in this file. - Various UI/UX issues plausible/analytics#503 ### Security + - Do not run the plausible Docker container as root plausible/analytics#362 ## [1.1.1] - 2020-10-14 ### Fixed + - Revert Dockerfile change that introduced a regression ## [1.1.0] - 2020-10-14 ### Added + - Linkify top pages [plausible/analytics#91](https://github.com/plausible/analytics/issues/91) -- Filter by country, screen size, browser and operating system [plausible/analytics#303](https://github.com/plausible/analytics/issues/303) +- Filter by country, screen size, browser and operating system [plausible/analytics#303](https://github.com/plausible/analytics/issues/303) ### Fixed + - Fix issue with creating a PostgreSQL database when `?ssl=true` [plausible/analytics#347](https://github.com/plausible/analytics/issues/347) - Do no disclose current URL to DuckDuckGo's favicon service [plausible/analytics#343](https://github.com/plausible/analytics/issues/343) - Updated UAInspector database to detect newer devices [plausible/analytics#309](https://github.com/plausible/analytics/issues/309) @@ -542,9 +659,11 @@ All notable changes to this project will be documented in this file. ## [1.0.0] - 2020-10-06 ### Added + - Collect and present link tags (`utm_medium`, `utm_source`, `utm_campaign`) in the dashboard ### Changed + - Replace configuration parameters `CLICKHOUSE_DATABASE_{HOST,NAME,USER,PASSWORD}` with a single `CLICKHOUSE_DATABASE_URL` [plausible/analytics#317](https://github.com/plausible/analytics/pull/317) - Disable subscriptions by default - Remove `CLICKHOUSE_DATABASE_POOLSIZE`, `DATABASE_POOLSIZE` and `DATABASE_TLS_ENABLED` parameters. Use query parameters in `CLICKHOUSE_DATABASE_URL` and `DATABASE_URL` instead. diff --git a/Dockerfile b/Dockerfile index 19145a3e045a..950a429b6e79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 " ARG BUILD_METADATA={} @@ -86,3 +88,4 @@ EXPOSE 8000 ENV DEFAULT_DATA_DIR=/var/lib/plausible VOLUME /var/lib/plausible CMD ["run"] + diff --git a/Makefile b/Makefile index d755b1a1c349..167706e68ac8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,10 @@ -.PHONY: help install server clickhouse clickhouse-prod clickhouse-stop postgres postgres-client postgres-prod postgres-stop +.PHONY: help install server clickhouse clickhouse-prod clickhouse-stop clickhouse-postgres-remote postgres postgres-client postgres-prod postgres-stop + +require = \ + $(foreach 1,$1,$(__require)) +__require = \ + $(if $(value $1),, \ + $(error Provide required parameter: $1$(if $(value 2), ($(strip $2))))) help: @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -15,30 +21,34 @@ install: ## Run the initial setup server: ## Start the web server mix phx.server -CH_FLAGS ?= --detach -p 8123:8123 -p 9000:9000 --ulimit nofile=262144:262144 --name plausible_clickhouse +CH_FLAGS ?= --detach -p 8123:8123 -p 9000:9000 --ulimit nofile=262144:262144 --name plausible_clickhouse --env CLICKHOUSE_SKIP_USER_SETUP=1 clickhouse: ## Start a container with a recent version of clickhouse - docker run $(CH_FLAGS) --network host --volume=$$PWD/.clickhouse_db_vol:/var/lib/clickhouse clickhouse/clickhouse-server:latest-alpine + docker run $(CH_FLAGS) --network host --volume=$$PWD/.clickhouse_db_vol:/var/lib/clickhouse --volume=$$PWD/.clickhouse_config:/etc/clickhouse-server/config.d clickhouse/clickhouse-server:latest-alpine clickhouse-client: ## Connect to clickhouse docker exec -it plausible_clickhouse clickhouse-client -d plausible_events_db clickhouse-prod: ## Start a container with the same version of clickhouse as the one in prod - docker run $(CH_FLAGS) --volume=$$PWD/.clickhouse_db_vol_prod:/var/lib/clickhouse clickhouse/clickhouse-server:24.12.2.29-alpine + docker run $(CH_FLAGS) --volume=$$PWD/.clickhouse_db_vol_prod:/var/lib/clickhouse clickhouse/clickhouse-server:25.11.5.8-alpine clickhouse-stop: ## Stop and remove the clickhouse container docker stop plausible_clickhouse && docker rm plausible_clickhouse +clickhouse-postgres-remote: ## Create postgres_remote database in ClickHouse for querying PostgreSQL + $(eval POSTGRES_IP := $(shell docker inspect plausible_db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')) + @docker exec plausible_clickhouse clickhouse-client --query "DROP DATABASE IF EXISTS postgres_remote; CREATE DATABASE postgres_remote ENGINE = PostgreSQL('$(POSTGRES_IP):5432', 'plausible_dev', 'postgres', 'postgres');" + PG_FLAGS ?= --detach -e POSTGRES_PASSWORD="postgres" -p 5432:5432 --name plausible_db postgres: ## Start a container with a recent version of postgres - docker run $(PG_FLAGS) --volume=plausible_db:/var/lib/postgresql/data postgres:latest + docker run $(PG_FLAGS) --volume=plausible_db:/var/lib/postgresql/docker postgres:latest postgres-client: ## Connect to postgres docker exec -it plausible_db psql -U postgres -d plausible_dev postgres-prod: ## Start a container with the same version of postgres as the one in prod - docker run $(PG_FLAGS) --volume=plausible_db_prod:/var/lib/postgresql/data postgres:15 + docker run $(PG_FLAGS) --volume=plausible_db_prod:/var/lib/postgresql/docker postgres:18 postgres-stop: ## Stop and remove the postgres container docker stop plausible_db && docker rm plausible_db @@ -56,3 +66,50 @@ minio: ## Start a transient container with a recent version of minio (s3) minio-stop: ## Stop and remove the minio container docker stop plausible_minio + +sso: + $(call require, integration_id) + @echo "Setting up local IdP service..." + @docker run --name=idp \ + -p 8080:8080 \ + -e SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:8000/sso/$(integration_id) \ + -e SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:8000/sso/saml/consume/$(integration_id) \ + -v $$PWD/extra/fixture/authsources.php:/var/www/simplesamlphp/config/authsources.php -d kenchan0130/simplesamlphp + + @sleep 2 + + @echo "Use the following IdP configuration:" + @echo "" + @echo "Sign-in URL: http://localhost:8080/simplesaml/saml2/idp/SSOService.php" + @echo "" + @echo "Entity ID: http://localhost:8080/simplesaml/saml2/idp/metadata.php" + @echo "" + @echo "PEM Certificate:" + @curl http://localhost:8080/simplesaml/module.php/saml/idp/certs.php/idp.crt 2>/dev/null + @echo "" + @echo "" + @echo "Following accounts are configured:" + @echo "- user@plausible.test / plausible" + @echo "- user1@plausible.test / plausible" + @echo "- user2@plausible.test / plausible" + +sso-stop: + docker stop idp + docker remove idp + +generate-corefile: + $(call require, domain_id) + domain_id=$(domain_id) envsubst < $(PWD)/extra/fixture/Corefile.template > $(PWD)/extra/fixture/Corefile.gen.$(domain_id) + +mock-dns: generate-corefile + $(call require, domain_id) + docker run --rm -p 5354:53/udp -v $(PWD)/extra/fixture/Corefile.gen.$(domain_id):/Corefile coredns/coredns:latest -conf Corefile + +loadtest-server: + @echo "Ensure your OTP installation is built with --enable-lock-counter" + MIX_ENV=load ERL_FLAGS="-emu_type lcnt +Mdai max" iex -S mix do phx.digest + phx.server + +loadtest-client: + @echo "Set your limits for file descriptors/ephemeral ports high... Test begins shortly" + @sleep 5 + k6 run test/load/script.js diff --git a/README.md b/README.md index 80e6efb12f70..45d99a163fbf 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ Here's what makes Plausible a great Google Analytics alternative and why we're t - **Lightweight**: Plausible Analytics works by loading a script on your website, like Google Analytics. Our script is [small](https://plausible.io/lightweight-web-analytics), making your website quicker to load. You can also send events directly to our [events API](https://plausible.io/docs/events-api). - **Email or Slack reports**: Keep an eye on your traffic with weekly and/or monthly email or Slack reports. You can also get traffic spike notifications. - **Invite team members and share stats**: You have the option to be transparent and open your web analytics to everyone. Your website stats are private by default but you can choose to make them public so anyone with your custom link can view them. You can [invite team members](https://plausible.io/docs/users-roles) and assign user roles too. -- **Define key goals and track conversions**: Create custom events with custom dimensions to track conversions and attribution to understand and identify the trends that matter. Track ecommerce revenue, outbound link clicks, file downloads and 404 error pages. Increase conversions using funnel analysis. +- **Define key goals and track conversions**: Create custom events with custom dimensions to track conversions and attribution to understand and identify the trends that matter. Track ecommerce revenue, outbound link clicks, form completions, file downloads and 404 error pages. Increase conversions using funnel analysis. - **Search keywords**: Integrate your dashboard with Google Search Console to get the most accurate reporting on your search keywords. - **SPA support**: Plausible is built with modern web frameworks in mind and it works automatically with any pushState based router on the frontend. We also support frameworks that use the URL hash for routing. See [our documentation](https://plausible.io/docs/hash-based-routing). -- **Smooth transition from Google Analytics**: There's a realtime dashboard, entry pages report and integration with Search Console. You can track your paid campaigns and conversions. You can invite team members. You can even [import your historical Google Analytics stats](https://plausible.io/docs/google-analytics-import). Learn how to [get the most out of your Plausible experience](https://plausible.io/docs/your-plausible-experience) and join thousands who have already migrated from Google Analytics. +- **Smooth transition from Google Analytics**: There's a realtime dashboard, entry pages report and integration with Search Console. You can track your paid campaigns and conversions. You can invite team members. You can even [import your historical Google Analytics stats](https://plausible.io/docs/google-analytics-import) and there's [a Google Tag Manager template](https://plausible.io/gtm-template) too. Learn how to [get the most out of your Plausible experience](https://plausible.io/docs/your-plausible-experience) and join thousands who have already migrated from Google Analytics. Interested to learn more? [Read more on our website](https://plausible.io), learn more about the team and the goals of the project on [our about page](https://plausible.io/about) or explore [the documentation](https://plausible.io/docs). @@ -63,10 +63,10 @@ Plausible is [open source web analytics](https://plausible.io/open-source-websit | ------------- | ------------- | ------------- | | **Infrastructure management** | Easy and convenient. It takes 2 minutes to start counting your stats with a worldwide CDN, high availability, backups, security and maintenance all done for you by us. We manage everything so you don’t have to worry about anything and can focus on your stats. | You do it all yourself. You need to get a server and you need to manage your infrastructure. You are responsible for installation, maintenance, upgrades, server capacity, uptime, backup, security, stability, consistency, loading time and so on.| | **Release schedule** | Continuously developed and improved with new features and updates multiple times per week. | [It's a long term release](https://plausible.io/blog/building-open-source) published twice per year so latest features and improvements won't be immediately available.| -| **Premium features** | All features available as listed in [our pricing plans](https://plausible.io/#pricing). | Selected premium features such as funnels and ecommerce revenue goals are not available as we aim to ensure a [protective barrier around our cloud offering](https://plausible.io/blog/community-edition).| -| **Bot filtering** | Advanced bot filtering for more accurate stats. Our algorithm detects and excludes non-human traffic patterns. We also exclude known bots by the User-Agent header and filter out traffic from data centers and referrer spam domains. | Basic bot filtering that targets the most common non-human traffic based on the User-Agent header and referrer spam domains.| +| **Premium features** | All features available as listed in [our pricing plans](https://plausible.io/#pricing). | Premium features (marketing funnels, ecommerce revenue goals, SSO and sites API) are not available in order to help support [the project's long-term sustainability](https://plausible.io/blog/community-edition).| +| **Bot filtering** | Advanced bot filtering for more accurate stats. Our algorithm detects and excludes non-human traffic patterns. We also exclude known bots by the User-Agent header and filter out traffic from data centers and referrer spam domains. We exclude ~32K data center IP ranges (i.e. a lot of bot IP addresses) by default. | Basic bot filtering that targets the most common non-human traffic based on the User-Agent header and referrer spam domains.| | **Server location** | All visitor data is exclusively processed on EU-owned cloud infrastructure. We keep your site data on a secure, encrypted and green energy powered server in Germany. This ensures that your site data is protected by the strict European Union data privacy laws and ensures compliance with GDPR. Your website data never leaves the EU. | You have full control and can host your instance on any server in any country that you wish. Host it on a server in your basement or host it with any cloud provider wherever you want, even those that are not GDPR compliant.| -| **Data portability** | You see all your site stats and metrics on our modern-looking, simple to use and fast loading dashboard. You can only see the stats aggregated in the dashboard. You can download the stats using the [CSV export](https://plausible.io/docs/export-stats), [stats API](https://plausible.io/docs/stats-api) or tools such as the [Data Studio Connector](https://plausible.io/docs/integration-guides#google-data-studio). | Do you want access to the raw data? Self-hosting gives you that option. You can take the data directly from the ClickHouse database. | +| **Data portability** | You see all your site stats and metrics on our modern-looking, simple to use and fast loading dashboard. You can only see the stats aggregated in the dashboard. You can download the stats using the [CSV export](https://plausible.io/docs/export-stats), [stats API](https://plausible.io/docs/stats-api) or the [Looker Studio Connector](https://plausible.io/docs/looker-studio). | Do you want access to the raw data? Self-hosting gives you that option. You can take the data directly from the ClickHouse database. The Looker Studio Connector is not available. | | **Premium support** | Real support delivered by real human beings who build and maintain Plausible. | Premium support is not included. CE is community supported only.| | **Costs** | There's a cost associated with providing an analytics service so we charge a subscription fee. We choose the subscription business model rather than the business model of surveillance capitalism. Your money funds further development of Plausible. | You need to pay for your server, CDN, backups and whatever other cost there is associated with running the infrastructure. You never have to pay any fees to us. Your money goes to 3rd party companies with no connection to us.| diff --git a/assets/.stylelintrc.json b/assets/.stylelintrc.json index 799642a7b84b..51fbcf990f81 100644 --- a/assets/.stylelintrc.json +++ b/assets/.stylelintrc.json @@ -3,7 +3,7 @@ "ignoreFiles": ["./node_modules/**/*.*"], "rules": { "import-notation": "string", - "at-rule-no-unknown": [true, { "ignoreAtRules": ["apply", "screen"] }], + "at-rule-no-unknown": [true, { "ignoreAtRules": ["apply", "screen", "plugin", "source", "theme", "utility", "custom-variant"] }], "at-rule-empty-line-before": [ "always", { "except": ["blockless-after-same-name-blockless"], "ignoreAtRules": ["apply"] } diff --git a/assets/css/app.css b/assets/css/app.css index eb78a69a13a9..bdff4680350d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,15 +1,109 @@ -@import 'tailwindcss/base'; -@import 'flatpickr/dist/flatpickr.min.css'; -@import './modal.css'; -@import './loader.css'; -@import './tooltip.css'; -@import './flatpickr-colors.css'; -@import './chartjs.css'; -@import 'tailwindcss/components'; -@import 'tailwindcss/utilities'; +@import 'tailwindcss' source(none); +@import 'flatpickr/dist/flatpickr.min.css' layer(components); +@import './modal.css' layer(components); +@import './loader.css' layer(components); +@import './tooltip.css' layer(components); +@import './flatpickr-colors.css' layer(components); + +@plugin "@tailwindcss/forms"; + +@source "../css"; +@source "../js"; +@source "../../lib/plausible_web"; +@source "../../extra/lib/plausible_web"; + +/* Tailwind v3 compatibility: restore v3 default border and ring styling */ + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } -[x-cloak] { - display: none; + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; + } + + *:focus-visible { + @apply ring-2 ring-indigo-500 ring-offset-2 dark:ring-offset-gray-900 outline-none; + } + + :focus:not(:focus-visible) { + @apply outline-none; + } +} + +@layer components { + /* Replace Tailwind form plugin's focus with focus-visible */ + [type='checkbox']:focus, + [type='radio']:focus { + outline: none; + box-shadow: none; + } + + [type='checkbox']:focus-visible, + [type='radio']:focus-visible { + @apply ring-2 ring-indigo-500 ring-offset-2 outline-none; + } +} + +@theme { + /* Color aliases from tailwind.config.js */ + + /* yellow: colors.amber - Map yellow to amber colors */ + --color-yellow-50: var(--color-amber-50); + --color-yellow-100: var(--color-amber-100); + --color-yellow-200: var(--color-amber-200); + --color-yellow-300: var(--color-amber-300); + --color-yellow-400: var(--color-amber-400); + --color-yellow-500: var(--color-amber-500); + --color-yellow-600: var(--color-amber-600); + --color-yellow-700: var(--color-amber-700); + --color-yellow-800: var(--color-amber-800); + --color-yellow-900: var(--color-amber-900); + --color-yellow-950: var(--color-amber-950); + + /* green: colors.emerald - Map green to emerald colors */ + --color-green-50: var(--color-emerald-50); + --color-green-100: var(--color-emerald-100); + --color-green-200: var(--color-emerald-200); + --color-green-300: var(--color-emerald-300); + --color-green-400: var(--color-emerald-400); + --color-green-500: var(--color-emerald-500); + --color-green-600: var(--color-emerald-600); + --color-green-700: var(--color-emerald-700); + --color-green-800: var(--color-emerald-800); + --color-green-900: var(--color-emerald-900); + --color-green-950: var(--color-emerald-950); + + /* gray: colors.zinc - Map gray to zinc colors */ + --color-gray-50: var(--color-zinc-50); + --color-gray-100: var(--color-zinc-100); + --color-gray-200: var(--color-zinc-200); + --color-gray-300: var(--color-zinc-300); + --color-gray-400: var(--color-zinc-400); + --color-gray-500: var(--color-zinc-500); + --color-gray-600: var(--color-zinc-600); + --color-gray-700: var(--color-zinc-700); + --color-gray-800: var(--color-zinc-800); + --color-gray-900: var(--color-zinc-900); + --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); + --color-gray-850: rgb(34 34 38); + + /* Set v3 default ring behavior */ + --default-ring-width: 2px; + --default-ring-color: var(--color-indigo-500); + --animate-pulse: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @media print { @@ -19,28 +113,27 @@ } } -.button { - @apply inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500; +@utility container { + margin-inline: auto; + padding-inline: 1rem; } -.button[disabled] { - @apply bg-gray-400 dark:bg-gray-600; -} +@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); +@custom-variant phx-hook-loading (.phx-hook-loading&, .phx-hook-loading &); +@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); +@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); -.button-outline { - @apply text-indigo-600 bg-transparent border border-indigo-600; -} - -.button-outline:hover { - @apply text-white; +[x-cloak] { + display: none; } -.button-sm { - @apply px-4 py-2 text-sm; +.button { + @apply inline-flex justify-center px-3.5 py-2.5 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700; } -.button-md { - @apply px-4 py-2; +.button[disabled] { + @apply bg-gray-400 dark:bg-gray-600; } html { @@ -54,30 +147,12 @@ body { overflow-x: hidden; } -blockquote { - @apply px-4 py-2 my-4 border-l-4 border-gray-500; -} - -@screen xl { +@media (width >= 1280px) { .container { max-width: 70rem; } } -.pricing-table { - height: 920px; -} - -@screen md { - .pricing-table { - height: auto; - } -} - -.light-text { - color: #f0f4f8; -} - .transition { transition: all 0.1s ease-in; } @@ -147,41 +222,6 @@ blockquote { } } -.just-text h1, -.just-text h2, -.just-text h3 { - margin-top: 1em; - margin-bottom: 0.5em; -} - -.just-text p { - margin-top: 0; - margin-bottom: 1rem; -} - -.dropdown-content::before { - top: -16px; - right: 8px; - left: auto; - border: 8px solid transparent; - border-bottom-color: rgb(27 31 35 / 15%); -} - -.dropdown-content::before, -.dropdown-content::after { - position: absolute; - display: inline-block; - content: ''; -} - -.dropdown-content::after { - top: -14px; - right: 9px; - left: auto; - border: 7px solid transparent; - border-bottom-color: #fff; -} - .feather { height: 1em; width: 1em; @@ -196,43 +236,12 @@ blockquote { display: inline; } -.table-striped tbody tr:nth-child(odd) { - background-color: #f1f5f8; -} - -.dark .table-striped tbody tr:nth-child(odd) { - background-color: rgb(37 47 63); -} - -.dark .table-striped tbody tr:nth-child(even) { - background-color: rgb(26 32 44); -} - -.stats-item { - min-height: 436px; -} - -@screen md { - .stats-item { - margin-left: 6px; - margin-right: 6px; - width: calc(50% - 6px); - position: relative; - min-height: initial; - height: 27.25rem; - } - - .stats-item-header { - height: inherit; - } +.table-striped tbody tr:nth-child(odd) td { + background-color: var(--color-gray-75); } -.stats-item:first-child { - margin-left: 0; -} - -.stats-item:last-child { - margin-right: 0; +.dark .table-striped tbody tr:nth-child(odd) td { + background-color: var(--color-gray-850); } .fade-enter { @@ -244,24 +253,6 @@ blockquote { transition: opacity 100ms ease-in; } -.datamaps-subunit { - cursor: pointer; -} - -.fullwidth-shadow::before { - @apply absolute top-0 w-screen h-full bg-gray-50 dark:bg-gray-850; - - box-shadow: 0 4px 2px -2px rgb(0 0 0 / 6%); - content: ''; - z-index: -1; - left: calc(-1 * calc(50vw - 50%)); - background-color: inherit; -} - -.dark .fullwidth-shadow::before { - box-shadow: 0 4px 2px -2px rgb(200 200 200 / 10%); -} - iframe[hidden] { display: none; } @@ -270,54 +261,8 @@ iframe[hidden] { @apply cursor-default bg-gray-100 dark:bg-gray-300 pointer-events-none; } -#chartjs-tooltip { - background-color: rgba(25 30 56); - position: absolute; - font-size: 14px; - font-style: normal; - padding: 10px 12px; - pointer-events: none; - border-radius: 5px; - z-index: 100; -} - -.active-prop-heading { - /* Properties related to text-decoration are all here in one place. TailwindCSS does support underline but that's about it. */ - text-decoration-line: underline; - text-decoration-color: #4338ca; /* tailwind's indigo-700 */ - text-decoration-thickness: 2px; -} - -@media (prefers-color-scheme: dark) { - .active-prop-heading { - text-decoration-color: #6366f1; /* tailwind's indigo-500 */ - } -} - /* This class is used for styling embedded dashboards. Do not remove. */ /* stylelint-disable */ /* prettier-ignore */ .date-option-group { } /* stylelint-enable */ - -.popper-tooltip { - background-color: rgba(25 30 56); -} - -.tooltip-arrow, -.tooltip-arrow::before { - position: absolute; - width: 10px; - height: 10px; - background: inherit; -} - -.tooltip-arrow { - visibility: hidden; -} - -.tooltip-arrow::before { - visibility: visible; - content: ''; - transform: rotate(45deg) translateY(1px); -} diff --git a/assets/css/chartjs.css b/assets/css/chartjs.css deleted file mode 100644 index 569d39dd96f7..000000000000 --- a/assets/css/chartjs.css +++ /dev/null @@ -1,10 +0,0 @@ -#chartjs-tooltip { - background-color: rgb(25 30 56); - position: absolute; - font-size: 14px; - font-style: normal; - padding: 10px 12px; - pointer-events: none; - border-radius: 5px; - z-index: 100; -} diff --git a/assets/css/loader.css b/assets/css/loader.css index 2c041f35e13b..2f658fb3eef4 100644 --- a/assets/css/loader.css +++ b/assets/css/loader.css @@ -5,8 +5,8 @@ } .loading.sm { - width: 25px; - height: 25px; + width: 20px; + height: 20px; } .loading div { @@ -25,8 +25,8 @@ } .loading.sm div { - width: 25px; - height: 25px; + width: 20px; + height: 20px; } @keyframes spin { diff --git a/assets/css/modal.css b/assets/css/modal.css index 500ffab9a079..7fa04121b426 100644 --- a/assets/css/modal.css +++ b/assets/css/modal.css @@ -28,37 +28,10 @@ position: fixed; inset: 0; background: rgb(0 0 0 / 60%); - z-index: 99; + z-index: 999; 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; diff --git a/assets/css/storybook.css b/assets/css/storybook.css index e78c04fc7d86..2764e94fa378 100644 --- a/assets/css/storybook.css +++ b/assets/css/storybook.css @@ -1,10 +1,8 @@ -@import 'tailwindcss/base'; -@import 'tailwindcss/components'; -@import 'tailwindcss/utilities'; +@import 'tailwindcss' source(none); /* * Put your component styling within the Tailwind utilities layer. - * See the https://hexdocs.pm/phoenix_storybook/sandboxing.html guide for more info. +* See the https://hexdocs.pm/phoenix_storybook/sandboxing.html guide for more info. */ @layer utilities { diff --git a/assets/jest.config.json b/assets/jest.config.json index 37c13e8d4113..97f7025e3163 100644 --- a/assets/jest.config.json +++ b/assets/jest.config.json @@ -8,6 +8,7 @@ }, "setupFilesAfterEnv": [ "/test-utils/extend-expect.ts", + "/test-utils/jsdom-mocks.ts", "/test-utils/reset-state.ts" ], "transform": { diff --git a/assets/js/app.js b/assets/js/app.js index 4224829b81c8..f73084a96689 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -115,7 +115,7 @@ if (embedButton) { embedLink.searchParams.set('background', background) } - embedCode.value = ` + embedCode.value = `
Stats powered by Plausible Analytics
` } catch (e) { diff --git a/assets/js/dashboard.tsx b/assets/js/dashboard.tsx index 8232cb50ae23..b0871d687397 100644 --- a/assets/js/dashboard.tsx +++ b/assets/js/dashboard.tsx @@ -7,7 +7,7 @@ import { createAppRouter } from './dashboard/router' import ErrorBoundary from './dashboard/error/error-boundary' import * as api from './dashboard/api' import * as timer from './dashboard/util/realtime-update-timer' -import { redirectForLegacyParams } from './dashboard/util/url-search-params' +import { maybeDoFERedirect } from './dashboard/util/url-search-params' import SiteContextProvider, { parseSiteFromDataset } from './dashboard/site-context' @@ -19,6 +19,8 @@ import { SomethingWentWrongMessage } from './dashboard/error/something-went-wrong' import { + getLimitedToSegment, + parseLimitedToSegmentId, parsePreloadedSegments, SegmentsContextProvider } from './dashboard/filtering/segments-context' @@ -39,8 +41,15 @@ if (container && container.dataset) { api.setSharedLinkAuth(sharedLinkAuth) } + const limitedToSegmentId = parseLimitedToSegmentId(container.dataset) + const preloadedSegments = parsePreloadedSegments(container.dataset) + const limitedToSegment = getLimitedToSegment( + limitedToSegmentId, + preloadedSegments + ) + try { - redirectForLegacyParams(window.location, window.history) + maybeDoFERedirect(window.location, window.history, limitedToSegment) } catch (e) { console.error('Error redirecting in a backwards compatible way', e) } @@ -64,17 +73,27 @@ if (container && container.dataset) { ? { loggedIn: true, id: parseInt(container.dataset.currentUserId!, 10), - role: container.dataset.currentUserRole as Role + role: container.dataset.currentUserRole as Role, + team: { + identifier: container.dataset.teamIdentifier ?? null, + hasConsolidatedView: + container.dataset.consolidatedViewAvailable === 'true' + } } : { loggedIn: false, id: null, - role: container.dataset.currentUserRole as Role + role: container.dataset.currentUserRole as Role, + team: { + identifier: null, + hasConsolidatedView: false + } } } > diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index fa407178e453..198d0d9d3388 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -1,10 +1,28 @@ -import { DashboardQuery } from './query' +import { Metric } from '../types/query-api' +import { DashboardState } from './dashboard-state' +import { PlausibleSite } from './site-context' +import { StatsQuery } from './stats-query' import { formatISO } from './util/date' import { serializeApiFilters } from './util/filters' +import * as url from './util/url' let abortController = new AbortController() let SHARED_LINK_AUTH: null | string = null +export type QueryApiResponse = { + query: { + metrics: Metric[] + date_range: [string, string] + comparison_date_range: [string, string] + } + meta: Record + results: { + metrics: Array + dimensions: Array + comparison: { metrics: Array; change: Array } + }[] +} + export class ApiError extends Error { payload: unknown constructor(message: string, payload: unknown) { @@ -32,39 +50,39 @@ export function cancelAll() { abortController = new AbortController() } -export function queryToSearchParams( - query: DashboardQuery, +export function dashboardStateToSearchParams( + dashboardState: DashboardState, extraQuery: unknown[] = [] ): string { const queryObj: Record = {} - if (query.period) { - queryObj.period = query.period + if (dashboardState.period) { + queryObj.period = dashboardState.period } - if (query.date) { - queryObj.date = formatISO(query.date) + if (dashboardState.date) { + queryObj.date = formatISO(dashboardState.date) } - if (query.from) { - queryObj.from = formatISO(query.from) + if (dashboardState.from) { + queryObj.from = formatISO(dashboardState.from) } - if (query.to) { - queryObj.to = formatISO(query.to) + if (dashboardState.to) { + queryObj.to = formatISO(dashboardState.to) } - if (query.filters) { - queryObj.filters = serializeApiFilters(query.filters) + if (dashboardState.filters) { + queryObj.filters = serializeApiFilters(dashboardState.filters) } - if (query.with_imported) { - queryObj.with_imported = String(query.with_imported) + if (dashboardState.with_imported) { + queryObj.with_imported = String(dashboardState.with_imported) } - if (query.comparison) { - queryObj.comparison = query.comparison - queryObj.compare_from = query.compare_from - ? formatISO(query.compare_from) + if (dashboardState.comparison) { + queryObj.comparison = dashboardState.comparison + queryObj.compare_from = dashboardState.compare_from + ? formatISO(dashboardState.compare_from) : undefined - queryObj.compare_to = query.compare_to - ? formatISO(query.compare_to) + queryObj.compare_to = dashboardState.compare_to + ? formatISO(dashboardState.compare_to) : undefined - queryObj.match_day_of_week = String(query.match_day_of_week) + queryObj.match_day_of_week = String(dashboardState.match_day_of_week) } const sharedLinkParams = getSharedLinkSearchParams() @@ -94,13 +112,33 @@ function getSharedLinkSearchParams(): Record { return SHARED_LINK_AUTH ? { auth: SHARED_LINK_AUTH } : {} } +export async function stats(site: PlausibleSite, statsQuery: StatsQuery) { + const sharedLinkParams = getSharedLinkSearchParams() + const queryString = sharedLinkParams.auth + ? new URLSearchParams(sharedLinkParams).toString() + : '' + const path = url.apiPath(site, '/query') + const response = await fetch(queryString ? `${path}?${queryString}` : path, { + method: 'POST', + signal: abortController.signal, + headers: { + ...getHeaders(), + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(statsQuery) + }) + + return handleApiResponse(response) +} + export async function get( url: string, - query?: DashboardQuery, + dashboardState?: DashboardState, ...extraQueryParams: unknown[] ) { - const queryString = query - ? queryToSearchParams(query, [...extraQueryParams]) + const queryString = dashboardState + ? dashboardStateToSearchParams(dashboardState, [...extraQueryParams]) : serializeUrlParams(getSharedLinkSearchParams()) const response = await fetch(queryString ? `${url}?${queryString}` : url, { diff --git a/assets/js/dashboard/components/combobox.js b/assets/js/dashboard/components/combobox.js index c414ad8c88de..17670f218066 100644 --- a/assets/js/dashboard/components/combobox.js +++ b/assets/js/dashboard/components/combobox.js @@ -12,10 +12,9 @@ import { useMountedEffect, useDebounce } from '../custom-hooks' function Option({ isHighlighted, onClick, onMouseEnter, text, id }) { const className = classNames( - 'relative cursor-pointer select-none py-2 px-3', + 'relative cursor-pointer select-none py-2 px-3 text-gray-900 dark:text-gray-300', { - 'text-gray-900 dark:text-gray-300': !isHighlighted, - 'bg-indigo-600 text-white': isHighlighted + 'bg-gray-100 dark:bg-gray-700': isHighlighted } ) @@ -221,7 +220,7 @@ export default function PlausibleCombobox({ }, [isEmpty, singleOption, autoFocus]) const searchBoxClass = - 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm' + 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-hidden focus:ring-0 text-sm' const containerClass = classNames('relative w-full', { [className]: !!className, @@ -264,7 +263,7 @@ export default function PlausibleCombobox({ return (
{value.label}
    {renderDropDownContent()}
diff --git a/assets/js/dashboard/components/drilldown-link.tsx b/assets/js/dashboard/components/drilldown-link.tsx index 7e66ab643e0c..e9f20483a1ef 100644 --- a/assets/js/dashboard/components/drilldown-link.tsx +++ b/assets/js/dashboard/components/drilldown-link.tsx @@ -6,8 +6,8 @@ import { import classNames from 'classnames' import { cleanLabels, replaceFilterByPrefix } from '../util/filters' import { plainFilterText } from '../util/filter-text' -import { useQueryContext } from '../query-context' -import { Filter, FilterClauseLabels } from '../query' +import { useDashboardStateContext } from '../dashboard-state-context' +import { Filter, FilterClauseLabels } from '../dashboard-state' export type FilterInfo = { prefix: string @@ -25,19 +25,24 @@ export function DrilldownLink({ extraClass?: string filterInfo: FilterInfo | null }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const className = classNames(`${extraClass}`, { 'hover:underline': !!filterInfo }) if (filterInfo) { const { prefix, filter, labels } = filterInfo - const newFilters = replaceFilterByPrefix(query, prefix, filter) - const newLabels = cleanLabels(newFilters, query.labels, filter[1], labels) + const newFilters = replaceFilterByPrefix(dashboardState, prefix, filter) + const newLabels = cleanLabels( + newFilters, + dashboardState.labels, + filter[1], + labels + ) return ( void onRetry?: () => void diff --git a/assets/js/dashboard/components/filter-operator-selector.js b/assets/js/dashboard/components/filter-operator-selector.js index c7afb4652698..46db51d0e5d5 100644 --- a/assets/js/dashboard/components/filter-operator-selector.js +++ b/assets/js/dashboard/components/filter-operator-selector.js @@ -1,4 +1,4 @@ -import React, { Fragment, useRef } from 'react' +import React, { useRef } from 'react' import { FILTER_OPERATIONS, @@ -7,97 +7,89 @@ import { supportsIsNot, supportsHasDoneNot } from '../util/filters' -import { Menu, Transition } from '@headlessui/react' +import { Transition, Popover } from '@headlessui/react' import { ChevronDownIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' -import { BlurMenuButtonOnEscape } from '../keybinding' +import { popover, BlurMenuButtonOnEscape } from './popover' export default function FilterOperatorSelector(props) { const filterName = props.forFilter const buttonRef = useRef() - function renderTypeItem(operation, shouldDisplay) { - return ( - shouldDisplay && ( - - {({ active }) => ( - props.onSelect(operation)} - className={classNames('cursor-pointer block px-4 py-2 text-sm', { - 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100': - active, - 'text-gray-700 dark:text-gray-200': !active - })} - > - {FILTER_OPERATIONS_DISPLAY_NAMES[operation]} - - )} - - ) - ) - } - - const containerClass = classNames('w-full', { - 'opacity-20 cursor-default pointer-events-none': props.isDisabled - }) - return ( -
- - {({ open }) => ( +
+ + {({ close: closeDropdown }) => ( <> -
- + + {FILTER_OPERATIONS_DISPLAY_NAMES[props.selectedType]} - -
- + +
+
) } diff --git a/assets/js/dashboard/components/notice.js b/assets/js/dashboard/components/notice.js index 1089f4257944..df5b007d365b 100644 --- a/assets/js/dashboard/components/notice.js +++ b/assets/js/dashboard/components/notice.js @@ -1,5 +1,5 @@ import React from 'react' -import { sectionTitles } from '../stats/behaviours' +import { MODES } from '../stats/behaviours/modes-context' import * as api from '../api' import { useSiteContext } from '../site-context' @@ -11,7 +11,7 @@ export function FeatureSetupNotice({ onHideAction }) { const site = useSiteContext() - const sectionTitle = sectionTitles[feature] + const sectionTitle = MODES[feature].title const requestHideSection = () => { if ( @@ -35,17 +35,18 @@ export function FeatureSetupNotice({ function renderCallToAction() { return ( - -

- {callToAction.action} -

+
+

{callToAction.action}

Hide this report @@ -69,13 +70,13 @@ export function FeatureSetupNotice({ } return ( -
-
-
+
+
+
{title}
-
+
{info}
diff --git a/assets/js/dashboard/components/pill.tsx b/assets/js/dashboard/components/pill.tsx new file mode 100644 index 000000000000..15bb1d124c1e --- /dev/null +++ b/assets/js/dashboard/components/pill.tsx @@ -0,0 +1,20 @@ +import React, { ReactNode } from 'react' +import classNames from 'classnames' + +export type PillProps = { + className?: string + children: ReactNode +} + +export function Pill({ className, children }: PillProps) { + return ( +
+ {children} +
+ ) +} diff --git a/assets/js/dashboard/components/popover.tsx b/assets/js/dashboard/components/popover.tsx index f2f3e7931ed4..66ca3fc91c2f 100644 --- a/assets/js/dashboard/components/popover.tsx +++ b/assets/js/dashboard/components/popover.tsx @@ -1,5 +1,7 @@ -import { TransitionClasses } from '@headlessui/react' +import React, { RefObject } from 'react' import classNames from 'classnames' +import { isModifierPressed, isTyping, Keybind } from '../keybinding' +import { TransitionClasses } from '@headlessui/react' const TRANSITION_CONFIG: TransitionClasses = { enter: 'transition ease-out duration-100', @@ -11,25 +13,34 @@ const TRANSITION_CONFIG: TransitionClasses = { } const transition = { - props: TRANSITION_CONFIG, - classNames: { fullwidth: 'z-10 absolute left-0 right-0' } + props: { + ...TRANSITION_CONFIG + }, + classNames: { + fullwidth: 'z-10 absolute left-0 right-0 origin-top', + left: 'z-10 absolute left-0 origin-top-left', + right: 'z-10 absolute right-0 origin-top-right' + } } const panel = { classNames: { roundedSheet: - 'focus:outline-none rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 font-medium text-gray-800 dark:text-gray-200' + 'flex flex-col gap-0.5 p-1 focus:outline-hidden rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black/5 font-medium text-gray-800 dark:text-gray-200' } } const toggleButton = { classNames: { - rounded: 'flex items-center rounded text-sm leading-tight h-9', + rounded: + 'flex items-center rounded text-sm leading-tight h-9 transition-all duration-150', shadow: - 'bg-white dark:bg-gray-800 shadow text-gray-800 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900', + 'bg-white dark:bg-gray-750 shadow-sm text-gray-800 dark:text-gray-200 dark:hover:bg-gray-700', ghost: 'text-gray-700 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-900', - truncatedText: 'truncate block font-medium' + truncatedText: 'truncate block font-medium', + linkLike: + 'text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors duration-150' } } @@ -37,28 +48,25 @@ const items = { classNames: { navigationLink: classNames( 'flex items-center justify-between', - 'px-4 py-2 text-sm leading-tight' + 'px-4 py-2.5 text-sm leading-tight rounded-md', + 'cursor-pointer' + ), + selectedOption: classNames( + 'data-[selected=true]:bg-gray-100', + 'data-[selected=true]:dark:bg-gray-700', + 'data-[selected=true]:text-gray-900', + 'data-[selected=true]:dark:text-gray-100' ), - selectedOption: classNames('data-[selected=true]:font-bold'), hoverLink: classNames( 'hover:bg-gray-100', 'hover:text-gray-900', - 'dark:hover:bg-gray-900', + 'dark:hover:bg-gray-700', 'dark:hover:text-gray-100', 'focus-within:bg-gray-100', 'focus-within:text-gray-900', - 'dark:focus-within:bg-gray-900', + 'dark:focus-within:bg-gray-700', 'dark:focus-within:text-gray-100' - ), - roundedStartEnd: classNames( - 'first-of-type:rounded-t-md', - 'last-of-type:rounded-b-md' - ), - roundedEnd: classNames('last-of-type:rounded-b-md'), - groupRoundedStartEnd: classNames( - 'group-first-of-type:rounded-t-md', - 'group-last-of-type:rounded-b-md' ) } } @@ -69,3 +77,32 @@ export const popover = { transition, items } + +/** + * Rendering this component captures the Escape key on targetRef.current, a PopoverButton, + * blurring the element on Escape, and stopping the event from propagating. + * Needed to prevent other Escape handlers that may exist from running. + */ +export function BlurMenuButtonOnEscape({ + targetRef +}: { + targetRef: RefObject +}) { + return ( + { + const t = event.target as HTMLElement | null + if (typeof t?.blur === 'function') { + if (t === targetRef.current) { + t.blur() + event.stopPropagation() + } + } + }} + targetRef={targetRef} + shouldIgnoreWhen={[isModifierPressed, isTyping]} + /> + ) +} diff --git a/assets/js/dashboard/components/search-input.tsx b/assets/js/dashboard/components/search-input.tsx index 50a1e204d616..7992089aad08 100644 --- a/assets/js/dashboard/components/search-input.tsx +++ b/assets/js/dashboard/components/search-input.tsx @@ -1,21 +1,28 @@ -import React, { ChangeEventHandler, useCallback, useState, useRef } from 'react' +import React, { + ChangeEventHandler, + useCallback, + useState, + RefObject +} from 'react' import { isModifierPressed, Keybind } from '../keybinding' import { useDebounce } from '../custom-hooks' import classNames from 'classnames' export const SearchInput = ({ + searchRef, onSearch, className, - placeholderFocused = 'Search', - placeholderUnfocused = 'Press / to search' + placeholderFocusedOrMobile = 'Search', + placeholderUnfocusedOnlyDesktop = 'Press / to search' }: { + searchRef: RefObject onSearch: (value: string) => void className?: string - placeholderFocused?: string - placeholderUnfocused?: string + placeholderFocusedOrMobile?: string + placeholderUnfocusedOnlyDesktop?: string }) => { - const searchBoxRef = useRef(null) const [isFocused, setIsFocused] = useState(false) + const [hasValue, setHasValue] = useState(false) const onSearchInputChange: ChangeEventHandler = useCallback( (event) => { @@ -25,14 +32,26 @@ export const SearchInput = ({ ) const debouncedOnSearchInputChange = useDebounce(onSearchInputChange) + const handleInputChange: ChangeEventHandler = useCallback( + (event) => { + const value = event.target.value + setHasValue(value.length > 0) + debouncedOnSearchInputChange(event) + }, + [debouncedOnSearchInputChange] + ) + const blurSearchBox = useCallback(() => { - searchBoxRef.current?.blur() - }, []) + searchRef.current?.blur() + }, [searchRef]) - const focusSearchBox = useCallback((event: KeyboardEvent) => { - searchBoxRef.current?.focus() - event.stopPropagation() - }, []) + const focusSearchBox = useCallback( + (event: KeyboardEvent) => { + searchRef.current?.focus() + event.stopPropagation() + }, + [searchRef] + ) return ( <> @@ -41,7 +60,7 @@ export const SearchInput = ({ type="keyup" handler={blurSearchBox} shouldIgnoreWhen={[isModifierPressed, () => !isFocused]} - targetRef={searchBoxRef} + targetRef={searchRef} /> isFocused]} targetRef="document" /> - setIsFocused(false)} - onFocus={() => setIsFocused(true)} - ref={searchBoxRef} - type="text" - placeholder={isFocused ? placeholderFocused : placeholderUnfocused} - className={classNames( - 'shadow-sm dark:bg-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800 w-48', - className +
+ setIsFocused(false)} + onFocus={() => setIsFocused(true)} + ref={searchRef} + type="text" + placeholder=" " + className="peer w-full text-sm dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 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" + onChange={handleInputChange} + /> + {!hasValue && ( + <> + + {placeholderFocusedOrMobile} + + + {placeholderUnfocusedOnlyDesktop} + + )} - onChange={debouncedOnSearchInputChange} - /> +
) } diff --git a/assets/js/dashboard/components/sort-button.tsx b/assets/js/dashboard/components/sort-button.tsx index eaf556193300..3ca98b298c4e 100644 --- a/assets/js/dashboard/components/sort-button.tsx +++ b/assets/js/dashboard/components/sort-button.tsx @@ -15,14 +15,18 @@ export const SortButton = ({ return (
` } diff --git a/assets/js/dashboard/extra/funnel.js b/assets/js/dashboard/extra/funnel.js index f889ed3ab44a..b3987c31dfda 100644 --- a/assets/js/dashboard/extra/funnel.js +++ b/assets/js/dashboard/extra/funnel.js @@ -10,17 +10,47 @@ import RocketIcon from '../stats/modals/rocket-icon' import * as api from '../api' import LazyLoader from '../components/lazy-loader' -import { useQueryContext } from '../query-context' +import { useDashboardStateContext } from '../dashboard-state-context' import { useSiteContext } from '../site-context' +import { UIMode, useTheme } from '../theme-context' + +const getPalette = (theme) => { + if (theme.mode === UIMode.dark) { + return { + dataLabelBackground: 'rgb(9, 9, 11)', + dataLabelTextColor: 'rgb(244, 244, 245)', + visitorsBackground: 'rgb(99, 102, 241)', + dropoffBackground: 'rgb(63, 63, 70)', + dropoffStripes: 'rgb(9, 9, 11)', + stepNameLegendColor: 'rgb(228, 228, 231)', + visitorsLegendClass: 'bg-indigo-500', + dropoffLegendClass: 'bg-gray-600', + smallBarClass: 'bg-indigo-500' + } + } else { + return { + dataLabelBackground: 'rgb(39, 39, 42)', + dataLabelTextColor: 'rgb(244, 244, 245)', + visitorsBackground: 'rgb(99, 102, 241)', + dropoffBackground: 'rgb(224, 231, 255)', + dropoffStripes: 'rgb(255, 255, 255)', + stepNameLegendColor: 'rgb(24, 24, 27)', + visitorsLegendClass: 'bg-indigo-500', + dropoffLegendClass: 'bg-indigo-100', + smallBarClass: 'bg-indigo-300' + } + } +} export default function Funnel({ funnelName, tabs }) { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const [loading, setLoading] = useState(true) const [visible, setVisible] = useState(false) const [error, setError] = useState(undefined) const [funnel, setFunnel] = useState(null) const [isSmallScreen, setSmallScreen] = useState(false) + const theme = useTheme() const chartRef = useRef(null) const canvasRef = useRef(null) @@ -46,14 +76,14 @@ export default function Funnel({ funnelName, tabs }) { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, funnelName, visible, isSmallScreen]) + }, [dashboardState, funnelName, visible, isSmallScreen]) useEffect(() => { if (canvasRef.current && funnel && visible && !isSmallScreen) { - initialiseChart() + initialiseChart(getPalette(theme)) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [funnel, visible]) + }, [funnel, visible, theme]) useEffect(() => { const mediaQuery = window.matchMedia('(max-width: 768px)') @@ -67,38 +97,29 @@ export default function Funnel({ funnelName, tabs }) { } }, []) - const isDarkMode = () => { - return document.querySelector('html').classList.contains('dark') || false - } - - const getPalette = () => { - if (isDarkMode()) { - return { - dataLabelBackground: 'rgba(25, 30, 56, 0.97)', - dataLabelTextColor: 'rgb(243, 244, 246)', - visitorsBackground: 'rgb(99, 102, 241)', - dropoffBackground: '#2F3949', - dropoffStripes: 'rgb(25, 30, 56)', - stepNameLegendColor: 'rgb(228, 228, 231)', - visitorsLegendClass: 'bg-indigo-500', - dropoffLegendClass: 'bg-gray-600', - smallBarClass: 'bg-indigo-500' - } - } else { - return { - dataLabelBackground: 'rgba(25, 30, 56, 0.97)', - dataLabelTextColor: 'rgb(243, 244, 246)', - visitorsBackground: 'rgb(99, 102, 241)', - dropoffBackground: 'rgb(224, 231, 255)', - dropoffStripes: 'rgb(255, 255, 255)', - stepNameLegendColor: 'rgb(12, 24, 39)', - visitorsLegendClass: 'bg-indigo-500', - dropoffLegendClass: 'bg-indigo-100', - smallBarClass: 'bg-indigo-300' + const repositionFunnelTooltip = (e) => { + const tooltipEl = document.getElementById('chartjs-tooltip-funnel') + if (tooltipEl && window.innerWidth >= 768) { + if (e.clientX > 0.66 * window.innerWidth) { + tooltipEl.style.right = + window.innerWidth - e.clientX + window.pageXOffset + 'px' + tooltipEl.style.left = null + } else { + tooltipEl.style.right = null + tooltipEl.style.left = e.clientX + window.pageXOffset + 'px' } + tooltipEl.style.top = e.clientY + window.pageYOffset + 'px' + tooltipEl.style.opacity = 1 } } + useEffect(() => { + window.addEventListener('mousemove', repositionFunnelTooltip) + return () => { + window.removeEventListener('mousemove', repositionFunnelTooltip) + } + }, []) + const formatDataLabel = (visitors, ctx) => { if (ctx.dataset.label === 'Visitors') { const conversionRate = funnel.steps[ctx.dataIndex].conversion_rate @@ -132,18 +153,16 @@ export default function Funnel({ funnelName, tabs }) { } else { return api.get( `/api/stats/${encodeURIComponent(site.domain)}/funnels/${funnelMeta.id}`, - query + dashboardState ) } } - const initialiseChart = () => { + const initialiseChart = (palette) => { if (chartRef.current) { chartRef.current.destroy() } - const palette = getPalette() - const createDiagonalPattern = (color1, color2) => { // create a 10x10 px canvas for the pattern's base shape let shape = document.createElement('canvas') @@ -275,7 +294,9 @@ export default function Funnel({ funnelName, tabs }) { const header = () => { return (
-

{funnelName}

+

+ {funnelName} +

{tabs}
) @@ -311,7 +332,7 @@ export default function Funnel({ funnelName, tabs }) { } } - const renderInner = () => { + const renderInner = (theme) => { if (loading) { return (
@@ -327,19 +348,20 @@ export default function Funnel({ funnelName, tabs }) { return (
{header()} -

+

{funnel.steps.length}-step funnel ‱ {conversionRate}% conversion rate

- {isSmallScreen &&
{renderBars(funnel)}
} + {isSmallScreen && ( +
{renderBars(funnel, theme)}
+ )}
) } } - const renderBar = (step) => { - const palette = getPalette() - + const renderBar = (step, theme) => { + const palette = getPalette(theme) return ( <>
@@ -366,7 +388,7 @@ export default function Funnel({ funnelName, tabs }) { ) } - const renderBars = (funnel) => { + const renderBars = (funnel, theme) => { return ( <>
@@ -375,7 +397,9 @@ export default function Funnel({ funnelName, tabs }) { Visitors
- {funnel.steps.map(renderBar)} + + {funnel.steps.map((step) => renderBar(step, theme))} + ) } @@ -383,7 +407,7 @@ export default function Funnel({ funnelName, tabs }) { return (
setVisible(true)}> - {renderInner()} + {renderInner(theme)} {!isSmallScreen && ( diff --git a/assets/js/dashboard/filtering/segments-context.test.tsx b/assets/js/dashboard/filtering/segments-context.test.tsx index 01889aa5d396..3925e6d39b6c 100644 --- a/assets/js/dashboard/filtering/segments-context.test.tsx +++ b/assets/js/dashboard/filtering/segments-context.test.tsx @@ -35,6 +35,7 @@ describe('SegmentsContext functions', () => { test('deleteOne works', () => { render( @@ -51,7 +52,10 @@ describe('SegmentsContext functions', () => { test('addOne adds to head of list', async () => { render( - + ) @@ -68,6 +72,7 @@ describe('SegmentsContext functions', () => { test('updateOne works: updated segment is at head of list', () => { render( diff --git a/assets/js/dashboard/filtering/segments-context.tsx b/assets/js/dashboard/filtering/segments-context.tsx index cbc15233e303..aacebb4bdac8 100644 --- a/assets/js/dashboard/filtering/segments-context.tsx +++ b/assets/js/dashboard/filtering/segments-context.tsx @@ -17,17 +17,33 @@ export function parsePreloadedSegments(dataset: DOMStringMap): SavedSegments { return JSON.parse(dataset.segments!).map(handleSegmentResponse) } +export function parseLimitedToSegmentId(dataset: DOMStringMap): number | null { + return JSON.parse(dataset.limitedToSegmentId!) +} + +export function getLimitedToSegment( + limitedToSegmentId: number | null, + preloadedSegments: SavedSegments +): Pick | null { + if (limitedToSegmentId !== null) { + return preloadedSegments.find((s) => s.id === limitedToSegmentId) ?? null + } + return null +} + type ChangeSegmentState = ( segment: (SavedSegment | SavedSegmentPublic) & { segment_data: SegmentData } ) => void const initialValue: { segments: SavedSegments + limitedToSegment: Pick | null updateOne: ChangeSegmentState addOne: ChangeSegmentState removeOne: ChangeSegmentState } = { segments: [], + limitedToSegment: null, updateOne: () => {}, addOne: () => {}, removeOne: () => {} @@ -41,9 +57,11 @@ export const useSegmentsContext = () => { export const SegmentsContextProvider = ({ preloadedSegments, + limitedToSegment, children }: { preloadedSegments: SavedSegments + limitedToSegment: Pick | null children: ReactNode }) => { const [segments, setSegments] = useState(preloadedSegments) @@ -73,7 +91,13 @@ export const SegmentsContextProvider = ({ return ( {children} diff --git a/assets/js/dashboard/filtering/segments.test.ts b/assets/js/dashboard/filtering/segments.test.ts index c8c3ba602a59..b356be92559a 100644 --- a/assets/js/dashboard/filtering/segments.test.ts +++ b/assets/js/dashboard/filtering/segments.test.ts @@ -1,8 +1,7 @@ import { remapToApiFilters } from '../util/filters' import { formatSegmentIdAsLabelKey, - getFilterSegmentsByNameInsensitive, - getSearchToApplySingleSegmentFilter, + getSearchToSetSegmentFilter, getSegmentNamePlaceholder, isSegmentIdLabelKey, parseApiSegmentData, @@ -11,42 +10,12 @@ import { SegmentType, SavedSegment, SegmentData, - canSeeSegmentDetails + canExpandSegment } from './segments' -import { Filter } from '../query' +import { Filter } from '../dashboard-state' import { PlausibleSite } from '../site-context' import { Role, UserContextValue } from '../user-context' -describe(`${getFilterSegmentsByNameInsensitive.name}`, () => { - const unfilteredSegments = [ - { name: 'APAC Region' }, - { name: 'EMEA Region' }, - { name: 'Scandinavia' } - ] - it('generates insensitive filter function', () => { - expect( - unfilteredSegments.filter(getFilterSegmentsByNameInsensitive('region')) - ).toEqual([{ name: 'APAC Region' }, { name: 'EMEA Region' }]) - }) - - it('ignores preceding and following whitespace', () => { - expect( - unfilteredSegments.filter(getFilterSegmentsByNameInsensitive(' scandi ')) - ).toEqual([{ name: 'Scandinavia' }]) - }) - - it.each([[undefined], [''], [' '], ['\n\n']])( - 'generates always matching filter for search value %p', - (searchValue) => { - expect( - unfilteredSegments.filter( - getFilterSegmentsByNameInsensitive(searchValue) - ) - ).toEqual(unfilteredSegments) - } - ) -}) - describe(`${getSegmentNamePlaceholder.name}`, () => { it('gives readable result', () => { const placeholder = getSegmentNamePlaceholder({ @@ -95,9 +64,57 @@ describe(`${parseApiSegmentData.name}`, () => { }) }) -describe(`${getSearchToApplySingleSegmentFilter.name}`, () => { - test('generated search function applies single segment correctly', () => { - const searchFunction = getSearchToApplySingleSegmentFilter({ +describe(`${getSearchToSetSegmentFilter.name}`, () => { + test('generated search function omits other filters segment correctly', () => { + const searchFunction = getSearchToSetSegmentFilter( + { + name: 'APAC', + id: 500 + }, + { omitAllOtherFilters: true } + ) + const existingSearch = { + date: '2025-02-10', + filters: [ + ['is', 'country', ['US']], + ['is', 'page', ['/blog']] + ], + labels: { US: 'United States' } + } + expect(searchFunction(existingSearch)).toEqual({ + date: '2025-02-10', + filters: [['is', 'segment', [500]]], + labels: { 'segment-500': 'APAC' } + }) + }) + + test('generated search function replaces existing segment filter correctly', () => { + const searchFunction = getSearchToSetSegmentFilter({ + name: 'APAC', + id: 500 + }) + const existingSearch = { + date: '2025-02-10', + filters: [ + ['is', 'segment', [100]], + ['is', 'country', ['US']], + ['is', 'page', ['/blog']] + ], + labels: { US: 'United States', 'segment-100': 'Scandinavia' } + } + expect(searchFunction(existingSearch)).toEqual({ + date: '2025-02-10', + filters: [ + ['is', 'segment', [500]], + ['is', 'country', ['US']], + ['is', 'page', ['/blog']] + ], + labels: { US: 'United States', 'segment-500': 'APAC' } + }) + }) + + test('generated search function sets new segment filter correctly', () => { + const searchFunction = getSearchToSetSegmentFilter({ name: 'APAC', id: 500 }) @@ -111,8 +128,12 @@ describe(`${getSearchToApplySingleSegmentFilter.name}`, () => { } expect(searchFunction(existingSearch)).toEqual({ date: '2025-02-10', - filters: [['is', 'segment', [500]]], - labels: { 'segment-500': 'APAC' } + filters: [ + ['is', 'segment', [500]], + ['is', 'country', ['US']], + ['is', 'page', ['/blog']] + ], + labels: { US: 'United States', 'segment-500': 'APAC' } }) }) }) @@ -121,7 +142,12 @@ describe(`${isListableSegment.name}`, () => { const site: Pick = { siteSegmentsAvailable: true } - const user: UserContextValue = { loggedIn: true, id: 1, role: Role.editor } + const user: UserContextValue = { + loggedIn: true, + id: 1, + role: Role.editor, + team: { identifier: null, hasConsolidatedView: false } + } it('should return true for site segment when siteSegmentsAvailable is true', () => { const segment = { id: 1, type: SegmentType.site, owner_id: 1 } @@ -134,7 +160,12 @@ describe(`${isListableSegment.name}`, () => { isListableSegment({ segment, site, - user: { loggedIn: false, role: Role.public, id: null } + user: { + loggedIn: false, + role: Role.public, + id: null, + team: { identifier: null, hasConsolidatedView: false } + } }) ).toBe(false) }) @@ -204,23 +235,91 @@ describe(`${resolveFilters.name}`, () => { ) }) -describe(`${canSeeSegmentDetails.name}`, () => { - it('should return true if the user is logged in and not a public role', () => { - const user: UserContextValue = { loggedIn: true, role: Role.admin, id: 1 } - expect(canSeeSegmentDetails({ user })).toBe(true) +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 user: UserContextValue = { + loggedIn: true, + role, + id: 1, + team: { identifier: null, hasConsolidatedView: false } + } + expect( + canExpandSegment({ + segment: { id: 1, owner_id: 1, type: SegmentType.site }, + user + }) + ).toBe(true) + } + ) + + it('allows expanding site segments defined by other users', () => { + expect( + canExpandSegment({ + segment: { id: 1, owner_id: 222, type: SegmentType.site }, + user: { + loggedIn: true, + role: Role.owner, + id: 111, + team: { identifier: null, hasConsolidatedView: false } + } + }) + ).toBe(true) }) - it('should return false if the user is not logged in', () => { - const user: UserContextValue = { - loggedIn: false, - role: Role.editor, - id: null + it.each([ + [Role.viewer], + [Role.billing], + [Role.editor], + [Role.admin], + [Role.owner] + ])( + 'allows expanding personal segment if it belongs to the user and the user is in role %p', + (role) => { + const user: UserContextValue = { + loggedIn: true, + role, + id: 1, + team: { identifier: null, hasConsolidatedView: false } + } + expect( + canExpandSegment({ + segment: { id: 1, owner_id: 1, type: SegmentType.personal }, + user + }) + ).toBe(true) } - expect(canSeeSegmentDetails({ user })).toBe(false) - }) + ) - it('should return false if the user has a public role', () => { - const user: UserContextValue = { loggedIn: true, role: Role.public, id: 1 } - expect(canSeeSegmentDetails({ user })).toBe(false) + it('forbids even site owners from expanding the 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 } + } + }) + ).toBe(false) }) + + it.each([[SegmentType.personal, SegmentType.site]])( + 'forbids public role from expanding %s segments', + (segmentType) => { + expect( + canExpandSegment({ + segment: { id: 1, owner_id: 1, type: segmentType }, + user: { + loggedIn: false, + role: Role.public, + id: null, + team: { identifier: null, hasConsolidatedView: false } + } + }) + ).toBe(false) + } + ) }) diff --git a/assets/js/dashboard/filtering/segments.ts b/assets/js/dashboard/filtering/segments.ts index 791fc8e2a0fa..e25dfdeac33d 100644 --- a/assets/js/dashboard/filtering/segments.ts +++ b/assets/js/dashboard/filtering/segments.ts @@ -1,4 +1,4 @@ -import { DashboardQuery, Filter } from '../query' +import { DashboardState, Filter } from '../dashboard-state' import { cleanLabels, remapFromApiFilters } from '../util/filters' import { plainFilterText } from '../util/filter-text' import { AppNavigationTarget } from '../navigation/use-app-navigate' @@ -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 } @@ -66,24 +76,15 @@ export function handleSegmentResponse( } } -export function getFilterSegmentsByNameInsensitive( - search?: string -): (s: Pick) => boolean { - return (s) => - search?.trim().length - ? s.name.toLowerCase().includes(search.trim().toLowerCase()) - : true -} - export const getSegmentNamePlaceholder = ( - query: Pick + dashboardState: Pick ) => - query.filters + dashboardState.filters .reduce( (combinedName, filter) => combinedName.length > 100 ? combinedName - : `${combinedName}${combinedName.length ? ' and ' : ''}${plainFilterText(query, filter)}`, + : `${combinedName}${combinedName.length ? ' and ' : ''}${plainFilterText(dashboardState, filter)}`, '' ) .slice(0, 255) @@ -114,16 +115,44 @@ export const parseApiSegmentData = ({ ...rest }) -export function getSearchToApplySingleSegmentFilter( - segment: Pick +export function getSearchToRemoveSegmentFilter(): Required['search'] { + return (searchRecord) => { + const updatedFilters = ( + (Array.isArray(searchRecord.filters) + ? searchRecord.filters + : []) as Filter[] + ).filter((f) => !isSegmentFilter(f)) + const currentLabels = searchRecord.labels ?? {} + return { + ...searchRecord, + filters: updatedFilters, + labels: cleanLabels(updatedFilters, currentLabels) + } + } +} + +export function getSearchToSetSegmentFilter( + segment: Pick, + options: { omitAllOtherFilters?: boolean } = {} ): Required['search'] { - return (search) => { - const filters = [['is', 'segment', [segment.id]]] - const labels = cleanLabels(filters, {}, 'segment', { + return (searchRecord) => { + const otherFilters = ( + (Array.isArray(searchRecord.filters) + ? searchRecord.filters + : []) as Filter[] + ).filter((f) => !isSegmentFilter(f)) + const currentLabels = searchRecord.labels ?? {} + + const filters = [ + ['is', 'segment', [segment.id]], + ...(options.omitAllOtherFilters ? [] : otherFilters) + ] + + const labels = cleanLabels(filters, currentLabels, 'segment', { [formatSegmentIdAsLabelKey(segment.id)]: segment.name }) return { - ...search, + ...searchRecord, filters, labels } @@ -157,6 +186,33 @@ export function resolveFilters( }) } +export function canExpandSegment({ + segment, + user +}: { + segment: Pick + user: UserContextValue +}) { + if ( + segment.type === SegmentType.site && + 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, @@ -186,6 +242,19 @@ export function canSeeSegmentDetails({ user }: { user: UserContextValue }) { return user.loggedIn && user.role !== Role.public } +export function canRemoveFilter( + filter: Filter, + limitedToSegment: Pick | null +) { + if (isSegmentFilter(filter) && limitedToSegment) { + const [_operation, _dimension, clauses] = filter + return ( + clauses.length === 1 && String(limitedToSegment.id) === String(clauses[1]) + ) + } + return true +} + export function findAppliedSegmentFilter({ filters }: { filters: Filter[] }) { const segmentFilter = filters.find(isSegmentFilter) if (!segmentFilter) { diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 7db4063436c0..3d01462ddbd5 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -5,18 +5,25 @@ import { QueryFilters } from '@tanstack/react-query' import * as api from '../api' -import { DashboardQuery } from '../query' +import { DashboardState } from '../dashboard-state' +import { DashboardPeriod } from '../dashboard-time-periods' +import { Dayjs } from 'dayjs' +import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer' -const LIMIT = 100 +// defines when queries that don't include the current time should be refetched +const HISTORICAL_RESPONSES_STALE_TIME_MS = 12 * 60 * 60 * 1000 + +// how many items per page for breakdown modals +const PAGINATION_LIMIT = 100 /** full endpoint URL */ type Endpoint = string -type PaginatedQueryKeyBase = [Endpoint, { query: DashboardQuery }] +type PaginatedQueryKeyBase = [Endpoint, { dashboardState: DashboardState }] type GetRequestParams = ( k: TKey -) => [DashboardQuery, Record] +) => [DashboardState, Record] /** * Hook that fetches the first page from the defined GET endpoint on mount, @@ -53,11 +60,11 @@ export function usePaginatedGetAPI< return useInfiniteQuery({ queryKey: key, queryFn: async ({ pageParam, queryKey }): Promise => { - const [query, params] = getRequestParams(queryKey) + const [dashboardState, params] = getRequestParams(queryKey) - const response: TResponse = await api.get(endpoint, query, { + const response: TResponse = await api.get(endpoint, dashboardState, { ...params, - limit: LIMIT, + limit: PAGINATION_LIMIT, page: pageParam }) @@ -78,7 +85,9 @@ export function usePaginatedGetAPI< return response.results }, getNextPageParam: (lastPageResults, _, lastPageIndex) => { - return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null + return lastPageResults.length === PAGINATION_LIMIT + ? lastPageIndex + 1 + : null }, initialPageParam, placeholderData: (previousData) => previousData @@ -98,3 +107,60 @@ export const cleanToPageOne = < } return data } + +export const getStaleTime = ( + /** the start of the current day */ + startOfDay: Dayjs, + { + period, + from, + to, + date + }: Pick +): number => { + if (DashboardPeriod.custom && to && from) { + // historical + if (from.isBefore(startOfDay) && to.isBefore(startOfDay)) { + return HISTORICAL_RESPONSES_STALE_TIME_MS + } + // period includes now + if (to.diff(from, 'days') < 7) { + return 5 * 60 * 1000 + } + if (to.diff(from, 'months') < 1) { + return 15 * 60 * 1000 + } + if (to.diff(from, 'months') < 12) { + return 60 * 60 * 1000 + } + return 3 * 60 * 60 * 1000 + } + + const historical = date?.isBefore(startOfDay) + if (historical) { + return HISTORICAL_RESPONSES_STALE_TIME_MS + } + + switch (period) { + case DashboardPeriod.realtime: + return REALTIME_UPDATE_TIME_MS + case DashboardPeriod['24h']: + case DashboardPeriod.day: + return 5 * 60 * 1000 + case DashboardPeriod['7d']: + return 15 * 60 * 1000 + case DashboardPeriod['28d']: + case DashboardPeriod['30d']: + case DashboardPeriod['91d']: + case DashboardPeriod['6mo']: + return 60 * 60 * 1000 + case DashboardPeriod['12mo']: + case DashboardPeriod.year: + return 3 * 60 * 60 * 1000 + case DashboardPeriod.all: + default: + // err on the side of less caching, + // to avoid the user refresheshing + return 15 * 60 * 1000 + } +} diff --git a/assets/js/dashboard/hooks/use-searchable-items.ts b/assets/js/dashboard/hooks/use-searchable-items.ts new file mode 100644 index 000000000000..992633936ded --- /dev/null +++ b/assets/js/dashboard/hooks/use-searchable-items.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export function useSearchableItems({ + data, + maxItemsInitially, + itemMatchesSearchValue +}: { + data: TItem[] + maxItemsInitially: number + itemMatchesSearchValue: (t: TItem, trimmedSearchString: string) => boolean +}): { + data: TItem[] + filteredData: TItem[] + showableData: TItem[] + searchRef: React.RefObject + showSearch: boolean + searching: boolean + countOfMoreToShow: number + handleSearchInput: (v: string) => void + handleClearSearch: () => void + handleShowAll: () => void +} { + const searchRef = useRef(null) + const [searchValue, setSearch] = useState() + const [showAll, setShowAll] = useState(false) + const trimmedSearch = searchValue?.trim() + const searching = !!trimmedSearch?.length + + useEffect(() => { + setShowAll(false) + }, [searching]) + + const filteredData = searching + ? data.filter((item) => itemMatchesSearchValue(item, trimmedSearch)) + : data + + const showableData = showAll + ? filteredData + : filteredData.slice(0, maxItemsInitially) + + const handleClearSearch = useCallback(() => { + if (searchRef.current) { + searchRef.current.value = '' + setSearch(undefined) + } + }, []) + + return { + searchRef, + data, + filteredData, + showableData, + showSearch: data.length > maxItemsInitially, + searching, + countOfMoreToShow: filteredData.length - showableData.length, + handleSearchInput: setSearch, + handleClearSearch, + handleShowAll: () => setShowAll(true) + } +} diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index f4c51038cff4..ed22e0c22acf 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -6,7 +6,7 @@ import Locations from './stats/locations' import Devices from './stats/devices' import { TopBar } from './nav-menu/top-bar' import Behaviours from './stats/behaviours' -import { useQueryContext } from './query-context' +import { useDashboardStateContext } from './dashboard-state-context' import { isRealTimeDashboard } from './util/filters' function DashboardStats({ @@ -16,30 +16,13 @@ function DashboardStats({ importedDataInView?: boolean updateImportedDataInView?: (v: boolean) => void }) { - const statsBoxClass = - 'stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded' - return ( <> -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- + + + + ) @@ -47,8 +30,8 @@ function DashboardStats({ function useIsRealtimeDashboard() { const { - query: { period } - } = useQueryContext() + dashboardState: { period } + } = useDashboardStateContext() return useMemo(() => isRealTimeDashboard({ period }), [period]) } @@ -57,7 +40,7 @@ function Dashboard() { const [importedDataInView, setImportedDataInView] = useState(false) return ( -
+
@@ -153,32 +153,3 @@ export function KeybindHint({ ) } - -/** - * Rendering this component captures the Escape key on targetRef.current, - * blurring the element on Escape, and stopping the event from propagating. - * Needed to prevent other Escape handlers that may exist from running. - */ -export function BlurMenuButtonOnEscape({ - targetRef: targetRef -}: { - targetRef: RefObject -}) { - return ( - { - const t = event.target as HTMLElement | null - if (typeof t?.blur === 'function') { - if (t === targetRef.current) { - t.blur() - event.stopPropagation() - } - } - }} - targetRef={targetRef} - shouldIgnoreWhen={[isModifierPressed, isTyping]} - /> - ) -} diff --git a/assets/js/dashboard/nav-menu/filter-menu.tsx b/assets/js/dashboard/nav-menu/filter-menu.tsx index 50e9ee36b70e..ba1c6178a71c 100644 --- a/assets/js/dashboard/nav-menu/filter-menu.tsx +++ b/assets/js/dashboard/nav-menu/filter-menu.tsx @@ -7,11 +7,11 @@ import { PlausibleSite, useSiteContext } from '../site-context' import { filterRoute } from '../router' import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' import { Popover, Transition } from '@headlessui/react' -import { popover } from '../components/popover' +import { popover, BlurMenuButtonOnEscape } from '../components/popover' import classNames from 'classnames' import { AppNavigationLink } from '../navigation/use-app-navigate' -import { BlurMenuButtonOnEscape } from '../keybinding' import { SearchableSegmentsSection } from './segments/searchable-segments-section' +import { useSegmentsContext } from '../filtering/segments-context' export function getFilterListItems({ propsAvailable @@ -49,6 +49,8 @@ const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => { const site = useSiteContext() const columns = useMemo(() => getFilterListItems(site), [site]) const buttonRef = useRef(null) + const panelRef = useRef(null) + const { limitedToSegment } = useSegmentsContext() return ( <> @@ -67,15 +69,17 @@ const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => {
{columns.map((filterGroups, index) => ( @@ -105,7 +109,12 @@ const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => {
))}
- + {limitedToSegment === null && ( + + )} diff --git a/assets/js/dashboard/nav-menu/filter-pills-list.tsx b/assets/js/dashboard/nav-menu/filter-pills-list.tsx index 6ca03290aa0a..9b04d6cb3529 100644 --- a/assets/js/dashboard/nav-menu/filter-pills-list.tsx +++ b/assets/js/dashboard/nav-menu/filter-pills-list.tsx @@ -1,5 +1,5 @@ import React, { DetailedHTMLProps, HTMLAttributes } from 'react' -import { useQueryContext } from '../query-context' +import { useDashboardStateContext } from '../dashboard-state-context' import { FilterPill, FilterPillProps } from './filter-pill' import { cleanLabels, @@ -10,6 +10,8 @@ import { styledFilterText, plainFilterText } from '../util/filter-text' import { useAppNavigate } from '../navigation/use-app-navigate' import classNames from 'classnames' import { filterRoute } from '../router' +import { canRemoveFilter } from '../filtering/segments' +import { useSegmentsContext } from '../filtering/segments-context' export const PILL_X_GAP_PX = 16 export const PILL_Y_GAP_PX = 8 @@ -47,13 +49,14 @@ export const AppliedFilterPillsList = React.forwardRef< HTMLDivElement, AppliedFilterPillsListProps >(({ className, style, slice, direction, pillClassName }, ref) => { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() + const { limitedToSegment } = useSegmentsContext() const navigate = useAppNavigate() const renderableFilters = slice?.type === 'no-render-outside' - ? query.filters.slice(slice.start, slice.end) - : query.filters + ? dashboardState.filters.slice(slice.start, slice.end) + : dashboardState.filters const indexAdjustment = slice?.type === 'no-render-outside' ? (slice.start ?? 0) : 0 @@ -61,7 +64,7 @@ export const AppliedFilterPillsList = React.forwardRef< const isInvisible = (index: number) => { return slice?.type === 'invisible-outside' ? index < (slice.start ?? 0) || - index > (slice.end ?? query.filters.length) - 1 + index > (slice.end ?? dashboardState.filters.length) - 1 : false } @@ -69,8 +72,8 @@ export const AppliedFilterPillsList = React.forwardRef< ({ className: classNames(isInvisible(index) && 'invisible', pillClassName), - plainText: plainFilterText(query, filter), - children: styledFilterText(query, filter), + plainText: plainFilterText(dashboardState, filter), + children: styledFilterText(dashboardState, filter), interactive: { navigationTarget: { path: filterRoute.path, @@ -82,19 +85,21 @@ export const AppliedFilterPillsList = React.forwardRef< ] } }, - onRemoveClick: () => { - const newFilters = query.filters.filter( - (_, i) => i !== index + indexAdjustment - ) + onRemoveClick: canRemoveFilter(filter, limitedToSegment) + ? () => { + const newFilters = dashboardState.filters.filter( + (_, i) => i !== index + indexAdjustment + ) - navigate({ - search: (search) => ({ - ...search, - filters: newFilters, - labels: cleanLabels(newFilters, query.labels) - }) - }) - } + navigate({ + search: (searchRecord) => ({ + ...searchRecord, + filters: newFilters, + labels: cleanLabels(newFilters, dashboardState.labels) + }) + }) + } + : undefined } }))} className={className} diff --git a/assets/js/dashboard/nav-menu/filters-bar.test.tsx b/assets/js/dashboard/nav-menu/filters-bar.test.tsx index 42c1e07c2f2d..ea228a61bfe9 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.test.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.test.tsx @@ -5,24 +5,14 @@ import { TestContextProviders } from '../../../test-utils/app-context-providers' import { FiltersBar, handleVisibility } from './filters-bar' import { getRouterBasepath } from '../router' import { stringifySearch } from '../util/url-search-params' +import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks' -const domain = 'dummy.site' +mockAnimationsApi() +const resizeObserver = mockResizeObserver() -beforeAll(() => { - const mockResizeObserver = jest.fn( - (handleEntries) => - ({ - observe: jest.fn().mockImplementation((entry) => { - handleEntries([entry], null as unknown as ResizeObserver) - }), - unobserve: jest.fn(), - disconnect: jest.fn() - }) as unknown as ResizeObserver - ) - global.ResizeObserver = mockResizeObserver -}) +const domain = 'dummy.site' -test('user can see expected filters and clear them one by one or all together', async () => { +test('user can see expected filters and clear them one by one or all together on small screens', async () => { const searchRecord = { filters: [ ['is', 'country', ['DE']], @@ -67,10 +57,13 @@ test('user can see expected filters and clear them one by one or all together', } ) + // needed to initiate the layout calculation effect of the component + resizeObserver.resize() + const queryFilterPills = () => screen.queryAllByRole('link', { hidden: false, name: /.* is .*/i }) - // all filters appear in See more menu + // all filters appear in See more menu (see the mock widths in props) expect(queryFilterPills().map((m) => m.textContent)).toEqual([]) await userEvent.click( diff --git a/assets/js/dashboard/nav-menu/filters-bar.tsx b/assets/js/dashboard/nav-menu/filters-bar.tsx index 6988d9386188..976e11b17eda 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.tsx @@ -2,14 +2,13 @@ import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid' import classNames from 'classnames' import React, { useRef, useState, useLayoutEffect } from 'react' import { AppliedFilterPillsList, PILL_X_GAP_PX } from './filter-pills-list' -import { useQueryContext } from '../query-context' +import { useDashboardStateContext } from '../dashboard-state-context' import { AppNavigationLink } from '../navigation/use-app-navigate' import { Popover, Transition } from '@headlessui/react' -import { popover } from '../components/popover' -import { BlurMenuButtonOnEscape } from '../keybinding' +import { popover, BlurMenuButtonOnEscape } from '../components/popover' import { isSegmentFilter } from '../filtering/segments' import { useRoutelessModalsContext } from '../navigation/routeless-modals-context' -import { DashboardQuery } from '../query' +import { DashboardState } from '../dashboard-state' // Component structure is // `..[ filter (x) ]..[ filter (x) ]..[ three dot menu ]..` @@ -109,23 +108,25 @@ interface FiltersBarProps { const canShowClearAllAction = ({ filters -}: Pick): boolean => filters.length >= 2 +}: Pick): boolean => filters.length >= 2 const canShowSaveAsSegmentAction = ({ filters, isEditingSegment -}: Pick & { isEditingSegment: boolean }): boolean => +}: Pick & { isEditingSegment: boolean }): boolean => filters.length >= 1 && !filters.some(isSegmentFilter) && !isEditingSegment export const FiltersBar = ({ accessors }: FiltersBarProps) => { const containerRef = useRef(null) const pillsRef = useRef(null) const [visibility, setVisibility] = useState(null) - const { query, expandedSegment } = useQueryContext() + const { dashboardState, expandedSegment } = useDashboardStateContext() - const showingClearAll = canShowClearAllAction({ filters: query.filters }) + const showingClearAll = canShowClearAllAction({ + filters: dashboardState.filters + }) const showingSaveAsSegment = canShowSaveAsSegmentAction({ - filters: query.filters, + filters: dashboardState.filters, isEditingSegment: !!expandedSegment }) @@ -174,9 +175,9 @@ export const FiltersBar = ({ accessors }: FiltersBarProps) => { return () => { resizeObserver.disconnect() } - }, [accessors, query.filters, mustShowSeeMoreMenu]) + }, [accessors, dashboardState.filters, mustShowSeeMoreMenu]) - if (!query.filters.length) { + if (!dashboardState.filters.length) { // functions as spacer between elements.leftSection and elements.rightSection return
} @@ -204,12 +205,12 @@ export const FiltersBar = ({ accessors }: FiltersBarProps) => { />
{visibility !== null && - (query.filters.length !== visibility.visibleCount || + (dashboardState.filters.length !== visibility.visibleCount || mustShowSeeMoreMenu) && ( )} @@ -266,18 +267,18 @@ const SeeMoreMenu = ({ aria-hidden="true" className="absolute flex justify-end left-0 right-0 bottom-0 translate-y-1/4 pr-[3px]" > -
+
+{filtersInMenuCount}
)}
{showSomeActions && ( -
+
)} )} {showSomeActions && (
- {actions.map((action, index) => { + {actions.map((action) => { const linkClassName = classNames( popover.items.classNames.navigationLink, popover.items.classNames.selectedOption, popover.items.classNames.hoverLink, - index === 0 && !showMoreFilters - ? popover.items.classNames.roundedStartEnd - : popover.items.classNames.roundedEnd, 'whitespace-nowrap' ) diff --git a/assets/js/dashboard/nav-menu/nav-menu-components.tsx b/assets/js/dashboard/nav-menu/nav-menu-components.tsx index 2f82c63ff455..95952c223e1e 100644 --- a/assets/js/dashboard/nav-menu/nav-menu-components.tsx +++ b/assets/js/dashboard/nav-menu/nav-menu-components.tsx @@ -1,5 +1,5 @@ import React from 'react' export const MenuSeparator = () => ( -
+
) diff --git a/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx b/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx index 5ca9d47492b6..34f17cc47c12 100644 --- a/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx @@ -1,9 +1,8 @@ import React, { useRef } from 'react' -import { clearedComparisonSearch } from '../../query' +import { clearedComparisonSearch } from '../../dashboard-state' import classNames from 'classnames' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { BlurMenuButtonOnEscape } from '../../keybinding' import { AppNavigationLink, useAppNavigate @@ -16,9 +15,9 @@ import { ComparisonMatchMode, getCurrentComparisonPeriodDisplayName, getSearchToApplyCustomComparisonDates -} from '../../query-time-periods' +} from '../../dashboard-time-periods' import { Popover, Transition } from '@headlessui/react' -import { popover } from '../../components/popover' +import { popover, BlurMenuButtonOnEscape } from '../../components/popover' import { datemenuButtonClassName, DateMenuChevron, @@ -38,19 +37,19 @@ export const ComparisonPeriodMenuItems = ({ closeDropdown: () => void toggleCalendar: () => void }) => { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() - if (!isComparisonEnabled(query.comparison)) { + if (!isComparisonEnabled(dashboardState.comparison)) { return null } return ( @@ -61,7 +60,7 @@ export const ComparisonPeriodMenuItems = ({ ].map((comparisonMode) => ( ({ ...search, @@ -74,18 +73,18 @@ export const ComparisonPeriodMenuItems = ({ ))} s} onClick={toggleCalendar} > {COMPARISON_MODES[ComparisonMode.custom]} - {query.comparison !== ComparisonMode.custom && ( + {dashboardState.comparison !== ComparisonMode.custom && ( <> ({ ...s, match_day_of_week: true })} onClick={closeDropdown} @@ -93,7 +92,7 @@ export const ComparisonPeriodMenuItems = ({ {COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchDayOfWeek]} ({ ...s, match_day_of_week: false })} onClick={closeDropdown} @@ -112,7 +111,7 @@ export const ComparisonPeriodMenu = ({ closeDropdown }: PopoverMenuProps) => { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const buttonRef = useRef(null) const toggleCalendar = () => { @@ -126,7 +125,7 @@ export const ComparisonPeriodMenu = ({ - {getCurrentComparisonPeriodDisplayName({ site, query })} + {getCurrentComparisonPeriodDisplayName({ site, dashboardState })} @@ -144,7 +143,7 @@ export const ComparisonCalendarMenu = ({ }: PopoverMenuProps) => { const site = useSiteContext() const navigate = useAppNavigate() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() return ( <> @@ -166,8 +165,11 @@ export const ComparisonCalendarMenu = ({ minDate={site.statsBegin} maxDate={formatISO(nowForSite(site))} defaultDates={ - query.compare_from && query.compare_to - ? [formatISO(query.compare_from), formatISO(query.compare_to)] + dashboardState.compare_from && dashboardState.compare_to + ? [ + formatISO(dashboardState.compare_from), + formatISO(dashboardState.compare_to) + ] : undefined } /> diff --git a/assets/js/dashboard/nav-menu/query-periods/query-dates.test.tsx b/assets/js/dashboard/nav-menu/query-periods/dashboard-dates.test.tsx similarity index 79% rename from assets/js/dashboard/nav-menu/query-periods/query-dates.test.tsx rename to assets/js/dashboard/nav-menu/query-periods/dashboard-dates.test.tsx index c6a543d6abfb..64d3eca180ff 100644 --- a/assets/js/dashboard/nav-menu/query-periods/query-dates.test.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/dashboard-dates.test.tsx @@ -5,14 +5,18 @@ import { TestContextProviders } from '../../../../test-utils/app-context-provide import { stringifySearch } from '../../util/url-search-params' import { useNavigate } from 'react-router-dom' import { getRouterBasepath } from '../../router' -import { QueryPeriodsPicker } from './query-periods-picker' +import { DashboardPeriodPicker } from './dashboard-period-picker' +import { mockAnimationsApi, mockResizeObserver } from 'jsdom-testing-mocks' -const domain = 'picking-query-dates.test' +mockAnimationsApi() +mockResizeObserver() + +const domain = 'picking-dashboard-dates.test' const periodStorageKey = `period__${domain}` test('if no period is stored, loads with default value of "Last 28 days", all expected options are present', async () => { expect(localStorage.getItem(periodStorageKey)).toBe(null) - render(, { + render(, { wrapper: (props) => ( ) @@ -26,6 +30,7 @@ test('if no period is stored, loads with default value of "Last 28 days", all ex ['Today', 'D'], ['Yesterday', 'E'], ['Realtime', 'R'], + ['Last 24 Hours', 'H'], ['Last 7 Days', 'W'], ['Last 28 Days', 'F'], ['Last 91 Days', 'N'], @@ -41,23 +46,24 @@ test('if no period is stored, loads with default value of "Last 28 days", all ex }) test('user can select a new period and its value is stored', async () => { - render(, { + render(, { wrapper: (props) => ( ) }) + expect(screen.queryByTestId('datemenu')).toBeNull() await userEvent.click(screen.getByText('Last 28 days')) expect(screen.getByTestId('datemenu')).toBeVisible() await userEvent.click(screen.getByText('All time')) - expect(screen.queryByTestId('datemenu')).toBeNull() + expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument() expect(localStorage.getItem(periodStorageKey)).toBe('all') }) test('period "all" is respected, and Compare option is not present for it in menu', async () => { localStorage.setItem(periodStorageKey, 'all') - render(, { + render(, { wrapper: (props) => ( ) @@ -73,11 +79,11 @@ test.each([ [{ period: 'month' }, 'Month to Date'], [{ period: 'year' }, 'Year to Date'] ])( - 'the query period from search %p is respected and stored', + 'the dashboardState period from search %p is respected and stored', async (searchRecord, buttonText) => { const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}` - render(, { + render(, { wrapper: (props) => ( { const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}` - render(, { + render(, { wrapper: (props) => ( { + 'if the stored period is %p but dashboardState period is %p, dashboardState is respected and the stored period is overwritten', + async (storedPeriod, dashboardPeriod, buttonText) => { localStorage.setItem(periodStorageKey, storedPeriod) - const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch({ period: queryPeriod })}` + const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch({ period: dashboardPeriod })}` - render(, { + render(, { wrapper: (props) => ( { +test('going back resets the stored dashboardState period to previous value', async () => { const BrowserBackButton = () => { const navigate = useNavigate() return ( @@ -153,7 +159,7 @@ test('going back resets the stored query period to previous value', async () => } render( <> - + , { @@ -165,10 +171,14 @@ test('going back resets the stored query period to previous value', async () => await userEvent.click(screen.getByText('Last 28 days')) await userEvent.click(screen.getByText('Year to Date')) + expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument() + expect(localStorage.getItem(periodStorageKey)).toBe('year') await userEvent.click(screen.getByText('Year to Date')) await userEvent.click(screen.getByText('Month to Date')) + expect(screen.queryByTestId('datemenu')).not.toBeInTheDocument() + expect(localStorage.getItem(periodStorageKey)).toBe('month') await userEvent.click(screen.getByTestId('browser-back')) diff --git a/assets/js/dashboard/nav-menu/query-periods/query-period-menu.tsx b/assets/js/dashboard/nav-menu/query-periods/dashboard-period-menu.tsx similarity index 78% rename from assets/js/dashboard/nav-menu/query-periods/query-period-menu.tsx rename to assets/js/dashboard/nav-menu/query-periods/dashboard-period-menu.tsx index a7e962374393..e5fd6afcd26d 100644 --- a/assets/js/dashboard/nav-menu/query-periods/query-period-menu.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/dashboard-period-menu.tsx @@ -1,9 +1,8 @@ import React, { useMemo, useRef } from 'react' import classNames from 'classnames' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { - BlurMenuButtonOnEscape, isModifierPressed, isTyping, Keybind, @@ -17,15 +16,15 @@ import { getCompareLinkItem, getDatePeriodGroups, LinkItem, - QueryPeriod, + DashboardPeriod, getCurrentPeriodDisplayName, getSearchToApplyCustomDates, isComparisonForbidden -} from '../../query-time-periods' +} from '../../dashboard-time-periods' import { useMatch } from 'react-router-dom' import { rootRoute } from '../../router' import { Popover, Transition } from '@headlessui/react' -import { popover } from '../../components/popover' +import { popover, BlurMenuButtonOnEscape } from '../../components/popover' import { datemenuButtonClassName, DateMenuChevron, @@ -38,7 +37,7 @@ import { DateRangeCalendar } from './date-range-calendar' import { formatISO, nowForSite } from '../../util/date' import { MenuSeparator } from '../nav-menu-components' -function QueryPeriodMenuKeybinds({ +function DashboardPeriodMenuKeybinds({ closeDropdown, groups }: { @@ -80,12 +79,12 @@ function QueryPeriodMenuKeybinds({ ) } -export const QueryPeriodMenu = ({ +export const DashboardPeriodMenu = ({ closeDropdown, calendarButtonRef }: PopoverMenuProps) => { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const buttonRef = useRef(null) const toggleCalendar = () => { if (typeof calendarButtonRef.current?.click === 'function') { @@ -97,12 +96,15 @@ export const QueryPeriodMenu = ({ <> - - {getCurrentPeriodDisplayName({ query, site })} + + {getCurrentPeriodDisplayName({ dashboardState, site })} - @@ -110,7 +112,7 @@ export const QueryPeriodMenu = ({ ) } -const QueryPeriodMenuInner = ({ +const DashboardPeriodMenuInner = ({ closeDropdown, toggleCalendar }: { @@ -118,10 +120,14 @@ const QueryPeriodMenuInner = ({ toggleCalendar: () => void }) => { const site = useSiteContext() - const { query, expandedSegment } = useQueryContext() + const { dashboardState, expandedSegment } = useDashboardStateContext() const groups = useMemo(() => { - const compareLink = getCompareLinkItem({ site, query }) + const compareLink = getCompareLinkItem({ + site, + dashboardState, + onEvent: closeDropdown + }) return getDatePeriodGroups({ site, onEvent: closeDropdown, @@ -130,29 +136,33 @@ const QueryPeriodMenuInner = ({ ['Custom Range', 'C'], { search: (s) => s, - isActive: ({ query }) => query.period === QueryPeriod.custom, + isActive: ({ dashboardState }) => + dashboardState.period === DashboardPeriod.custom, onEvent: toggleCalendar } ] ], extraGroups: isComparisonForbidden({ - period: query.period, + period: dashboardState.period, segmentIsExpanded: !!expandedSegment }) ? [] : [[compareLink]] }) - }, [site, query, closeDropdown, toggleCalendar, expandedSegment]) + }, [site, dashboardState, closeDropdown, toggleCalendar, expandedSegment]) return ( <> - + onEvent(e))} @@ -198,7 +208,7 @@ export const MainCalendar = ({ calendarButtonRef }: PopoverMenuProps) => { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const navigate = useAppNavigate() return ( @@ -221,8 +231,8 @@ export const MainCalendar = ({ minDate={site.statsBegin} maxDate={formatISO(nowForSite(site))} defaultDates={ - query.from && query.to - ? [formatISO(query.from), formatISO(query.to)] + dashboardState.from && dashboardState.to + ? [formatISO(dashboardState.from), formatISO(dashboardState.to)] : undefined } /> diff --git a/assets/js/dashboard/nav-menu/query-periods/query-periods-picker.tsx b/assets/js/dashboard/nav-menu/query-periods/dashboard-period-picker.tsx similarity index 75% rename from assets/js/dashboard/nav-menu/query-periods/query-periods-picker.tsx rename to assets/js/dashboard/nav-menu/query-periods/dashboard-period-picker.tsx index ce5960368fad..aceb3d3f2359 100644 --- a/assets/js/dashboard/nav-menu/query-periods/query-periods-picker.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/dashboard-period-picker.tsx @@ -1,27 +1,30 @@ import React, { useRef } from 'react' import classNames from 'classnames' -import { useQueryContext } from '../../query-context' -import { isComparisonEnabled } from '../../query-time-periods' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { isComparisonEnabled } from '../../dashboard-time-periods' import { MovePeriodArrows } from './move-period-arrows' -import { MainCalendar, QueryPeriodMenu } from './query-period-menu' +import { MainCalendar, DashboardPeriodMenu } from './dashboard-period-menu' import { ComparisonCalendarMenu, ComparisonPeriodMenu } from './comparison-period-menu' import { Popover } from '@headlessui/react' -export function QueryPeriodsPicker({ className }: { className?: string }) { - const { query } = useQueryContext() - const isComparing = isComparisonEnabled(query.comparison) +export function DashboardPeriodPicker({ className }: { className?: string }) { + const { dashboardState } = useDashboardStateContext() + const isComparing = isComparisonEnabled(dashboardState.comparison) const mainCalendarButtonRef = useRef(null) const compareCalendarButtonRef = useRef(null) return ( -
+
{({ close }) => ( - diff --git a/assets/js/dashboard/nav-menu/query-periods/move-period-arrows.tsx b/assets/js/dashboard/nav-menu/query-periods/move-period-arrows.tsx index 78a73ee8a70b..71e93a311f96 100644 --- a/assets/js/dashboard/nav-menu/query-periods/move-period-arrows.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/move-period-arrows.tsx @@ -1,11 +1,14 @@ import React, { useMemo } from 'react' -import { shiftQueryPeriod, getDateForShiftedPeriod } from '../../query' +import { + shiftDashboardPeriod, + getDateForShiftedPeriod +} from '../../dashboard-state' import classNames from 'classnames' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { NavigateKeybind } from '../../keybinding' import { AppNavigationLink } from '../../navigation/use-app-navigate' -import { QueryPeriod } from '../../query-time-periods' +import { DashboardPeriod } from '../../dashboard-time-periods' import { useMatch } from 'react-router-dom' import { rootRoute } from '../../router' @@ -15,17 +18,17 @@ const ArrowKeybind = ({ keyboardKey: 'ArrowLeft' | 'ArrowRight' }) => { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const search = useMemo( () => - shiftQueryPeriod({ - query, + shiftDashboardPeriod({ + dashboardState, site, direction: ({ ArrowLeft: -1, ArrowRight: 1 } as const)[keyboardKey], keybindHint: keyboardKey }), - [site, query, keyboardKey] + [site, dashboardState, keyboardKey] ) return ( @@ -37,10 +40,24 @@ const ArrowKeybind = ({ ) } -function ArrowIcon({ direction }: { direction: 'left' | 'right' }) { +function ArrowIcon({ + testId, + direction, + disabled = false +}: { + testId?: string + direction: 'left' | 'right' + disabled?: boolean +}) { return ( @@ -93,16 +111,20 @@ export function MovePeriodArrows({ className }: { className?: string }) { )} search={ canGoBack - ? shiftQueryPeriod({ + ? shiftDashboardPeriod({ site, - query, + dashboardState, direction: -1, keybindHint: null }) : (search) => search } > - + search } > - + {!!dashboardRouteMatch && } {!!dashboardRouteMatch && } diff --git a/assets/js/dashboard/nav-menu/query-periods/shared-menu-items.tsx b/assets/js/dashboard/nav-menu/query-periods/shared-menu-items.tsx index 813e188d09bb..aacf0d5c57e3 100644 --- a/assets/js/dashboard/nav-menu/query-periods/shared-menu-items.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/shared-menu-items.tsx @@ -7,8 +7,7 @@ import { Popover, Transition } from '@headlessui/react' export const linkClassName = classNames( popover.items.classNames.navigationLink, popover.items.classNames.selectedOption, - popover.items.classNames.hoverLink, - popover.items.classNames.roundedStartEnd + popover.items.classNames.hoverLink ) export const datemenuButtonClassName = classNames( @@ -41,10 +40,11 @@ export const CalendarPanel = React.forwardRef< >(({ children, className }, ref) => { return ( diff --git a/assets/js/dashboard/nav-menu/segments/searchable-segments-section.tsx b/assets/js/dashboard/nav-menu/segments/searchable-segments-section.tsx index 89853702881f..2d1436317d16 100644 --- a/assets/js/dashboard/nav-menu/segments/searchable-segments-section.tsx +++ b/assets/js/dashboard/nav-menu/segments/searchable-segments-section.tsx @@ -1,16 +1,13 @@ -import React, { useEffect, useState } from 'react' -import { useQueryContext } from '../../query-context' +import React, { RefObject } from 'react' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { - formatSegmentIdAsLabelKey, - getFilterSegmentsByNameInsensitive, - isSegmentFilter, SavedSegmentPublic, SavedSegment, SEGMENT_TYPE_LABELS, - isListableSegment + isListableSegment, + getSearchToSetSegmentFilter } from '../../filtering/segments' -import { cleanLabels } from '../../util/filters' import classNames from 'classnames' import { Tooltip } from '../../util/tooltip' import { SegmentAuthorship } from '../../segments/segment-authorship' @@ -21,128 +18,142 @@ import { AppNavigationLink } from '../../navigation/use-app-navigate' import { MenuSeparator } from '../nav-menu-components' import { Role, useUserContext } from '../../user-context' import { useSegmentsContext } from '../../filtering/segments-context' +import { useSearchableItems } from '../../hooks/use-searchable-items' const linkClassName = classNames( popover.items.classNames.navigationLink, popover.items.classNames.selectedOption, - popover.items.classNames.hoverLink, - popover.items.classNames.groupRoundedStartEnd + popover.items.classNames.hoverLink ) -const initialSliceLength = 5 +const INITIAL_SEGMENTS_SHOWN = 5 export const SearchableSegmentsSection = ({ - closeList + closeList, + tooltipContainerRef }: { closeList: () => void + tooltipContainerRef: RefObject }) => { const site = useSiteContext() const segmentsContext = useSegmentsContext() - const { expandedSegment } = useQueryContext() + const { expandedSegment } = useDashboardStateContext() const user = useUserContext() const isPublicListQuery = !user.loggedIn || user.role === Role.public - const data = segmentsContext.segments.filter((segment) => - isListableSegment({ segment, site, user }) - ) - - const [searchValue, setSearch] = useState() - const [showAll, setShowAll] = useState(false) - - const searching = !searchValue?.trim().length - - useEffect(() => { - setShowAll(false) - }, [searching]) - - const filteredData = data?.filter( - getFilterSegmentsByNameInsensitive(searchValue) - ) - - const showableSlice = showAll - ? filteredData - : filteredData?.slice(0, initialSliceLength) + const { + data, + filteredData, + showableData, + showSearch, + countOfMoreToShow, + handleShowAll, + handleClearSearch, + handleSearchInput, + searchRef, + searching + } = useSearchableItems({ + data: segmentsContext.segments.filter((segment) => + isListableSegment({ segment, site, user }) + ), + maxItemsInitially: INITIAL_SEGMENTS_SHOWN, + itemMatchesSearchValue: (segment, trimmedSearch) => + segment.name.toLowerCase().includes(trimmedSearch.toLowerCase()) + }) if (expandedSegment) { return null } + if (!data.length) { + return null + } + return ( <> - {!!data?.length && ( - <> - -
-
- Segments -
- {data.length > initialSliceLength && ( - - )} -
- - {showableSlice!.map((segment) => { - return ( - -
{segment.name}
-
- {SEGMENT_TYPE_LABELS[segment.type]} -
- - + +
+
+ Segments +
+ {showSearch && ( + + )} +
+ +
+ {showableData.map((segment) => { + return ( + +
{segment.name}
+
+ {SEGMENT_TYPE_LABELS[segment.type]}
- } - > - -
- ) - })} - {!!filteredData?.length && - !!showableSlice?.length && - filteredData?.length > showableSlice?.length && - showAll === false && ( - - s} - onClick={() => setShowAll(true)} - > - {`Show ${filteredData.length - showableSlice.length} more`} - - - + + +
+ } + > + +
+ ) + })} + {countOfMoreToShow > 0 && ( + + + + )} +
+ {searching && !filteredData.length && ( + + )} @@ -156,26 +167,12 @@ const SegmentLink = ({ }: Pick & { closeList: () => void }) => { - const { query } = useQueryContext() - return ( { - const otherFilters = query.filters.filter((f) => !isSegmentFilter(f)) - - const updatedFilters = [['is', 'segment', [id]], ...otherFilters] - - return { - ...search, - filters: updatedFilters, - labels: cleanLabels(updatedFilters, query.labels, 'segment', { - [formatSegmentIdAsLabelKey(id)]: name - }) - } - }} + search={getSearchToSetSegmentFilter({ id, name })} >
{name}
diff --git a/assets/js/dashboard/nav-menu/segments/segment-menu.tsx b/assets/js/dashboard/nav-menu/segments/segment-menu.tsx index b52d9eb51542..9ceed0b91108 100644 --- a/assets/js/dashboard/nav-menu/segments/segment-menu.tsx +++ b/assets/js/dashboard/nav-menu/segments/segment-menu.tsx @@ -12,16 +12,15 @@ import { XMarkIcon } from '@heroicons/react/24/outline' import { ChevronDownIcon } from '@heroicons/react/20/solid' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useRoutelessModalsContext } from '../../navigation/routeless-modals-context' import { SavedSegment } from '../../filtering/segments' -import { DashboardQuery } from '../../query' +import { DashboardState } from '../../dashboard-state' const linkClassName = classNames( popover.items.classNames.navigationLink, popover.items.classNames.selectedOption, - popover.items.classNames.hoverLink, - popover.items.classNames.roundedStartEnd + popover.items.classNames.hoverLink ) const buttonClassName = classNames( 'text-white font-medium bg-indigo-600 hover:bg-indigo-700' @@ -29,15 +28,15 @@ const buttonClassName = classNames( export const useClearExpandedSegmentModeOnFilterClear = ({ expandedSegment, - query + dashboardState }: { expandedSegment: SavedSegment | null - query: DashboardQuery + dashboardState: DashboardState }) => { const navigate = useAppNavigate() useEffect(() => { // clear edit mode on clearing all filters or removing last filter - if (!!expandedSegment && !query.filters.length) { + if (!!expandedSegment && !dashboardState.filters.length) { navigate({ search: (s) => s, state: { @@ -46,19 +45,19 @@ export const useClearExpandedSegmentModeOnFilterClear = ({ replace: true }) } - }, [query.filters, expandedSegment, navigate]) + }, [dashboardState.filters, expandedSegment, navigate]) } export const SegmentMenu = () => { const { setModal } = useRoutelessModalsContext() - const { expandedSegment } = useQueryContext() + const { expandedSegment } = useDashboardStateContext() if (!expandedSegment) { return null } return ( -
+
{ )} >
+
{children} @@ -44,18 +43,12 @@ function TopBarStickyWrapper({ children }: { children: ReactNode }) { } function TopBarInner({ showCurrentVisitors }: TopBarProps) { - const site = useSiteContext() - const user = useUserContext() const leftActionsRef = useRef(null) return (
-
- +
+ {showCurrentVisitors && ( )} @@ -77,7 +70,7 @@ function TopBarInner({ showCurrentVisitors }: TopBarProps) {
- +
) diff --git a/assets/js/dashboard/navigation/use-app-navigate.tsx b/assets/js/dashboard/navigation/use-app-navigate.tsx index fa2fe9257fe7..1569dbedcf67 100644 --- a/assets/js/dashboard/navigation/use-app-navigate.tsx +++ b/assets/js/dashboard/navigation/use-app-navigate.tsx @@ -9,6 +9,8 @@ import { LinkProps } from 'react-router-dom' import { parseSearch, stringifySearch } from '../util/url-search-params' +import { useSegmentsContext } from '../filtering/segments-context' +import { getSearchToSetSegmentFilter } from '../filtering/segments' export type AppNavigationTarget = { /** @@ -24,7 +26,7 @@ export type AppNavigationTarget = { * - `(s) => s` preserves current search value, * - `(s) => ({ ...s, calendar: !s.calendar })` toggles the value for calendar search parameter, * - `() => ({ page: 5 })` sets the search to `?page=5`, - * - `undefined` empties the search + * - `undefined` empties the search, except for the enforced segment */ search?: (search: Record) => Record } @@ -44,11 +46,24 @@ const getNavigateToOptions = ( export const useGetNavigateOptions = () => { const location = useLocation() + const { limitedToSegment } = useSegmentsContext() + const getToOptions = useCallback( ({ path, params, search }: AppNavigationTarget) => { - return getNavigateToOptions(location.search, { path, params, search }) + const wrappedSearch: typeof search = (searchRecord) => { + const updatedSearchRecord = + typeof search === 'function' ? search(searchRecord) : searchRecord + return limitedToSegment + ? getSearchToSetSegmentFilter(limitedToSegment)(updatedSearchRecord) + : updatedSearchRecord + } + return getNavigateToOptions(location.search, { + path, + params, + search: wrappedSearch + }) }, - [location.search] + [location.search, limitedToSegment] ) return getToOptions } diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx index eff35b3e73db..df0d7078510a 100644 --- a/assets/js/dashboard/router.tsx +++ b/assets/js/dashboard/router.tsx @@ -23,7 +23,7 @@ import ScreenSizesModal from './stats/modals/devices/screen-sizes' import PropsModal from './stats/modals/props' import ConversionsModal from './stats/modals/conversions' import FilterModal from './stats/modals/filter-modal' -import QueryContextProvider from './query-context' +import DashboardStateContextProvider from './dashboard-state-context' import { DashboardKeybinds } from './dashboard-keybinds' import LastLoadContextProvider from './last-load-context' import { RoutelessModalsContextProvider } from './navigation/routeless-modals-context' @@ -41,14 +41,14 @@ function DashboardElement() { return ( - + {/** render any children of the root route below */} - + ) diff --git a/assets/js/dashboard/segments/routeless-segment-modals.tsx b/assets/js/dashboard/segments/routeless-segment-modals.tsx index 6053bc969223..3f8f2dc9a4ba 100644 --- a/assets/js/dashboard/segments/routeless-segment-modals.tsx +++ b/assets/js/dashboard/segments/routeless-segment-modals.tsx @@ -5,7 +5,7 @@ import { UpdateSegmentModal } from './segment-modals' import { - getSearchToApplySingleSegmentFilter, + getSearchToSetSegmentFilter, getSegmentNamePlaceholder, handleSegmentResponse, SavedSegment, @@ -16,8 +16,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useSiteContext } from '../site-context' import { cleanLabels, remapToApiFilters } from '../util/filters' import { useAppNavigate } from '../navigation/use-app-navigate' -import { useQueryContext } from '../query-context' -import { Role, useUserContext } from '../user-context' +import { useDashboardStateContext } from '../dashboard-state-context' +import { useUserContext } from '../user-context' import { mutation } from '../api' import { useRoutelessModalsContext } from '../navigation/routeless-modals-context' import { useSegmentsContext } from '../filtering/segments-context' @@ -30,7 +30,7 @@ export const RoutelessSegmentModals = () => { const queryClient = useQueryClient() const site = useSiteContext() const { modal, setModal } = useRoutelessModalsContext() - const { query, expandedSegment } = useQueryContext() + const { dashboardState, expandedSegment } = useDashboardStateContext() const user = useUserContext() const patchSegment = useMutation({ @@ -67,7 +67,9 @@ export const RoutelessSegmentModals = () => { updateOne(segment) queryClient.invalidateQueries({ queryKey: ['segments'] }) navigate({ - search: getSearchToApplySingleSegmentFilter(segment), + search: getSearchToSetSegmentFilter(segment, { + omitAllOtherFilters: true + }), state: { expandedSegment: null } @@ -104,7 +106,9 @@ export const RoutelessSegmentModals = () => { addOne(segment) queryClient.invalidateQueries({ queryKey: ['segments'] }) navigate({ - search: getSearchToApplySingleSegmentFilter(segment), + search: getSearchToSetSegmentFilter(segment, { + omitAllOtherFilters: true + }), state: { expandedSegment: null } @@ -147,21 +151,14 @@ export const RoutelessSegmentModals = () => { return null } - const userCanSelectSiteSegment = [ - Role.admin, - Role.owner, - Role.editor, - 'super_admin' - ].includes(user.role) - return ( <> {modal === 'update' && expandedSegment && ( { setModal(null) patchSegment.reset() @@ -172,8 +169,8 @@ export const RoutelessSegmentModals = () => { name, type, segment_data: { - filters: query.filters, - labels: query.labels + filters: dashboardState.filters, + labels: dashboardState.labels } }) } @@ -184,9 +181,9 @@ export const RoutelessSegmentModals = () => { )} {modal === 'create' && ( { setModal(null) @@ -197,8 +194,8 @@ export const RoutelessSegmentModals = () => { name, type, segment_data: { - filters: query.filters, - labels: query.labels + filters: dashboardState.filters, + labels: dashboardState.labels } }) } diff --git a/assets/js/dashboard/segments/segment-authorship.tsx b/assets/js/dashboard/segments/segment-authorship.tsx index b3b561022a71..1a701e610963 100644 --- a/assets/js/dashboard/segments/segment-authorship.tsx +++ b/assets/js/dashboard/segments/segment-authorship.tsx @@ -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, diff --git a/assets/js/dashboard/segments/segment-modals.test.tsx b/assets/js/dashboard/segments/segment-modals.test.tsx index 3adc096d314b..a22160928ee2 100644 --- a/assets/js/dashboard/segments/segment-modals.test.tsx +++ b/assets/js/dashboard/segments/segment-modals.test.tsx @@ -4,6 +4,7 @@ import { SegmentModal } from './segment-modals' import { TestContextProviders } from '../../../test-utils/app-context-providers' import { SavedSegment, + SavedSegmentPublic, SavedSegments, SegmentData, SegmentType @@ -18,7 +19,7 @@ beforeEach(() => { }) describe('Segment details modal - errors', () => { - const anySiteSegment: SavedSegment & { segment_data: SegmentData } = { + const anySegment: SavedSegment & { segment_data: SegmentData } = { id: 1, type: SegmentType.site, owner_id: 1, @@ -33,7 +34,7 @@ describe('Segment details modal - errors', () => { } const anyPersonalSegment: SavedSegment & { segment_data: SegmentData } = { - ...anySiteSegment, + ...anySegment, id: 2, type: SegmentType.personal } @@ -48,35 +49,16 @@ describe('Segment details modal - errors', () => { }[] = [ { case: 'segment is not in list', - segments: [anyPersonalSegment, anySiteSegment], + segments: [anyPersonalSegment, anySegment], segmentId: 202020, - user: { loggedIn: true, id: 1, role: Role.owner }, + user: { + loggedIn: true, + id: 1, + role: Role.owner, + team: { identifier: null, hasConsolidatedView: false } + }, 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 }, - 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 }, - 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 }, - message: 'Not enough permissions to see segment details', - siteOptions: { siteSegmentsAvailable: true } } ] it.each(cases)( @@ -100,12 +82,125 @@ describe('Segment details modal - errors', () => { }) describe('Segment details modal - other cases', () => { - it('displays site segment correctly', () => { - const anySiteSegment: SavedSegment & { segment_data: SegmentData } = { + it.each([ + [SegmentType.site, 'Site segment'], + [SegmentType.personal, 'Personal segment'] + ])( + 'displays segment with type %s correctly for logged in user', + (segmentType, expectedSegmentTypeText) => { + const user: UserContextValue = { + loggedIn: true, + role: Role.editor, + id: 1, + team: { identifier: null, hasConsolidatedView: false } + } + const anySegment: SavedSegment & { segment_data: SegmentData } = { + id: 100, + type: segmentType, + owner_id: user.id, + owner_name: 'Jane Smith', + name: 'Blog or About', + segment_data: { + filters: [['is', 'page', ['/blog', '/about']]], + labels: {} + }, + inserted_at: '2025-03-13T13:00:00', + updated_at: '2025-03-13T16:00:00' + } + + render(, { + wrapper: (props) => ( + + ) + }) + expect(screen.getByText(anySegment.name)).toBeVisible() + expect(screen.getByText(expectedSegmentTypeText)).toBeVisible() + + expect(screen.getByText('Filters in segment')).toBeVisible() + expect(screen.getByTitle('Page is /blog or /about')).toBeVisible() + + expect( + screen.getByText(`Last updated at 13 Mar by ${anySegment.owner_name}`) + ).toBeVisible() + expect(screen.getByText(`Created at 13 Mar`)).toBeVisible() + + expect(screen.getByText('Edit segment')).toBeVisible() + expect(screen.getByText('Remove filter')).toBeVisible() + } + ) + + it.each([ + [SegmentType.site, 'Site segment'], + [SegmentType.personal, 'Personal segment'] + ])( + 'displays segment with type %s correctly for public role', + (segmentType, expectedSegmentTypeText) => { + const user: UserContextValue = { + loggedIn: false, + role: Role.public, + id: null, + team: { identifier: null, hasConsolidatedView: false } + } + const anySegment: SavedSegment & { segment_data: SegmentData } = { + id: 100, + type: segmentType, + owner_id: null, + owner_name: null, + name: 'Blog or About', + segment_data: { + filters: [['is', 'page', ['/blog', '/about']]], + labels: {} + }, + inserted_at: '2025-03-13T13:00:00', + updated_at: '2025-03-13T16:00:00' + } + + render(, { + wrapper: (props) => ( + + ) + }) + expect(screen.getByText(anySegment.name)).toBeVisible() + expect(screen.getByText(expectedSegmentTypeText)).toBeVisible() + + expect(screen.getByText('Filters in segment')).toBeVisible() + expect(screen.getByTitle('Page is /blog or /about')).toBeVisible() + + expect(screen.getByText(`Last updated at 13 Mar`)).toBeVisible() + expect(screen.queryByText('by ')).toBeNull() // no segment author is shown to public role + expect(screen.getByText(`Created at 13 Mar`)).toBeVisible() + + expect(screen.getByText('Remove filter')).toBeVisible() + expect(screen.queryByText('Edit segment')).toBeNull() + } + ) + + it('allows elevated roles to expand site segments even if site segments are not available on their plan (to update type to personal segment)', () => { + const user: UserContextValue = { + loggedIn: true, + role: Role.owner, + id: 1, + team: { identifier: null, hasConsolidatedView: false } + } + const anySegment: SavedSegmentPublic & { segment_data: SegmentData } = { id: 100, type: SegmentType.site, - owner_id: 100100, - owner_name: 'Test User', + owner_id: null, + owner_name: null, name: 'Blog or About', segment_data: { filters: [['is', 'page', ['/blog', '/about']]], @@ -115,30 +210,77 @@ describe('Segment details modal - other cases', () => { updated_at: '2025-03-13T16:00:00' } - render(, { + render(, { wrapper: (props) => ( ) }) - expect(screen.getByText(anySiteSegment.name)).toBeVisible() + expect(screen.getByText(anySegment.name)).toBeVisible() expect(screen.getByText('Site segment')).toBeVisible() expect(screen.getByText('Filters in segment')).toBeVisible() expect(screen.getByTitle('Page is /blog or /about')).toBeVisible() expect( - screen.getByText(`Last updated at 13 Mar by ${anySiteSegment.owner_name}`) + screen.getByText(`Last updated at 13 Mar by (Removed User)`) ).toBeVisible() expect(screen.getByText(`Created at 13 Mar`)).toBeVisible() - expect(screen.getByText('Edit segment')).toBeVisible() expect(screen.getByText('Remove filter')).toBeVisible() + expect(screen.getByText('Edit segment')).toBeVisible() + }) + + it('does not display clear filter button if the dashboard is limited to this segment', () => { + const user: UserContextValue = { + loggedIn: false, + role: Role.public, + id: null, + team: { identifier: null, hasConsolidatedView: false } + } + const anySegment: SavedSegmentPublic & { segment_data: SegmentData } = { + id: 100, + type: SegmentType.site, + owner_id: null, + owner_name: null, + name: 'Blog or About', + segment_data: { + filters: [['is', 'page', ['/blog', '/about']]], + labels: {} + }, + inserted_at: '2025-03-13T13:00:00', + updated_at: '2025-03-13T16:00:00' + } + + render(, { + wrapper: (props) => ( + + ) + }) + expect(screen.getByText(anySegment.name)).toBeVisible() + expect(screen.getByText('Site segment')).toBeVisible() + + expect(screen.getByText('Filters in segment')).toBeVisible() + expect(screen.getByTitle('Page is /blog or /about')).toBeVisible() + + expect(screen.getByText(`Last updated at 13 Mar`)).toBeVisible() + expect(screen.getByText(`Created at 13 Mar`)).toBeVisible() + + expect(screen.queryByText('Remove filter')).toBeNull() + expect(screen.queryByText('Edit segment')).toBeNull() }) }) diff --git a/assets/js/dashboard/segments/segment-modals.tsx b/assets/js/dashboard/segments/segment-modals.tsx index 67589a6aa45c..92c18f1b69a2 100644 --- a/assets/js/dashboard/segments/segment-modals.tsx +++ b/assets/js/dashboard/segments/segment-modals.tsx @@ -1,30 +1,31 @@ -import React, { ReactNode, useState } from 'react' +import React, { ReactNode, useCallback, useState } from 'react' import ModalWithRouting from '../stats/modals/modal' import { - canSeeSegmentDetails, - isListableSegment, - isSegmentFilter, + canRemoveFilter, + getSearchToRemoveSegmentFilter, + canExpandSegment, SavedSegment, SEGMENT_TYPE_LABELS, SegmentData, SegmentType } from '../filtering/segments' -import { useQueryContext } from '../query-context' -import { AppNavigationLink } from '../navigation/use-app-navigate' -import { cleanLabels } from '../util/filters' +import { + AppNavigationLink, + useAppNavigate +} from '../navigation/use-app-navigate' import { plainFilterText, styledFilterText } from '../util/filter-text' import { rootRoute } from '../router' import { FilterPillsList } from '../nav-menu/filter-pills-list' import classNames from 'classnames' import { SegmentAuthorship } from './segment-authorship' import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' -import { MutationStatus } from '@tanstack/react-query' -import { ApiError } from '../api' +import { MutationStatus, useQuery } from '@tanstack/react-query' +import { ApiError, get } from '../api' import { ErrorPanel } from '../components/error-panel' import { useSegmentsContext } from '../filtering/segments-context' -import { useSiteContext } from '../site-context' -import { useUserContext } from '../user-context' +import { Role, UserContextValue, useUserContext } from '../user-context' import { removeFilterButtonClassname } from '../components/remove-filter-button' +import { useSiteContext } from '../site-context' interface ApiRequestProps { status: MutationStatus @@ -32,12 +33,9 @@ interface ApiRequestProps { reset: () => void } -interface SegmentTypeSelectorProps { +interface SegmentModalProps { + user: UserContextValue siteSegmentsAvailable: boolean - userCanSelectSiteSegment: boolean -} - -interface SharedSegmentModalProps { onClose: () => void namePlaceholder: string } @@ -45,13 +43,14 @@ interface SharedSegmentModalProps { const primaryNeutralButtonClassName = 'button !px-3' const primaryNegativeButtonClassName = classNames( - 'button !px-3', - 'items-center !bg-red-500 dark:!bg-red-500 hover:!bg-red-600 dark:hover:!bg-red-700 whitespace-nowrap' + 'button !px-3.5', + 'items-center !bg-red-500 dark:!bg-red-500 hover:!bg-red-600 dark:hover:!bg-red-700 whitespace-nowrap', + 'disabled:!bg-red-400 disabled:cursor-not-allowed' ) const secondaryButtonClassName = classNames( - 'button !px-3', - 'border !border-gray-300 dark:!border-gray-500 !text-gray-700 dark:!text-gray-300 !bg-transparent hover:!bg-gray-100 dark:hover:!bg-gray-850' + 'button !px-3.5', + 'border !border-gray-300 dark:!border-gray-700 !bg-white dark:!bg-gray-700 !text-gray-800 dark:!text-gray-100 hover:!text-gray-900 hover:!shadow-sm dark:hover:!bg-gray-600 dark:hover:!text-white' ) const SegmentActionModal = ({ @@ -77,14 +76,13 @@ export const CreateSegmentModal = ({ onClose, onSave, siteSegmentsAvailable: siteSegmentsAvailable, - userCanSelectSiteSegment, + user, namePlaceholder, error, reset, status -}: SharedSegmentModalProps & - ApiRequestProps & - SegmentTypeSelectorProps & { +}: SegmentModalProps & + ApiRequestProps & { segment?: SavedSegment onSave: (input: Pick) => void }) => { @@ -95,43 +93,40 @@ export const CreateSegmentModal = ({ const defaultType = segment?.type === SegmentType.site && siteSegmentsAvailable && - userCanSelectSiteSegment + hasSiteSegmentPermission(user) ? SegmentType.site : SegmentType.personal const [type, setType] = useState(defaultType) + const { disabled, disabledMessage, onSegmentTypeChange } = + useSegmentTypeDisabledState({ + siteSegmentsAvailable, + user, + setType + }) + return ( - Create segment + Create segment - + + {disabled && } - + { + const trimmedName = name.trim() + const saveableName = trimmedName.length + ? trimmedName + : namePlaceholder + onSave({ name: saveableName, type }) + }} + /> @@ -151,6 +146,12 @@ export const CreateSegmentModal = ({ ) } +function getLinksDeleteNotice(links: string[]) { + return links.length === 1 + ? 'This segment is used in a shared link. To delete it, you also need to delete the shared link.' + : `This segment is used in ${links.length} shared links. To delete it, you also need to delete the shared links.` +} + export const DeleteSegmentModal = ({ onClose, onSave, @@ -163,22 +164,77 @@ export const DeleteSegmentModal = ({ onSave: (input: Pick) => void segment: SavedSegment & { segment_data?: SegmentData } } & ApiRequestProps) => { + const site = useSiteContext() + const [confirmed, setConfirmed] = useState(false) + + const linksQuery = useQuery({ + queryKey: [segment.id], + queryFn: async () => { + const response: string[] = await get( + `/api/${encodeURIComponent(site.domain)}/segments/${segment.id}/shared-links` + ) + return response + } + }) + + const deleteDisabled = + status === 'pending' || + linksQuery.status !== 'success' || + (!!linksQuery.data?.length && !confirmed) + return ( - + Delete {SEGMENT_TYPE_LABELS[segment.type].toLowerCase()} {` "${segment.name}"?`} + {linksQuery.status === 'pending' && ( +
+
+
+ )} + {linksQuery.status === 'success' && !!linksQuery.data?.length && ( + + {getLinksDeleteNotice(linksQuery.data)} + + } + /> + )} + {linksQuery.status === 'error' && ( + + )} {!!segment.segment_data && ( - +
+ +
+ )} + {!!linksQuery.data?.length && ( + <> +
+ +
+
+ setConfirmed(e.currentTarget.checked)} + > + Yes, delete the associated shared links + +
+ )} - + ) +} + +const SegmentTypeDisabledMessage = ({ + message +}: { + message: ReactNode | null +}) => { + if (!message) return null + + return ( +
+ +
{message}
+
+ ) +} + export const UpdateSegmentModal = ({ onClose, onSave, segment, siteSegmentsAvailable, - userCanSelectSiteSegment, + user, namePlaceholder, status, error, reset -}: SharedSegmentModalProps & - ApiRequestProps & - SegmentTypeSelectorProps & { +}: SegmentModalProps & + ApiRequestProps & { onSave: (input: Pick) => void segment: SavedSegment }) => { const [name, setName] = useState(segment.name) const [type, setType] = useState(segment.type) + const { disabled, disabledMessage, onSegmentTypeChange } = + useSegmentTypeDisabledState({ + siteSegmentsAvailable, + user, + setType + }) + return ( - Update segment + Update segment - + + {disabled && } - + { + const trimmedName = name.trim() + const saveableName = trimmedName.length + ? trimmedName + : namePlaceholder + onSave({ id: segment.id, name: saveableName, type }) + }} + /> @@ -395,7 +548,7 @@ export const UpdateSegmentModal = ({ const FiltersInSegment = ({ segment_data }: { segment_data: SegmentData }) => { return ( <> -

Filters in segment

+ Filters in segment
{ ) } +const SecondaryTitle = ({ children }: { children: ReactNode }) => ( +

{children}

+) + +/** Keep this component styled the same as checkboxes in PlausibleWeb.Live.Installation.Instructions */ +const Checkbox = ({ + id, + checked, + onChange, + children +}: React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +>) => { + return ( + + ) +} + const Placeholder = ({ children, placeholder @@ -430,15 +614,18 @@ const Placeholder = ({ ) +const hasSiteSegmentPermission = (user: UserContextValue) => { + return [Role.admin, Role.owner, Role.editor, 'super_admin'].includes( + user.role + ) +} + export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => { - const site = useSiteContext() const user = useUserContext() - const { query } = useQueryContext() - const { segments } = useSegmentsContext() + const { segments, limitedToSegment } = useSegmentsContext() + const navigate = useAppNavigate() - 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 @@ -446,14 +633,15 @@ 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 + const showClearButton = canRemoveFilter( + ['is', 'segment', [id]], + limitedToSegment + ) + return (
@@ -470,54 +658,48 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => { {data?.segment_data ? SEGMENT_TYPE_LABELS[data.type] : false}
-
+
{!!data?.segment_data && ( <>
- ({ - ...s, - filters: data.segment_data.filters, - labels: data.segment_data.labels - })} - state={{ - expandedSegment: data - }} - > - Edit segment - - - { - const nonSegmentFilters = query.filters.filter( - (f) => !isSegmentFilter(f) - ) - return { + {canExpandSegment({ segment: data, user }) && ( + ({ ...s, - filters: nonSegmentFilters, - labels: cleanLabels( - nonSegmentFilters, - query.labels, - 'segment', - {} - ) + filters: data.segment_data.filters, + labels: data.segment_data.labels + })} + state={{ + expandedSegment: data + }} + > + Edit segment + + )} + + {showClearButton && ( + + )}
diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index 8b5f16c3b0e6..183ec47b741e 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -67,7 +67,8 @@ describe('parseSiteFromDataset', () => { realtime: ['minute'], year: ['day', 'week', 'month'] }, - shared: false + shared: false, + isConsolidatedView: false } it('parses from dom string map correctly', () => { diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index 90a13f94a67f..ac6213b21c16 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -21,14 +21,15 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { isDbip: dataset.isDbip === 'true', flags: JSON.parse(dataset.flags!), validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!), - shared: !!dataset.sharedLinkAuth + shared: !!dataset.sharedLinkAuth, + isConsolidatedView: dataset.isConsolidatedView === 'true' } } // Update this object when new feature flags are added to the frontend. type FeatureFlags = Record -const siteContextDefaultValue = { +export const siteContextDefaultValue = { domain: '', /** offset in seconds from UTC at site load time, @example 7200 */ offset: 0, @@ -51,7 +52,8 @@ const siteContextDefaultValue = { isDbip: false, flags: {} as FeatureFlags, validIntervalsByPeriod: {} as Record>, - shared: false + shared: false, + isConsolidatedView: false } export type PlausibleSite = typeof siteContextDefaultValue diff --git a/assets/js/dashboard/site-switcher.js b/assets/js/dashboard/site-switcher.js deleted file mode 100644 index 2612bc23d712..000000000000 --- a/assets/js/dashboard/site-switcher.js +++ /dev/null @@ -1,283 +0,0 @@ -/** - * @prettier - */ -import React from 'react' -import { Transition } from '@headlessui/react' -import { Cog8ToothIcon, ChevronDownIcon } from '@heroicons/react/20/solid' -import classNames from 'classnames' - -function Favicon({ domain, className }) { - return ( - { - e.target.onerror = null - e.target.src = '/favicon/sources/placeholder' - }} - referrerPolicy="no-referrer" - className={className} - /> - ) -} - -export default class SiteSwitcher extends React.Component { - constructor() { - super() - this.handleClick = this.handleClick.bind(this) - this.handleKeydown = this.handleKeydown.bind(this) - this.populateSites = this.populateSites.bind(this) - this.toggle = this.toggle.bind(this) - this.siteSwitcherButton = React.createRef() - this.state = { - open: false, - sites: null, - error: null, - loading: true - } - } - - componentDidMount() { - this.populateSites() - this.siteSwitcherButton.current.addEventListener('click', this.toggle) - document.addEventListener('keydown', this.handleKeydown) - document.addEventListener('click', this.handleClick, false) - } - - componentWillUnmount() { - this.siteSwitcherButton.current.removeEventListener('click', this.toggle) - document.removeEventListener('keydown', this.handleKeydown) - document.removeEventListener('click', this.handleClick, false) - } - - populateSites() { - if (!this.props.loggedIn) return - - fetch('/api/sites') - .then((response) => { - if (!response.ok) { - throw response - } - return response.json() - }) - .then((sites) => - this.setState({ - loading: false, - sites: sites.data.map((s) => s.domain) - }) - ) - .catch((e) => this.setState({ loading: false, error: e })) - } - - handleClick(e) { - // If this is an interaction with the dropdown menu itself, do nothing. - if (this.dropDownNode && this.dropDownNode.contains(e.target)) return - - // If the dropdown is not open, do nothing. - if (!this.state.open) return - - // In any other case, close it. - this.setState({ open: false }) - } - - handleKeydown(e) { - if (!this.props.loggedIn) return - - const { site } = this.props - const { sites } = this.state - - if (e.target.tagName === 'INPUT') return true - if ( - e.ctrlKey || - e.metaKey || - e.altKey || - e.isComposing || - e.keyCode === 229 || - !sites - ) - return - - const siteNum = parseInt(e.key) - - if ( - 1 <= siteNum && - siteNum <= 9 && - siteNum <= sites.length && - sites[siteNum - 1] !== site.domain - ) { - // has to change window.location because Router is rendered with /${site.domain} as the basepath - window.location = `/${encodeURIComponent(sites[siteNum - 1])}` - } - } - - toggle(e) { - /** - * React doesn't seem to prioritise its own events when events are bubbling, and is unable to stop its events from propagating to the document's (root) event listeners which are attached on the DOM. - * - * A simple trick is to hook up our own click event listener via a ref node, which allows React to manage events in this situation better between the two. - */ - e.stopPropagation() - e.preventDefault() - if (!this.props.loggedIn) return - - this.setState((prevState) => ({ - open: !prevState.open - })) - - if (this.props.loggedIn && !this.state.sites) { - this.populateSites() - } - } - - renderSiteLink(domain, index) { - const extraClass = - domain === this.props.site.domain - ? 'font-medium text-gray-900 dark:text-gray-100 cursor-default font-bold' - : 'hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100' - const showHotkey = this.props.loggedIn && this.state.sites.length > 1 - return ( -
- - - - {domain} - - - {showHotkey && index < 9 ? {index + 1} : null} - - ) - } - - renderSettingsLink() { - if ( - ['owner', 'admin', 'editor', 'super_admin'].includes( - this.props.currentUserRole - ) - ) { - return ( - - -
-
- ) - } - } - - /** - * Render a dropdown regardless of whether the user is logged in or not. In case they are not logged in (such as in an embed), the dropdown merely contains the current domain name. - */ - renderDropdown() { - if (this.state.loading) { - return ( -
-
-
-
-
- ) - } else if (this.state.error) { - return ( -
- Something went wrong, try again -
- ) - } else if (!this.props.loggedIn) { - return ( - -
- {[this.props.site.domain].map(this.renderSiteLink.bind(this))} -
-
- ) - } else { - return ( - - {this.renderSettingsLink()} -
- {this.state.sites.map(this.renderSiteLink.bind(this))} -
-
- - View All - -
- ) - } - } - - render() { - const hoverClass = this.props.loggedIn - ? 'hover:text-gray-500 dark:hover:text-gray-200 focus:border-blue-300 focus:ring ' - : 'cursor-default' - - return ( -
- - - -
(this.dropDownNode = node)} - > -
- {this.renderDropdown()} -
-
-
-
- ) - } -} diff --git a/assets/js/dashboard/site-switcher.tsx b/assets/js/dashboard/site-switcher.tsx new file mode 100644 index 000000000000..22a9d667cdf4 --- /dev/null +++ b/assets/js/dashboard/site-switcher.tsx @@ -0,0 +1,283 @@ +/** + * @prettier + */ +import React, { useRef } from 'react' +import { Popover, Transition } from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { Cog8ToothIcon, ArrowLeftIcon } from '@heroicons/react/24/outline' +import classNames from 'classnames' +import { isModifierPressed, isTyping, Keybind, KeybindHint } from './keybinding' +import { popover, BlurMenuButtonOnEscape } from './components/popover' +import { useQuery } from '@tanstack/react-query' +import { Role, useUserContext } from './user-context' +import { PlausibleSite, useSiteContext } from './site-context' +import { MenuSeparator } from './nav-menu/nav-menu-components' +import { useMatch } from 'react-router-dom' +import { rootRoute } from './router' +import { get } from './api' +import { ErrorPanel } from './components/error-panel' +import { useRoutelessModalsContext } from './navigation/routeless-modals-context' + +const Favicon = ({ + domain, + className +}: { + domain: string + className?: string +}) => ( + { + const target = e.target as HTMLImageElement + target.onerror = null + target.src = '/favicon/sources/placeholder' + }} + referrerPolicy="no-referrer" + className={className} + /> +) + +const GlobeIcon = ({ className }: { className?: string }) => ( + + + + +) + +const menuItemClassName = classNames( + popover.items.classNames.navigationLink, + popover.items.classNames.selectedOption, + popover.items.classNames.hoverLink +) + +const buttonLinkClassName = classNames( + 'flex-1 flex items-center justify-center', + 'my-1 mx-1', + 'border border-gray-300 dark:border-gray-700', + 'px-3 py-2 text-sm font-medium rounded-md', + 'bg-white text-gray-700 dark:text-gray-300 dark:bg-gray-700', + 'transition-all duration-200', + 'hover:text-gray-900 hover:border-gray-400/70 dark:hover:bg-gray-600 dark:hover:border-gray-600 dark:hover:text-white' +) + +const getSwitchToSiteURL = ( + currentSite: PlausibleSite, + site: { domain: string } +): string | null => { + // Prevents reloading the page when the current site is selected + if (currentSite.domain === site.domain) { + return null + } + return `/${encodeURIComponent(site.domain)}` +} + +export const SiteSwitcher = () => { + const dashboardRouteMatch = useMatch(rootRoute.path) + const { modal } = useRoutelessModalsContext() + const user = useUserContext() + const currentSite = useSiteContext() + const buttonRef = useRef(null) + const sitesQuery = useQuery({ + enabled: user.loggedIn, + queryKey: ['sites'], + queryFn: async (): Promise<{ data: Array<{ domain: string }> }> => { + const response = await get('/api/sites') + return response + }, + placeholderData: (previousData) => previousData + }) + + const sitesInDropdown = user.loggedIn + ? sitesQuery.data?.data + : // show only current site in dropdown when viewing public / embedded dashboard + [{ domain: currentSite.domain }] + + const canSeeSiteSettings: boolean = + user.loggedIn && + [Role.owner, Role.admin, Role.editor, 'super_admin'].includes(user.role) + + const canSeeViewAllSites: boolean = user.loggedIn + + return ( + + {({ close: closePopover }) => ( + <> + {!!dashboardRouteMatch && + !modal && + sitesQuery.data?.data.slice(0, 8).map(({ domain }, index) => ( + { + const url = getSwitchToSiteURL(currentSite, { domain }) + if (!url) { + closePopover() + } else { + closePopover() + window.location.assign(url) + } + }} + shouldIgnoreWhen={[isModifierPressed, isTyping]} + targetRef="document" + /> + ))} + + {!!dashboardRouteMatch && + !modal && + user.team?.hasConsolidatedView && + user.team.identifier && ( + { + const url = getSwitchToSiteURL(currentSite, { + domain: user.team.identifier! + }) + if (!url) { + closePopover() + } else { + closePopover() + window.location.assign(url) + } + }} + shouldIgnoreWhen={[isModifierPressed, isTyping]} + targetRef="document" + /> + )} + + + + {currentSite.isConsolidatedView ? ( + + ) : ( + + )} + + + + + +
+ {canSeeViewAllSites && ( + + + Back to sites + + )} + {canSeeSiteSettings && ( + + + Site settings + + )} +
+ {(canSeeSiteSettings || canSeeViewAllSites) && } + {sitesQuery.isLoading && ( +
+
+
+
+
+ )} + {sitesQuery.isError && ( +
+ +
+ )} + {user.team.hasConsolidatedView && user.team.identifier && ( + closePopover()} + > + + All sites + 0 + + )} + {!!sitesInDropdown && + sitesInDropdown.map(({ domain }, index) => ( + closePopover() + : () => {} + } + > + + {domain} + {sitesInDropdown.length > 1 && ( + {index + 1} + )} + + ))} + + + + )} + + ) +} diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts new file mode 100644 index 000000000000..1378bb8933f8 --- /dev/null +++ b/assets/js/dashboard/stats-query.ts @@ -0,0 +1,82 @@ +import { Metric } from '../types/query-api' +import { DashboardState, Filter } from './dashboard-state' +import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' +import { formatISO } from './util/date' +import { remapToApiFilters } from './util/filters' + +type DateRange = DashboardPeriod | [string, string] +type IncludeCompare = + | ComparisonMode.previous_period + | ComparisonMode.year_over_year + | string[] + | null + +type QueryInclude = { + imports: boolean + imports_meta: boolean + time_labels: boolean + compare: IncludeCompare + compare_match_day_of_week: boolean +} + +export type ReportParams = { + metrics: Metric[] + dimensions?: string[] + include?: Partial +} + +export type StatsQuery = { + date_range: DateRange + relative_date: string | null + filters: Filter[] + dimensions: string[] + metrics: Metric[] + include: QueryInclude +} + +export function createStatsQuery( + dashboardState: DashboardState, + reportParams: ReportParams +): StatsQuery { + return { + date_range: createDateRange(dashboardState), + relative_date: dashboardState.date ? formatISO(dashboardState.date) : null, + dimensions: reportParams.dimensions || [], + metrics: reportParams.metrics, + filters: remapToApiFilters(dashboardState.filters), + include: { + imports: dashboardState.with_imported, + imports_meta: reportParams.include?.imports_meta || false, + time_labels: reportParams.include?.time_labels || false, + compare: createIncludeCompare(dashboardState), + compare_match_day_of_week: dashboardState.match_day_of_week + } + } +} + +function createDateRange(dashboardState: DashboardState): DateRange { + if (dashboardState.period === DashboardPeriod.custom) { + return [formatISO(dashboardState.from), formatISO(dashboardState.to)] + } else { + return dashboardState.period + } +} + +function createIncludeCompare(dashboardState: DashboardState) { + switch (dashboardState.comparison) { + case ComparisonMode.custom: + return [ + formatISO(dashboardState.compare_from), + formatISO(dashboardState.compare_to) + ] + + case ComparisonMode.previous_period: + return ComparisonMode.previous_period + + case ComparisonMode.year_over_year: + return ComparisonMode.year_over_year + + default: + return null + } +} diff --git a/assets/js/dashboard/stats/bar.js b/assets/js/dashboard/stats/bar.js index b6848a0ba312..e46c924c3548 100644 --- a/assets/js/dashboard/stats/bar.js +++ b/assets/js/dashboard/stats/bar.js @@ -26,7 +26,7 @@ export default function Bar({ return (
{children} diff --git a/assets/js/dashboard/stats/behaviours/conversions.js b/assets/js/dashboard/stats/behaviours/conversions.js index 37c00517ae90..aa5ff34dc11a 100644 --- a/assets/js/dashboard/stats/behaviours/conversions.js +++ b/assets/js/dashboard/stats/behaviours/conversions.js @@ -5,15 +5,16 @@ import * as url from '../../util/url' import * as metrics from '../reports/metrics' import ListReport from '../reports/list' import { useSiteContext } from '../../site-context' -import { useQueryContext } from '../../query-context' -import { conversionsRoute } from '../../router' +import { useDashboardStateContext } from '../../dashboard-state-context' export default function Conversions({ afterFetchData, onGoalFilterClick }) { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() function fetchConversions() { - return api.get(url.apiPath(site, '/conversions'), query, { limit: 9 }) + return api.get(url.apiPath(site, '/conversions'), dashboardState, { + limit: 9 + }) } function getFilterInfo(listItem) { @@ -26,11 +27,11 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) { function chooseMetrics() { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Uniques', + renderLabel: (_dashboardState) => 'Uniques', meta: { plot: true } }), metrics.createEvents({ - renderLabel: (_query) => 'Total', + renderLabel: (_dashboardState) => 'Total', meta: { hiddenOnMobile: true } }), metrics.createConversionRate(), @@ -50,11 +51,7 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) { keyLabel="Goal" onClick={onGoalFilterClick} metrics={chooseMetrics()} - detailsLinkProps={{ - path: conversionsRoute.path, - search: (search) => search - }} - color="bg-red-50" + color="bg-red-50 group-hover/row:bg-red-100" colMinWidth={90} /> ) diff --git a/assets/js/dashboard/stats/behaviours/goal-conversions.js b/assets/js/dashboard/stats/behaviours/goal-conversions.js deleted file mode 100644 index d2dda5cc926f..000000000000 --- a/assets/js/dashboard/stats/behaviours/goal-conversions.js +++ /dev/null @@ -1,120 +0,0 @@ -import React from 'react' -import Conversions from './conversions' -import ListReport from '../reports/list' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import * as api from '../../api' -import { - EVENT_PROPS_PREFIX, - getGoalFilter, - FILTER_OPERATIONS -} from '../../util/filters' -import { useSiteContext } from '../../site-context' -import { useQueryContext } from '../../query-context' -import { customPropsRoute } from '../../router' - -export const SPECIAL_GOALS = { - 404: { title: '404 Pages', prop: 'path' }, - 'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' }, - 'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' }, - 'File Download': { title: 'File Downloads', prop: 'url' }, - 'WP Search Queries': { - title: 'WordPress Search Queries', - prop: 'search_query' - }, - 'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' } -} - -function getSpecialGoal(query) { - const goalFilter = getGoalFilter(query) - if (!goalFilter) { - return null - } - const [operation, _filterKey, clauses] = goalFilter - if (operation === FILTER_OPERATIONS.is && clauses.length == 1) { - return SPECIAL_GOALS[clauses[0]] || null - } - return null -} - -export function specialTitleWhenGoalFilter(query, defaultTitle) { - return getSpecialGoal(query)?.title || defaultTitle -} - -function SpecialPropBreakdown({ prop, afterFetchData }) { - const site = useSiteContext() - const { query } = useQueryContext() - - function fetchData() { - return api.get(url.apiPath(site, `/custom-prop-values/${prop}`), query) - } - - function getExternalLinkUrlFactory() { - if (prop === 'path') { - return (listItem) => url.externalLinkForPage(site.domain, listItem.name) - } else { - return (listItem) => listItem.name - } - } - - function getFilterInfo(listItem) { - return { - prefix: EVENT_PROPS_PREFIX, - filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ - renderLabel: (_query) => 'Visitors', - meta: { plot: true } - }), - metrics.createEvents({ - renderLabel: (_query) => 'Events', - meta: { hiddenOnMobile: true } - }), - metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - search - }} - getExternalLinkUrl={getExternalLinkUrlFactory()} - maybeHideDetails={true} - color="bg-red-50" - colMinWidth={90} - /> - ) -} - -export default function GoalConversions({ afterFetchData, onGoalFilterClick }) { - const { query } = useQueryContext() - - const specialGoal = getSpecialGoal(query) - if (specialGoal) { - return ( - - ) - } else { - return ( - - ) - } -} diff --git a/assets/js/dashboard/stats/behaviours/index.js b/assets/js/dashboard/stats/behaviours/index.js index 43d8b8c74dc3..0ca81e0b1f70 100644 --- a/assets/js/dashboard/stats/behaviours/index.js +++ b/assets/js/dashboard/stats/behaviours/index.js @@ -1,26 +1,34 @@ -import React, { - Fragment, - useState, - useEffect, - useCallback, - useRef -} from 'react' -import { Menu, Transition } from '@headlessui/react' -import { ChevronDownIcon } from '@heroicons/react/20/solid' -import classNames from 'classnames' +import React, { useState, useEffect, useCallback } from 'react' import * as storage from '../../util/storage' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' -import GoalConversions, { - specialTitleWhenGoalFilter, - SPECIAL_GOALS -} from './goal-conversions' import Properties from './props' import { FeatureSetupNotice } from '../../components/notice' -import { hasConversionGoalFilter } from '../../util/filters' +import { + hasConversionGoalFilter, + getGoalFilter, + FILTER_OPERATIONS +} from '../../util/filters' import { useSiteContext } from '../../site-context' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useUserContext } from '../../user-context' -import { BlurMenuButtonOnEscape } from '../../keybinding' +import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' +import { Pill } from '../../components/pill' +import * as api from '../../api' +import * as url from '../../util/url' +import { conversionsRoute, customPropsRoute } from '../../router' +import { + Mode, + getFirstPreferenceFromEnabledModes, + ModesContextProvider, + useModesContext +} from './modes-context' +import { SpecialGoalPropBreakdown } from './special-goal-prop-breakdown' +import Conversions from './conversions' +import { getSpecialGoal, isPageViewGoal, isSpecialGoal } from '../../util/goals' /*global BUILD_EXTRA*/ /*global require*/ @@ -35,220 +43,237 @@ function maybeRequire() { const Funnel = maybeRequire().default -const ACTIVE_CLASS = - 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left' -const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left' +function singleGoalFilterApplied(dashboardState) { + const goalFilter = getGoalFilter(dashboardState) + if (goalFilter) { + const [operation, _filterKey, clauses] = goalFilter + return operation === FILTER_OPERATIONS.is && clauses.length === 1 + } else { + return false + } +} -export const CONVERSIONS = 'conversions' -export const PROPS = 'props' -export const FUNNELS = 'funnels' +const STORAGE_KEYS = { + getForTab: ({ site }) => + storage.getDomainScopedStorageKey('behavioursTab', site.domain), + getForFunnel: ({ site }) => + storage.getDomainScopedStorageKey('behavioursTabFunnel', site.domain), + getForPropKey: ({ site }) => + storage.getDomainScopedStorageKey('prop_key', site.domain), + getForPropKeyForGoal: ({ goalName, site }) => { + return storage.getDomainScopedStorageKey( + `${goalName}__prop_key)`, + site.domain + ) + } +} -export const sectionTitles = { - [CONVERSIONS]: 'Goal Conversions', - [PROPS]: 'Custom Properties', - [FUNNELS]: 'Funnels' +function getPropKeyFromStorage({ site, dashboardState }) { + if (singleGoalFilterApplied(dashboardState)) { + const [_operation, _dimension, [goalName]] = getGoalFilter(dashboardState) + const storedForGoal = storage.getItem( + STORAGE_KEYS.getForPropKeyForGoal({ goalName, site }) + ) + if (storedForGoal) { + return storedForGoal + } + } + + return storage.getItem(STORAGE_KEYS.getForPropKey({ site })) +} + +function storePropKey({ site, propKey, dashboardState }) { + if (singleGoalFilterApplied(dashboardState)) { + const [_operation, _dimension, [goalName]] = getGoalFilter(dashboardState) + storage.setItem( + STORAGE_KEYS.getForPropKeyForGoal({ goalName, site }), + propKey + ) + } else { + storage.setItem(STORAGE_KEYS.getForPropKey({ site }), propKey) + } } -export default function Behaviours({ importedDataInView }) { - const { query } = useQueryContext() +function getDefaultSelectedFunnel({ site }) { + const stored = storage.getItem(STORAGE_KEYS.getForFunnel({ site })) + const storedExists = stored && site.funnels.some((f) => f.name === stored) + + if (storedExists) { + return stored + } else if (site.funnels.length > 0) { + const firstAvailable = site.funnels[0].name + storage.setItem(STORAGE_KEYS.getForFunnel({ site }), firstAvailable) + return firstAvailable + } +} + +function Behaviours({ importedDataInView, setMode, mode }) { + const { dashboardState } = useDashboardStateContext() + const goalFilter = getGoalFilter(dashboardState) + const specialGoal = goalFilter ? getSpecialGoal(goalFilter) : null const site = useSiteContext() const user = useUserContext() - const buttonRef = useRef() + const { enabledModes, disableMode } = useModesContext() const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes( user.role ) - const tabKey = storage.getDomainScopedStorageKey('behavioursTab', site.domain) - const funnelKey = storage.getDomainScopedStorageKey( - 'behavioursTabFunnel', - site.domain - ) - const [enabledModes, setEnabledModes] = useState(getEnabledModes()) - const [mode, setMode] = useState(defaultMode()) const [loading, setLoading] = useState(true) - const [funnelNames, _setFunnelNames] = useState( - site.funnels.map(({ name }) => name) + const [selectedFunnel, setSelectedFunnel] = useState( + getDefaultSelectedFunnel({ site }) + ) + const initialSelectedPropKey = + getPropKeyFromStorage({ site, dashboardState }) || null + const [selectedPropKey, setSelectedPropKey] = useState(initialSelectedPropKey) + const [propertyKeys, setPropertyKeys] = useState( + selectedPropKey !== null ? [selectedPropKey] : [] ) - const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel()) const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false) const [skipImportedReason, setSkipImportedReason] = useState(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) + + const onGoalFilterClick = useCallback( + (e) => { + const goalName = e.target.innerHTML + const isSpecial = isSpecialGoal(goalName) + const isPageview = isPageViewGoal(goalName) + + if ( + !isSpecial && + !isPageview && + enabledModes.includes(Mode.PROPS) && + site.hasProps + ) { + setShowingPropsForGoalFilter(true) + setMode(Mode.PROPS) + } + }, + [enabledModes, setMode, site.hasProps] + ) - const onGoalFilterClick = useCallback((e) => { - const goalName = e.target.innerHTML - const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName) - const isPageviewGoal = goalName.startsWith('Visit ') - + useEffect(() => { + const justRemovedGoalFilter = !hasConversionGoalFilter(dashboardState) if ( - !isSpecialGoal && - !isPageviewGoal && - enabledModes.includes(PROPS) && - site.hasProps + mode === Mode.PROPS && + justRemovedGoalFilter && + showingPropsForGoalFilter ) { - setShowingPropsForGoalFilter(true) - setMode(PROPS) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useEffect(() => { - const justRemovedGoalFilter = !hasConversionGoalFilter(query) - if (mode === PROPS && justRemovedGoalFilter && showingPropsForGoalFilter) { setShowingPropsForGoalFilter(false) - setMode(CONVERSIONS) + setMode(Mode.CONVERSIONS) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasConversionGoalFilter(query)]) + }, [hasConversionGoalFilter(dashboardState)]) + useEffect(() => setLoading(true), [dashboardState, mode]) useEffect(() => { - setMode(defaultMode()) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabledModes]) - - useEffect(() => setLoading(true), [query, mode]) - - function disableMode(mode) { - setEnabledModes( - enabledModes.filter((m) => { - return m !== mode - }) - ) - } + if (mode === Mode.PROPS && !selectedPropKey) { + setMoreLinkState(MoreLinkState.HIDDEN) + } else { + setMoreLinkState(MoreLinkState.LOADING) + } + }, [dashboardState, mode, selectedPropKey]) - function setFunnel(selectedFunnel) { + function setFunnelFactory(selectedFunnelName) { return () => { - storage.setItem(tabKey, FUNNELS) - storage.setItem(funnelKey, selectedFunnel) - setMode(FUNNELS) - setSelectedFunnel(selectedFunnel) + storage.setItem(STORAGE_KEYS.getForTab({ site }), Mode.FUNNELS) + storage.setItem(STORAGE_KEYS.getForFunnel({ site }), selectedFunnelName) + setMode(Mode.FUNNELS) + setSelectedFunnel(selectedFunnelName) } } - function defaultSelectedFunnel() { - const stored = storage.getItem(funnelKey) - const storedExists = stored && site.funnels.some((f) => f.name === stored) - - if (storedExists) { - return stored - } else if (site.funnels.length > 0) { - const firstAvailable = site.funnels[0].name - - storage.setItem(funnelKey, firstAvailable) - return firstAvailable + function setPropKeyFactory(selectedPropKeyName) { + return () => { + storage.setItem(STORAGE_KEYS.getForTab({ site }), Mode.PROPS) + storePropKey({ site, propKey: selectedPropKeyName, dashboardState }) + setMode(Mode.PROPS) + setSelectedPropKey(selectedPropKeyName) } } - function hasFunnels() { - return site.funnels.length > 0 && site.funnelsAvailable - } - - function tabFunnelPicker() { - return ( - - -
- - - Funnels - - -
- - - -
- {funnelNames.map((funnelName) => { - return ( - - {({ active }) => ( - - {funnelName} - - )} - - ) - })} -
-
-
-
- ) - } - - function tabSwitcher(toMode, displayName) { - const className = classNames({ - [ACTIVE_CLASS]: mode == toMode, - [DEFAULT_CLASS]: mode !== toMode - }) - const setTab = () => { - storage.setItem(tabKey, toMode) - setMode(toMode) + useEffect(() => { + // Fetch property keys when PROPS mode is enabled (not just when active) + // This ensures the dropdown appears immediately on page refresh + if ( + enabledModes.includes(Mode.PROPS) && + site.hasProps && + site.propsAvailable + ) { + api + .get(url.apiPath(site, '/suggestions/prop_key'), dashboardState, { + q: '' + }) + .then((propKeys) => { + const propKeyValues = propKeys.map((entry) => entry.value) + setPropertyKeys(propKeyValues) + if (propKeyValues.length > 0) { + const stored = getPropKeyFromStorage({ site, dashboardState }) + const storedExists = stored && propKeyValues.includes(stored) + + if (storedExists) { + setSelectedPropKey(stored) + } else { + const firstAvailable = propKeyValues[0] + setSelectedPropKey(firstAvailable) + storePropKey({ site, propKey: firstAvailable, dashboardState }) + } + } else { + setSelectedPropKey(null) + } + }) + .catch((error) => { + console.error('Failed to fetch property keys:', error) + setPropertyKeys([]) + setSelectedPropKey(null) + }) + } else { + // Clear property keys when PROPS is not available + setPropertyKeys([]) + setSelectedPropKey(null) } + }, [site, dashboardState, enabledModes]) - return ( -
- {displayName} -
- ) - } - - function tabs() { - return ( -
- {isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')} - {isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')} - {isEnabled(FUNNELS) && - Funnel && - (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))} -
- ) + function setTabFactory(tab) { + return () => { + storage.setItem(STORAGE_KEYS.getForTab({ site }), tab) + setMode(tab) + } } function afterFetchData(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } function renderConversions() { if (site.hasGoals) { - return ( - - ) + if (specialGoal) { + return ( + + ) + } else { + return ( + + ) + } } else if (adminAccess) { return ( disableMode(Mode.CONVERSIONS)} /> ) } else { @@ -284,13 +309,13 @@ export default function Behaviours({ importedDataInView }) { return ( disableMode(Mode.FUNNELS)} /> ) } else { @@ -300,7 +325,9 @@ export default function Behaviours({ importedDataInView }) { function renderProps() { if (site.hasProps && site.propsAvailable) { - return + return ( + + ) } else if (adminAccess) { let callToAction @@ -315,13 +342,13 @@ export default function Behaviours({ importedDataInView }) { return ( disableMode(Mode.PROPS)} /> ) } else { @@ -345,60 +372,42 @@ export default function Behaviours({ importedDataInView }) { ) } - function onHideAction(mode) { - return () => { - disableMode(mode) - } - } - function renderContent() { switch (mode) { - case CONVERSIONS: + case Mode.CONVERSIONS: return renderConversions() - case PROPS: + case Mode.PROPS: return renderProps() - case FUNNELS: + case Mode.FUNNELS: return renderFunnels() } } - function defaultMode() { - if (enabledModes.length === 0) { - return null - } - - const storedMode = storage.getItem(tabKey) - if (storedMode && enabledModes.includes(storedMode)) { - return storedMode - } - - if (enabledModes.includes(CONVERSIONS)) { - return CONVERSIONS - } - if (enabledModes.includes(PROPS)) { - return PROPS - } - return FUNNELS - } - - function getEnabledModes() { - let enabledModes = [] - - for (const feature of Object.keys(sectionTitles)) { - const isOptedOut = site[feature + 'OptedOut'] - const isAvailable = site[feature + 'Available'] !== false - - // If the feature is not supported by the site owner's subscription, - // it only makes sense to display the feature tab to the owner itself - // as only they can upgrade to make the feature available. - const callToActionIsMissing = !isAvailable && user.role !== 'owner' - - if (!isOptedOut && !callToActionIsMissing) { - enabledModes.push(feature) - } + function getMoreLinkProps() { + switch (mode) { + case Mode.CONVERSIONS: + return specialGoal + ? { + path: customPropsRoute.path, + params: { propKey: url.maybeEncodeRouteParam(specialGoal.prop) }, + search: (search) => search + } + : { + path: conversionsRoute.path, + search: (search) => search + } + case Mode.PROPS: + if (!selectedPropKey) { + return null + } + return { + path: customPropsRoute.path, + params: { propKey: url.maybeEncodeRouteParam(selectedPropKey) }, + search: (search) => search + } + default: + return null } - - return enabledModes } function isEnabled(mode) { @@ -406,26 +415,18 @@ export default function Behaviours({ importedDataInView }) { } function isRealtime() { - return query.period === 'realtime' - } - - function sectionTitle() { - if (mode === CONVERSIONS) { - return specialTitleWhenGoalFilter(query, sectionTitles[mode]) - } else { - return sectionTitles[mode] - } + return dashboardState.period === 'realtime' } function renderImportedQueryUnsupportedWarning() { - if (mode === CONVERSIONS) { + if (mode === Mode.CONVERSIONS) { return ( ) - } else if (mode === PROPS) { + } else if (mode === Mode.PROPS) { return ( -
-
-
-

- {sectionTitle() + (isRealtime() ? ' (last 30min)' : '')} -

- {renderImportedQueryUnsupportedWarning()} -
- {tabs()} -
- {renderContent()} -
-
- ) - } else { + if (!mode) { return null } + + return ( + + +
+ + {isEnabled(Mode.CONVERSIONS) && + (specialGoal ? ( + + {specialGoal.title} + + ) : ( + + Goals + + ))} + {isEnabled(Mode.PROPS) && + !!propertyKeys.length && + site.propsAvailable ? ( + ({ + label: key, + onClick: setPropKeyFactory(key), + selected: selectedPropKey === key + }))} + searchable={true} + > + Properties + + ) : ( + + Properties + + )} + {!site.isConsolidatedView && + isEnabled(Mode.FUNNELS) && + Funnel && + (site.funnels.length > 0 && site.funnelsAvailable ? ( + ({ + label: name, + onClick: setFunnelFactory(name), + selected: mode === Mode.FUNNELS && selectedFunnel === name + }))} + searchable={true} + > + Funnels + + ) : ( + + Funnels + + ))} + + {isRealtime() && last 30min} + {renderImportedQueryUnsupportedWarning()} +
+ {mode !== Mode.FUNNELS && ( + + )} +
+ {renderContent()} +
+ ) +} + +function BehavioursOuter({ importedDataInView }) { + const site = useSiteContext() + const { enabledModes } = useModesContext() + const [mode, setMode] = useState(null) + + useEffect(() => { + const storedMode = storage.getItem(STORAGE_KEYS.getForTab({ site })) + // updates current mode when available modes change (if needed), loads user's stored mode + setMode((currentMode) => + getFirstPreferenceFromEnabledModes( + [currentMode, storedMode], + enabledModes + ) + ) + }, [enabledModes, site]) + + return enabledModes.length && mode ? ( + + ) : null +} + +export default function BehavioursWrapped({ importedDataInView }) { + return ( + + + + ) } diff --git a/assets/js/dashboard/stats/behaviours/modes-context.tsx b/assets/js/dashboard/stats/behaviours/modes-context.tsx new file mode 100644 index 000000000000..5daf6c93ffb5 --- /dev/null +++ b/assets/js/dashboard/stats/behaviours/modes-context.tsx @@ -0,0 +1,95 @@ +import React, { useCallback, useContext, useState } from 'react' +import { PlausibleSite, useSiteContext } from '../../site-context' +import { UserContextValue, useUserContext } from '../../user-context' + +export enum Mode { + CONVERSIONS = 'conversions', + PROPS = 'props', + FUNNELS = 'funnels' +} + +export const MODES = { + [Mode.CONVERSIONS]: { + title: 'Goal conversions', + isAvailableKey: null, // always available + optedOutKey: `${Mode.CONVERSIONS}OptedOut` + }, + [Mode.PROPS]: { + title: 'Custom properties', + isAvailableKey: `${Mode.PROPS}Available`, + optedOutKey: `${Mode.PROPS}OptedOut` + }, + [Mode.FUNNELS]: { + title: 'Funnels', + isAvailableKey: `${Mode.FUNNELS}Available`, + optedOutKey: `${Mode.FUNNELS}OptedOut` + } +} as const + +export const getFirstPreferenceFromEnabledModes = ( + preferredModes: Mode[], + enabledModes: Mode[] +): Mode | null => { + const defaultPreferenceOrder = [Mode.CONVERSIONS, Mode.PROPS, Mode.FUNNELS] + for (const mode of [...preferredModes, ...defaultPreferenceOrder]) { + if (enabledModes.includes(mode)) { + return mode + } + } + return null +} + +function getInitiallyAvailableModes({ + site, + user +}: { + site: PlausibleSite + user: UserContextValue +}): Mode[] { + return Object.entries(MODES) + .filter(([_, { isAvailableKey, optedOutKey }]) => { + const isOptedOut = site[optedOutKey] + const isAvailable = isAvailableKey ? site[isAvailableKey] : true + + // If the feature is not supported by the site owner's subscription, + // it only makes sense to display the feature tab to the owner itself + // as only they can upgrade to make the feature available. + const callToActionIsMissing = !isAvailable && user.role !== 'owner' + if (!isOptedOut && !callToActionIsMissing) { + return true + } + return false + }) + .map(([mode, _]) => mode as Mode) +} + +const modesContextDefaultValue = { + enabledModes: [] as Mode[], + disableMode: (() => {}) as (mode: Mode) => void +} +const ModesContext = React.createContext(modesContextDefaultValue) +export const useModesContext = () => { + return useContext(ModesContext) +} + +export const ModesContextProvider = ({ + children +}: { + children: React.ReactNode +}) => { + const site = useSiteContext() + const user = useUserContext() + const [enabledModes, setEnabledModes] = useState( + getInitiallyAvailableModes({ site, user }) + ) + + const disableMode = useCallback((mode: Mode) => { + setEnabledModes((modes) => modes.filter((m) => m !== mode)) + }, []) + + return ( + + {children} + + ) +} diff --git a/assets/js/dashboard/stats/behaviours/props.js b/assets/js/dashboard/stats/behaviours/props.js index 11ccdf7b5b24..30b23312194c 100644 --- a/assets/js/dashboard/stats/behaviours/props.js +++ b/assets/js/dashboard/stats/behaviours/props.js @@ -1,122 +1,36 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React from 'react' import ListReport, { MIN_HEIGHT } from '../reports/list' -import Combobox from '../../components/combobox' import * as metrics from '../reports/metrics' import * as api from '../../api' import * as url from '../../util/url' -import * as storage from '../../util/storage' -import { - EVENT_PROPS_PREFIX, - getGoalFilter, - FILTER_OPERATIONS, - hasConversionGoalFilter -} from '../../util/filters' -import classNames from 'classnames' -import { useQueryContext } from '../../query-context' +import { EVENT_PROPS_PREFIX, hasConversionGoalFilter } from '../../util/filters' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { customPropsRoute } from '../../router' -export default function Properties({ afterFetchData }) { - const { query } = useQueryContext() +export default function Properties({ propKey, afterFetchData }) { + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() - const propKeyStorageName = `prop_key__${site.domain}` - const propKeyStorageNameForGoal = () => { - const [_operation, _filterKey, [goal]] = getGoalFilter(query) - return `${goal}__prop_key__${site.domain}` - } - - const [propKey, setPropKey] = useState(null) - const [propKeyLoading, setPropKeyLoading] = useState(true) - - function singleGoalFilterApplied() { - const goalFilter = getGoalFilter(query) - if (goalFilter) { - const [operation, _filterKey, clauses] = goalFilter - return operation === FILTER_OPERATIONS.is && clauses.length === 1 - } else { - return false - } - } - - useEffect(() => { - setPropKeyLoading(true) - setPropKey(null) - - fetchPropKeyOptions()('').then((propKeys) => { - const propKeyValues = propKeys.map((entry) => entry.value) - - if (propKeyValues.length > 0) { - const storedPropKey = getPropKeyFromStorage() - - if (propKeyValues.includes(storedPropKey)) { - setPropKey(storedPropKey) - } else { - setPropKey(propKeys[0].value) - } - } - - setPropKeyLoading(false) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]) - - function getPropKeyFromStorage() { - if (singleGoalFilterApplied()) { - const storedForGoal = storage.getItem(propKeyStorageNameForGoal()) - if (storedForGoal) { - return storedForGoal - } - } - - return storage.getItem(propKeyStorageName) - } - function fetchProps() { return api.get( url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`), - query + dashboardState ) } - const fetchPropKeyOptions = useCallback(() => { - return (input) => { - return api.get(url.apiPath(site, '/suggestions/prop_key'), query, { - q: input.trim() - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]) - - function onPropKeySelect() { - return (selectedOptions) => { - const newPropKey = - selectedOptions.length === 0 ? null : selectedOptions[0].value - - if (newPropKey) { - const storageName = singleGoalFilterApplied() - ? propKeyStorageNameForGoal() - : propKeyStorageName - storage.setItem(storageName, newPropKey) - } - - setPropKey(newPropKey) - } - } - /*global BUILD_EXTRA*/ function chooseMetrics() { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Visitors', + renderLabel: (_dashboardState) => 'Visitors', meta: { plot: true } }), metrics.createEvents({ - renderLabel: (_query) => 'Events', + renderLabel: (_dashboardState) => 'Events', meta: { hiddenOnMobile: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate(), - !hasConversionGoalFilter(query) && metrics.createPercentage(), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate(), + !hasConversionGoalFilter(dashboardState) && metrics.createPercentage(), BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }), BUILD_EXTRA && @@ -132,13 +46,7 @@ export default function Properties({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel={propKey} metrics={chooseMetrics()} - detailsLinkProps={{ - path: customPropsRoute.path, - params: { propKey }, - search: (search) => search - }} - maybeHideDetails={true} - color="bg-red-50" + color="bg-red-50 group-hover/row:bg-red-100" colMinWidth={90} /> ) @@ -149,37 +57,17 @@ export default function Properties({ afterFetchData }) { filter: ['is', `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]] }) - const comboboxDisabled = !propKeyLoading && !propKey - const comboboxPlaceholder = comboboxDisabled - ? 'No custom properties found' - : '' - const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : [] - const boxClass = classNames( - 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500', - { - 'pointer-events-none': comboboxDisabled - } - ) - - const COMBOBOX_HEIGHT = 40 + if (!propKey) { + return ( +
+ No custom properties found +
+ ) + } return ( -
-
- -
- {propKey && renderBreakdown()} +
+ {renderBreakdown()}
) } diff --git a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js new file mode 100644 index 000000000000..700b92042512 --- /dev/null +++ b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js @@ -0,0 +1,64 @@ +import React from 'react' +import ListReport from '../reports/list' +import * as metrics from '../reports/metrics' +import * as url from '../../util/url' +import * as api from '../../api' +import { EVENT_PROPS_PREFIX } from '../../util/filters' +import { useSiteContext } from '../../site-context' +import { useDashboardStateContext } from '../../dashboard-state-context' + +export function SpecialGoalPropBreakdown({ prop, afterFetchData }) { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + + function fetchData() { + return api.get( + url.apiPath(site, `/custom-prop-values/${prop}`), + dashboardState + ) + } + + function getExternalLinkUrlFactory() { + if (prop === 'path') { + return (listItem) => url.externalLinkForPage(site, listItem.name) + } else if (prop === 'search_query') { + return null // WP Search Queries should not become external links + } else { + return (listItem) => listItem.name + } + } + + function getFilterInfo(listItem) { + return { + prefix: EVENT_PROPS_PREFIX, + filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]] + } + } + + function chooseMetrics() { + return [ + metrics.createVisitors({ + renderLabel: (_dashboardState) => 'Visitors', + meta: { plot: true } + }), + metrics.createEvents({ + renderLabel: (_dashboardState) => 'Events', + meta: { hiddenOnMobile: true } + }), + metrics.createConversionRate() + ].filter((metric) => !!metric) + } + + return ( + + ) +} diff --git a/assets/js/dashboard/stats/current-visitors.js b/assets/js/dashboard/stats/current-visitors.js index 4df0bc23c45e..79e149dcfbf4 100644 --- a/assets/js/dashboard/stats/current-visitors.js +++ b/assets/js/dashboard/stats/current-visitors.js @@ -3,7 +3,7 @@ import { AppNavigationLink } from '../navigation/use-app-navigate' import * as api from '../api' import { Tooltip } from '../util/tooltip' import { SecondsSinceLastLoad } from '../util/seconds-since-last-load' -import { useQueryContext } from '../query-context' +import { useDashboardStateContext } from '../dashboard-state-context' import { useSiteContext } from '../site-context' import { useLastLoadContext } from '../last-load-context' import classNames from 'classnames' @@ -12,7 +12,7 @@ export default function CurrentVisitors({ className = '', tooltipBoundaryRef }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const lastLoadTimestamp = useLastLoadContext() const site = useSiteContext() const [currentVisitors, setCurrentVisitors] = useState(null) @@ -33,9 +33,9 @@ export default function CurrentVisitors({ useEffect(() => { updateCount() - }, [query, updateCount]) + }, [dashboardState, updateCount]) - if (currentVisitors !== null && query.filters.length === 0) { + if (currentVisitors !== null && dashboardState.filters.length === 0) { return ( ({ ...prev, period: 'realtime' })} className={classNames( - 'h-9 flex items-center text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300', + 'h-9 flex items-center text-xs md:text-sm font-medium text-gray-500 dark:text-gray-300', className )} > diff --git a/assets/js/dashboard/stats/devices/index.js b/assets/js/dashboard/stats/devices/index.js index ca9dd8ae2f8c..f383821c2f00 100644 --- a/assets/js/dashboard/stats/devices/index.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -10,8 +10,11 @@ import * as metrics from '../reports/metrics' import * as api from '../../api' import * as url from '../../util/url' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' import { browsersRoute, browserVersionsRoute, @@ -19,6 +22,8 @@ import { operatingSystemVersionsRoute, screenSizesRoute } from '../../router' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' // Icons copied from https://github.com/alrra/browser-logos const BROWSER_ICONS = { @@ -56,9 +61,9 @@ export function browserIconFor(browser) { function Browsers({ afterFetchData }) { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() function fetchData() { - return api.get(url.apiPath(site, '/browsers'), query) + return api.get(url.apiPath(site, '/browsers'), dashboardState) } function getFilterInfo(listItem) { @@ -75,8 +80,9 @@ function Browsers({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate(), - !hasConversionGoalFilter(query) && metrics.createPercentage() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -88,19 +94,15 @@ function Browsers({ afterFetchData }) { keyLabel="Browser" metrics={chooseMetrics()} renderIcon={renderIcon} - detailsLinkProps={{ - path: browsersRoute.path, - search: (search) => search - }} /> ) } function BrowserVersions({ afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/browser-versions'), query) + return api.get(url.apiPath(site, '/browser-versions'), dashboardState) } function renderIcon(listItem) { @@ -108,7 +110,7 @@ function BrowserVersions({ afterFetchData }) { } function getFilterInfo(listItem) { - if (getSingleFilter(query, 'browser') == '(not set)') { + if (getSingleFilter(dashboardState, 'browser') == '(not set)') { return null } return { @@ -120,8 +122,9 @@ function BrowserVersions({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate(), - !hasConversionGoalFilter(query) && metrics.createPercentage() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -133,10 +136,6 @@ function BrowserVersions({ afterFetchData }) { keyLabel="Browser version" metrics={chooseMetrics()} renderIcon={renderIcon} - detailsLinkProps={{ - path: browserVersionsRoute.path, - search: (search) => search - }} /> ) } @@ -170,10 +169,10 @@ export function osIconFor(os) { } function OperatingSystems({ afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/operating-systems'), query) + return api.get(url.apiPath(site, '/operating-systems'), dashboardState) } function getFilterInfo(listItem) { @@ -186,9 +185,11 @@ function OperatingSystems({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate(), - !hasConversionGoalFilter(query) && - metrics.createPercentage({ meta: { hiddenonMobile: true } }) + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ + meta: { showOnHover: true } + }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -204,20 +205,19 @@ function OperatingSystems({ afterFetchData }) { renderIcon={renderIcon} keyLabel="Operating system" metrics={chooseMetrics()} - detailsLinkProps={{ - path: operatingSystemsRoute.path, - search: (search) => search - }} /> ) } function OperatingSystemVersions({ afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/operating-system-versions'), query) + return api.get( + url.apiPath(site, '/operating-system-versions'), + dashboardState + ) } function renderIcon(listItem) { @@ -225,7 +225,7 @@ function OperatingSystemVersions({ afterFetchData }) { } function getFilterInfo(listItem) { - if (getSingleFilter(query, 'os') == '(not set)') { + if (getSingleFilter(dashboardState, 'os') == '(not set)') { return null } return { @@ -237,8 +237,9 @@ function OperatingSystemVersions({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate(), - !hasConversionGoalFilter(query) && metrics.createPercentage() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -248,22 +249,18 @@ function OperatingSystemVersions({ afterFetchData }) { renderIcon={renderIcon} afterFetchData={afterFetchData} getFilterInfo={getFilterInfo} - keyLabel="Operating System Version" + keyLabel="Operating system version" metrics={chooseMetrics()} - detailsLinkProps={{ - path: operatingSystemVersionsRoute.path, - search: (search) => search - }} /> ) } function ScreenSizes({ afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/screen-sizes'), query) + return api.get(url.apiPath(site, '/screen-sizes'), dashboardState) } function renderIcon(listItem) { @@ -280,8 +277,9 @@ function ScreenSizes({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate(), - !hasConversionGoalFilter(query) && metrics.createPercentage() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -290,13 +288,9 @@ function ScreenSizes({ afterFetchData }) { fetchData={fetchData} afterFetchData={afterFetchData} getFilterInfo={getFilterInfo} - keyLabel="Screen size" + keyLabel="Device" metrics={chooseMetrics()} renderIcon={renderIcon} - detailsLinkProps={{ - path: screenSizesRoute.path, - search: (search) => search - }} /> ) } @@ -385,13 +379,49 @@ export function screenSizeIconFor(screenSize) { ) + } else if (screenSize === 'Ultra-wide') { + svg = ( + + + + + + ) + } else if (screenSize === '(not set)') { + svg = ( + + + + + + ) } return {svg} } export default function Devices() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() const tabKey = `deviceTab__${site.domain}` @@ -399,6 +429,7 @@ export default function Devices() { const [mode, setMode] = useState(storedTab || 'browser') const [loading, setLoading] = useState(true) const [skipImportedReason, setSkipImportedReason] = useState(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) function switchTab(mode) { storage.setItem(tabKey, mode) @@ -408,19 +439,60 @@ export default function Devices() { function afterFetchData(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } - useEffect(() => setLoading(true), [query, mode]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [dashboardState, mode]) + + function moreLinkProps() { + switch (mode) { + case 'browser': + if (isFilteringOnFixedValue(dashboardState, 'browser')) { + return { + path: browserVersionsRoute.path, + search: (search) => search + } + } + return { + path: browsersRoute.path, + search: (search) => search + } + case 'os': + if (isFilteringOnFixedValue(dashboardState, 'os')) { + return { + path: operatingSystemVersionsRoute.path, + search: (search) => search + } + } + return { + path: operatingSystemsRoute.path, + search: (search) => search + } + case 'size': + default: + return { + path: screenSizesRoute.path, + search: (search) => search + } + } + } function renderContent() { switch (mode) { case 'browser': - if (isFilteringOnFixedValue(query, 'browser')) { + if (isFilteringOnFixedValue(dashboardState, 'browser')) { return } return case 'os': - if (isFilteringOnFixedValue(query, 'os')) { + if (isFilteringOnFixedValue(dashboardState, 'os')) { return } return @@ -430,50 +502,39 @@ export default function Devices() { } } - function renderPill(name, pill) { - const isActive = mode === pill - - if (isActive) { - return ( - - ) - } - - return ( - - ) - } - return ( -
-
-
-

Devices

+ + +
+ + {[ + { label: 'Browsers', value: 'browser' }, + { label: 'Operating systems', value: 'os' }, + { label: 'Devices', value: 'size' } + ].map(({ label, value }) => ( + switchTab(value)} + > + {label} + + ))} +
-
- {renderPill('Browser', 'browser')} - {renderPill('OS', 'os')} - {renderPill('Size', 'size')} -
-
+ + {renderContent()} -
+ ) } -function getSingleFilter(query, filterKey) { - const matches = getFiltersByKeyPrefix(query, filterKey) +function getSingleFilter(dashboardState, filterKey) { + const matches = getFiltersByKeyPrefix(dashboardState, filterKey) if (matches.length != 1) { return null } diff --git a/assets/js/dashboard/stats/graph/date-formatter.js b/assets/js/dashboard/stats/graph/date-formatter.js index 690dc36f5ad4..caf646111977 100644 --- a/assets/js/dashboard/stats/graph/date-formatter.js +++ b/assets/js/dashboard/stats/graph/date-formatter.js @@ -92,10 +92,10 @@ const factory = { * while other intervals require dates to be displayed. * @param {Object} config - Configuration object for determining formatter. * - * @param {string} config.interval - The interval of the query, e.g. `minute`, `hour` + * @param {string} config.interval - The interval of the dashboardState, e.g. `minute`, `hour` * @param {boolean} config.longForm - Whether the formatted result should be in long or * short form. - * @param {string} config.period - The period of the query, e.g. `12mo`, `day` + * @param {string} config.period - The `DashboardPeriod`, e.g. `12mo`, `day` * @param {boolean} config.isPeriodFull - Indicates whether the interval has been cut * off by the requested date range or not. If false, the returned formatted date * indicates this cut off, e.g. `Partial week of November 8`. diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts new file mode 100644 index 000000000000..a57082511d07 --- /dev/null +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -0,0 +1,251 @@ +import { + DashboardState, + dashboardStateDefaultValue, + Filter +} from '../../dashboard-state' +import { ComparisonMode, DashboardPeriod } from '../../dashboard-time-periods' +import { PlausibleSite, siteContextDefaultValue } from '../../site-context' +import { StatsQuery } from '../../stats-query' +import { remapToApiFilters } from '../../util/filters' +import { chooseMetrics, MetricDef, topStatsQueries } from './fetch-top-stats' + +const aGoalFilter = ['is', 'goal', ['any goal']] as Filter +const aPageFilter = ['is', 'page', ['/any/page']] as Filter +const aPeriodNotRealtime = DashboardPeriod['28d'] + +const expectedBaseInclude: StatsQuery['include'] = { + compare: ComparisonMode.previous_period, + compare_match_day_of_week: true, + imports: true, + imports_meta: true, + time_labels: false +} + +const expectedRealtimeVisitorsQuery: StatsQuery = { + date_range: DashboardPeriod.realtime, + dimensions: [], + filters: [], + include: { + ...expectedBaseInclude, + compare: null, + imports_meta: false + }, + metrics: ['visitors'], + relative_date: null +} + +type TestCase = [ + /** situation */ + string, + /** input dashboard state & site state */ + Pick & + Partial<{ site?: Pick }>, + /** expected metrics */ + MetricDef[], + /** expected queries */ + [StatsQuery, null | StatsQuery] +] + +const cases: TestCase[] = [ + [ + 'realtime and goal filter', + { period: DashboardPeriod.realtime, filters: [aGoalFilter] }, + [ + { key: 'visitors', label: 'Unique conversions (last 30 min)' }, + { key: 'events', label: 'Total conversions (last 30 min)' } + ], + [ + { + date_range: DashboardPeriod.realtime_30m, + dimensions: [], + filters: remapToApiFilters([aGoalFilter]), + include: { ...expectedBaseInclude, compare: null }, + metrics: ['visitors', 'events'], + relative_date: null + }, + expectedRealtimeVisitorsQuery + ] + ], + + [ + 'realtime', + { period: DashboardPeriod.realtime, filters: [] }, + [ + { key: 'visitors', label: 'Unique visitors (last 30 min)' }, + { key: 'pageviews', label: 'Pageviews (last 30 min)' } + ], + [ + { + date_range: DashboardPeriod.realtime_30m, + dimensions: [], + filters: [], + include: { + ...expectedBaseInclude, + compare: null + }, + metrics: ['visitors', 'pageviews'], + relative_date: null + }, + expectedRealtimeVisitorsQuery + ] + ], + + [ + 'goal filter with revenue metrics', + { + period: aPeriodNotRealtime, + filters: [aGoalFilter], + site: { + revenueGoals: [{ display_name: 'a revenue goal', currency: 'USD' }] + } + }, + [ + { key: 'visitors', label: 'Unique conversions' }, + { key: 'events', label: 'Total conversions' }, + { key: 'total_revenue', label: 'Total revenue' }, + { key: 'average_revenue', label: 'Average revenue' }, + { key: 'conversion_rate', label: 'Conversion rate' } + ], + [ + { + date_range: aPeriodNotRealtime, + dimensions: [], + filters: remapToApiFilters([aGoalFilter]), + include: expectedBaseInclude, + metrics: [ + 'visitors', + 'events', + 'total_revenue', + 'average_revenue', + 'conversion_rate' + ], + relative_date: null + }, + null + ] + ], + + [ + 'goal filter', + { period: aPeriodNotRealtime, filters: [aGoalFilter] }, + [ + { key: 'visitors', label: 'Unique conversions' }, + { key: 'events', label: 'Total conversions' }, + { key: 'conversion_rate', label: 'Conversion rate' } + ], + [ + { + date_range: aPeriodNotRealtime, + dimensions: [], + filters: remapToApiFilters([aGoalFilter]), + include: expectedBaseInclude, + metrics: ['visitors', 'events', 'conversion_rate'], + relative_date: null + }, + null + ] + ], + + [ + 'page filter', + { + period: aPeriodNotRealtime, + filters: [aPageFilter] + }, + [ + { key: 'visitors', label: 'Unique visitors' }, + { key: 'visits', label: 'Total visits' }, + { key: 'pageviews', label: 'Total pageviews' }, + { key: 'bounce_rate', label: 'Bounce rate' }, + { key: 'scroll_depth', label: 'Scroll depth' }, + { key: 'time_on_page', label: 'Time on page' } + ], + + [ + { + date_range: aPeriodNotRealtime, + dimensions: [], + filters: remapToApiFilters([aPageFilter]), + include: { ...expectedBaseInclude }, + metrics: [ + 'visitors', + 'visits', + 'pageviews', + 'bounce_rate', + 'scroll_depth', + 'time_on_page' + ], + relative_date: null + }, + null + ] + ], + + [ + 'default', + { period: aPeriodNotRealtime, filters: [] }, + [ + { key: 'visitors', label: 'Unique visitors' }, + { key: 'visits', label: 'Total visits' }, + { key: 'pageviews', label: 'Total pageviews' }, + { key: 'views_per_visit', label: 'Views per visit' }, + { key: 'bounce_rate', label: 'Bounce rate' }, + { key: 'visit_duration', label: 'Visit duration' } + ], + [ + { + date_range: aPeriodNotRealtime, + dimensions: [], + filters: [], + include: expectedBaseInclude, + metrics: [ + 'visitors', + 'visits', + 'pageviews', + 'views_per_visit', + 'bounce_rate', + 'visit_duration' + ], + relative_date: null + }, + null + ] + ] +] + +describe(`${chooseMetrics.name}`, () => { + test.each( + cases.map(([name, inputDashboardState, expectedMetrics]) => [ + name, + inputDashboardState, + expectedMetrics + ]) + )( + 'for %s dashboard, top stats metrics are as expected', + (_, { site, ...inputDashboardState }, expectedMetrics) => { + const dashboardState = { + ...dashboardStateDefaultValue, + resolvedFilters: inputDashboardState.filters, + ...inputDashboardState + } + expect( + chooseMetrics({ ...siteContextDefaultValue, ...site }, dashboardState) + ).toEqual(expectedMetrics) + } + ) +}) + +describe(`${topStatsQueries.name}`, () => { + test.each(cases)( + 'for %s dashboard, queries are as expected', + (_, { site: _site, ...inputDashboardState }, metrics, expectedQueries) => { + const dashboardState = { + ...dashboardStateDefaultValue, + resolvedFilters: inputDashboardState.filters, + ...inputDashboardState + } + const queries = topStatsQueries(dashboardState, metrics) + expect(queries).toEqual(expectedQueries) + } + ) +}) diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.ts new file mode 100644 index 000000000000..86bffcb5e742 --- /dev/null +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.ts @@ -0,0 +1,197 @@ +import { Metric } from '../../../types/query-api' +import * as api from '../../api' +import { DashboardState } from '../../dashboard-state' +import { + ComparisonMode, + DashboardPeriod, + isComparisonEnabled, + isComparisonForbidden +} from '../../dashboard-time-periods' +import { PlausibleSite } from '../../site-context' +import { createStatsQuery, ReportParams, StatsQuery } from '../../stats-query' +import { + hasConversionGoalFilter, + hasPageFilter, + isRealTimeDashboard +} from '../../util/filters' + +export function topStatsQueries( + dashboardState: DashboardState, + metrics: MetricDef[] +): [StatsQuery, StatsQuery | null] { + let currentVisitorsQuery = null + + if (isRealTimeDashboard(dashboardState)) { + currentVisitorsQuery = createStatsQuery(dashboardState, { + metrics: ['visitors'] + }) + + currentVisitorsQuery.filters = [] + } + const topStatsQuery = constructTopStatsQuery(dashboardState, metrics) + + return [topStatsQuery, currentVisitorsQuery] +} + +export async function fetchTopStats( + site: PlausibleSite, + dashboardState: DashboardState +) { + const metrics = chooseMetrics(site, dashboardState) + const [topStatsQuery, currentVisitorsQuery] = topStatsQueries( + dashboardState, + metrics + ) + const topStatsPromise = api.stats(site, topStatsQuery) + + const currentVisitorsPromise = currentVisitorsQuery + ? api.stats(site, currentVisitorsQuery) + : null + + const [topStatsResponse, currentVisitorsResponse] = await Promise.all([ + topStatsPromise, + currentVisitorsPromise + ]) + + return formatTopStatsData(topStatsResponse, currentVisitorsResponse, metrics) +} + +export type MetricDef = { key: Metric; label: string } + +export function chooseMetrics( + site: Pick, + dashboardState: DashboardState +): MetricDef[] { + const revenueMetrics: MetricDef[] = + site.revenueGoals.length > 0 + ? [ + { key: 'total_revenue', label: 'Total revenue' }, + { key: 'average_revenue', label: 'Average revenue' } + ] + : [] + + if ( + isRealTimeDashboard(dashboardState) && + hasConversionGoalFilter(dashboardState) + ) { + return [ + { key: 'visitors', label: 'Unique conversions (last 30 min)' }, + { key: 'events', label: 'Total conversions (last 30 min)' } + ] + } else if (isRealTimeDashboard(dashboardState)) { + return [ + { key: 'visitors', label: 'Unique visitors (last 30 min)' }, + { key: 'pageviews', label: 'Pageviews (last 30 min)' } + ] + } else if (hasConversionGoalFilter(dashboardState)) { + return [ + { key: 'visitors', label: 'Unique conversions' }, + { key: 'events', label: 'Total conversions' }, + ...revenueMetrics, + { key: 'conversion_rate', label: 'Conversion rate' } + ] + } else if (hasPageFilter(dashboardState)) { + return [ + { key: 'visitors', label: 'Unique visitors' }, + { key: 'visits', label: 'Total visits' }, + { key: 'pageviews', label: 'Total pageviews' }, + { key: 'bounce_rate', label: 'Bounce rate' }, + { key: 'scroll_depth', label: 'Scroll depth' }, + { key: 'time_on_page', label: 'Time on page' } + ] + } else { + return [ + { key: 'visitors', label: 'Unique visitors' }, + { key: 'visits', label: 'Total visits' }, + { key: 'pageviews', label: 'Total pageviews' }, + { key: 'views_per_visit', label: 'Views per visit' }, + { key: 'bounce_rate', label: 'Bounce rate' }, + { key: 'visit_duration', label: 'Visit duration' } + ] + } +} + +function constructTopStatsQuery( + dashboardState: DashboardState, + metrics: MetricDef[] +): StatsQuery { + const reportParams: ReportParams = { + metrics: metrics.map((m) => m.key), + include: { imports_meta: true } + } + + const statsQuery = createStatsQuery(dashboardState, reportParams) + + if ( + !isComparisonEnabled(dashboardState.comparison) && + !isComparisonForbidden({ + period: dashboardState.period, + segmentIsExpanded: false + }) + ) { + statsQuery.include.compare = ComparisonMode.previous_period + } + + if (isRealTimeDashboard(dashboardState)) { + statsQuery.date_range = DashboardPeriod.realtime_30m + } + + return statsQuery +} + +type TopStatItem = { + metric: Metric + value: number + name: string + graphable: boolean + change?: number + comparisonValue?: number +} + +function formatTopStatsData( + topStatsResponse: api.QueryApiResponse, + currentVisitorsResponse: api.QueryApiResponse | null, + metrics: MetricDef[] +) { + const { query, meta, results } = topStatsResponse + + const topStats: TopStatItem[] = [] + + if (currentVisitorsResponse) { + topStats.push({ + metric: currentVisitorsResponse.query.metrics[0], + value: currentVisitorsResponse.results[0].metrics[0], + name: 'Current visitors', + graphable: false + }) + } + + for (let i = 0; i < query.metrics.length; i++) { + const metricKey = query.metrics[i] + const metricDef = metrics.find((m) => m.key === metricKey) + + if (!metricDef) { + throw new Error('API response returned a metric that was not asked for') + } + + topStats.push({ + metric: metricKey, + value: results[0].metrics[i], + name: metricDef.label, + graphable: true, + change: results[0].comparison?.change[i], + comparisonValue: results[0].comparison?.metrics[i] + }) + } + + const [from, to] = query.date_range.map((d) => d.split('T')[0]) + + const comparingFrom = query.comparison_date_range + ? query.comparison_date_range[0].split('T')[0] + : null + const comparingTo = query.comparison_date_range + ? query.comparison_date_range[1].split('T')[0] + : null + + return { topStats, meta, from, to, comparingFrom, comparingTo } +} diff --git a/assets/js/dashboard/stats/graph/graph-tooltip.js b/assets/js/dashboard/stats/graph/graph-tooltip.js index 8a593a8e00fa..ad439abc26e2 100644 --- a/assets/js/dashboard/stats/graph/graph-tooltip.js +++ b/assets/js/dashboard/stats/graph/graph-tooltip.js @@ -4,9 +4,10 @@ import dateFormatter from './date-formatter' import { METRIC_LABELS, hasMultipleYears } from './graph-util' import { MetricFormatterShort } from '../reports/metric-formatter' import { ChangeArrow } from '../reports/change-arrow' +import { UIMode } from '../../theme-context' const renderBucketLabel = function ( - query, + dashboardState, graphData, label, comparison = false @@ -19,16 +20,16 @@ const renderBucketLabel = function ( const formattedLabel = dateFormatter({ interval: graphData.interval, longForm: true, - period: query.period, + period: dashboardState.period, isPeriodFull, shouldShowYear })(label) - if (query.period === 'realtime') { + if (dashboardState.period === 'realtime') { return dateFormatter({ interval: graphData.interval, longForm: true, - period: query.period, + period: dashboardState.period, shouldShowYear })(label) } @@ -37,7 +38,7 @@ const renderBucketLabel = function ( const date = dateFormatter({ interval: 'day', longForm: true, - period: query.period, + period: dashboardState.period, shouldShowYear })(label) return `${date}, ${formattedLabel}` @@ -56,7 +57,12 @@ const calculatePercentageDifference = function (oldValue, newValue) { } } -const buildTooltipData = function (query, graphData, metric, tooltipModel) { +const buildTooltipData = function ( + dashboardState, + graphData, + metric, + tooltipModel +) { const data = tooltipModel.dataPoints.find( (dataPoint) => dataPoint.dataset.yAxisID == 'y' ) @@ -66,26 +72,32 @@ const buildTooltipData = function (query, graphData, metric, tooltipModel) { const label = data && - renderBucketLabel(query, graphData, graphData.labels[data.dataIndex]) + renderBucketLabel( + dashboardState, + graphData, + graphData.labels[data.dataIndex] + ) const comparisonLabel = comparisonData && renderBucketLabel( - query, + dashboardState, graphData, graphData.comparison_labels[comparisonData.dataIndex], true ) - const value = graphData.plot[data.dataIndex] + const value = data && graphData.plot?.[data.dataIndex] const formatter = MetricFormatterShort[metric] - const comparisonValue = graphData.comparison_plot?.[comparisonData.dataIndex] + const comparisonValue = + comparisonData && graphData.comparison_plot?.[comparisonData.dataIndex] const comparisonDifference = label && comparisonData && + value && calculatePercentageDifference(comparisonValue, value) - const formattedValue = formatter(value) + const formattedValue = value && formatter(value) const formattedComparisonValue = comparisonData && formatter(comparisonValue) return { @@ -99,23 +111,27 @@ const buildTooltipData = function (query, graphData, metric, tooltipModel) { let tooltipRoot -export default function GraphTooltip(graphData, metric, query) { +export default function GraphTooltip(graphData, metric, dashboardState, theme) { return (context) => { const tooltipModel = context.tooltip const offset = document .getElementById('main-graph-canvas') .getBoundingClientRect() - let tooltipEl = document.getElementById('chartjs-tooltip') + let tooltipEl = document.getElementById('chartjs-tooltip-main') if (!tooltipEl) { tooltipEl = document.createElement('div') - tooltipEl.id = 'chartjs-tooltip' + tooltipEl.id = 'chartjs-tooltip-main' + tooltipEl.className = 'chartjs-tooltip' tooltipEl.style.display = 'none' tooltipEl.style.opacity = 0 document.body.appendChild(tooltipEl) tooltipRoot = createRoot(tooltipEl) } + const bgClass = theme.mode === UIMode.dark ? 'bg-gray-950' : 'bg-gray-800' + tooltipEl.className = `absolute text-sm font-normal py-3 px-4 pointer-events-none rounded-md z-[100] min-w-[180px] ${bgClass}` + if (tooltipEl && offset && window.innerWidth < 768) { tooltipEl.style.top = offset.y + offset.height + window.scrollY + 15 + 'px' @@ -131,16 +147,21 @@ export default function GraphTooltip(graphData, metric, query) { if (tooltipModel.body) { const tooltipData = buildTooltipData( - query, + dashboardState, graphData, metric, tooltipModel ) + if (!tooltipData.label) { + tooltipEl.style.display = 'none' + return + } + tooltipRoot.render( -
+ } + className="cursor-pointer w-4 h-4" + > + + + +
+ ) +} diff --git a/assets/js/dashboard/stats/graph/sampling-notice.js b/assets/js/dashboard/stats/graph/sampling-notice.js deleted file mode 100644 index 93728a932e73..000000000000 --- a/assets/js/dashboard/stats/graph/sampling-notice.js +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react' - -export default function SamplingNotice({ topStatData }) { - const samplePercent = topStatData?.samplePercent - - if (samplePercent && samplePercent < 100) { - return ( -
- - - -
- ) - } else { - return null - } -} diff --git a/assets/js/dashboard/stats/graph/stats-export.js b/assets/js/dashboard/stats/graph/stats-export.tsx similarity index 74% rename from assets/js/dashboard/stats/graph/stats-export.js rename to assets/js/dashboard/stats/graph/stats-export.tsx index ffff70d99b78..8fdfcb4c0d12 100644 --- a/assets/js/dashboard/stats/graph/stats-export.js +++ b/assets/js/dashboard/stats/graph/stats-export.tsx @@ -1,12 +1,16 @@ import React, { useState } from 'react' import * as api from '../../api' -import { getCurrentInterval } from './interval-picker' import { useSiteContext } from '../../site-context' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { Tooltip } from '../../util/tooltip' -export default function StatsExport() { +export default function StatsExport({ + selectedInterval +}: { + selectedInterval: string +}) { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const [exporting, setExporting] = useState(false) function startExport() { @@ -49,14 +53,18 @@ export default function StatsExport() { } function renderExportLink() { - const interval = getCurrentInterval(site, query) - const queryParams = api.queryToSearchParams(query, [ - { interval, comparison: undefined } + const params = api.dashboardStateToSearchParams(dashboardState, [ + { interval: selectedInterval, comparison: undefined } ]) - const endpoint = `/${encodeURIComponent(site.domain)}/export?${queryParams}` + const endpoint = `/${encodeURIComponent(site.domain)}/export?${params}` return ( - + + Click to export stats
} + className="w-4 h-4" + > {exporting && renderLoading()} {!exporting && renderExportLink()} -
+ ) } diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index 3df7be524cde..55b268e3a427 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -2,9 +2,8 @@ import React from 'react' import { Tooltip } from '../../util/tooltip' import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load' import classNames from 'classnames' -import * as storage from '../../util/storage' import { formatDateRange } from '../../util/date' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { useLastLoadContext } from '../../last-load-context' import { ChangeArrow } from '../reports/change-arrow' @@ -25,30 +24,30 @@ function topStatNumberLong(metric, value) { export default function TopStats({ data, - onMetricUpdate, - tooltipBoundary, - graphableMetrics + selectedMetric, + onMetricClick, + tooltipBoundary }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const lastLoadTimestamp = useLastLoadContext() const site = useSiteContext() - const isComparison = query.comparison && data && data.comparing_from + const isComparison = + (dashboardState.comparison && data && data.comparingFrom !== null) || false function tooltip(stat) { let statName = stat.name.toLowerCase() - const warning = warningText(stat.graph_metric, site) - statName = stat.value === 1 ? statName.slice(0, -1) : statName + const warning = warningText(stat.metric, site) + statName = stat.value === 1 ? statName.replace(/s$/, '') : statName return (
{isComparison && (
- {topStatNumberLong(stat.graph_metric, stat.value)} vs.{' '} - {topStatNumberLong(stat.graph_metric, stat.comparison_value)}{' '} - {statName} + {topStatNumberLong(stat.metric, stat.value)} vs.{' '} + {topStatNumberLong(stat.metric, stat.comparisonValue)} {statName} @@ -57,7 +56,7 @@ export default function TopStats({ {!isComparison && (
- {topStatNumberLong(stat.graph_metric, stat.value)} {statName} + {topStatNumberLong(stat.metric, stat.value)} {statName}
)} @@ -81,6 +80,13 @@ export default function TopStats({ return null } + if ( + metric === 'bounce_rate' && + warning.code === 'no_imported_bounce_rate' + ) { + return 'Does not include imported data' + } + if ( metric === 'scroll_depth' && warning.code === 'no_imported_scroll_depth' @@ -95,17 +101,6 @@ export default function TopStats({ return null } - function canMetricBeGraphed(stat) { - return graphableMetrics.includes(stat.graph_metric) - } - - function maybeUpdateMetric(stat) { - if (canMetricBeGraphed(stat)) { - storage.setItem(`metric__${site.domain}`, stat.graph_metric) - onMetricUpdate(stat.graph_metric) - } - } - function blinkingDot() { return (
{statExtraName} )} - {warningText(stat.graph_metric) && ( + {warningText(stat.metric) && ( * )}
@@ -152,20 +143,17 @@ export default function TopStats({ const className = classNames( 'px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', { - 'cursor-pointer': canMetricBeGraphed(stat), - 'lg:border-l border-gray-300': index > 0, + 'cursor-pointer': stat.graphable, + 'lg:border-l border-gray-300 dark:border-gray-700': index > 0, 'border-r lg:border-r-0': index % 2 === 0 } ) - return ( { - maybeUpdateMetric(stat) - }} + onClick={stat.graphable ? () => onMetricClick(stat.metric) : () => {}} boundary={tooltipBoundary} > {renderStatName(stat)} @@ -174,13 +162,17 @@ export default function TopStats({

- {topStatNumberShort(stat.graph_metric, stat.value)} + {topStatNumberShort(stat.metric, stat.value)}

{!isComparison && stat.change != null ? ( @@ -195,11 +187,14 @@ export default function TopStats({ {isComparison ? (
-

- {topStatNumberShort(stat.graph_metric, stat.comparison_value)} +

+ {topStatNumberShort(stat.metric, stat.comparisonValue)}

- {formatDateRange(site, data.comparing_from, data.comparing_to)} + {formatDateRange(site, data.comparingFrom, data.comparingTo)}

) : null} @@ -209,11 +204,11 @@ export default function TopStats({ } const stats = - data && data.top_stats.filter((stat) => stat.value !== null).map(renderStat) + data && data.topStats.filter((stat) => stat.value !== null).map(renderStat) - if (stats && query.period === 'realtime') { + if (stats && dashboardState.period === 'realtime') { stats.push(blinkingDot()) } - return stats || null + return stats ? <>{stats} : null } diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js deleted file mode 100644 index 87b81f2c4370..000000000000 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ /dev/null @@ -1,230 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import React, { useState, useEffect, useRef, useCallback } from 'react' -import * as api from '../../api' -import * as storage from '../../util/storage' -import TopStats from './top-stats' -import { IntervalPicker, getCurrentInterval } from './interval-picker' -import StatsExport from './stats-export' -import WithImportedSwitch from './with-imported-switch' -import SamplingNotice from './sampling-notice' -import FadeIn from '../../fade-in' -import * as url from '../../util/url' -import { isComparisonEnabled } from '../../query-time-periods' -import LineGraphWithRouter from './line-graph' -import { useQueryContext } from '../../query-context' -import { useSiteContext } from '../../site-context' -import { ExclamationCircleIcon } from '@heroicons/react/24/outline' - -function fetchTopStats(site, query) { - const q = { ...query } - - if (!isComparisonEnabled(q.comparison) && query.period !== 'realtime') { - q.comparison = 'previous_period' - } - - return api.get(url.apiPath(site, '/top-stats'), q) -} - -function fetchMainGraph(site, query, metric, interval) { - const params = { metric, interval } - return api.get(url.apiPath(site, '/main-graph'), query, params) -} - -export default function VisitorGraph({ updateImportedDataInView }) { - const { query } = useQueryContext() - const site = useSiteContext() - - const isRealtime = query.period === 'realtime' - const isDarkTheme = - document.querySelector('html').classList.contains('dark') || false - - const topStatsBoundary = useRef(null) - - const [topStatData, setTopStatData] = useState(null) - const [topStatsLoading, setTopStatsLoading] = useState(true) - const [graphData, setGraphData] = useState(null) - const [graphLoading, setGraphLoading] = useState(true) - - // This state is explicitly meant for the situation where either graph interval - // or graph metric is changed. That results in behaviour where Top Stats stay - // intact, but the graph container alone will display a loading spinner for as - // long as new graph data is fetched. - const [graphRefreshing, setGraphRefreshing] = useState(false) - - const onIntervalUpdate = useCallback( - (newInterval) => { - setGraphData(null) - setGraphRefreshing(true) - fetchGraphData(getStoredMetric(), newInterval) - }, - [query] - ) - - const onMetricUpdate = useCallback( - (newMetric) => { - setGraphData(null) - setGraphRefreshing(true) - fetchGraphData(newMetric, getCurrentInterval(site, query)) - }, - [query] - ) - - useEffect(() => { - setTopStatData(null) - setTopStatsLoading(true) - setGraphData(null) - setGraphLoading(true) - fetchTopStatsAndGraphData() - - if (isRealtime) { - document.addEventListener('tick', fetchTopStatsAndGraphData) - } - - return () => { - document.removeEventListener('tick', fetchTopStatsAndGraphData) - } - }, [query]) - - useEffect(() => { - if (topStatData) { - storeTopStatsContainerHeight() - } - }, [topStatData]) - - async function fetchTopStatsAndGraphData() { - const response = await fetchTopStats(site, query) - - let metric = getStoredMetric() - const availableMetrics = response.graphable_metrics - - if (!availableMetrics.includes(metric)) { - metric = availableMetrics[0] - storage.setItem(`metric__${site.domain}`, metric) - } - - const interval = getCurrentInterval(site, query) - - if (response.updateImportedDataInView) { - updateImportedDataInView(response.includes_imported) - } - - setTopStatData(response) - setTopStatsLoading(false) - - fetchGraphData(metric, interval) - } - - function fetchGraphData(metric, interval) { - fetchMainGraph(site, query, metric, interval).then((res) => { - setGraphData(res) - setGraphLoading(false) - setGraphRefreshing(false) - }) - } - - function getStoredMetric() { - return storage.getItem(`metric__${site.domain}`) - } - - function storeTopStatsContainerHeight() { - storage.setItem( - `topStatsHeight__${site.domain}`, - document.getElementById('top-stats-container').clientHeight - ) - } - - // This function is used for maintaining the main-graph/top-stats container height in the - // loading process. The container height depends on how many top stat metrics are returned - // from the API, but in the loading state, we don't know that yet. We can use localStorage - // to keep track of the Top Stats container height. - function getTopStatsHeight() { - if (topStatData) { - return 'auto' - } else { - return `${storage.getItem(`topStatsHeight__${site.domain}`) || 89}px` - } - } - - function importedSwitchVisible() { - return ( - !!topStatData?.with_imported_switch && - topStatData?.with_imported_switch.visible - ) - } - - function renderImportedIntervalUnsupportedWarning() { - const unsupportedInterval = ['hour', 'minute'].includes( - getCurrentInterval(site, query) - ) - const showingImported = - importedSwitchVisible() && query.with_imported === true - - return ( - - - - - - ) - } - - return ( -
- {(topStatsLoading || graphLoading) && renderLoader()} - -
- -
-
- {graphRefreshing && renderLoader()} -
- {renderImportedIntervalUnsupportedWarning()} - {!isRealtime && } - - {importedSwitchVisible() && ( - - )} - -
- -
-
-
- ) -} - -function renderLoader() { - return ( -
-
-
-
-
- ) -} diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx new file mode 100644 index 000000000000..c88d2061e542 --- /dev/null +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -0,0 +1,360 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react' +import * as api from '../../api' +import * as storage from '../../util/storage' +import TopStats from './top-stats' +import { fetchTopStats } from './fetch-top-stats' +import { IntervalPicker, useStoredInterval } from './interval-picker' +import StatsExport from './stats-export' +import WithImportedSwitch from './with-imported-switch' +import { NoticesIcon } from './notices' +import * as url from '../../util/url' +import LineGraphWithRouter, { LineGraphContainer } from './line-graph' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { PlausibleSite, useSiteContext } from '../../site-context' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Metric } from '../../../types/query-api' +import { DashboardPeriod } from '../../dashboard-time-periods' +import { DashboardState } from '../../dashboard-state' +import { nowForSite } from '../../util/date' +import { getStaleTime } from '../../hooks/api-client' + +// height of at least one row of top stats +const DEFAULT_TOP_STATS_LOADING_HEIGHT_PX = 85 + +export default function VisitorGraph({ + updateImportedDataInView +}: { + updateImportedDataInView?: (v: boolean) => void +}) { + const topStatsBoundary = useRef(null) + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + const isRealtime = dashboardState.period === DashboardPeriod.realtime + const queryClient = useQueryClient() + const startOfDay = nowForSite(site).startOf('day') + + const { selectedInterval, onIntervalClick, availableIntervals } = + useStoredInterval(site, { + to: dashboardState.to, + from: dashboardState.from, + period: dashboardState.period + }) + + const [selectedMetric, setSelectedMetric] = useState( + getStoredMetric(site) + ) + const onMetricClick = useCallback( + (metric: Metric) => { + setStoredMetric(site, metric) + setSelectedMetric(metric) + }, + [site] + ) + + const topStatsQuery = useQuery({ + queryKey: ['top-stats', { dashboardState }] as const, + queryFn: async ({ queryKey }) => { + const [_, opts] = queryKey + return await fetchTopStats(site, opts.dashboardState) + }, + placeholderData: (previousData) => previousData, + staleTime: ({ queryKey, meta }) => { + const [_, opts] = queryKey + return getStaleTime( + meta!.startOfDay as typeof startOfDay, + opts.dashboardState + ) + }, + meta: { startOfDay } + }) + + const mainGraphQuery = useQuery({ + enabled: !!selectedMetric, + queryKey: [ + 'main-graph', + { dashboardState, metric: selectedMetric, interval: selectedInterval } + ] as const, + queryFn: async ({ queryKey }) => { + const [_, opts] = queryKey + const data = await api.get( + url.apiPath(site, '/main-graph'), + opts.dashboardState, + { + metric: opts.metric, + interval: opts.interval + } + ) + return { ...data, interval: opts.interval } + }, + placeholderData: (previousData) => previousData, + staleTime: ({ queryKey, meta }) => { + const [_, opts] = queryKey + return getStaleTime( + meta!.startOfDay as typeof startOfDay, + opts.dashboardState + ) + }, + meta: { startOfDay } + }) + + // update metric to one that exists + useEffect(() => { + if (topStatsQuery.data) { + const availableMetrics = topStatsQuery.data.topStats + .filter((stat) => stat.graphable) + .map((stat) => stat.metric) + + setSelectedMetric((currentlySelectedMetric) => { + if ( + currentlySelectedMetric && + availableMetrics.includes(currentlySelectedMetric) + ) { + return currentlySelectedMetric + } else { + return availableMetrics[0] + } + }) + } + }, [topStatsQuery.data]) + + const [isRealtimeSilentUpdate, setIsRealtimeSilentUpdate] = useState({ + topStats: false, + mainGraph: false + }) + useEffect(() => { + setIsRealtimeSilentUpdate((current) => ({ ...current, mainGraph: false })) + }, [selectedMetric]) + + useEffect(() => { + if (!mainGraphQuery.isRefetching) { + setIsRealtimeSilentUpdate((current) => ({ ...current, mainGraph: false })) + } + }, [mainGraphQuery.isRefetching]) + + useEffect(() => { + if (!topStatsQuery.isRefetching) { + setIsRealtimeSilentUpdate((current) => ({ ...current, topStats: false })) + } + }, [topStatsQuery.isRefetching]) + + useEffect(() => { + if (!isRealtime) { + setIsRealtimeSilentUpdate({ + topStats: false, + mainGraph: false + }) + } + }, [isRealtime]) + + // sync import related info + useEffect(() => { + if (topStatsQuery.data && typeof updateImportedDataInView === 'function') { + updateImportedDataInView( + topStatsQuery.data.meta.imports_included as boolean + ) + } + }, [topStatsQuery.data, updateImportedDataInView]) + + // fetch realtime stats + const refetchTopStats = topStatsQuery.refetch + const refetchMainGraph = mainGraphQuery.refetch + + useEffect(() => { + const onTick = () => { + setIsRealtimeSilentUpdate({ topStats: true, mainGraph: true }) + queryClient.invalidateQueries({ + predicate: ({ queryKey }) => { + const realtimeTopStatsOrMainGraphQuery = + ['top-stats', 'main-graph'].includes(queryKey[0] as string) && + typeof queryKey[1] === 'object' && + (queryKey[1] as { dashboardState?: DashboardState })?.dashboardState + ?.period === DashboardPeriod.realtime + + return realtimeTopStatsOrMainGraphQuery + } + }) + refetchTopStats() + refetchMainGraph() + } + + if (isRealtime) { + document.addEventListener('tick', onTick) + } + + return () => { + document.removeEventListener('tick', onTick) + } + }, [queryClient, isRealtime, refetchTopStats, refetchMainGraph]) + + const importedSwitchVisible = !['no_imported_data', 'out_of_range'].includes( + topStatsQuery.data?.meta.imports_skip_reason as string + ) + + const importedIntervalUnsupportedNotice = + ['hour', 'minute'].includes(selectedInterval) && + importedSwitchVisible && + dashboardState.with_imported + ? 'Interval is too short to graph imported data' + : null + + const { heightPx } = useGuessTopStatsHeight(site, topStatsBoundary) + + const showFullLoader = + topStatsQuery.isFetching && + topStatsQuery.isStale && + !isRealtimeSilentUpdate.topStats + + const showGraphLoader = + mainGraphQuery.isFetching && + mainGraphQuery.isStale && + !isRealtimeSilentUpdate.mainGraph && + !showFullLoader + + return ( +
+ <> +
+ {topStatsQuery.data ? ( + + ) : ( + // prevent the top stats area from jumping on initial load +
+ )} +
+
+ {topStatsQuery.data && ( +
+ !!n + ) as string[] + } + /> + {!isRealtime && ( + + )} + {importedSwitchVisible && ( + + )} + +
+ )} + + {mainGraphQuery.data && ( + <> + {!showGraphLoader && ( + + )} + {showGraphLoader && } + + )} + +
+ + {(!(topStatsQuery.data && mainGraphQuery.data) || showFullLoader) && ( + + )} +
+ ) +} + +const Loader = () => { + return ( +
+
+
+
+
+ ) +} + +function getStoredMetricKey(site: Pick) { + return storage.getDomainScopedStorageKey('metric', site.domain) +} + +function getStoredMetric(site: Pick) { + return storage.getItem(getStoredMetricKey(site)) as Metric | null +} + +function setStoredMetric(site: Pick, metric: Metric) { + storage.setItem(getStoredMetricKey(site), metric) +} + +function getStoredTopStatsHeightKey(site: Pick) { + return storage.getDomainScopedStorageKey('topStatsHeight', site.domain) +} + +function getStoredTopStatsHeight(site: Pick) { + return storage.getItem(getStoredTopStatsHeightKey(site)) as string +} + +function setStoredTopStatsHeight( + site: Pick, + heightPx: string +) { + storage.setItem(getStoredTopStatsHeightKey(site), heightPx) +} + +function useGuessTopStatsHeight( + site: Pick, + topStatsBoundary: React.RefObject +): { heightPx: string } { + useEffect(() => { + const resizeObserver = new ResizeObserver(() => { + if (topStatsBoundary.current) { + setStoredTopStatsHeight( + site, + `${Math.max(topStatsBoundary.current.clientHeight, DEFAULT_TOP_STATS_LOADING_HEIGHT_PX)}` + ) + } + }) + + if (topStatsBoundary.current) { + resizeObserver.observe(topStatsBoundary.current) + } + + return () => { + resizeObserver.disconnect() + } + }, [site, topStatsBoundary]) + + return { + heightPx: + getStoredTopStatsHeight(site) ?? DEFAULT_TOP_STATS_LOADING_HEIGHT_PX + } +} diff --git a/assets/js/dashboard/stats/graph/with-imported-switch.js b/assets/js/dashboard/stats/graph/with-imported-switch.js deleted file mode 100644 index f6d64dcb009d..000000000000 --- a/assets/js/dashboard/stats/graph/with-imported-switch.js +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' -import { BarsArrowUpIcon } from '@heroicons/react/20/solid' -import classNames from 'classnames' -import { useQueryContext } from '../../query-context' -import { AppNavigationLink } from '../../navigation/use-app-navigate' - -export default function WithImportedSwitch({ tooltipMessage, disabled }) { - const { query } = useQueryContext() - const importsSwitchedOn = query.with_imported - - const iconClass = classNames('mt-0.5', { - 'dark:text-gray-300 text-gray-700': importsSwitchedOn, - 'dark:text-gray-500 text-gray-400': !importsSwitchedOn - }) - - return ( -
- search - : (search) => ({ ...search, with_imported: !importsSwitchedOn }) - } - > - - -
- ) -} diff --git a/assets/js/dashboard/stats/graph/with-imported-switch.tsx b/assets/js/dashboard/stats/graph/with-imported-switch.tsx new file mode 100644 index 000000000000..1beef3cdaebb --- /dev/null +++ b/assets/js/dashboard/stats/graph/with-imported-switch.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { BarsArrowUpIcon } from '@heroicons/react/20/solid' +import classNames from 'classnames' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { AppNavigationLink } from '../../navigation/use-app-navigate' +import { Tooltip } from '../../util/tooltip' + +export default function WithImportedSwitch({ + tooltipMessage, + disabled +}: { + tooltipMessage: string + disabled?: boolean +}) { + const { dashboardState } = useDashboardStateContext() + const importsSwitchedOn = dashboardState.with_imported + + const iconClass = classNames('size-4', { + 'dark:text-gray-300 text-gray-700': importsSwitchedOn, + 'dark:text-gray-500 text-gray-400': !importsSwitchedOn + }) + + return ( + {tooltipMessage}
} + className="size-4" + > + search + : (search) => ({ ...search, with_imported: !importsSwitchedOn }) + } + className="flex items-center justify-center" + > + + + + ) +} diff --git a/assets/js/dashboard/stats/imported-query-unsupported-warning.js b/assets/js/dashboard/stats/imported-query-unsupported-warning.js index a6aab6128c80..f17719bc5094 100644 --- a/assets/js/dashboard/stats/imported-query-unsupported-warning.js +++ b/assets/js/dashboard/stats/imported-query-unsupported-warning.js @@ -1,7 +1,8 @@ -import React from 'react' +import React, { useRef, useEffect } from 'react' import { ExclamationCircleIcon } from '@heroicons/react/24/outline' import FadeIn from '../fade-in' -import { useQueryContext } from '../query-context' +import { useDashboardStateContext } from '../dashboard-state-context' +import { Tooltip } from '../util/tooltip' export default function ImportedQueryUnsupportedWarning({ loading, @@ -9,21 +10,28 @@ export default function ImportedQueryUnsupportedWarning({ altCondition, message }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() + const portalRef = useRef(null) const tooltipMessage = message || 'Imported data is excluded due to applied filters' const show = - query && - query.with_imported && + dashboardState && + dashboardState.with_imported && skipImportedReason === 'unsupported_query' && - query.period !== 'realtime' + dashboardState.period !== 'realtime' + + useEffect(() => { + if (typeof document !== 'undefined') { + portalRef.current = document.body + } + }, []) if (show || altCondition) { return ( - - - - + + + + ) } else { diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js index ec95cf26620f..1421f4d42198 100644 --- a/assets/js/dashboard/stats/locations/index.js +++ b/assets/js/dashboard/stats/locations/index.js @@ -13,12 +13,17 @@ import { } from '../../util/filters' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import { citiesRoute, countriesRoute, regionsRoute } from '../../router' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' -function Countries({ query, site, onClick, afterFetchData }) { +function Countries({ dashboardState, site, onClick, afterFetchData }) { function fetchData() { - return api.get(apiPath(site, '/countries'), query, { limit: 9 }) + return api.get(apiPath(site, '/countries'), dashboardState, { limit: 9 }) } function renderIcon(country) { @@ -36,7 +41,9 @@ function Countries({ query, site, onClick, afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -48,19 +55,15 @@ function Countries({ query, site, onClick, afterFetchData }) { onClick={onClick} keyLabel="Country" metrics={chooseMetrics()} - detailsLinkProps={{ - path: countriesRoute.path, - search: (search) => search - }} renderIcon={renderIcon} - color="bg-orange-50" + color="bg-orange-50 group-hover/row:bg-orange-100" /> ) } -function Regions({ query, site, onClick, afterFetchData }) { +function Regions({ dashboardState, site, onClick, afterFetchData }) { function fetchData() { - return api.get(apiPath(site, '/regions'), query, { limit: 9 }) + return api.get(apiPath(site, '/regions'), dashboardState, { limit: 9 }) } function renderIcon(region) { @@ -78,7 +81,9 @@ function Regions({ query, site, onClick, afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -90,16 +95,15 @@ function Regions({ query, site, onClick, afterFetchData }) { onClick={onClick} keyLabel="Region" 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" /> ) } -function Cities({ query, site, afterFetchData }) { +function Cities({ dashboardState, site, afterFetchData }) { function fetchData() { - return api.get(apiPath(site, '/cities'), query, { limit: 9 }) + return api.get(apiPath(site, '/cities'), dashboardState, { limit: 9 }) } function renderIcon(city) { @@ -117,7 +121,9 @@ function Cities({ query, site, afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -128,19 +134,12 @@ function Cities({ query, site, afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="City" 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" /> ) } -const labelFor = { - countries: 'Countries', - regions: 'Regions', - cities: 'Cities' -} - class Locations extends React.Component { constructor(props) { super(props) @@ -152,15 +151,17 @@ class Locations extends React.Component { this.state = { mode: storedTab || 'map', loading: true, - skipImportedReason: null + skipImportedReason: null, + moreLinkState: MoreLinkState.LOADING } } componentDidUpdate(prevProps, prevState) { const isRemovingFilter = (filterName) => { return ( - getFiltersByKeyPrefix(prevProps.query, filterName).length > 0 && - getFiltersByKeyPrefix(this.props.query, filterName).length == 0 + getFiltersByKeyPrefix(prevProps.dashboardState, filterName).length > + 0 && + getFiltersByKeyPrefix(this.props.dashboardState, filterName).length == 0 ) } @@ -173,10 +174,10 @@ class Locations extends React.Component { } if ( - this.props.query !== prevProps.query || + this.props.dashboardState !== prevProps.dashboardState || this.state.mode !== prevState.mode ) { - this.setState({ loading: true }) + this.setState({ loading: true, moreLinkState: MoreLinkState.LOADING }) } } @@ -199,8 +200,16 @@ class Locations extends React.Component { } afterFetchData(apiResponse) { + let newMoreLinkState + + if (apiResponse.results && apiResponse.results.length > 0) { + newMoreLinkState = MoreLinkState.READY + } else { + newMoreLinkState = MoreLinkState.HIDDEN + } this.setState({ loading: false, + moreLinkState: newMoreLinkState, skipImportedReason: apiResponse.skip_imported_reason }) } @@ -211,7 +220,7 @@ class Locations extends React.Component { return ( ) @@ -220,7 +229,7 @@ class Locations extends React.Component { ) @@ -229,7 +238,7 @@ class Locations extends React.Component { ) @@ -244,56 +253,63 @@ class Locations extends React.Component { } } - renderPill(name, mode) { - const isActive = this.state.mode === mode + getMoreLinkProps() { + let path - if (isActive) { - return ( - - ) + if (this.state.mode === 'regions') { + path = regionsRoute.path + } else if (this.state.mode === 'cities') { + path = citiesRoute.path + } else { + path = countriesRoute.path } - return ( - - ) + return { path: path, search: (search) => search } } render() { return ( -
-
-
-

- {labelFor[this.state.mode] || 'Locations'} -

+ + +
+ + {[ + { label: 'Map', value: 'map' }, + { label: 'Countries', value: 'countries' }, + { label: 'Regions', value: 'regions' }, + { label: 'Cities', value: 'cities' } + ].map(({ value, label }) => ( + + {label} + + ))} +
-
- {this.renderPill('Map', 'map')} - {this.renderPill('Countries', 'countries')} - {this.renderPill('Regions', 'regions')} - {this.renderPill('Cities', 'cities')} -
-
+ + {this.renderContent()} -
+ ) } } function LocationsWithContext() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() - return + return } export default LocationsWithContext diff --git a/assets/js/dashboard/stats/locations/map-tooltip.tsx b/assets/js/dashboard/stats/locations/map-tooltip.tsx index 7a36a9a8f85a..9e28c5c3f947 100644 --- a/assets/js/dashboard/stats/locations/map-tooltip.tsx +++ b/assets/js/dashboard/stats/locations/map-tooltip.tsx @@ -32,7 +32,10 @@ export const MapTooltip = ({ name, value, label, x, y }: MapTooltipProps) => ( top: y }} > -
{name}
- {value} {label} +
{name}
+
+ {value} + {label} +
) diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 8107dfb70332..33dd356aace8 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -2,21 +2,25 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as d3 from 'd3' import classNames from 'classnames' import * as api from '../../api' -import { replaceFilterByPrefix, cleanLabels } from '../../util/filters' +import { + replaceFilterByPrefix, + cleanLabels, + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' import { useAppNavigate } from '../../navigation/use-app-navigate' import { numberShortFormatter } from '../../util/number-formatter' import * as topojson from 'topojson-client' import { useQuery } from '@tanstack/react-query' import { useSiteContext } from '../../site-context' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import worldJson from 'visionscarto-world-atlas/world/110m.json' import { UIMode, useTheme } from '../../theme-context' import { apiPath } from '../../util/url' -import MoreLink from '../more-link' -import { countriesRoute } from '../../router' import { MIN_HEIGHT } from '../reports/list' import { MapTooltip } from './map-tooltip' import { GeolocationNotice } from './geolocation-notice' +import { DashboardState } from '../../dashboard-state' const width = 475 const height = 335 @@ -29,6 +33,16 @@ type CountryData = { } type WorldJsonCountryData = { properties: { name: string; a3: string } } +function getMetricLabel(dashboardState: DashboardState) { + if (hasConversionGoalFilter(dashboardState)) { + return { singular: 'Conversion', plural: 'Conversions' } + } + if (isRealTimeDashboard(dashboardState)) { + return { singular: 'Current visitor', plural: 'Current visitors' } + } + return { singular: 'Visitor', plural: 'Visitors' } +} + const WorldMap = ({ onCountrySelect, afterFetchData @@ -39,7 +53,7 @@ const WorldMap = ({ const navigate = useAppNavigate() const { mode } = useTheme() const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const svgRef = useRef(null) const [tooltip, setTooltip] = useState<{ x: number @@ -47,18 +61,18 @@ const WorldMap = ({ hoveredCountryAlpha3Code: string | null }>({ x: 0, y: 0, hoveredCountryAlpha3Code: null }) - const labels = - query.period === 'realtime' - ? { singular: 'Current visitor', plural: 'Current visitors' } - : { singular: 'Visitor', plural: 'Visitors' } + const metricLabel = useMemo( + () => getMetricLabel(dashboardState), + [dashboardState] + ) const { data, refetch, isFetching, isError } = useQuery({ - queryKey: ['countries', 'map', query], + queryKey: ['countries', 'map', dashboardState], placeholderData: (previousData) => previousData, queryFn: async (): Promise<{ results: CountryData[] }> => { - return await api.get(apiPath(site, '/countries'), query, { + return await api.get(apiPath(site, '/countries'), dashboardState, { limit: 300 }) } @@ -66,19 +80,19 @@ const WorldMap = ({ useEffect(() => { const onTickRefetchData = () => { - if (query.period === 'realtime') { + if (dashboardState.period === 'realtime') { refetch() } } document.addEventListener('tick', onTickRefetchData) return () => document.removeEventListener('tick', onTickRefetchData) - }, [query.period, refetch]) + }, [dashboardState.period, refetch]) useEffect(() => { if (data) { afterFetchData(data) } - }, [afterFetchData, data]) + }, [afterFetchData, data, isFetching]) const { maxValue, dataByCountryCode } = useMemo(() => { const dataByCountryCode: Map = new Map() @@ -97,19 +111,21 @@ const WorldMap = ({ const country = dataByCountryCode.get(d.properties.a3) const clickable = country && country.visitors if (clickable) { - const filters = replaceFilterByPrefix(query, 'country', [ + const filters = replaceFilterByPrefix(dashboardState, 'country', [ 'is', 'country', [country.code] ]) - const labels = cleanLabels(filters, query.labels, 'country', { + const labels = cleanLabels(filters, dashboardState.labels, 'country', { [country.code]: country.name }) onCountrySelect() - navigate({ search: (search) => ({ ...search, filters, labels }) }) + navigate({ + search: (searchRecord) => ({ ...searchRecord, filters, labels }) + }) } }, - [navigate, query, dataByCountryCode, onCountrySelect] + [navigate, dashboardState, dataByCountryCode, onCountrySelect] ) useEffect(() => { @@ -117,7 +133,28 @@ const WorldMap = ({ return } - const svg = drawInteractiveCountries(svgRef.current, setTooltip) + const { svg, countriesSelection } = drawInteractiveCountries(svgRef.current) + const highlightSelection = drawHighlightedCountryOutline(svgRef.current) + + countriesSelection + .on('mouseover', function (event, country) { + const [x, y] = d3.pointer(event, svg.node()?.parentNode) + setTooltip({ x, y, hoveredCountryAlpha3Code: country.properties.a3 }) + + highlightSelection + .attr('d', this.getAttribute('d')) + .attr('class', hoveredOutlineClass) + }) + + .on('mousemove', function (event) { + const [x, y] = d3.pointer(event, svg.node()?.parentNode) + setTooltip((currentState) => ({ ...currentState, x, y })) + }) + + .on('mouseout', function () { + setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null }) + highlightSelection.attr('d', null).attr('class', initialOutlineClass) + }) return () => { svg.selectAll('*').remove() @@ -148,10 +185,12 @@ const WorldMap = ({ : undefined return ( -
-
+
)} @@ -179,44 +220,50 @@ const WorldMap = ({
))}
- ) => search - }} - className={undefined} - onClick={undefined} - /> {site.isDbip && }
) } const colorScales = { - [UIMode.dark]: ['#2e3954', '#6366f1'], - [UIMode.light]: ['#f3ebff', '#a779e9'] + [UIMode.dark]: ['#2a276d', '#6366f1'], // custom color between indigo-900 and indigo-950, indigo-500 + [UIMode.light]: ['#e0e7ff', '#818cf8'] // indigo-100, indigo-400 } -const sharedCountryClass = classNames('transition-colors') +const countryElementClass = 'country' +const countrySelector = `path.${countryElementClass}` +const initialStroke = classNames( + 'stroke-white', + 'dark:stroke-gray-900', + 'stroke-1px' +) +const hoveredStroke = classNames( + 'stroke-[1.5px]', + 'stroke-indigo-400', + 'dark:stroke-indigo-500' +) const countryClass = classNames( - sharedCountryClass, + countryElementClass, + initialStroke, + 'transition-colors', 'stroke-1', - 'fill-[#f8fafc]', - 'stroke-[#dae1e7]', - 'dark:fill-[#2d3747]', - 'dark:stroke-[#1f2937]' + 'fill-gray-150', + 'dark:fill-gray-750' +) + +const sharedOutlineClass = classNames( + 'transition-colors', + 'fill-none', + 'pointer-events-none' ) -const highlightedCountryClass = classNames( - sharedCountryClass, - 'stroke-2', - 'fill-[#f5f5f5]', - 'stroke-[#a779e9]', - 'dark:fill-[#374151]', - 'dark:stroke-[#4f46e5]' +const initialOutlineClass = classNames( + sharedOutlineClass, + initialStroke, + 'opacity-0' ) +const hoveredOutlineClass = classNames(sharedOutlineClass, hoveredStroke) /** * Used to color the countries @@ -236,7 +283,7 @@ function colorInCountriesWithValues( const svg = d3.select(element) return svg - .selectAll('path') + .selectAll(countrySelector) .style('fill', (countryPath) => { const country = getCountryByCountryPath(countryPath) if (!country?.visitors) { @@ -253,48 +300,25 @@ function colorInCountriesWithValues( }) } +function drawHighlightedCountryOutline(element: SVGSVGElement) { + return d3.select(element).append('path').attr('class', initialOutlineClass) +} + /** @returns the d3 selected svg element */ -function drawInteractiveCountries( - element: SVGSVGElement, - setTooltip: React.Dispatch< - React.SetStateAction<{ - x: number - y: number - hoveredCountryAlpha3Code: string | null - }> - > -) { +function drawInteractiveCountries(element: SVGSVGElement) { const path = setupProjetionPath() const data = parseWorldTopoJsonToGeoJsonFeatures() const svg = d3.select(element) - svg - .selectAll('path') + const countriesSelection = svg + .selectAll(countrySelector) .data(data) .enter() .append('path') .attr('class', countryClass) .attr('d', path as never) - .on('mouseover', function (event, country) { - const [x, y] = d3.pointer(event, svg.node()?.parentNode) - setTooltip({ x, y, hoveredCountryAlpha3Code: country.properties.a3 }) - // brings country to front - this.parentNode?.appendChild(this) - d3.select(this).attr('class', highlightedCountryClass) - }) - - .on('mousemove', function (event) { - const [x, y] = d3.pointer(event, svg.node()?.parentNode) - setTooltip((currentState) => ({ ...currentState, x, y })) - }) - - .on('mouseout', function () { - setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null }) - d3.select(this).attr('class', countryClass) - }) - - return svg + return { svg, countriesSelection } } function setupProjetionPath() { diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index 93a82063661c..b2ab4f6cae15 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -1,6 +1,6 @@ import React, { useState, ReactNode, useMemo } from 'react' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { usePaginatedGetAPI } from '../../hooks/api-client' import { rootRoute } from '../../router' import { @@ -11,12 +11,14 @@ import { useRememberOrderBy } from '../../hooks/use-order-by' import { Metric } from '../reports/metrics' -import { BreakdownResultMeta, DashboardQuery } from '../../query' +import * as metricsModule from '../reports/metrics' +import { BreakdownResultMeta, DashboardState } from '../../dashboard-state' 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. */ @@ -32,9 +34,11 @@ export type ReportInfo = { type BreakdownModalProps = { /** Dimension and title of the breakdown. */ reportInfo: ReportInfo - /** Function that must return a new query that contains appropriate search filter for searchValue param. */ - addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery + /** Function that must return a new dashboardState that contains appropriate search filter for searchValue param. */ + addSearchFilter?: (q: DashboardState, searchValue: string) => DashboardState searchEnabled?: boolean + /** When true, keep the percentage metric as a permanently visible, sortable column. */ + showPercentageColumn?: boolean } /** @@ -43,7 +47,7 @@ type BreakdownModalProps = { BreakdownModal is expected to be rendered inside a ``, which has it's own specific URL pathname (e.g. /plausible.io/sources). During the lifecycle of a - BreakdownModal, the `query` object is not expected to change. + BreakdownModal, the `dashboardState` object is not expected to change. ### Search As You Type @see BreakdownTable @@ -62,51 +66,63 @@ export default function BreakdownModal({ renderIcon, getExternalLinkUrl, searchEnabled = true, + showPercentageColumn = false, afterFetchData, afterFetchNextPage, addSearchFilter, getFilterInfo }: Omit, 'fetchData'> & BreakdownModalProps) { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const [meta, setMeta] = useState(null) + const breakdownMetrics = useMemo(() => { + const hasPercentage = metrics.some((m) => m.key === 'percentage') + if (!hasPercentage && !hasConversionGoalFilter(dashboardState)) { + return [...metrics, metricsModule.createPercentage()] + } + return metrics + }, [metrics, dashboardState]) + 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< { results: Array; meta: BreakdownResultMeta }, - [string, { query: DashboardQuery; search: string; orderBy: OrderBy }] + [ + string, + { dashboardState: DashboardState; search: string; orderBy: OrderBy } + ] >({ - key: [reportInfo.endpoint, { query, search, orderBy }], + key: [reportInfo.endpoint, { dashboardState, search, orderBy }], getRequestParams: (key) => { - const [_endpoint, { query, search }] = key + const [_endpoint, { dashboardState, search }] = key - let queryWithSearchFilter = { ...query } + let dashboardStateWithSearchFilter = { ...dashboardState } if ( searchEnabled && typeof addSearchFilter === 'function' && search !== '' ) { - queryWithSearchFilter = addSearchFilter(query, search) + dashboardStateWithSearchFilter = addSearchFilter(dashboardState, search) } return [ - queryWithSearchFilter, + dashboardStateWithSearchFilter, { detailed: true, order_by: JSON.stringify(orderBy) @@ -125,7 +141,7 @@ export default function BreakdownModal({ { 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) => ( ({ /> ) }, - ...metrics.map( - (m): ColumnConfiguraton => ({ - label: m.renderLabel(query), - key: m.key, - width: m.width, - align: 'right', - metricWarning: getMetricWarning(m, meta), - renderValue: (item) => m.renderValue(item, meta), - onSort: m.sortable ? () => toggleSortByMetric(m) : undefined, - sortDirection: orderByDictionary[m.key] - }) - ) + ...breakdownMetrics + .filter((m) => showPercentageColumn || m.key !== 'percentage') + .map( + (m): ColumnConfiguraton => ({ + label: m.renderLabel(dashboardState), + key: m.key, + width: m.width, + align: 'right', + metricWarning: getMetricWarning(m, meta), + renderValue: (item, isRowHovered) => + m.renderValue( + showPercentageColumn && m.key === 'visitors' + ? { ...item, percentage: null } + : item, + meta, + { detailedView: true, isRowHovered } + ), + onSort: m.sortable ? () => toggleSortByMetric(m) : undefined, + sortDirection: orderByDictionary[m.key] + }) + ) ], [ reportInfo.dimensionLabel, - metrics, + breakdownMetrics, getFilterInfo, - query, + dashboardState, orderByDictionary, toggleSortByMetric, renderIcon, getExternalLinkUrl, - meta + meta, + showPercentageColumn ] ) @@ -190,7 +216,7 @@ const NameCell = ({ renderIcon?: (item: TListItem) => ReactNode getExternalLinkUrl?: (listItem: TListItem) => string }) => ( - <> +
{typeof renderIcon === 'function' && renderIcon(item)} ({ {typeof getExternalLinkUrl === 'function' && ( )} - +
) const ExternalLinkIcon = ({ url }: { url?: string }) => @@ -231,6 +257,9 @@ const getMetricWarning = (metric: Metric, meta: BreakdownResultMeta | null) => { if (warnings && warnings[metric.key]) { const { code, message } = warnings[metric.key] + if (metric.key == 'bounce_rate' && code == 'no_imported_bounce_rate') { + return 'Does not include imported data' + } if (metric.key == 'scroll_depth' && code == 'no_imported_scroll_depth') { return 'Does not include imported data' } diff --git a/assets/js/dashboard/stats/modals/breakdown-table.tsx b/assets/js/dashboard/stats/modals/breakdown-table.tsx index 4a5281768d62..954a29991b2c 100644 --- a/assets/js/dashboard/stats/modals/breakdown-table.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-table.tsx @@ -1,11 +1,12 @@ -import React, { ReactNode } from 'react' +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 = ({ title, @@ -19,7 +20,8 @@ export const BreakdownTable = ({ data, status, error, - displayError + displayError, + onClose }: { title: ReactNode onSearch?: (input: string) => void @@ -34,42 +36,60 @@ export const BreakdownTable = ({ error?: Error | null /** Controls whether the component displays API request errors or ignores them. */ displayError?: boolean -}) => ( -
-
-
-

{title}

- {!isPending && isFetching && } + onClose?: () => void +}) => { + const searchRef = useRef(null) + const navigate = useAppNavigate() + const handleClose = + onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s })) + + return ( + <> +
+
+

+ {title} +

+ {!!onSearch && ( + + )} + {!isPending && isFetching && } +
+
- {!!onSearch && ( - - )} -
-
-
- {displayError && status === 'error' && } - {isPending && } - {data && data={data} columns={columns} />} - {!isPending && !isFetching && hasNextPage && ( - fetchNextPage()} - isFetchingNextPage={isFetchingNextPage} - /> - )} -
-
-) +
+
+ {displayError && status === 'error' && } + {isPending && } + {data && data={data} columns={columns} />} + {!isPending && !isFetching && hasNextPage && ( + fetchNextPage()} + isFetchingNextPage={isFetchingNextPage} + /> + )} +
+ + ) +} const InitialLoadingSpinner = () => ( -
+
@@ -83,10 +103,7 @@ const SmallLoadingSpinner = () => ( ) const ErrorMessage = ({ error }: { error?: unknown }) => ( -
+
diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index ff1365d7a45c..92d6834f1b18 100644 --- a/assets/js/dashboard/stats/modals/conversions.js +++ b/assets/js/dashboard/stats/modals/conversions.js @@ -5,7 +5,7 @@ import BreakdownModal from './breakdown-modal' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useSiteContext } from '../../site-context' -import { addFilter } from '../../query' +import { addFilter } from '../../dashboard-state' /*global BUILD_EXTRA*/ function ConversionsModal() { @@ -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' @@ -30,8 +30,8 @@ function ConversionsModal() { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -43,8 +43,8 @@ function ConversionsModal() { function chooseMetrics() { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Uniques' }), - metrics.createEvents({ renderLabel: (_query) => 'Total' }), + metrics.createVisitors({ renderLabel: (_dashboardState) => 'Uniques' }), + metrics.createEvents({ renderLabel: (_dashboardState) => 'Total' }), metrics.createConversionRate(), showRevenue && metrics.createAverageRevenue(), showRevenue && metrics.createTotalRevenue() diff --git a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js index 7fa8bfb48f38..460a04aaca76 100644 --- a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js @@ -1,20 +1,20 @@ import React, { useCallback } from 'react' import Modal from './../modal' -import { addFilter } from '../../../query' +import { addFilter } from '../../../dashboard-state' import BreakdownModal from './../breakdown-modal' import * as url from '../../../util/url' -import { useQueryContext } from '../../../query-context' +import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { browserIconFor } from '../../devices' import chooseMetrics from './choose-metrics' import { SortDirection } from '../../../hooks/use-order-by' function BrowserVersionsModal() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() const reportInfo = { - title: 'Browser Versions', + title: 'Browser versions', dimension: 'browser_version', endpoint: url.apiPath(site, '/browser-versions'), dimensionLabel: 'Browser version', @@ -32,8 +32,8 @@ function BrowserVersionsModal() { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -52,7 +52,7 @@ function BrowserVersionsModal() { { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -52,7 +52,7 @@ function BrowsersModal() { 'Conversions', - width: 'w-28' + renderLabel: (_dashboardState) => 'Conversions', + width: 'w-32 md:w-28' }), - metrics.createConversionRate() - ] + metrics.createConversionRate(), + showRevenueMetrics && metrics.createTotalRevenue(), + showRevenueMetrics && metrics.createAverageRevenue() + ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if ( + isRealTimeDashboard(dashboardState) && + !hasConversionGoalFilter(dashboardState) + ) { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Current visitors', - width: 'w-36' + renderLabel: (_dashboardState) => 'Current visitors', + width: 'w-32' }), metrics.createPercentage() ] } return [ - metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }), + metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), metrics.createPercentage(), metrics.createBounceRate(), metrics.createVisitDuration() diff --git a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js index 0749cc84de0f..ca3ae1919333 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js @@ -1,20 +1,20 @@ import React, { useCallback } from 'react' import Modal from './../modal' -import { addFilter } from '../../../query' +import { addFilter } from '../../../dashboard-state' import BreakdownModal from './../breakdown-modal' import * as url from '../../../util/url' -import { useQueryContext } from '../../../query-context' +import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { osIconFor } from '../../devices' import chooseMetrics from './choose-metrics' import { SortDirection } from '../../../hooks/use-order-by' function OperatingSystemVersionsModal() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() 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', @@ -32,8 +32,8 @@ function OperatingSystemVersionsModal() { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -49,7 +49,7 @@ function OperatingSystemVersionsModal() { { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -49,7 +49,7 @@ function OperatingSystemsModal() { { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -47,32 +51,38 @@ function EntryPagesModal() { ) function chooseMetrics() { - if (hasConversionGoalFilter(query)) { + if (hasConversionGoalFilter(dashboardState)) { return [ metrics.createTotalVisitors(), metrics.createVisitors({ - renderLabel: (_query) => 'Conversions', + renderLabel: (_dashboardState) => 'Conversions', width: 'w-28' }), - metrics.createConversionRate() - ] + metrics.createConversionRate(), + showRevenueMetrics && metrics.createTotalRevenue(), + showRevenueMetrics && metrics.createAverageRevenue() + ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if ( + isRealTimeDashboard(dashboardState) && + !hasConversionGoalFilter(dashboardState) + ) { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Current visitors', - width: 'w-36' + renderLabel: (_dashboardState) => 'Current visitors', + width: 'w-32' }) ] } return [ - metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }), + metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), metrics.createVisits({ - renderLabel: (_query) => 'Total Entrances', - width: 'w-36' + renderLabel: (_dashboardState) => 'Total entrances', + width: 'w-32' }), + metrics.createBounceRate(), metrics.createVisitDuration() ] } diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 039efadbd7c2..84a5b6f59b8f 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -1,20 +1,27 @@ import React, { useCallback } from 'react' import Modal from './modal' -import { hasConversionGoalFilter } from '../../util/filters' -import { addFilter } from '../../query' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { addFilter, revenueAvailable } from '../../dashboard-state' import BreakdownModal from './breakdown-modal' import * as metrics from '../reports/metrics' import * as url from '../../util/url' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { SortDirection } from '../../hooks/use-order-by' function ExitPagesModal() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() + /*global BUILD_EXTRA*/ + const showRevenueMetrics = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + const reportInfo = { - title: 'Exit Pages', + title: 'Exit pages', dimension: 'exit_page', endpoint: url.apiPath(site, '/exit-pages'), dimensionLabel: 'Page url', @@ -32,8 +39,8 @@ function ExitPagesModal() { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -44,33 +51,39 @@ function ExitPagesModal() { ) function chooseMetrics() { - if (hasConversionGoalFilter(query)) { + if (hasConversionGoalFilter(dashboardState)) { return [ metrics.createTotalVisitors(), metrics.createVisitors({ - renderLabel: (_query) => 'Conversions', + renderLabel: (_dashboardState) => 'Conversions', width: 'w-28' }), - metrics.createConversionRate() - ] + metrics.createConversionRate(), + showRevenueMetrics && metrics.createTotalRevenue(), + showRevenueMetrics && metrics.createAverageRevenue() + ].filter((metric) => !!metric) } - if (query.period === 'realtime') { + if ( + isRealTimeDashboard(dashboardState) && + !hasConversionGoalFilter(dashboardState) + ) { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Current visitors', - width: 'w-36' + renderLabel: (_dashboardState) => 'Current visitors', + width: 'w-32' }) ] } return [ metrics.createVisitors({ - renderLabel: (_query) => 'Visitors', + renderLabel: (_dashboardState) => 'Visitors', sortable: true }), metrics.createVisits({ - renderLabel: (_query) => 'Total Exits', + renderLabel: (_dashboardState) => 'Total exits', + width: 'w-32', sortable: true }), metrics.createExitRate() diff --git a/assets/js/dashboard/stats/modals/filter-modal-group.js b/assets/js/dashboard/stats/modals/filter-modal-group.js index 2458e3575754..0e5c18069219 100644 --- a/assets/js/dashboard/stats/modals/filter-modal-group.js +++ b/assets/js/dashboard/stats/modals/filter-modal-group.js @@ -46,6 +46,7 @@ export default function FilterModalGroup({ {rows.map(({ id, filter }) => filterGroup === 'props' ? ( 1} @@ -55,6 +56,7 @@ export default function FilterModalGroup({ /> ) : ( +
search + }) + } + selectFiltersAndCloseModal(filters) { this.props.navigate({ path: rootRoute.path, - search: (search) => ({ - ...search, + search: (searchRecord) => ({ + ...searchRecord, filters: filters, labels: cleanLabels(filters, this.state.labelState) }), @@ -130,7 +139,7 @@ class FilterModal extends React.Component { }, labelState: cleanLabels( Object.values(this.state.filterState).concat( - this.state.query.filters + this.state.dashboardState.filters ), prevState.labelState, filterKey, @@ -169,13 +178,23 @@ class FilterModal extends React.Component { render() { return ( - -

- Filter by {formatFilterGroup(this.props.modalType)} -

+ +
+

+ Filter by {formatFilterGroup(this.props.modalType)} +

+ +
-
-
+
+
))} -
+
-
- - {this.props.children} +
+
+ + {this.props.children} +
diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index 18c679c938b7..b93ab5869e16 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -4,20 +4,24 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import { addFilter } from '../../query' +import { addFilter, revenueAvailable } from '../../dashboard-state' import BreakdownModal from './breakdown-modal' import * as metrics from '../reports/metrics' import * as url from '../../util/url' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { SortDirection } from '../../hooks/use-order-by' function PagesModal() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() + /*global BUILD_EXTRA*/ + const showRevenueMetrics = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + const reportInfo = { - title: 'Top Pages', + title: 'Top pages', dimension: 'page', endpoint: url.apiPath(site, '/pages'), dimensionLabel: 'Page url', @@ -35,8 +39,8 @@ function PagesModal() { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -47,28 +51,33 @@ function PagesModal() { ) function chooseMetrics() { - if (hasConversionGoalFilter(query)) { + if (hasConversionGoalFilter(dashboardState)) { return [ metrics.createTotalVisitors(), metrics.createVisitors({ - renderLabel: (_query) => 'Conversions', + renderLabel: (_dashboardState) => 'Conversions', width: 'w-28' }), - metrics.createConversionRate() - ] + metrics.createConversionRate(), + showRevenueMetrics && metrics.createTotalRevenue(), + showRevenueMetrics && metrics.createAverageRevenue() + ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if ( + isRealTimeDashboard(dashboardState) && + !hasConversionGoalFilter(dashboardState) + ) { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Current visitors', - width: 'w-36' + renderLabel: (_dashboardState) => 'Current visitors', + width: 'w-32' }) ] } return [ - metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }), + metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), metrics.createPageviews(), metrics.createBounceRate(), metrics.createTimeOnPage(), diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 371a053a9200..8dcc8e6e0b27 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -2,26 +2,34 @@ import React, { useCallback } from 'react' import { useParams } from 'react-router-dom' import Modal from './modal' -import { addFilter, revenueAvailable } from '../../query' -import { specialTitleWhenGoalFilter } from '../behaviours/goal-conversions' -import { EVENT_PROPS_PREFIX, hasConversionGoalFilter } from '../../util/filters' +import { addFilter, revenueAvailable } from '../../dashboard-state' +import { getSpecialGoal } from '../../util/goals' +import { + EVENT_PROPS_PREFIX, + getGoalFilter, + hasConversionGoalFilter +} from '../../util/filters' import BreakdownModal from './breakdown-modal' import * as metrics from '../reports/metrics' import * as url from '../../util/url' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { SortDirection } from '../../hooks/use-order-by' function PropsModal() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() const { propKey } = useParams() /*global BUILD_EXTRA*/ - const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) + const showRevenueMetrics = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const goalFilter = getGoalFilter(dashboardState) + const specialGoal = goalFilter ? getSpecialGoal(goalFilter) : null const reportInfo = { - title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'), + title: specialGoal ? specialGoal.title : 'Custom property breakdown', dimension: propKey, endpoint: url.apiPath( site, @@ -42,8 +50,8 @@ function PropsModal() { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString], @@ -55,10 +63,10 @@ function PropsModal() { function chooseMetrics() { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }), - metrics.createEvents({ renderLabel: (_query) => 'Events' }), - hasConversionGoalFilter(query) && metrics.createConversionRate(), - !hasConversionGoalFilter(query) && metrics.createPercentage(), + metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), + metrics.createEvents({ renderLabel: (_dashboardState) => 'Events' }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate(), + !hasConversionGoalFilter(dashboardState) && metrics.createPercentage(), showRevenueMetrics && metrics.createAverageRevenue(), showRevenueMetrics && metrics.createTotalRevenue() ].filter((metric) => !!metric) @@ -71,6 +79,7 @@ function PropsModal() { metrics={chooseMetrics()} getFilterInfo={getFilterInfo} addSearchFilter={addSearchFilter} + showPercentageColumn /> ) diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 023c63cfb62d..8266a6fc5466 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -9,18 +9,23 @@ import { import BreakdownModal from './breakdown-modal' import * as metrics from '../reports/metrics' import * as url from '../../util/url' -import { addFilter } from '../../query' -import { useQueryContext } from '../../query-context' +import { addFilter, revenueAvailable } from '../../dashboard-state' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { SortDirection } from '../../hooks/use-order-by' +import { SourceFavicon } from '../sources/source-favicon' function ReferrerDrilldownModal() { const { referrer } = useParams() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() + /*global BUILD_EXTRA*/ + const showRevenueMetrics = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + const reportInfo = { - title: 'Referrer Drilldown', + title: 'Referrer drilldown', dimension: 'referrer', endpoint: url.apiPath( site, @@ -41,8 +46,8 @@ function ReferrerDrilldownModal() { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -53,28 +58,33 @@ function ReferrerDrilldownModal() { ) function chooseMetrics() { - if (hasConversionGoalFilter(query)) { + if (hasConversionGoalFilter(dashboardState)) { return [ metrics.createTotalVisitors(), metrics.createVisitors({ - renderLabel: (_query) => 'Conversions', + renderLabel: (_dashboardState) => 'Conversions', width: 'w-28' }), - metrics.createConversionRate() - ] + metrics.createConversionRate(), + showRevenueMetrics && metrics.createTotalRevenue(), + showRevenueMetrics && metrics.createAverageRevenue() + ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if ( + isRealTimeDashboard(dashboardState) && + !hasConversionGoalFilter(dashboardState) + ) { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Current visitors', - width: 'w-36' + renderLabel: (_dashboardState) => 'Current visitors', + width: 'w-32' }) ] } return [ - metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }), + metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), metrics.createBounceRate(), metrics.createVisitDuration() ] @@ -82,10 +92,9 @@ function ReferrerDrilldownModal() { const renderIcon = useCallback((listItem) => { return ( - ) }, []) diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index ea0d9313eb58..b52b8e20e2ab 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -7,15 +7,16 @@ import { import BreakdownModal from './breakdown-modal' import * as metrics from '../reports/metrics' import * as url from '../../util/url' -import { addFilter } from '../../query' -import { useQueryContext } from '../../query-context' +import { addFilter, revenueAvailable } from '../../dashboard-state' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { SortDirection } from '../../hooks/use-order-by' +import { SourceFavicon } from '../sources/source-favicon' const VIEWS = { sources: { info: { - title: 'Top Sources', + title: 'Top sources', dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source', @@ -23,17 +24,16 @@ const VIEWS = { }, renderIcon: (listItem) => { return ( - ) } }, channels: { info: { - title: 'Top Acquisition Channels', + title: 'Top acquisition channels', dimension: 'channel', endpoint: '/channels', dimensionLabel: 'Channel', @@ -42,55 +42,59 @@ 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] } } } function SourcesModal({ currentView }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() + /*global BUILD_EXTRA*/ + const showRevenueMetrics = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + let reportInfo = VIEWS[currentView].info reportInfo = { ...reportInfo, @@ -108,8 +112,8 @@ function SourcesModal({ currentView }) { ) const addSearchFilter = useCallback( - (query, searchString) => { - return addFilter(query, [ + (dashboardState, searchString) => { + return addFilter(dashboardState, [ 'contains', reportInfo.dimension, [searchString], @@ -120,28 +124,33 @@ function SourcesModal({ currentView }) { ) function chooseMetrics() { - if (hasConversionGoalFilter(query)) { + if (hasConversionGoalFilter(dashboardState)) { return [ metrics.createTotalVisitors(), metrics.createVisitors({ - renderLabel: (_query) => 'Conversions', + renderLabel: (_dashboardState) => 'Conversions', width: 'w-28' }), - metrics.createConversionRate() - ] + metrics.createConversionRate(), + showRevenueMetrics && metrics.createTotalRevenue(), + showRevenueMetrics && metrics.createAverageRevenue() + ].filter((metric) => !!metric) } - if (isRealTimeDashboard(query)) { + if ( + isRealTimeDashboard(dashboardState) && + !hasConversionGoalFilter(dashboardState) + ) { return [ metrics.createVisitors({ - renderLabel: (_query) => 'Current visitors', - width: 'w-36' + renderLabel: (_dashboardState) => 'Current visitors', + width: 'w-32' }) ] } return [ - metrics.createVisitors({ renderLabel: (_query) => 'Visitors' }), + metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), metrics.createBounceRate(), metrics.createVisitDuration() ] diff --git a/assets/js/dashboard/stats/more-link-state.js b/assets/js/dashboard/stats/more-link-state.js new file mode 100644 index 000000000000..45052d31d909 --- /dev/null +++ b/assets/js/dashboard/stats/more-link-state.js @@ -0,0 +1,5 @@ +export const MoreLinkState = { + HIDDEN: 'hidden', + LOADING: 'loading', + READY: 'ready' +} diff --git a/assets/js/dashboard/stats/more-link.js b/assets/js/dashboard/stats/more-link.js index c5495aba251d..f85622aa02ec 100644 --- a/assets/js/dashboard/stats/more-link.js +++ b/assets/js/dashboard/stats/more-link.js @@ -1,11 +1,12 @@ -import React from 'react' +import React, { useRef, useEffect } from 'react' import { AppNavigationLink } from '../navigation/use-app-navigate' +import { Tooltip } from '../util/tooltip' +import { MoreLinkState } from './more-link-state' function detailsIcon() { return ( 0) { +export default function MoreLink({ linkProps, state }) { + const portalRef = useRef(null) + + useEffect(() => { + if (typeof document !== 'undefined') { + portalRef.current = document.body + } + }, []) + + const baseClassName = + 'relative flex mt-px text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150 before:absolute before:inset-[-8px] before:content-[" "]' + const icon = detailsIcon() + + if (state === MoreLinkState.HIDDEN) { + return null + } + + if (state === MoreLinkState.LOADING || !linkProps) { return ( -
- - {detailsIcon()} - DETAILS - -
+ +
{icon}
+
) } - return null + + return ( + + + {icon} + + + ) } diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index ba5a51951c43..962cb935a5ae 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -7,19 +7,26 @@ import ListReport from './../reports/list' import * as metrics from './../reports/metrics' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import { hasConversionGoalFilter } from '../../util/filters' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { entryPagesRoute, exitPagesRoute, topPagesRoute } from '../../router' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' function EntryPages({ afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/entry-pages'), query, { limit: 9 }) + return api.get(url.apiPath(site, '/entry-pages'), dashboardState, { + limit: 9 + }) } function getExternalLinkUrl(page) { - return url.externalLinkForPage(site.domain, page.name) + return url.externalLinkForPage(site, page.name) } function getFilterInfo(listItem) { @@ -32,11 +39,13 @@ function EntryPages({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ - defaultLabel: 'Unique Entrances', + defaultLabel: 'Unique entrances', width: 'w-36', meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -47,25 +56,23 @@ function EntryPages({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Entry page" metrics={chooseMetrics()} - detailsLinkProps={{ - path: entryPagesRoute.path, - search: (search) => search - }} getExternalLinkUrl={getExternalLinkUrl} - color="bg-orange-50" + color="bg-orange-50 group-hover/row:bg-orange-100" /> ) } function ExitPages({ afterFetchData }) { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() function fetchData() { - return api.get(url.apiPath(site, '/exit-pages'), query, { limit: 9 }) + return api.get(url.apiPath(site, '/exit-pages'), dashboardState, { + limit: 9 + }) } function getExternalLinkUrl(page) { - return url.externalLinkForPage(site.domain, page.name) + return url.externalLinkForPage(site, page.name) } function getFilterInfo(listItem) { @@ -78,11 +85,13 @@ function ExitPages({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ - defaultLabel: 'Unique Exits', + defaultLabel: 'Unique exits', width: 'w-36', meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -93,25 +102,21 @@ function ExitPages({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Exit page" metrics={chooseMetrics()} - detailsLinkProps={{ - path: exitPagesRoute.path, - search: (search) => search - }} getExternalLinkUrl={getExternalLinkUrl} - color="bg-orange-50" + color="bg-orange-50 group-hover/row:bg-orange-100" /> ) } function TopPages({ afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/pages'), query, { limit: 9 }) + return api.get(url.apiPath(site, '/pages'), dashboardState, { limit: 9 }) } function getExternalLinkUrl(page) { - return url.externalLinkForPage(site.domain, page.name) + return url.externalLinkForPage(site, page.name) } function getFilterInfo(listItem) { @@ -124,7 +129,9 @@ function TopPages({ afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -135,24 +142,14 @@ function TopPages({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Page" metrics={chooseMetrics()} - detailsLinkProps={{ - path: topPagesRoute.path, - 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' -} - export default function Pages() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() const tabKey = `pageTab__${site.domain}` @@ -160,6 +157,7 @@ export default function Pages() { const [mode, setMode] = useState(storedTab || 'pages') const [loading, setLoading] = useState(true) const [skipImportedReason, setSkipImportedReason] = useState(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) function switchTab(mode) { storage.setItem(tabKey, mode) @@ -169,9 +167,38 @@ export default function Pages() { function afterFetchData(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } - useEffect(() => setLoading(true), [query, mode]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [dashboardState, mode]) + + function moreLinkProps() { + switch (mode) { + case 'entry-pages': + return { + path: entryPagesRoute.path, + search: (search) => search + } + case 'exit-pages': + return { + path: exitPagesRoute.path, + search: (search) => search + } + case 'pages': + default: + return { + path: topPagesRoute.path, + search: (search) => search + } + } + } function renderContent() { switch (mode) { @@ -185,48 +212,38 @@ export default function Pages() { } } - function renderPill(name, pill) { - const isActive = mode === pill - - if (isActive) { - return ( - - ) - } - - return ( - - ) - } - return ( -
- {/* Header Container */} -
-
-

- {labelFor[mode] || 'Page Visits'} -

+ + +
+ + {[ + { + label: hasConversionGoalFilter(dashboardState) + ? 'Conversion pages' + : 'Top pages', + value: 'pages' + }, + { label: 'Entry pages', value: 'entry-pages' }, + { label: 'Exit pages', value: 'exit-pages' } + ].map(({ value, label }) => ( + switchTab(value)} + > + {label} + + ))} +
-
- {renderPill('Top Pages', 'pages')} - {renderPill('Entry Pages', 'entry-pages')} - {renderPill('Exit Pages', 'exit-pages')} -
-
- {/* Main Contents */} + + {renderContent()} -
+ ) } diff --git a/assets/js/dashboard/stats/reports/change-arrow.test.tsx b/assets/js/dashboard/stats/reports/change-arrow.test.tsx index c4429e78a39c..c226575ccd29 100644 --- a/assets/js/dashboard/stats/reports/change-arrow.test.tsx +++ b/assets/js/dashboard/stats/reports/change-arrow.test.tsx @@ -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', () => { diff --git a/assets/js/dashboard/stats/reports/change-arrow.tsx b/assets/js/dashboard/stats/reports/change-arrow.tsx index 3226790dd1ce..3156ca670940 100644 --- a/assets/js/dashboard/stats/reports/change-arrow.tsx +++ b/assets/js/dashboard/stats/reports/change-arrow.tsx @@ -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 = } else if (change < 0) { icon = - } else if (change === 0 && !hideNumber) { - icon = <>〰 } + const formattedChange = hideNumber + ? null + : `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%` + return ( {icon} diff --git a/assets/js/dashboard/stats/reports/list.tsx b/assets/js/dashboard/stats/reports/list.tsx index b440403726f7..43810bbdf4c2 100644 --- a/assets/js/dashboard/stats/reports/list.tsx +++ b/assets/js/dashboard/stats/reports/list.tsx @@ -1,9 +1,7 @@ import React, { useState, useEffect, useCallback, ReactNode } from 'react' -import { AppNavigationLinkProps } from '../../navigation/use-app-navigate' import FlipMove from 'react-flip-move' import FadeIn from '../../fade-in' -import MoreLink from '../more-link' import Bar from '../bar' import LazyLoader from '../../components/lazy-loader' import { trimURL } from '../../util/url' @@ -11,13 +9,13 @@ import { isRealTimeDashboard, hasConversionGoalFilter } from '../../util/filters' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { Metric } from './metrics' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' -import { BreakdownResultMeta } from '../../query' +import { BreakdownResultMeta } from '../../dashboard-state' const MAX_ITEMS = 9 -export const MIN_HEIGHT = 380 +export const MIN_HEIGHT = 356 const ROW_HEIGHT = 32 const ROW_GAP_HEIGHT = 4 const DATA_CONTAINER_HEIGHT = @@ -26,27 +24,34 @@ const COL_MIN_WIDTH = 70 function ExternalLink({ 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 ( - + - - + ) @@ -86,13 +91,6 @@ type ListReportProps = { keyLabel: string metrics: Metric[] 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. */ @@ -101,10 +99,10 @@ type ListReportProps = { /** * @returns {HTMLElement} Table of metrics, in the following format: - * | keyLabel | METRIC_1.renderLabel(query) | METRIC_2.renderLabel(query) | ... - * |--------------------|-----------------------------|-----------------------------| --- - * | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ... - * | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ... + * | keyLabel | METRIC_1.renderLabel(dashboardState) | METRIC_2.renderLabel(dashboardState) | ... + * |--------------------|--------------------------------------|--------------------------------------| --- + * | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ... + * | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ... */ export default function ListReport< TListItem extends Record & { name: string } @@ -113,8 +111,6 @@ export default function ListReport< metrics, colMinWidth = COL_MIN_WIDTH, afterFetchData, - detailsLinkProps, - maybeHideDetails, onClick, color, getFilterInfo, @@ -122,16 +118,17 @@ export default function ListReport< getExternalLinkUrl, fetchData }: Omit, 'afterFetchNextPage'> & ListReportProps) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const [state, setState] = useState<{ loading: boolean list: TListItem[] | null meta: BreakdownResultMeta | null }>({ loading: true, list: null, meta: null }) const [visible, setVisible] = useState(false) + const [tappedRow, setTappedRow] = useState(null) - const isRealtime = isRealTimeDashboard(query) - const goalFilterApplied = hasConversionGoalFilter(query) + const isRealtime = isRealTimeDashboard(dashboardState) + const goalFilterApplied = hasConversionGoalFilter(dashboardState) const getData = useCallback(() => { if (!isRealtime) { @@ -145,7 +142,7 @@ export default function ListReport< setState({ loading: false, list: response.results, meta: response.meta }) }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [keyLabel, query]) + }, [keyLabel, dashboardState]) const onVisible = () => { setVisible(true) @@ -173,7 +170,7 @@ export default function ListReport< document.removeEventListener('tick', getData) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [keyLabel, query, visible]) + }, [keyLabel, dashboardState, visible]) // returns a filtered `metrics` list. Since currently, the backend can return different // metrics based on filters and existing data, this function validates that the metrics @@ -194,28 +191,52 @@ 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 (
{renderReportHeader()}
-
- +
+ {state.list.slice(0, MAX_ITEMS).map(renderRow)}
- - {!!detailsLinkProps && - !state.loading && - !(maybeHideDetails && !(state.list.length >= MAX_ITEMS)) && ( - - )}
) } @@ -223,30 +244,50 @@ export default function ListReport< } function renderReportHeader() { - const metricLabels = getAvailableMetrics().map((metric) => { - return ( -
- {metric.renderLabel(query)} -
- ) - }) + const metricLabels = getAvailableMetrics() + .filter((metric) => !metric.meta.showOnHover) + .map((metric) => { + return ( +
+ {metric.renderLabel(dashboardState)} +
+ ) + }) return ( -
- {keyLabel} +
+ + {keyLabel} + {metricLabels}
) } 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 (
-
+
{renderBarFor(listItem)} {renderMetricValuesFor(listItem)}
@@ -255,19 +296,19 @@ 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 ( -
+
-
+
@@ -296,19 +338,36 @@ export default function ListReport< } function renderMetricValuesFor(listItem: TListItem) { - return getAvailableMetrics().map((metric) => { - return ( -
- - {metric.renderValue(listItem, state.meta)} - -
- ) - }) + 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 ( +
+ + {metric.renderValue(listItem, state.meta, { + detailedView: false, + isRowHovered: false + })} + +
+ ) + })} + + ) } function renderLoading() { diff --git a/assets/js/dashboard/stats/reports/metric-value.test.tsx b/assets/js/dashboard/stats/reports/metric-value.test.tsx index bd78894a95c3..e18b91e907f4 100644 --- a/assets/js/dashboard/stats/reports/metric-value.test.tsx +++ b/assets/js/dashboard/stats/reports/metric-value.test.tsx @@ -11,10 +11,9 @@ const REVENUE = { long: '$1,659.50', short: '$1.7K' } describe('single value', () => { it('renders small value', async () => { - await renderWithTooltip() + render() 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() + render() expect(screen.getByTestId('metric-value')).toHaveTextContent('5.3%') - expect(screen.getByRole('tooltip')).toHaveTextContent('5.3%') }) it('renders durations', async () => { - await renderWithTooltip( - - ) + render() expect(screen.getByTestId('metric-value')).toHaveTextContent('1m 00s') - expect(screen.getByRole('tooltip')).toHaveTextContent('1m 00s') }) it('renders with custom formatter', async () => { - await renderWithTooltip( + render( `${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('') @@ -242,7 +223,7 @@ function valueProps( date_range_label: '01 Aug - 31 Aug', comparison_date_range_label: '01 July - 31 July' }, - renderLabel: (_query: unknown) => metric.toUpperCase() + renderLabel: (_dashboardState: unknown) => metric.toUpperCase() } as any /* eslint-disable-line @typescript-eslint/no-explicit-any */ } diff --git a/assets/js/dashboard/stats/reports/metric-value.tsx b/assets/js/dashboard/stats/reports/metric-value.tsx index 3e3debdfc0ee..820005bd700e 100644 --- a/assets/js/dashboard/stats/reports/metric-value.tsx +++ b/assets/js/dashboard/stats/reports/metric-value.tsx @@ -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' @@ -7,8 +7,8 @@ import { MetricFormatterShort, ValueType } from './metric-formatter' -import { BreakdownResultMeta, DashboardQuery } from '../../query' -import { useQueryContext } from '../../query-context' +import { BreakdownResultMeta, DashboardState } from '../../dashboard-state' +import { useDashboardStateContext } from '../../dashboard-state-context' type MetricValues = Record @@ -33,26 +33,90 @@ function valueRenderProps(listItem: ListItem, metric: Metric) { export default function MetricValue(props: { listItem: ListItem metric: Metric - renderLabel: (query: DashboardQuery) => string + renderLabel: (dashboardState: DashboardState) => string formatter?: (value: ValueType) => string meta: BreakdownResultMeta | null + detailedView?: boolean + isRowHovered?: boolean }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() + const portalRef = useRef(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 metricLabel = useMemo( + () => props.renderLabel(dashboardState), + [dashboardState, 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 {shortFormatter(value)} + return {displayFormatter(value)} + } + + const valueContent = ( + + {percentageDisplay && ( + + {percentageDisplay} + + )} + {displayFormatter(value)} + {comparison ? ( + + ) : null} + + ) + + if (!showTooltip) { + return valueContent } return ( } info={ } > - - {shortFormatter(value)} - {comparison ? ( - - ) : null} - + {valueContent} ) } @@ -106,34 +160,34 @@ function ComparisonTooltipContent({ return (
-
- - {longFormatter(value)} {label} - +
+
+ + {longFormatter(value)} {label} + +
+ {meta.date_range_label} +
+
-
{meta.date_range_label}
-
vs
+
-
+
{longFormatter(comparison.value)} {label}
-
+
{meta.comparison_date_range_label}
) } else { - return ( -
- {longFormatter(value)} {label} -
- ) + return
{longFormatter(value)}
} } diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index 38554065c3df..dc6fba1562ee 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -16,7 +16,7 @@ import { hasConversionGoalFilter } from '../../util/filters' // and returns the "rendered" version of it. Can be JSX or a string. // * `renderLabel` - a function rendering a label for this metric given a -// query argument. Returns string. +// dashboardState argument. Returns string. // ### Optional props @@ -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 ( ) } @@ -69,23 +72,23 @@ export const createVisitors = (props) => { if (typeof props.renderLabel === 'function') { renderLabel = props.renderLabel } else { - renderLabel = (query) => { + renderLabel = (dashboardState) => { const defaultLabel = props.defaultLabel || 'Visitors' const realtimeLabel = props.realtimeLabel || 'Current visitors' const goalFilterLabel = props.goalFilterLabel || 'Conversions' - if (query.period === 'realtime') { - return realtimeLabel - } - if (query && hasConversionGoalFilter(query)) { + if (dashboardState && hasConversionGoalFilter(dashboardState)) { return goalFilterLabel } + if (dashboardState.period === 'realtime') { + return realtimeLabel + } return defaultLabel } } return new Metric({ - width: 'w-24', + width: 'w-36', sortable: true, ...props, key: 'visitors', @@ -94,9 +97,9 @@ export const createVisitors = (props) => { } export const createConversionRate = (props) => { - const renderLabel = (_query) => 'CR' + const renderLabel = (_dashboardState) => 'CR' return new Metric({ - width: 'w-24', + width: 'w-28 md:w-24', ...props, key: 'conversion_rate', renderLabel, @@ -105,7 +108,7 @@ export const createConversionRate = (props) => { } export const createPercentage = (props) => { - const renderLabel = (_query) => '%' + const renderLabel = (_dashboardState) => '%' return new Metric({ width: 'w-24', ...props, @@ -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' + const renderLabel = (_dashboardState) => 'Revenue' return new Metric({ - width: 'w-24', + width: 'w-32', ...props, key: 'total_revenue', renderLabel, @@ -131,9 +134,9 @@ export const createTotalRevenue = (props) => { } export const createAverageRevenue = (props) => { - const renderLabel = (_query) => 'Average' + const renderLabel = (_dashboardState) => '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 = (_dashboardState) => '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 = (_dashboardState) => '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 = (_dashboardState) => 'Bounce rate' return new Metric({ - width: 'w-28', + width: 'w-28 md:w-24', ...props, key: 'bounce_rate', renderLabel, @@ -179,7 +182,7 @@ export const createBounceRate = (props) => { } export const createPageviews = (props) => { - const renderLabel = (_query) => 'Pageviews' + const renderLabel = (_dashboardState) => 'Pageviews' return new Metric({ width: 'w-28', ...props, @@ -190,9 +193,9 @@ export const createPageviews = (props) => { } export const createTimeOnPage = (props) => { - const renderLabel = (_query) => 'Time on Page' + const renderLabel = (_dashboardState) => '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 = (_dashboardState) => '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 = (_dashboardState) => 'Scroll depth' return new Metric({ - width: 'w-28', + width: 'w-28 md:w-24', ...props, key: 'scroll_depth', renderLabel, diff --git a/assets/js/dashboard/stats/reports/report-header.js b/assets/js/dashboard/stats/reports/report-header.js new file mode 100644 index 000000000000..c5b8c381abdb --- /dev/null +++ b/assets/js/dashboard/stats/reports/report-header.js @@ -0,0 +1,9 @@ +import React from 'react' + +export function ReportHeader({ children }) { + return ( +
+ {children} +
+ ) +} diff --git a/assets/js/dashboard/stats/reports/report-layout.js b/assets/js/dashboard/stats/reports/report-layout.js new file mode 100644 index 000000000000..8342c849099e --- /dev/null +++ b/assets/js/dashboard/stats/reports/report-layout.js @@ -0,0 +1,20 @@ +import React from 'react' +import classNames from 'classnames' + +export function ReportLayout({ + children, + testId = undefined, + className = undefined +}) { + return ( +
+ {children} +
+ ) +} diff --git a/assets/js/dashboard/stats/sources/index.js b/assets/js/dashboard/stats/sources/index.js index 01aa552e827a..f37672b26aca 100644 --- a/assets/js/dashboard/stats/sources/index.js +++ b/assets/js/dashboard/stats/sources/index.js @@ -6,16 +6,16 @@ import { getFiltersByKeyPrefix, isFilteringOnFixedValue } from '../../util/filters' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' export default function Sources() { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() - if (isFilteringOnFixedValue(query, 'source', 'Google')) { + if (isFilteringOnFixedValue(dashboardState, 'source', 'Google')) { return - } else if (isFilteringOnFixedValue(query, 'source')) { + } else if (isFilteringOnFixedValue(dashboardState, 'source')) { const [[_operation, _filterKey, clauses]] = getFiltersByKeyPrefix( - query, + dashboardState, 'source' ) return diff --git a/assets/js/dashboard/stats/sources/referrer-list.js b/assets/js/dashboard/stats/sources/referrer-list.js index 1552016a6036..602fc6261e59 100644 --- a/assets/js/dashboard/stats/sources/referrer-list.js +++ b/assets/js/dashboard/stats/sources/referrer-list.js @@ -5,24 +5,34 @@ import * as metrics from '../reports/metrics' import { hasConversionGoalFilter } from '../../util/filters' import ListReport from '../reports/list' import ImportedQueryUnsupportedWarning from '../../stats/imported-query-unsupported-warning' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { referrersDrilldownRoute } from '../../router' +import { SourceFavicon } from './source-favicon' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' const NO_REFERRER = 'Direct / None' export default function Referrers({ source }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() const [skipImportedReason, setSkipImportedReason] = useState(null) const [loading, setLoading] = useState(true) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) - useEffect(() => setLoading(true), [query]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [dashboardState]) function fetchReferrers() { return api.get( url.apiPath(site, `/referrers/${encodeURIComponent(source)}`), - query, + dashboardState, { limit: 9 } ) } @@ -30,6 +40,11 @@ export default function Referrers({ source }) { function afterFetchReferrers(apiResponse) { setLoading(false) setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } function getExternalLinkUrl(referrer) { @@ -52,11 +67,9 @@ export default function Referrers({ source }) { function renderIcon(listItem) { return ( - ) } @@ -64,34 +77,43 @@ export default function Referrers({ source }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } return ( -
-
-

Top Referrers

- + +
+ + {}}> + Top referrers + + + +
+ search + }} /> -
+ search - }} getExternalLinkUrl={getExternalLinkUrl} renderIcon={renderIcon} color="bg-blue-50" /> -
+ ) } diff --git a/assets/js/dashboard/stats/sources/search-terms.tsx b/assets/js/dashboard/stats/sources/search-terms.tsx index 6233362b837c..0f9c4099f242 100644 --- a/assets/js/dashboard/stats/sources/search-terms.tsx +++ b/assets/js/dashboard/stats/sources/search-terms.tsx @@ -1,14 +1,18 @@ import React, { useEffect, useCallback } from 'react' import FadeIn from '../../fade-in' import Bar from '../bar' -import MoreLink from '../more-link' import { numberShortFormatter } from '../../util/number-formatter' import RocketIcon from '../modals/rocket-icon' import * as api from '../../api' import LazyLoader from '../../components/lazy-loader' -import { referrersGoogleRoute } from '../../router' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { PlausibleSite, useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' +import { referrersGoogleRoute } from '../../router' interface SearchTerm { name: string @@ -73,7 +77,10 @@ function ConfigureSearchTermsCTA({ export function SearchTerms() { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() + const [moreLinkState, setMoreLinkState] = React.useState( + MoreLinkState.LOADING + ) const [loading, setLoading] = React.useState(true) const [errorPayload, setErrorPayload] = React.useState( @@ -88,27 +95,34 @@ export function SearchTerms() { api .get( `/api/stats/${encodeURIComponent(site.domain)}/referrers/Google`, - query + dashboardState ) .then((res) => { setLoading(false) setSearchTerms(res.results) setErrorPayload(null) + if (res.results && res.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } }) .catch((error) => { setLoading(false) setSearchTerms(null) setErrorPayload(error.payload) + setMoreLinkState(MoreLinkState.HIDDEN) }) - }, [query, site.domain]) + }, [dashboardState, site.domain]) useEffect(() => { if (visible) { setLoading(true) setSearchTerms([]) + setMoreLinkState(MoreLinkState.LOADING) fetchSearchTerms() } - }, [query, fetchSearchTerms, visible]) + }, [dashboardState, fetchSearchTerms, visible]) const onVisible = () => { setVisible(true) @@ -131,7 +145,7 @@ export function SearchTerms() { @@ -143,15 +157,6 @@ export function SearchTerms() {
))} - ) => search - }} - className="w-full mt-2" - onClick={undefined} - /> ) } @@ -186,9 +191,24 @@ export function SearchTerms() { } return ( -
-

Search Terms

-
+ + +
+ + {}}> + Search terms + + +
+ search + }} + /> +
+
{loading && (
@@ -196,7 +216,7 @@ export function SearchTerms() {
)} - + {searchTerms && searchTerms.length > 0 && renderList()} {searchTerms && searchTerms.length === 0 && renderNoDataYet()} @@ -204,6 +224,6 @@ export function SearchTerms() {
-
+ ) } diff --git a/assets/js/dashboard/stats/sources/source-favicon.tsx b/assets/js/dashboard/stats/sources/source-favicon.tsx new file mode 100644 index 000000000000..9c0bdd008293 --- /dev/null +++ b/assets/js/dashboard/stats/sources/source-favicon.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import classNames from 'classnames' + +interface SourceFaviconProps { + name: string + className?: string +} + +export const SourceFavicon = ({ name, className }: SourceFaviconProps) => { + const sourceName = name.toLowerCase() + const needsWhiteBg = + sourceName.includes('github') || sourceName.includes('chatgpt.com') + + return ( + + ) +} diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index a814db5fb878..495a3b3b8ae1 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import * as storage from '../../util/storage' import * as url from '../../util/url' @@ -10,12 +10,10 @@ import { getFiltersByKeyPrefix, hasConversionGoalFilter } from '../../util/filters' -import { Menu, Transition } from '@headlessui/react' -import { ChevronDownIcon } from '@heroicons/react/20/solid' -import classNames from 'classnames' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' -import { useQueryContext } from '../../query-context' +import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' +import { SourceFavicon } from './source-favicon' import { sourcesRoute, channelsRoute, @@ -25,37 +23,41 @@ import { utmSourcesRoute, utmTermsRoute } from '../../router' -import { BlurMenuButtonOnEscape } from '../../keybinding' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' 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 }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/sources'), query, { limit: 9 }) + return api.get(url.apiPath(site, '/sources'), dashboardState, { limit: 9 }) } function getFilterInfo(listItem) { @@ -66,19 +68,15 @@ function AllSources({ afterFetchData }) { } function renderIcon(listItem) { - return ( - - ) + return } function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -89,19 +87,18 @@ function AllSources({ afterFetchData }) { getFilterInfo={getFilterInfo} keyLabel="Source" 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" /> ) } function Channels({ onClick, afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() function fetchData() { - return api.get(url.apiPath(site, '/channels'), query, { limit: 9 }) + return api.get(url.apiPath(site, '/channels'), dashboardState, { limit: 9 }) } function getFilterInfo(listItem) { @@ -114,7 +111,9 @@ function Channels({ onClick, afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -126,30 +125,20 @@ function Channels({ onClick, afterFetchData }) { keyLabel="Channel" onClick={onClick} metrics={chooseMetrics()} - detailsLinkProps={{ - path: channelsRoute.path, - search: (search) => search - }} - color="bg-blue-50" + color="bg-blue-50 group-hover/row:bg-blue-100" /> ) } function UTMSources({ tab, afterFetchData }) { - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const site = useSiteContext() const utmTag = UTM_TAGS[tab] - const route = { - utm_medium: utmMediumsRoute, - utm_source: utmSourcesRoute, - utm_campaign: utmCampaignsRoute, - utm_content: utmContentsRoute, - utm_term: utmTermsRoute - }[tab] - function fetchData() { - return api.get(url.apiPath(site, utmTag.endpoint), query, { limit: 9 }) + return api.get(url.apiPath(site, utmTag.endpoint), dashboardState, { + limit: 9 + }) } function getFilterInfo(listItem) { @@ -162,7 +151,9 @@ function UTMSources({ tab, afterFetchData }) { function chooseMetrics() { return [ metrics.createVisitors({ meta: { plot: true } }), - hasConversionGoalFilter(query) && metrics.createConversionRate() + !hasConversionGoalFilter(dashboardState) && + metrics.createPercentage({ meta: { showOnHover: true } }), + hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() ].filter((metric) => !!metric) } @@ -173,41 +164,34 @@ function UTMSources({ tab, afterFetchData }) { getFilterInfo={getFilterInfo} 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' -} - -for (const [key, utm_tag] of Object.entries(UTM_TAGS)) { - labelFor[key] = utm_tag.title -} - export default function SourceList() { const site = useSiteContext() - const { query } = useQueryContext() + const { dashboardState } = useDashboardStateContext() const tabKey = 'sourceTab__' + site.domain const storedTab = storage.getItem(tabKey) const [currentTab, setCurrentTab] = useState(storedTab || 'all') const [loading, setLoading] = useState(true) const [skipImportedReason, setSkipImportedReason] = useState(null) - const previousQuery = usePrevious(query) - const dropdownButtonRef = useRef(null) + const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) + const previousDashboardState = usePrevious(dashboardState) - useEffect(() => setLoading(true), [query, currentTab]) + useEffect(() => { + setLoading(true) + setMoreLinkState(MoreLinkState.LOADING) + }, [dashboardState, currentTab]) useEffect(() => { const isRemovingFilter = (filterName) => { - if (!previousQuery) return false + if (!previousDashboardState) return false return ( - getFiltersByKeyPrefix(previousQuery, filterName).length > 0 && - getFiltersByKeyPrefix(query, filterName).length == 0 + getFiltersByKeyPrefix(previousDashboardState, filterName).length > 0 && + getFiltersByKeyPrefix(dashboardState, filterName).length == 0 ) } @@ -215,7 +199,7 @@ export default function SourceList() { setTab('channels')() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, currentTab]) + }, [dashboardState, currentTab]) function setTab(tab) { return () => { @@ -224,129 +208,107 @@ export default function SourceList() { } } - function renderTabs() { - const activeClass = - 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left' - const defaultClass = - 'hover:text-indigo-600 cursor-pointer truncate text-left' - const dropdownOptions = Object.keys(UTM_TAGS) - let buttonText = UTM_TAGS[currentTab] - ? UTM_TAGS[currentTab].title - : 'Campaigns' - - return ( -
-
- Channels -
-
- Sources -
- - - -
- - - {buttonText} - - -
+ function onChannelClick() { + setTab('all')() + } - - -
- {dropdownOptions.map((option) => { - return ( - - {({ active }) => ( - - {UTM_TAGS[option].title} - - )} - - ) - })} -
-
-
-
-
- ) + function afterFetchData(apiResponse) { + setLoading(false) + setSkipImportedReason(apiResponse.skip_imported_reason) + if (apiResponse.results && apiResponse.results.length > 0) { + setMoreLinkState(MoreLinkState.READY) + } else { + setMoreLinkState(MoreLinkState.HIDDEN) + } } - function onChannelClick() { - setTab('all')() + function moreLinkProps() { + if (Object.keys(UTM_TAGS).includes(currentTab)) { + const route = { + utm_medium: utmMediumsRoute, + utm_source: utmSourcesRoute, + utm_campaign: utmCampaignsRoute, + utm_content: utmContentsRoute, + utm_term: utmTermsRoute + }[currentTab] + return route + ? { + path: route.path, + search: (search) => search + } + : null + } + + switch (currentTab) { + case 'channels': + return { + path: channelsRoute.path, + search: (search) => search + } + case 'all': + default: + return { + path: sourcesRoute.path, + search: (search) => search + } + } } function renderContent() { - if (currentTab === 'all') { - return - } else if (currentTab == 'channels') { - return ( - - ) - } else { + if (Object.keys(UTM_TAGS).includes(currentTab)) { return } - } - function afterFetchData(apiResponse) { - setLoading(false) - setSkipImportedReason(apiResponse.skip_imported_reason) + switch (currentTab) { + case 'channels': + return ( + + ) + case 'all': + default: + return + } } return ( -
- {/* Header Container */} -
-
-

- {labelFor[currentTab]} -

+ + +
+ + {[ + { value: 'channels', label: 'Channels' }, + { value: 'all', label: 'Sources' } + ].map(({ value, label }) => ( + + {label} + + ))} + ({ + value, + label: title, + onClick: setTab(value), + selected: currentTab === value + }))} + > + {UTM_TAGS[currentTab] ? UTM_TAGS[currentTab].title : 'Campaigns'} + +
- {renderTabs()} -
- {/* Main Contents */} + + {renderContent()} -
+ ) } diff --git a/assets/js/dashboard/user-context.tsx b/assets/js/dashboard/user-context.tsx index 8549748224b9..e178136175bb 100644 --- a/assets/js/dashboard/user-context.tsx +++ b/assets/js/dashboard/user-context.tsx @@ -12,10 +12,24 @@ export enum Role { const userContextDefaultValue = { loggedIn: false, id: null, - role: Role.public + role: Role.public, + team: { + identifier: null, + hasConsolidatedView: false + } } as - | { loggedIn: false; id: null; role: Role } - | { loggedIn: true; id: number; role: Role } + | { + loggedIn: false + id: null + role: Role + team: { identifier: null; hasConsolidatedView: false } + } + | { + loggedIn: true + id: number + role: Role + team: { identifier: string | null; hasConsolidatedView: boolean } + } export type UserContextValue = typeof userContextDefaultValue diff --git a/assets/js/dashboard/util/filter-text.test.tsx b/assets/js/dashboard/util/filter-text.test.tsx index ca2dc815a9f6..0e02d49dccbf 100644 --- a/assets/js/dashboard/util/filter-text.test.tsx +++ b/assets/js/dashboard/util/filter-text.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Filter, FilterClauseLabels } from '../query' +import { Filter, FilterClauseLabels } from '../dashboard-state' import { plainFilterText, styledFilterText } from './filter-text' import { render, screen } from '@testing-library/react' @@ -25,11 +25,15 @@ describe('styledFilterText() and plainFilterText()', () => { ])( 'when filter is %p and labels are %p, functions return %p', (filter, labels, expectedPlainText) => { - const query = { labels } + const dashboardState = { labels } - expect(plainFilterText(query, filter)).toBe(expectedPlainText) + expect(plainFilterText(dashboardState, filter)).toBe(expectedPlainText) - render(

{styledFilterText(query, filter)}

) + render( +

+ {styledFilterText(dashboardState, filter)} +

+ ) expect(screen.getByTestId('filter-text')).toHaveTextContent( expectedPlainText ) diff --git a/assets/js/dashboard/util/filter-text.tsx b/assets/js/dashboard/util/filter-text.tsx index 1a92b6453c6d..444adf0d51b9 100644 --- a/assets/js/dashboard/util/filter-text.tsx +++ b/assets/js/dashboard/util/filter-text.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, isValidElement, Fragment } from 'react' -import { DashboardQuery, Filter } from '../query' +import { DashboardState, Filter } from '../dashboard-state' import { EVENT_PROPS_PREFIX, FILTER_OPERATIONS_DISPLAY_NAMES, @@ -9,7 +9,7 @@ import { } from './filters' export function styledFilterText( - query: Pick, + dashboardState: Pick, [operation, filterKey, clauses]: Filter ) { if (filterKey.startsWith(EVENT_PROPS_PREFIX)) { @@ -26,7 +26,7 @@ export function styledFilterText( formattedFilters as Record )[filterKey] const clausesLabels = clauses.map((value) => - getLabel(query.labels, filterKey, value) + getLabel(dashboardState.labels, filterKey, value) ) if (!formattedFilter) { @@ -42,10 +42,10 @@ export function styledFilterText( } export function plainFilterText( - query: Pick, + dashboardState: Pick, filter: Filter ) { - return reactNodeToString(styledFilterText(query, filter)) + return reactNodeToString(styledFilterText(dashboardState, filter)) } function formatClauses(labels: Array): ReactNode[] { diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js index 6e5ec2502259..4e15f3ef8327 100644 --- a/assets/js/dashboard/util/filters.js +++ b/assets/js/dashboard/util/filters.js @@ -86,8 +86,8 @@ export function getPropertyKeyFromFilterKey(filterKey) { return filterKey.slice(EVENT_PROPS_PREFIX.length) } -export function getFiltersByKeyPrefix(query, prefix) { - return query.filters.filter(hasDimensionPrefix(prefix)) +export function getFiltersByKeyPrefix(dashboardState, prefix) { + return dashboardState.filters.filter(hasDimensionPrefix(prefix)) } const hasDimensionPrefix = @@ -95,18 +95,24 @@ const hasDimensionPrefix = ([_operation, dimension, _clauses]) => dimension.startsWith(prefix) -function omitFiltersByKeyPrefix(query, prefix) { - return query.filters.filter( +function omitFiltersByKeyPrefix(dashboardState, prefix) { + return dashboardState.filters.filter( ([_operation, filterKey, _clauses]) => !filterKey.startsWith(prefix) ) } -export function replaceFilterByPrefix(query, prefix, filter) { - return omitFiltersByKeyPrefix(query, prefix).concat([filter]) +export function replaceFilterByPrefix(dashboardState, prefix, filter) { + return omitFiltersByKeyPrefix(dashboardState, prefix).concat([filter]) } -export function isFilteringOnFixedValue(query, filterKey, expectedValue) { - const filters = query.filters.filter(([_operation, key]) => filterKey == key) +export function isFilteringOnFixedValue( + dashboardState, + filterKey, + expectedValue +) { + const filters = dashboardState.filters.filter( + ([_operation, key]) => filterKey == key + ) if (filters.length == 1) { const [operation, _filterKey, clauses] = filters[0] return ( @@ -118,8 +124,12 @@ export function isFilteringOnFixedValue(query, filterKey, expectedValue) { return false } -export function hasConversionGoalFilter(query) { - const resolvedGoalFilters = query.resolvedFilters.filter( +export function hasPageFilter(dashboardState) { + return dashboardState.resolvedFilters.some(hasDimensionPrefix('page')) +} + +export function hasConversionGoalFilter(dashboardState) { + const resolvedGoalFilters = dashboardState.resolvedFilters.filter( hasDimensionPrefix('goal') ) @@ -128,13 +138,13 @@ export function hasConversionGoalFilter(query) { }) } -export function isRealTimeDashboard(query) { - return query?.period === 'realtime' +export function isRealTimeDashboard(dashboardState) { + return dashboardState?.period === 'realtime' } // Note: Currently only a single goal filter can be applied at a time. -export function getGoalFilter(query) { - return getFiltersByKeyPrefix(query, 'goal')[0] || null +export function getGoalFilter(dashboardState) { + return getFiltersByKeyPrefix(dashboardState, 'goal')[0] || null } export function formatFilterGroup(filterGroup) { @@ -260,24 +270,33 @@ function remapToApiFilter([operation, filterKey, clauses, ...modifiers]) { } } -export function fetchSuggestions(apiPath, query, input, additionalFilter) { - const updatedQuery = queryForSuggestions(query, additionalFilter) +export function fetchSuggestions( + apiPath, + dashboardState, + input, + additionalFilter +) { + const updatedQuery = queryForSuggestions(dashboardState, additionalFilter) return api.get(apiPath, updatedQuery, { q: input.trim() }) } -function queryForSuggestions(query, additionalFilter) { - let filters = query.filters +function queryForSuggestions(dashboardState, additionalFilter) { + let filters = dashboardState.filters if (additionalFilter) { const [_operation, filterKey, clauses] = additionalFilter - // For suggestions, we remove already-applied filter with same key from query and add new filter (if feasible) + // For suggestions, we remove already-applied filter with same key from dashboardState and add new filter (if feasible) if (clauses.length > 0) { - filters = replaceFilterByPrefix(query, filterKey, additionalFilter) + filters = replaceFilterByPrefix( + dashboardState, + filterKey, + additionalFilter + ) } else { - filters = omitFiltersByKeyPrefix(query, filterKey) + filters = omitFiltersByKeyPrefix(dashboardState, filterKey) } } - return { ...query, filters } + return { ...dashboardState, filters } } export function getFilterGroup([_operation, filterKey, _clauses]) { @@ -291,23 +310,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' } diff --git a/assets/js/dashboard/util/goals.ts b/assets/js/dashboard/util/goals.ts new file mode 100644 index 000000000000..cd6ca0c21162 --- /dev/null +++ b/assets/js/dashboard/util/goals.ts @@ -0,0 +1,33 @@ +import { Filter } from '../dashboard-state' +import { FILTER_OPERATIONS } from './filters' + +export const isPageViewGoal = (goalName: string) => { + goalName.startsWith('Visit ') +} + +export const SPECIAL_GOALS = { + '404': { title: '404 Pages', prop: 'path' }, + 'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' }, + 'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' }, + 'File Download': { title: 'File Downloads', prop: 'url' }, + 'WP Search Queries': { + title: 'WordPress Search Queries', + prop: 'search_query' + }, + 'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' } +} + +export function isSpecialGoal( + goalName: string | number +): goalName is keyof typeof SPECIAL_GOALS { + return goalName in SPECIAL_GOALS +} + +export function getSpecialGoal(goalFilter: Filter) { + const [operation, _filterKey, clauses] = goalFilter + if (operation === FILTER_OPERATIONS.is && clauses.length == 1) { + const goalName = clauses[0] + return isSpecialGoal(goalName) ? SPECIAL_GOALS[goalName] : null + } + return null +} diff --git a/assets/js/dashboard/util/number-formatter.ts b/assets/js/dashboard/util/number-formatter.ts index 814955ab9500..68038321e6d7 100644 --- a/assets/js/dashboard/util/number-formatter.ts +++ b/assets/js/dashboard/util/number-formatter.ts @@ -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 '-' } diff --git a/assets/js/dashboard/util/realtime-update-timer.js b/assets/js/dashboard/util/realtime-update-timer.js index 5650192dc5ce..07d773883b99 100644 --- a/assets/js/dashboard/util/realtime-update-timer.js +++ b/assets/js/dashboard/util/realtime-update-timer.js @@ -1,8 +1,8 @@ -const THIRTY_SECONDS = 30000 +export const REALTIME_UPDATE_TIME_MS = 30_000 const tickEvent = new Event('tick') export function start() { setInterval(() => { document.dispatchEvent(tickEvent) - }, THIRTY_SECONDS) + }, REALTIME_UPDATE_TIME_MS) } diff --git a/assets/js/dashboard/util/tooltip.tsx b/assets/js/dashboard/util/tooltip.tsx index 497eef4db8bf..a4a43a21cc03 100644 --- a/assets/js/dashboard/util/tooltip.tsx +++ b/assets/js/dashboard/util/tooltip.tsx @@ -1,19 +1,24 @@ -import React, { ReactNode, useState } from 'react' +import React, { CSSProperties, ReactNode, RefObject, useState } from 'react' import { usePopper } from 'react-popper' import classNames from 'classnames' +import { createPortal } from 'react-dom' export function Tooltip({ children, info, className, onClick, - boundary + boundary, + containerRef }: { info: ReactNode children: ReactNode className?: string onClick?: () => void - boundary?: HTMLElement + /** if provided, the tooltip is confined to the particular element */ + boundary?: HTMLElement | null + /** if defined, the tooltip is rendered in a portal to this element */ + containerRef?: RefObject }) { const [visible, setVisible] = useState(false) const [referenceElement, setReferenceElement] = @@ -21,25 +26,27 @@ export function Tooltip({ const [popperElement, setPopperElement] = useState( null ) - const [arrowElement, setArrowElement] = useState(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 && { - name: 'preventOverflow', - options: { - boundary: boundary - } - } - ].filter((x) => !!x) + ...(boundary + ? [ + { + name: 'preventOverflow', + options: { + boundary: boundary + } + } + ] + : []) + ] }) return ( @@ -53,21 +60,50 @@ export function Tooltip({ {children}
{info && visible && ( -
{info} -
-
+ )}
) } + +function TooltipMessage({ + containerRef, + popperStyle, + popperAttributes, + setPopperElement, + children +}: { + containerRef?: RefObject + popperStyle: CSSProperties + popperAttributes?: Record + setPopperElement: (element: HTMLDivElement) => void + children: ReactNode +}) { + const messageElement = ( +
+ {children} +
+ ) + if (containerRef) { + if (containerRef.current) { + return createPortal(messageElement, containerRef.current) + } else { + return null + } + } + + return messageElement +} diff --git a/assets/js/dashboard/util/url-search-params-v1.ts b/assets/js/dashboard/util/url-search-params-v1.ts index 08d14aa2380d..c7cc740af7dd 100644 --- a/assets/js/dashboard/util/url-search-params-v1.ts +++ b/assets/js/dashboard/util/url-search-params-v1.ts @@ -1,4 +1,4 @@ -import { DashboardQuery, Filter } from '../query' +import { DashboardState, Filter } from '../dashboard-state' import { EVENT_PROPS_PREFIX, FILTER_OPERATIONS } from './filters' // As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means @@ -40,21 +40,22 @@ const LEGACY_URL_PARAMETERS = { exit_page: null } -function isV1(searchRecord: Record): 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 -): Record { - const searchRecordEntries = Object.entries(searchRecord) +function parseSearch(searchString: string): Record { + const searchParams = new URLSearchParams(searchString) const updatedSearchRecordEntries = [] const filters: Filter[] = [] - let labels: DashboardQuery['labels'] = {} + let labels: DashboardState['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 } diff --git a/assets/js/dashboard/util/url-search-params.test.ts b/assets/js/dashboard/util/url-search-params.test.ts index 9ac792d768b0..cab2c86f0a78 100644 --- a/assets/js/dashboard/util/url-search-params.test.ts +++ b/assets/js/dashboard/util/url-search-params.test.ts @@ -1,8 +1,9 @@ -import { Filter } from '../query' +import { Filter } from '../dashboard-state' import { encodeURIComponentPermissive, + getSearchWithEnforcedSegment, isSearchEntryDefined, - getRedirectTarget, + maybeGetLatestReadableSearch, parseFilter, parseLabelsEntry, parseSearch, @@ -206,57 +207,55 @@ describe(`${stringifySearch.name}`, () => { }) }) -describe(`${getRedirectTarget.name}`, () => { +describe(`${maybeGetLatestReadableSearch.name}`, () => { it.each([ [''], ['?auth=_Y6YOjUl2beUJF_XzG1hk&theme=light&background=%23ee00ee'], ['?keybindHint=Escape&with_imported=true'], ['?f=is,page,/blog/:category/:article-name&date=2024-10-10&period=day'], ['?f=is,country,US&l=US,United%20States'] - ])('for modern search %p returns null', (search) => { - expect( - getRedirectTarget({ - pathname: '/example.com%2Fdeep%2Fpath', - search - } as Location) - ).toBeNull() + ])('for modern search string %p returns null', (search) => { + expect(maybeGetLatestReadableSearch(search)).toBeNull() }) - it('returns updated URL for jsonurl style filters (v2), and running the updated value through the function again returns null (no redirect loop)', () => { - const pathname = '/' + it('returns updated search string for jsonurl style filters (v2), and running the updated value through the function again returns null (no redirect loop)', () => { const search = '?filters=((is,exit_page,(/plausible.io)),(is,source,(Brave)),(is,city,(993800)))&labels=(993800:Johannesburg)' const expectedUpdatedSearch = '?f=is,exit_page,/plausible.io&f=is,source,Brave&f=is,city,993800&l=993800,Johannesburg&r=v2' - expect( - getRedirectTarget({ - pathname, - search - } as Location) - ).toEqual(`${pathname}${expectedUpdatedSearch}`) - expect( - getRedirectTarget({ - pathname, - search: expectedUpdatedSearch - } as Location) - ).toBeNull() + expect(maybeGetLatestReadableSearch(search)).toEqual(expectedUpdatedSearch) + expect(maybeGetLatestReadableSearch(expectedUpdatedSearch)).toBeNull() }) - it('returns updated URL for page=... style filters (v1), and running the updated value through the function again returns null (no redirect loop)', () => { - const pathname = '/' - const search = '?page=/docs' - const expectedUpdatedSearch = '?f=is,page,/docs&r=v1' - expect( - getRedirectTarget({ - pathname, - search - } as Location) - ).toEqual(`${pathname}${expectedUpdatedSearch}`) + it.each([ + ['?page=/docs', '?f=is,page,/docs&r=v1'], + ['?page=%C3%AA&embed=true', '?f=is,page,%C3%AA&embed=true&r=v1'], + [ + '?page=/|/foo&goal=~Signup&source=!Facebook|Instagram', + '?f=is,page,/,/foo&f=contains,goal,Signup&f=is_not,source,Facebook,Instagram&r=v1' + ] + ])( + 'returns updated search string v1 style filter %s, and running the updated value through the function again returns null (no redirect loop)', + (searchString, expectedSearchString) => { + expect(maybeGetLatestReadableSearch(searchString)).toEqual( + expectedSearchString + ) + expect(maybeGetLatestReadableSearch(expectedSearchString)).toBeNull() + } + ) +}) + +describe(`${getSearchWithEnforcedSegment.name}`, () => { + it('adds enforced segment appropriately, and running the updated value through the function again returns the same value', () => { + const segment = { id: 100, name: 'Eastern Europe' } + const search = '?auth=foo&embed=true' + const expectedUpdatedSearch = + '?f=is,segment,100&l=segment-100,Eastern%20Europe&auth=foo&embed=true' + expect(getSearchWithEnforcedSegment(search, segment)).toEqual( + expectedUpdatedSearch + ) expect( - getRedirectTarget({ - pathname, - search: expectedUpdatedSearch - } as Location) - ).toBeNull() + getSearchWithEnforcedSegment(expectedUpdatedSearch, segment) + ).toEqual(expectedUpdatedSearch) }) }) diff --git a/assets/js/dashboard/util/url-search-params.ts b/assets/js/dashboard/util/url-search-params.ts index 730d8effdb89..da07588e5875 100644 --- a/assets/js/dashboard/util/url-search-params.ts +++ b/assets/js/dashboard/util/url-search-params.ts @@ -1,9 +1,13 @@ -import { Filter, FilterClauseLabels } from '../query' +import { + getSearchToSetSegmentFilter, + SavedSegment +} from '../filtering/segments' +import { Filter, FilterClauseLabels } from '../dashboard-state' 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`` */ @@ -230,8 +234,10 @@ function isAlreadyRedirected(searchParams: URLSearchParams) { The purpose of this function is to redirect users from one of the previous versions to the current version, so previous dashboard links still work. */ -export function getRedirectTarget(windowLocation: Location): null | string { - const searchParams = new URLSearchParams(windowLocation.search) +export function maybeGetLatestReadableSearch( + searchString: string +): null | string { + const searchParams = new URLSearchParams(searchString) if (isAlreadyRedirected(searchParams)) { return null } @@ -241,28 +247,67 @@ 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' })}` + return stringifySearch({ + ...v2.parseSearch(searchString), + [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' + }) } - const searchRecord = v2.parseSearch(windowLocation.search) - const isV1 = v1.isV1(searchRecord) - - if (!isV1) { - return null + if (isV1) { + return stringifySearch({ + ...v1.parseSearch(searchString), + [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' + }) } - return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearchRecord(searchRecord), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}` + return null +} + +/** + * It's possible to set a particular segment to be always applied on the data on dashboards accessed with a shared link. + * This function ensures that the particular segment filter is set to the URL string on initial page load. + * Other functions ensure that it can't be removed. + */ +export function getSearchWithEnforcedSegment( + searchString: string, + enforcedSegment: Pick +): string { + const searchRecord = parseSearch(searchString) + return stringifySearch( + getSearchToSetSegmentFilter(enforcedSegment)(searchRecord) + ) } /** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */ -export function redirectForLegacyParams( +export function maybeDoFERedirect( windowLocation: Location, - windowHistory: History + windowHistory: History, + enforcedSegment: Pick | null ) { - const redirectTargetURL = getRedirectTarget(windowLocation) - if (redirectTargetURL === null) { + const originalSearchString = windowLocation.search + + let updatedSearchString = maybeGetLatestReadableSearch(originalSearchString) + + if (enforcedSegment) { + updatedSearchString = getSearchWithEnforcedSegment( + updatedSearchString ?? originalSearchString, + enforcedSegment + ) + } + + if ( + updatedSearchString === null || + updatedSearchString === originalSearchString + ) { return } - windowHistory.pushState({}, '', redirectTargetURL) + + windowHistory.pushState( + {}, + '', + `${windowLocation.pathname}${updatedSearchString}` + ) } diff --git a/assets/js/dashboard/util/url.test.ts b/assets/js/dashboard/util/url.test.ts index 346e5aa8452d..0ea052423be7 100644 --- a/assets/js/dashboard/util/url.test.ts +++ b/assets/js/dashboard/util/url.test.ts @@ -1,4 +1,5 @@ import { apiPath, externalLinkForPage, isValidHttpUrl, trimURL } from './url' +import { siteContextDefaultValue } from '../site-context' describe('apiPath', () => { it.each([ @@ -32,10 +33,19 @@ describe('externalLinkForPage', () => { ])( 'when domain is %s and page is %s, it should return %s', (domain, page, expected) => { - const result = externalLinkForPage(domain, page) + const site = { ...siteContextDefaultValue, domain: domain } + const result = externalLinkForPage(site, page) expect(result).toBe(expected) } ) + + it('returns null for consolidated view', () => { + const consolidatedView = { + ...siteContextDefaultValue, + isConsolidatedView: true + } + expect(externalLinkForPage(consolidatedView, '/some-page')).toBe(null) + }) }) describe('isValidHttpUrl', () => { diff --git a/assets/js/dashboard/util/url.ts b/assets/js/dashboard/util/url.ts index fc69afaca28e..efd546265f14 100644 --- a/assets/js/dashboard/util/url.ts +++ b/assets/js/dashboard/util/url.ts @@ -8,11 +8,19 @@ export function apiPath( } export function externalLinkForPage( - domain: PlausibleSite['domain'], + site: PlausibleSite, page: string -): string { - const domainURL = new URL(`https://${domain}`) - return `https://${domainURL.host}${page}` +): string | null { + if (site.isConsolidatedView) { + return null + } + + try { + const domainURL = new URL(`https://${site.domain}`) + return `https://${domainURL.host}${page}` + } catch (_error) { + return null + } } export function isValidHttpUrl(input: string): boolean { diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 5b8e0a0a0d1b..7df1ef9984c7 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -1,11 +1,14 @@ /** - These 3 modules are resolved from '../deps' folder, - which does not exist when running the lint command in Github CI + 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 topbar from 'topbar' /* eslint-enable import/no-unresolved */ import Alpine from 'alpinejs' @@ -13,17 +16,12 @@ import Alpine from 'alpinejs' let csrfToken = document.querySelector("meta[name='csrf-token']") let websocketUrl = document.querySelector("meta[name='websocket-url']") if (csrfToken && websocketUrl) { - let Hooks = {} + let Hooks = { Modal, Dropdown } Hooks.Metrics = { mounted() { this.handleEvent('send-metrics', ({ event_name }) => { - const afterMetrics = () => { - this.pushEvent('send-metrics-after', { event_name }) - } - setTimeout(afterMetrics, 5000) - if (window.trackCustomEvent) { - window.trackCustomEvent(event_name, { callback: afterMetrics }) - } + window.plausible(event_name) + this.pushEvent('send-metrics-after', { event_name }) }) } } @@ -66,6 +64,15 @@ if (csrfToken && websocketUrl) { } }) + topbar.config({ + barColors: { 0: '#303f9f' }, + shadowColor: 'rgba(0, 0, 0, .3)', + barThickness: 4 + }) + window.addEventListener('phx:page-loading-start', (_info) => topbar.show()) + window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide()) + window.addEventListener('scroll-to-top', () => window.scrollTo(0, 0)) + liveSocket.connect() window.liveSocket = liveSocket } diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts index af690bb85ce5..3dce7f674337 100644 --- a/assets/js/types/query-api.d.ts +++ b/assets/js/types/query-api.d.ts @@ -12,15 +12,13 @@ export type Metric = | "conversion_rate" | "group_conversion_rate" | "time_on_page" - | "exit_rate" | "total_revenue" | "average_revenue" | "scroll_depth"; export type DateRangeShorthand = - | "30m" - | "realtime" | "all" | "day" + | "24h" | "7d" | "28d" | "30d" @@ -71,7 +69,7 @@ export type SimpleFilterDimensions = | "visit:exit_page_hostname"; export type CustomPropertyFilterDimensions = string; export type GoalDimension = "event:goal"; -export type TimeDimensions = ("time" | "time:month" | "time:week" | "time:day" | "time:hour") | "time:minute"; +export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour"; export type FilterTree = FilterEntry | FilterAndOr | FilterNot | FilterHasDone; export type FilterEntry = FilterWithoutGoals | FilterWithIs | FilterWithContains | FilterWithPattern; /** @@ -126,7 +124,7 @@ export type FilterWithContains = * @maxItems 3 */ export type FilterWithPattern = [ - FilterOperationRegex | ("matches_wildcard" | "matches_wildcard_not"), + FilterOperationRegex, SimpleFilterDimensions | CustomPropertyFilterDimensions, Clauses ]; @@ -169,7 +167,6 @@ export interface QueryApiSchema { * @minItems 1 */ metrics: [Metric, ...Metric[]]; - date?: string; /** * Date range to query */ @@ -193,28 +190,10 @@ export interface QueryApiSchema { * If set, returns the total number of result rows rows before pagination under `meta.total_rows` */ total_rows?: boolean; - comparisons?: - | { - mode: "previous_period" | "year_over_year"; - /** - * If set and using time:day dimensions, day-of-week of comparison query is matched - */ - match_day_of_week?: boolean; - } - | { - mode: "custom"; - /** - * If set and using time:day dimensions, day-of-week of comparison query is matched - */ - match_day_of_week?: boolean; - /** - * If custom period. A list of two ISO8601 dates or timestamps to compare against. - * - * @minItems 2 - * @maxItems 2 - */ - date_range: [string, string]; - }; + /** + * If set and using `day`, `month` or `year` date_ranges, the query will be trimmed to the current date + */ + trim_relative_date_range?: boolean; }; pagination?: { /** diff --git a/assets/package-lock.json b/assets/package-lock.json index b2e5079ab73f..b32a3b16d491 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -9,13 +9,12 @@ "version": "1.4.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@headlessui/react": "^1.7.10", - "@heroicons/react": "^2.0.11", + "@headlessui/react": "^1.7.19", + "@heroicons/react": "^2.2.0", "@jsonurl/jsonurl": "^1.1.7", "@juggle/resize-observer": "^3.3.1", "@popperjs/core": "^2.11.6", - "@tailwindcss/aspect-ratio": "^0.4.2", - "@tailwindcss/forms": "^0.5.6", + "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.4.1", "@tanstack/react-query": "^5.51.1", "abortcontroller-polyfill": "^1.7.3", @@ -25,6 +24,7 @@ "classnames": "^2.3.1", "d3": "^7.9.0", "dayjs": "^1.11.7", + "fast-deep-equal": "^3.1.3", "iframe-resizer": "^4.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -34,6 +34,7 @@ "react-popper": "^2.3.0", "react-router-dom": "^6.25.1", "react-transition-group": "^4.4.2", + "topbar": "^3.0.0", "topojson-client": "^3.1.0", "url-search-params-polyfill": "^8.2.5", "visionscarto-world-atlas": "^1.0.0" @@ -56,18 +57,19 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-jest": "^28.11.0", "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jsdom-testing-mocks": "^1.13.1", "json-schema-to-typescript": "^15.0.2", "prettier": "^3.3.3", - "stylelint": "^16.8.1", + "stylelint": "^16.17.0", "stylelint-config-standard": "^36.0.1", "ts-jest": "^29.2.4", "typescript": "^5.5.4", - "typescript-eslint": "^8.28.0" + "typescript-eslint": "^8.29.0" } }, "node_modules/@adobe/css-tools": { @@ -118,12 +120,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, "dependencies": { - "@babel/highlight": "^7.24.7", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -266,18 +269,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -293,40 +296,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", - "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.25.0", - "@babel/types": "^7.25.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.25.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", - "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, "dependencies": { - "@babel/types": "^7.25.2" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -513,24 +501,25 @@ } }, "node_modules/@babel/runtime": { - "version": "7.21.5", - "license": "MIT", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", - "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.25.0", - "@babel/types": "^7.25.0" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -564,14 +553,13 @@ } }, "node_modules/@babel/types": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", - "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -584,9 +572,9 @@ "dev": true }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", - "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", + "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", "dev": true, "funding": [ { @@ -598,17 +586,18 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.4.1" + "@csstools/css-tokenizer": "^3.0.3" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", - "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", + "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", "dev": true, "funding": [ { @@ -620,37 +609,15 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=18" } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.13", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", - "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": "^14 || ^16 || >=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.7.1", - "@csstools/css-tokenizer": "^2.4.1" - } - }, - "node_modules/@csstools/selector-specificity": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz", - "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.2.tgz", + "integrity": "sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==", "dev": true, "funding": [ { @@ -662,11 +629,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=18" }, "peerDependencies": { - "postcss-selector-parser": "^6.0.13" + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" } }, "node_modules/@dual-bundle/import-meta-resolve": { @@ -711,16 +680,20 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -735,12 +708,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -749,19 +723,24 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", - "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -805,30 +784,36 @@ } }, "node_modules/@eslint/js": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", - "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.16.0", "levn": "^0.4.1" }, "engines": { @@ -836,9 +821,12 @@ } }, "node_modules/@headlessui/react": { - "version": "1.7.10", + "version": "1.7.19", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", + "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", "license": "MIT", "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", "client-only": "^0.0.1" }, "engines": { @@ -850,10 +838,11 @@ } }, "node_modules/@heroicons/react": { - "version": "2.0.11", - "license": "MIT", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", "peerDependencies": { - "react": ">= 16" + "react": ">= 16 || ^19.0.0-rc" } }, "node_modules/@humanfs/core": { @@ -1046,10 +1035,11 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1132,21 +1122,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/console/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1163,45 +1138,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/console/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/console/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/console/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/core": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", @@ -1249,21 +1185,6 @@ } } }, - "node_modules/@jest/core/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/core/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1280,45 +1201,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/core/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/core/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/core/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/core/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -1434,21 +1316,6 @@ } } }, - "node_modules/@jest/reporters/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/reporters/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1465,45 +1332,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/reporters/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/reporters/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/reporters/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/reporters/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1586,21 +1414,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/transform/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/transform/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1617,62 +1430,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/transform/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@jest/transform/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/@jest/transform/node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/transform/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/transform/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/transform/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/@jest/transform/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/transform/node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, "node_modules/@jest/types": { @@ -1692,21 +1466,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/types/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/types/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1723,45 +1482,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/types/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/types/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/types/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -1820,6 +1540,16 @@ "version": "3.3.1", "license": "Apache-2.0" }, + "node_modules/@keyv/serialize": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", + "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz", @@ -1880,9 +1610,10 @@ } }, "node_modules/@remix-run/router": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", - "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -1917,21 +1648,16 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@tailwindcss/aspect-ratio": { - "version": "0.4.2", - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" - } - }, "node_modules/@tailwindcss/forms": { - "version": "0.5.6", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", "license": "MIT", "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "node_modules/@tailwindcss/typography": { @@ -1971,6 +1697,31 @@ "react": "^18.0.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.5.tgz", + "integrity": "sha512-MzSSMGkFWCDSb2xXqmdbfQqBG4wcRI3JKVjpYGZG0CccnViLpfRW4tGU97ImfBbSYzvEWJ/2SK/OiIoSmcUBAA==", + "dependencies": { + "@tanstack/virtual-core": "3.13.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.5", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.5.tgz", + "integrity": "sha512-gMLNylxhJdUlfRR1G3U9rtuwUh2IjdrrniJIDcekVJN3/3i+bluvdMi3+eodnxzJq5nKnxnigo9h0lIpaqV6HQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -1990,21 +1741,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@testing-library/dom/node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -2030,33 +1766,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@testing-library/dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@testing-library/dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/dom/node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -2089,18 +1798,6 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.4.8", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.8.tgz", @@ -2122,21 +1819,6 @@ "yarn": ">=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -2150,51 +1832,12 @@ "node": ">=8" } }, - "node_modules/@testing-library/jest-dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true }, - "node_modules/@testing-library/jest-dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/react": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.0.tgz", @@ -2729,16 +2372,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", - "integrity": "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.0.tgz", + "integrity": "sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/type-utils": "8.28.0", - "@typescript-eslint/utils": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/type-utils": "8.29.0", + "@typescript-eslint/utils": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2758,15 +2402,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", - "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.0.tgz", + "integrity": "sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/typescript-estree": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4" }, "engines": { @@ -2782,13 +2427,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.28.0.tgz", - "integrity": "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz", + "integrity": "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0" + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2799,13 +2445,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.28.0.tgz", - "integrity": "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.29.0.tgz", + "integrity": "sha512-ahaWQ42JAOx+NKEf5++WC/ua17q5l+j1GFrbbpVKzFL/tKVc0aYY8rVSYUpUvt2hUP1YBr7mwXzx+E/DfUWI9Q==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.28.0", - "@typescript-eslint/utils": "8.28.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/utils": "8.29.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -2822,10 +2469,11 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.28.0.tgz", - "integrity": "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.0.tgz", + "integrity": "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2835,13 +2483,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.28.0.tgz", - "integrity": "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz", + "integrity": "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2861,10 +2510,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2874,6 +2524,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -2889,6 +2540,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2897,15 +2549,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.28.0.tgz", - "integrity": "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.0.tgz", + "integrity": "sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/typescript-estree": "8.28.0" + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2920,12 +2573,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.28.0.tgz", - "integrity": "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz", + "integrity": "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/types": "8.29.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2941,6 +2595,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3169,10 +2824,11 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3282,14 +2938,19 @@ } }, "node_modules/ansi-styles": { - "version": "3.2.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/any-promise": { @@ -3497,6 +3158,7 @@ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3576,21 +3238,6 @@ "@babel/core": "^7.8.0" } }, - "node_modules/babel-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/babel-jest/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3607,45 +3254,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/babel-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/babel-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/babel-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -3736,6 +3344,34 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "dev": true, + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.2.0", "license": "MIT", @@ -3745,7 +3381,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3816,12 +3454,58 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/cacheable": { + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.8.9.tgz", + "integrity": "sha512-FicwAUyWnrtnd4QqYAoRlNs44/a1jTL7XDKqm5gJ90wz1DQPlC7U2Rd1Tydpv+E7WAr4sQHuw8Q8M3nZMAyecQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.7.1", + "keyv": "^5.3.1" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.2.tgz", + "integrity": "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.0.3" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3915,19 +3599,6 @@ } ] }, - "node_modules/chalk": { - "version": "2.4.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -4012,6 +3683,8 @@ }, "node_modules/client-only": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, "node_modules/cliui": { @@ -4045,15 +3718,22 @@ "dev": true }, "node_modules/color-convert": { - "version": "1.9.3", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, "node_modules/color-name": { - "version": "1.1.3", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -4140,21 +3820,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/create-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/create-jest/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4171,45 +3836,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/create-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/create-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/create-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/create-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4225,21 +3851,30 @@ } }, "node_modules/css-functions-list": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.2.tgz", - "integrity": "sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", + "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12 || >=16" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", + "dev": true, + "license": "BSD" + }, "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, + "license": "MIT", "dependencies": { - "mdn-data": "2.0.30", + "mdn-data": "2.12.2", "source-map-js": "^1.0.1" }, "engines": { @@ -5172,14 +4807,6 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -5202,32 +4829,32 @@ } }, "node_modules/eslint": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", - "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.23.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5460,10 +5087,11 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.37.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", - "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, + "license": "MIT", "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -5475,7 +5103,7 @@ "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.8", + "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", @@ -5532,10 +5160,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -5559,20 +5188,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -5588,22 +5203,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, @@ -5616,10 +5215,11 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5627,25 +5227,16 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5655,10 +5246,11 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5696,6 +5288,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -5775,19 +5368,21 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -5815,10 +5410,21 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", - "dev": true + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", @@ -5866,10 +5472,11 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -5931,10 +5538,11 @@ "license": "MIT" }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", @@ -5968,13 +5576,16 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -6291,11 +5902,13 @@ } }, "node_modules/has-flag": { - "version": "3.0.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/has-property-descriptors": { @@ -6363,6 +5976,13 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.8.1.tgz", + "integrity": "sha512-GrO2l93P8xCWBSTBX9l2BxI78VU/MAAYag+pG8curS3aBGy0++ZlxrQ7PdUOUVMbn5BwkGb6+eRrnf43ipnFEA==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -6439,6 +6059,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/iframe-resizer": { "version": "4.3.2", "license": "MIT", @@ -7042,27 +6683,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/istanbul-lib-source-maps": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", @@ -7140,21 +6760,6 @@ "node": ">=10" } }, - "node_modules/jake/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jake/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7171,45 +6776,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jake/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jake/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jake/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jake/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -7281,21 +6847,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-circus/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7312,45 +6863,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-circus/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-circus/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-circus/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-circus/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-cli": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", @@ -7384,21 +6896,6 @@ } } }, - "node_modules/jest-cli/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-cli/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7415,45 +6912,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-cli/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-cli/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-cli/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-cli/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-config": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", @@ -7499,21 +6957,6 @@ } } }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-config/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7530,45 +6973,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-config/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-config/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-config/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-config/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -7584,21 +6988,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-diff/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7615,45 +7004,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-diff/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-diff/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-diff/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-docblock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", @@ -7682,21 +7032,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-each/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7713,45 +7048,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-each/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-each/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-each/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-each/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-environment-jsdom": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", @@ -7858,21 +7154,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-matcher-utils/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7889,45 +7170,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-matcher-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-matcher-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-matcher-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-message-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", @@ -7948,21 +7190,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-message-util/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7979,45 +7206,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-message-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-message-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-message-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-message-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-mock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", @@ -8091,21 +7279,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-resolve/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-resolve/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8122,45 +7295,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-resolve/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-resolve/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-resolve/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-resolve/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-runner": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", @@ -8193,21 +7327,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runner/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-runner/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8224,49 +7343,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-runner/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-runner/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-runner/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runner/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, "dependencies": { "@jest/environment": "^29.7.0", @@ -8296,21 +7376,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runtime/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-runtime/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8327,33 +7392,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-runtime/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-runtime/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-runtime/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-runtime/node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -8363,18 +7401,6 @@ "node": ">=8" } }, - "node_modules/jest-runtime/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -8406,21 +7432,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-snapshot/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8437,33 +7448,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-snapshot/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-snapshot/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-snapshot/node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -8476,18 +7460,6 @@ "node": ">=10" } }, - "node_modules/jest-snapshot/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -8505,21 +7477,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-util/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8536,45 +7493,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -8592,21 +7510,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-validate/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -8635,45 +7538,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-validate/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-validate/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-validate/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-watcher": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", @@ -8693,74 +7557,20 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-watcher/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-watcher/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-watcher/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-watcher/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-watcher/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-watcher/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/jest-worker": { @@ -8778,15 +7588,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -8815,10 +7616,11 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -8871,6 +7673,20 @@ } } }, + "node_modules/jsdom-testing-mocks": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/jsdom-testing-mocks/-/jsdom-testing-mocks-1.13.1.tgz", + "integrity": "sha512-8BAsnuoO4DLGTf7LDbSm8fcx5CUHSv4h+bdUbwyt6rMYAXWjeHLRx9f8sYiSxoOTXy3S1e06pe87KER39o1ckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bezier-easing": "^2.1.0", + "css-mediaquery": "^0.1.2" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -8919,19 +7735,21 @@ } }, "node_modules/json-schema-to-typescript/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/json-schema-to-typescript/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -9027,10 +7845,11 @@ } }, "node_modules/known-css-properties": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.34.0.tgz", - "integrity": "sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==", - "dev": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "dev": true, + "license": "MIT" }, "node_modules/language-subtag-registry": { "version": "0.3.23", @@ -9127,7 +7946,8 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.uniq": { "version": "4.5.0", @@ -9222,10 +8042,11 @@ } }, "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "dev": true + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" }, "node_modules/meow": { "version": "13.2.0", @@ -9305,6 +8126,8 @@ }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", "license": "MIT", "bin": { "mini-svg-data-uri": "cli.js" @@ -9355,9 +8178,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -9469,14 +8292,16 @@ } }, "node_modules/object.entries": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", - "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -9735,9 +8560,10 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -9838,9 +8664,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -9855,10 +8681,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -9953,15 +8780,16 @@ } }, "node_modules/postcss-resolve-nested-selector": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.4.tgz", - "integrity": "sha512-R6vHqZWgVnTAPq0C+xjyHfEZqfIYboCBVSy24MjxEDm+tIh1BU4O6o7DP7AA7kHzf136d+Qc5duI4tlpHjixDw==", - "dev": true + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT" }, "node_modules/postcss-safe-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.0.tgz", - "integrity": "sha512-ovehqRNVCpuFzbXoTb4qLtyzK3xn3t/CUBxOs8LsnQjQrShaB4lKiHoVqY8ANaC0hBMHq5QVWk77rwGklFUDrg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, "funding": [ { @@ -9977,6 +8805,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "engines": { "node": ">=18.0" }, @@ -9988,6 +8817,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -10201,11 +9031,12 @@ } }, "node_modules/react-router": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.1.tgz", - "integrity": "sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -10215,12 +9046,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.1.tgz", - "integrity": "sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0", - "react-router": "6.25.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -10299,8 +9131,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "license": "MIT" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -10336,6 +9169,7 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10701,6 +9535,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -10713,39 +9548,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/slice-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10756,9 +9558,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -10969,6 +9772,8 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -11034,9 +9839,9 @@ } }, "node_modules/stylelint": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.8.1.tgz", - "integrity": "sha512-O8aDyfdODSDNz/B3gW2HQ+8kv8pfhSu7ZR7xskQ93+vI6FhKKGUJMQ03Ydu+w3OvXXE0/u4hWU4hCPNOyld+OA==", + "version": "16.17.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.17.0.tgz", + "integrity": "sha512-I9OwVIWRMqVm2Br5iTbrfSqGRPWQUlvm6oXO1xZuYYu0Gpduy67N8wXOZv15p6E/JdlZiAtQaIoLKZEWk5hrjw==", "dev": true, "funding": [ { @@ -11048,45 +9853,45 @@ "url": "https://github.com/sponsors/stylelint" } ], + "license": "MIT", "dependencies": { - "@csstools/css-parser-algorithms": "^2.7.1", - "@csstools/css-tokenizer": "^2.4.1", - "@csstools/media-query-list-parser": "^2.1.13", - "@csstools/selector-specificity": "^3.1.1", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "@csstools/media-query-list-parser": "^4.0.2", + "@csstools/selector-specificity": "^5.0.0", "@dual-bundle/import-meta-resolve": "^4.1.0", "balanced-match": "^2.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.0", - "css-functions-list": "^3.2.2", - "css-tree": "^2.3.1", - "debug": "^4.3.6", - "fast-glob": "^3.3.2", + "css-functions-list": "^3.2.3", + "css-tree": "^3.1.0", + "debug": "^4.3.7", + "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^9.0.0", + "file-entry-cache": "^10.0.7", "global-modules": "^2.0.0", "globby": "^11.1.0", "globjoin": "^0.1.4", "html-tags": "^3.3.1", - "ignore": "^5.3.1", + "ignore": "^7.0.3", "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", - "known-css-properties": "^0.34.0", + "known-css-properties": "^0.35.0", "mathml-tag-names": "^2.1.3", "meow": "^13.2.0", - "micromatch": "^4.0.7", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", - "picocolors": "^1.0.1", - "postcss": "^8.4.40", - "postcss-resolve-nested-selector": "^0.1.4", - "postcss-safe-parser": "^7.0.0", - "postcss-selector-parser": "^6.1.1", + "picocolors": "^1.1.1", + "postcss": "^8.5.3", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.0", "postcss-value-parser": "^4.2.0", "resolve-from": "^5.0.0", "string-width": "^4.2.3", - "strip-ansi": "^7.1.0", - "supports-hyperlinks": "^3.0.0", + "supports-hyperlinks": "^3.2.0", "svg-tags": "^1.0.0", - "table": "^6.8.2", + "table": "^6.9.0", "write-file-atomic": "^5.0.1" }, "bin": { @@ -11143,16 +9948,27 @@ "stylelint": "^16.1.0" } }, - "node_modules/stylelint/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "node_modules/stylelint/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" } }, "node_modules/stylelint/node_modules/balanced-match": { @@ -11161,51 +9977,57 @@ "license": "MIT" }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-9.0.0.tgz", - "integrity": "sha512-6MgEugi8p2tiUhqO7GnPsmbCCzj0YRCwwaTbpGRyKZesjRSzkqkAE9fPp7V2yMs5hwfgbQLgdvSSkGNg1s5Uvw==", + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.0.7.tgz", + "integrity": "sha512-txsf5fu3anp2ff3+gOJJzRImtrtm/oa9tYLN0iTuINZ++EyVR/nRrg2fKYwvG/pXDofcrvvb0scEbX3NyW/COw==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^5.0.0" - }, - "engines": { - "node": ">=18" + "flat-cache": "^6.1.7" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-5.0.0.tgz", - "integrity": "sha512-JrqFmyUl2PnPi1OvLyTVHnQvwQ0S+e6lGSwu8OkAZlSaNIZciTY2H/cOOROxsBA1m/LZNHDsqAgDZt6akWcjsQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.7.tgz", + "integrity": "sha512-qwZ4xf1v1m7Rc9XiORly31YaChvKt6oNVHuqqZcoED/7O+ToyNVGobKsIAopY9ODcWpEDKEBAbrSOCBHtNQvew==", "dev": true, + "license": "MIT", "dependencies": { - "flatted": "^3.3.1", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=18" + "cacheable": "^1.8.9", + "flatted": "^3.3.3", + "hookified": "^1.7.1" } }, - "node_modules/stylelint/node_modules/resolve-from": { - "version": "5.0.0", + "node_modules/stylelint/node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 4" } }, - "node_modules/stylelint/node_modules/strip-ansi": { + "node_modules/stylelint/node_modules/postcss-selector-parser": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=4" + } + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/sucrase": { @@ -11257,48 +10079,33 @@ } }, "node_modules/supports-color": { - "version": "5.5.0", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/supports-hyperlinks": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz", - "integrity": "sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" }, "engines": { "node": ">=14.18" - } - }, - "node_modules/supports-hyperlinks/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -11322,10 +10129,11 @@ "dev": true }, "node_modules/table": { - "version": "6.8.2", - "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", - "integrity": "sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", @@ -11342,6 +10150,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11357,7 +10166,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tailwindcss": { "version": "3.3.3", @@ -11476,15 +10286,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11496,6 +10297,12 @@ "node": ">=8.0" } }, + "node_modules/topbar": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/topbar/-/topbar-3.0.0.tgz", + "integrity": "sha512-mhczD7KfYi1anfoMPKRdl0wPSWiYc0YOK4KyycYs3EaNT15pVVNDG5CtfgZcEBWIPJEdfR7r8K4hTXDD2ECBVQ==", + "license": "MIT" + }, "node_modules/topojson-client": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", @@ -11758,14 +10565,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.28.0.tgz", - "integrity": "sha512-jfZtxJoHm59bvoCMYCe2BM0/baMswRhMmYhy+w6VfcyHrjxZ0OJe0tGasydCpIpA+A/WIJhTyZfb3EtwNC/kHQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.29.0.tgz", + "integrity": "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", - "@typescript-eslint/utils": "8.28.0" + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", + "@typescript-eslint/utils": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12129,72 +10937,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/wrappy": { "version": "1.0.2", "license": "ISC" diff --git a/assets/package.json b/assets/package.json index 798387cbd3bf..559481596eca 100644 --- a/assets/package.json +++ b/assets/package.json @@ -4,7 +4,7 @@ "license": "AGPL-3.0-or-later", "scripts": { "test": "TZ=UTC jest", - "format": "prettier --write", + "format": "prettier --write \"**/*.{js,css,ts,tsx}\"", "check-format": "prettier --check \"**/*.{js,css,ts,tsx}\"", "eslint": "eslint js/**", "stylelint": "stylelint css/**", @@ -13,13 +13,12 @@ "generate-types": "json2ts ../priv/json-schemas/query-api-schema.json ../assets/js/types/query-api.d.ts --bannerComment '/* Autogenerated, recreate with `npm run --prefix assets generate-types` */'" }, "dependencies": { - "@headlessui/react": "^1.7.10", - "@heroicons/react": "^2.0.11", + "@headlessui/react": "^1.7.19", + "@heroicons/react": "^2.2.0", "@jsonurl/jsonurl": "^1.1.7", "@juggle/resize-observer": "^3.3.1", "@popperjs/core": "^2.11.6", - "@tailwindcss/aspect-ratio": "^0.4.2", - "@tailwindcss/forms": "^0.5.6", + "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.4.1", "@tanstack/react-query": "^5.51.1", "abortcontroller-polyfill": "^1.7.3", @@ -29,6 +28,7 @@ "classnames": "^2.3.1", "d3": "^7.9.0", "dayjs": "^1.11.7", + "fast-deep-equal": "^3.1.3", "iframe-resizer": "^4.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -38,6 +38,7 @@ "react-popper": "^2.3.0", "react-router-dom": "^6.25.1", "react-transition-group": "^4.4.2", + "topbar": "^3.0.0", "topojson-client": "^3.1.0", "url-search-params-polyfill": "^8.2.5", "visionscarto-world-atlas": "^1.0.0" @@ -60,18 +61,19 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-jest": "^28.11.0", "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jsdom-testing-mocks": "^1.13.1", "json-schema-to-typescript": "^15.0.2", "prettier": "^3.3.3", - "stylelint": "^16.8.1", + "stylelint": "^16.17.0", "stylelint-config-standard": "^36.0.1", "ts-jest": "^29.2.4", "typescript": "^5.5.4", - "typescript-eslint": "^8.28.0" + "typescript-eslint": "^8.29.0" }, "name": "assets" } diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js deleted file mode 100644 index 297edc2fc67f..000000000000 --- a/assets/tailwind.config.js +++ /dev/null @@ -1,59 +0,0 @@ -const colors = require('tailwindcss/colors') -const plugin = require('tailwindcss/plugin') - -module.exports = { - content: [ - "./js/**/*.{js,ts,tsx}", - "../lib/*_web.ex", - "../lib/*_web/**/*.*ex", - "../extra/**/*.*ex", - ], - safelist: [ - // PlausibleWeb.StatsView.stats_container_class/1 uses this class - // it's not used anywhere else in the templates or scripts - "max-w-screen-xl" - ], - darkMode: 'class', - theme: { - container: { - center: true, - padding: '1rem', - }, - extend: { - colors: { - yellow: colors.amber, // We started usign `yellow` in v2 but it was renamed to `amber` in v3 https://tailwindcss.com/docs/upgrade-guide#removed-color-aliases - gray: colors.slate, - 'gray-950': 'rgb(13, 18, 30)', - 'gray-850': 'rgb(26, 32, 44)', - 'gray-825': 'rgb(37, 47, 63)' - }, - spacing: { - '44': '11rem' - }, - width: { - 'content': 'fit-content' - }, - opacity: { - '15': '0.15', - }, - zIndex: { - '9': 9, - }, - maxWidth: { - '2xs': '15rem', - '3xs': '12rem', - }, - transitionProperty: { - 'padding': 'padding', - } - }, - }, - plugins: [ - require('@tailwindcss/forms'), - require('@tailwindcss/aspect-ratio'), - plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), - plugin(({ addVariant }) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), - plugin(({ addVariant }) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), - plugin(({ addVariant }) => addVariant("ui-disabled", ["&[data-ui-state~=\"disabled\"]", ":where([data-ui-state~=\"disabled\"]) &"])), - ] -} diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index 9c11a5579c57..d6f2564d85f4 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -8,11 +8,11 @@ import UserContextProvider, { } from '../js/dashboard/user-context' import { MemoryRouter, MemoryRouterProps } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import QueryContextProvider from '../js/dashboard/query-context' +import DashboardStateContextProvider from '../js/dashboard/dashboard-state-context' import { getRouterBasepath } from '../js/dashboard/router' import { RoutelessModalsContextProvider } from '../js/dashboard/navigation/routeless-modals-context' import { SegmentsContextProvider } from '../js/dashboard/filtering/segments-context' -import { SavedSegments } from '../js/dashboard/filtering/segments' +import { SavedSegment, SavedSegments } from '../js/dashboard/filtering/segments' type TestContextProvidersProps = { children: ReactNode @@ -20,6 +20,7 @@ type TestContextProvidersProps = { siteOptions?: Partial user?: UserContextValue preloaded?: { segments?: SavedSegments } + limitedToSegment?: SavedSegment | null } export const DEFAULT_SITE: PlausibleSite = { @@ -42,7 +43,8 @@ export const DEFAULT_SITE: PlausibleSite = { isDbip: false, flags: {}, validIntervalsByPeriod: {}, - shared: false + shared: false, + isConsolidatedView: false } export const TestContextProviders = ({ @@ -50,6 +52,7 @@ export const TestContextProviders = ({ routerProps, siteOptions, preloaded, + limitedToSegment, user }: TestContextProvidersProps) => { const site = { ...DEFAULT_SITE, ...siteOptions } @@ -68,9 +71,19 @@ export const TestContextProviders = ({ // not interactive component, default value is suitable - + - {children} + + {children} + diff --git a/assets/test-utils/jsdom-mocks.ts b/assets/test-utils/jsdom-mocks.ts new file mode 100644 index 000000000000..324a8b1a2456 --- /dev/null +++ b/assets/test-utils/jsdom-mocks.ts @@ -0,0 +1,5 @@ +import { configMocks } from 'jsdom-testing-mocks' +import { act } from '@testing-library/react' + +// as per jsdom-testing-mocks docs, this is needed to avoid having to wrap everything in act calls +configMocks({ act }) diff --git a/config/.env.dev b/config/.env.dev index cb957fe12675..c1b5361dcecc 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -14,6 +14,7 @@ ADMIN_USER_IDS=1 SHOW_CITIES=true PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a PADDLE_VENDOR_ID=3942 +SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5354 GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=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 diff --git a/config/.env.e2e_test b/config/.env.e2e_test new file mode 100644 index 000000000000..a387ecbd887c --- /dev/null +++ b/config/.env.e2e_test @@ -0,0 +1,39 @@ +BASE_URL=http://localhost:8111 +HTTP_PORT=8111 +SECURE_COOKIE=false +DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_e2e +CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_e2e +CLICKHOUSE_MAX_BUFFER_SIZE_BYTES=1000000 +SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg +TOTP_VAULT_KEY=Q3BD4nddbkVJIPXgHuo5NthGKSIH0yesRfG05J88HIo= +ENVIRONMENT=dev +MAILER_ADAPTER=Bamboo.LocalAdapter +LOG_LEVEL=error +SELFHOST=false +DISABLE_CRON=true +ADMIN_USER_IDS=1 +SHOW_CITIES=true +PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a +PADDLE_VENDOR_ID=3942 +SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5354 + +GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + +PROMEX_DISABLED=false +SITE_DEFAULT_INGEST_THRESHOLD=1000000 + +S3_DISABLED=false +S3_ACCESS_KEY_ID=minioadmin +S3_SECRET_ACCESS_KEY=minioadmin +S3_REGION=us-east-1 +S3_ENDPOINT=http://localhost:10000 +S3_EXPORTS_BUCKET=dev-exports +S3_IMPORTS_BUCKET=dev-imports + +HELP_SCOUT_APP_ID=fake_app_id +HELP_SCOUT_APP_SECRET=fake_app_secret +HELP_SCOUT_SIGNATURE_KEY=fake_signature_key +HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM= + +VERIFICATION_ENABLED=true diff --git a/config/.env.load b/config/.env.load new file mode 100644 index 000000000000..cb957fe12675 --- /dev/null +++ b/config/.env.load @@ -0,0 +1,37 @@ +BASE_URL=http://localhost:8000 +SECURE_COOKIE=false +DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_dev +CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_events_db +CLICKHOUSE_MAX_BUFFER_SIZE_BYTES=1000000 +SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg +TOTP_VAULT_KEY=Q3BD4nddbkVJIPXgHuo5NthGKSIH0yesRfG05J88HIo= +ENVIRONMENT=dev +MAILER_ADAPTER=Bamboo.LocalAdapter +LOG_LEVEL=debug +SELFHOST=false +DISABLE_CRON=true +ADMIN_USER_IDS=1 +SHOW_CITIES=true +PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a +PADDLE_VENDOR_ID=3942 + +GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + +PROMEX_DISABLED=false +SITE_DEFAULT_INGEST_THRESHOLD=1000000 + +S3_DISABLED=false +S3_ACCESS_KEY_ID=minioadmin +S3_SECRET_ACCESS_KEY=minioadmin +S3_REGION=us-east-1 +S3_ENDPOINT=http://localhost:10000 +S3_EXPORTS_BUCKET=dev-exports +S3_IMPORTS_BUCKET=dev-imports + +HELP_SCOUT_APP_ID=fake_app_id +HELP_SCOUT_APP_SECRET=fake_app_secret +HELP_SCOUT_SIGNATURE_KEY=fake_signature_key +HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM= + +VERIFICATION_ENABLED=true diff --git a/config/ce.exs b/config/ce.exs index 6ebd95e0e321..fb4cecbcfd00 100644 --- a/config/ce.exs +++ b/config/ce.exs @@ -15,3 +15,8 @@ config :esbuild, cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] + +config :plausible, Plausible.Auth.ApiKey, + legacy_per_user_hourly_request_limit: 1_000_000, + burst_request_limit: 1_000_000, + burst_period_seconds: 10 diff --git a/config/config.exs b/config/config.exs index be5ae85b7907..1e0f80d7ebc3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -26,27 +26,25 @@ config :esbuild, ] config :tailwind, - version: "3.4.7", + version: "4.1.12", default: [ args: ~w( - --config=tailwind.config.js - --input=css/app.css - --output=../priv/static/css/app.css + --input=assets/css/app.css + --output=priv/static/css/app.css ), - cd: Path.expand("../assets", __DIR__) + cd: Path.expand("..", __DIR__) ], storybook: [ args: ~w( - --config=tailwind.config.js - --input=css/storybook.css - --output=../priv/static/css/storybook.css + --input=assets/css/storybook.css + --output=priv/static/css/storybook.css ), - cd: Path.expand("../assets", __DIR__) + cd: Path.expand("..", __DIR__) ] config :ua_inspector, database_path: "priv/ua_inspector", - remote_release: "6.3.2" + remote_release: "6.5.0" config :ref_inspector, database_path: "priv/ref_inspector" @@ -90,4 +88,9 @@ config :sentry, config :prom_ex, :storage_adapter, Plausible.PromEx.StripedPeep config :peep, :bucket_calculator, Plausible.PromEx.Buckets +config :plausible, Plausible.Auth.ApiKey, + legacy_per_user_hourly_request_limit: 600, + burst_request_limit: 60, + burst_period_seconds: 10 + import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index ca0dbec390c2..41d6c44f6bee 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -27,5 +27,17 @@ config :plausible, PlausibleWeb.Endpoint, ] ] +config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock + config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime + +config :plausible, Plausible.Repo, stacktrace: true +config :plausible, Plausible.ClickhouseRepo, stacktrace: true +config :plausible, Plausible.IngestRepo, stacktrace: true +config :plausible, Plausible.AsyncInsertRepo, stacktrace: true + +config :phoenix_live_view, + debug_heex_annotations: true, + debug_attributes: true, + enable_expensive_runtime_checks: true diff --git a/config/e2e_test.exs b/config/e2e_test.exs new file mode 100644 index 000000000000..697727ec3a2c --- /dev/null +++ b/config/e2e_test.exs @@ -0,0 +1,18 @@ +import Config + +config :plausible, PlausibleWeb.Endpoint, + server: true, + check_origin: false + +config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock + +config :phoenix, :stacktrace_depth, 20 +config :phoenix, :plug_init_mode, :runtime + +config :bcrypt_elixir, :log_rounds, 4 + +config :plausible, Plausible.Ingestion.Counters, enabled: false + +config :plausible, Oban, testing: :manual + +config :plausible, Plausible.Session.Salts, interval: :timer.hours(1) diff --git a/config/load.exs b/config/load.exs new file mode 100644 index 000000000000..976ddcc1bb0e --- /dev/null +++ b/config/load.exs @@ -0,0 +1,17 @@ +import Config + +config :plausible, PlausibleWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json", + check_origin: false, + server: true, + code_reloader: false, + http: [ + transport_options: [ + num_acceptors: 1000 + ] + ], + protocol_options: [ + max_keepalive: 5_000, + idle_timeout: 120_000, + request_timeout: 120_000 + ] diff --git a/config/runtime.exs b/config/runtime.exs index 8bf8c83b091f..5ae1132919a8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -2,7 +2,7 @@ import Config import Plausible.ConfigHelpers require Logger -if config_env() in [:dev, :test] do +if config_env() in [:dev, :test, :load] do Envy.load(["config/.env.#{config_env()}"]) end @@ -14,6 +14,10 @@ if config_env() == :ce_test do Envy.load(["config/.env.test"]) end +if config_env() == :e2e_test do + Envy.load(["config/.env.e2e_test"]) +end + config_dir = System.get_env("CONFIG_DIR", "/run/secrets") log_format = @@ -27,7 +31,7 @@ log_level = |> String.to_existing_atom() config :logger, level: log_level -config :logger, :default_formatter, metadata: [:request_id] +config :logger, :default_formatter, metadata: [:request_id, :trace_id] config :logger, Sentry.LoggerBackend, capture_log_messages: true, @@ -143,6 +147,25 @@ end |> get_var_from_path_or_env("CLICKHOUSE_MAX_BUFFER_SIZE_BYTES", "100000") |> Integer.parse() +persistor_backend = + case get_var_from_path_or_env(config_dir, "PERSISTOR_BACKEND", "embedded") do + "embedded" -> Plausible.Ingestion.Persistor.Embedded + "embedded_with_relay" -> Plausible.Ingestion.Persistor.EmbeddedWithRelay + "remote" -> Plausible.Ingestion.Persistor.Remote + end + +{persistor_backend_percent_enabled, ""} = + config_dir + |> get_var_from_path_or_env("PERSISTOR_BACKEND_PERCENT_ENABLED", "0") + |> Integer.parse() + +persistor_url = + get_var_from_path_or_env(config_dir, "PERSISTOR_URL", "http://localhost:8001/event") + +persistor_count = get_int_from_path_or_env(config_dir, "PERSISTOR_COUNT", 200) + +persistor_timeout_ms = get_int_from_path_or_env(config_dir, "PERSISTOR_TIMEOUT_MS", 10_000) + # Can be generated with `Base.encode64(:crypto.strong_rand_bytes(32))` from # iex shell or `openssl rand -base64 32` from command line. totp_vault_key = @@ -217,10 +240,8 @@ help_scout_app_secret = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_SEC help_scout_signature_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_SIGNATURE_KEY") help_scout_vault_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_VAULT_KEY") -{otel_sampler_ratio, ""} = - config_dir - |> get_var_from_path_or_env("OTEL_SAMPLER_RATIO", "0.5") - |> Float.parse() +otlp_endpoint = + get_var_from_path_or_env(config_dir, "OTLP_ENDPOINT", "https://api.honeycomb.io:443") geolite2_country_db = get_var_from_path_or_env( @@ -240,6 +261,13 @@ persistent_cache_dir = get_var_from_path_or_env(config_dir, "PERSISTENT_CACHE_DI data_dir = data_dir || persistent_cache_dir || System.get_env("DEFAULT_DATA_DIR") persistent_cache_dir = persistent_cache_dir || data_dir +session_transfer_dir = + if get_bool_from_path_or_env(config_dir, "ENABLE_SESSION_TRANSFER", config_env() == :prod) do + if persistent_cache_dir do + Path.join(persistent_cache_dir, "sessions") + end + end + enable_email_verification = get_bool_from_path_or_env(config_dir, "ENABLE_EMAIL_VERIFICATION", false) @@ -299,6 +327,29 @@ secure_cookie = license_key = get_var_from_path_or_env(config_dir, "LICENSE_KEY", "") +sso_saml_adapter = + case get_var_from_path_or_env(config_dir, "SSO_SAML_ADAPTER", "fake") do + "fake" -> PlausibleWeb.SSO.FakeSAMLAdapter + "real" -> PlausibleWeb.SSO.RealSAMLAdapter + end + +sso_verification_nameservers = + case get_var_from_path_or_env(config_dir, "SSO_VERIFICATION_NAMESERVERS") do + nil -> + nil + + some when is_binary(some) -> + some + |> String.split(",") + |> Enum.map(fn addr -> + uri = URI.parse("dns://#{addr}") + host = uri.host + port = uri.port || 53 + {:ok, addr} = :inet.parse_address(to_charlist(host)) + {addr, port} + end) + end + config :plausible, environment: env, mailer_email: mailer_email, @@ -307,7 +358,10 @@ config :plausible, custom_script_name: custom_script_name, log_failed_login_attempts: log_failed_login_attempts, license_key: license_key, - data_dir: data_dir + data_dir: data_dir, + session_transfer_dir: session_transfer_dir, + sso_saml_adapter: sso_saml_adapter, + sso_verification_nameservers: sso_verification_nameservers config :plausible, :selfhost, enable_email_verification: enable_email_verification, @@ -323,7 +377,8 @@ config :plausible, PlausibleWeb.Endpoint, http: [port: http_port, ip: listen_ip] ++ default_http_opts, secret_key_base: secret_key_base, websocket_url: websocket_url, - secure_cookie: secure_cookie + secure_cookie: secure_cookie, + base_url: base_url if http_uds do uds_bind = [ip: {:local, http_uds}, port: 0] @@ -476,13 +531,48 @@ if db_socket_dir? do password: password end else - config :plausible, Plausible.Repo, - url: db_url, - socket_options: db_maybe_ipv6 + config :plausible, Plausible.Repo, url: db_url - if db_cacertfile do - config :plausible, Plausible.Repo, ssl: [cacertfile: db_cacertfile] + unless Enum.empty?(db_maybe_ipv6) do + config :plausible, Plausible.Repo, socket_options: db_maybe_ipv6 end + + db_query = URI.decode_query(db_uri.query || "") + # https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS + pg_sslmode = db_query["sslmode"] + + pg_ssl = + cond do + db_cacertfile -> + [cacertfile: db_cacertfile, verify: :verify_peer] + + pg_sslmode == "verify-full" -> + if pg_sslrootcert = db_query["sslrootcert"] do + [cacertfile: pg_sslrootcert, verify: :verify_peer] + else + raise ArgumentError, + "PostgreSQL SSL mode `sslmode=#{pg_sslmode}` requires a certificate, set it in `sslrootcert`" + end + + pg_sslmode == "verify-ca" -> + [cacerts: :public_key.cacerts_get(), verify: :verify_peer] + + pg_sslmode == "require" -> + [verify: :verify_none] + + pg_sslmode == "disable" -> + false + + pg_sslmode -> + raise ArgumentError, + "PostgreSQL SSL mode `sslmode=#{pg_sslmode}` is not supported, use `disable`, `require`, `verify-ca` or `verify-full` instead" + + true -> + # tls is disabled by default, because in self-hosted docker compose postgres is co-located + false + end + + config :plausible, Plausible.Repo, ssl: pg_ssl end sentry_app_version = runtime_metadata[:version] || app_version @@ -544,11 +634,19 @@ ch_transport_opts = config :plausible, Plausible.ClickhouseRepo, queue_target: 500, queue_interval: 2000, + timeout: 15_000, url: ch_db_url, transport_opts: ch_transport_opts, settings: [ readonly: 1, - join_algorithm: "direct,parallel_hash,hash" + join_algorithm: "direct,parallel_hash,hash", + # stops queries when :timeout ClickhouseRepo connection :timeout value reached + cancel_http_readonly_queries_on_client_close: 1, + # stops queries when they will likely take over 20s + # NB! when :timeout is overridden to be over 20s, + # for it to have meaningful effect, + # this must be overridden as well + max_execution_time: 20 ] config :plausible, Plausible.IngestRepo, @@ -585,6 +683,15 @@ config :plausible, Plausible.ImportDeletionRepo, transport_opts: ch_transport_opts, pool_size: 1 +config :plausible, Plausible.Ingestion.Persistor, + backend: persistor_backend, + backend_percent_enabled: persistor_backend_percent_enabled + +config :plausible, Plausible.Ingestion.Persistor.Remote, + url: persistor_url, + count: persistor_count, + timeout_ms: persistor_timeout_ms + config :ex_money, open_exchange_rates_app_id: get_var_from_path_or_env(config_dir, "OPEN_EXCHANGE_RATES_APP_ID"), retrieve_every: :timer.hours(24) @@ -751,7 +858,9 @@ cloud_queues = [ check_usage: 1, notify_annual_renewal: 1, lock_sites: 1, - legacy_time_on_page_cutoff: 1 + legacy_time_on_page_cutoff: 1, + purge_cdn_cache: 1, + sso_domain_ownership_verification: 32 ] queues = if(is_selfhost, do: base_queues, else: base_queues ++ cloud_queues) @@ -759,7 +868,7 @@ cron_enabled = !disable_cron thirty_days_in_seconds = 60 * 60 * 24 * 30 -if config_env() in [:prod, :ce] do +if config_env() in [:prod, :ce, :load] do config :plausible, Oban, repo: Plausible.Repo, plugins: [ @@ -776,8 +885,7 @@ if config_env() in [:prod, :ce] do else config :plausible, Oban, repo: Plausible.Repo, - queues: queues, - plugins: false + queues: queues end config :plausible, :hcaptcha, @@ -793,47 +901,16 @@ config :plausible, Plausible.Sentry.Client, receive_timeout: get_int_from_path_or_env(config_dir, "SENTRY_FINCH_RECEIVE_TIMEOUT", 15000) ] +config :plausible, Plausible.Workers.PurgeCDNCache, + pullzone_id: get_var_from_path_or_env(config_dir, "BUNNY_PULLZONE_ID"), + api_key: get_var_from_path_or_env(config_dir, "BUNNY_API_KEY") + config :ref_inspector, init: {Plausible.Release, :configure_ref_inspector} config :ua_inspector, init: {Plausible.Release, :configure_ua_inspector} -if config_env() in [:dev, :staging, :prod, :test] do - config :kaffy, - otp_app: :plausible, - ecto_repo: Plausible.Repo, - router: PlausibleWeb.Router, - admin_title: "Plausible Admin", - extensions: [Plausible.CrmExtensions], - resources: [ - auth: [ - resources: [ - user: [schema: Plausible.Auth.User, admin: Plausible.Auth.UserAdmin], - api_key: [schema: Plausible.Auth.ApiKey, admin: Plausible.Auth.ApiKeyAdmin] - ] - ], - teams: [ - resources: [ - team: [schema: Plausible.Teams.Team, admin: Plausible.Teams.TeamAdmin] - ] - ], - sites: [ - resources: [ - site: [schema: Plausible.Site, admin: Plausible.SiteAdmin] - ] - ], - billing: [ - resources: [ - enterprise_plan: [ - schema: Plausible.Billing.EnterprisePlan, - admin: Plausible.Billing.EnterprisePlanAdmin - ] - ] - ] - ] -end - geo_opts = cond do maxmind_license_key -> @@ -875,13 +952,12 @@ end if honeycomb_api_key && honeycomb_dataset do config :opentelemetry, resource: Plausible.OpenTelemetry.resource_attributes(runtime_metadata), - sampler: {Plausible.OpenTelemetry.Sampler, %{ratio: otel_sampler_ratio}}, span_processor: :batch, traces_exporter: :otlp config :opentelemetry_exporter, otlp_protocol: :grpc, - otlp_endpoint: "https://api.honeycomb.io:443", + otlp_endpoint: otlp_endpoint, otlp_headers: [ {"x-honeycomb-team", honeycomb_api_key}, {"x-honeycomb-dataset", honeycomb_dataset} @@ -892,6 +968,30 @@ else traces_exporter: :none end +beam_metrics_enabled? = get_bool_from_path_or_env(config_dir, "BEAM_METRICS_ENABLED", false) + +if beam_metrics_enabled? do + beam_metrics_interval = get_int_from_path_or_env(config_dir, "BEAM_METRICS_INTERVAL_MS", 5_000) + + beam_metrics_otlp_endpoint = + get_var_from_path_or_env(config_dir, "OTEL_EXPORTER_OTLP_ENDPOINT") || otlp_endpoint + + config :opentelemetry_experimental, + readers: [ + %{ + module: :otel_metric_reader, + config: %{ + export_interval_ms: beam_metrics_interval, + exporter: + {:otel_exporter_metrics_otlp, + %{ + endpoints: [beam_metrics_otlp_endpoint] + }} + } + } + ] +end + config :tzdata, :data_dir, Path.join(persistent_cache_dir || System.tmp_dir!(), "tzdata_data") promex_disabled? = get_bool_from_path_or_env(config_dir, "PROMEX_DISABLED", true) @@ -903,7 +1003,7 @@ config :plausible, Plausible.PromEx, grafana: :disabled, metrics_server: :disabled -config :plausible, Plausible.Verification.Checks.Installation, +config :plausible, Plausible.InstallationSupport.BrowserlessConfig, token: get_var_from_path_or_env(config_dir, "BROWSERLESS_TOKEN", "dummy_token"), endpoint: get_var_from_path_or_env(config_dir, "BROWSERLESS_ENDPOINT", "http://0.0.0.0:3000") diff --git a/config/test.exs b/config/test.exs index b9e44e5d6a81..17f97b139843 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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], @@ -17,7 +17,7 @@ config :plausible, Plausible.ClickhouseRepo, config :plausible, Plausible.Mailer, adapter: Bamboo.TestAdapter config :plausible, - paddle_api: Plausible.PaddleApi.Mock, + paddle_api: Plausible.Billing.TestPaddleApiMock, google_api: Plausible.Google.API.Mock config :bamboo, :refute_timeout, 10 @@ -26,6 +26,9 @@ config :plausible, session_timeout: 0, http_impl: Plausible.HTTPClient.Mock +config :plausible, + dns_lookup_impl: Plausible.DnsLookup.Mock + config :plausible, Plausible.Cache, enabled: false config :ex_money, api_module: Plausible.ExchangeRateMock @@ -34,17 +37,31 @@ config :plausible, Plausible.Ingestion.Counters, enabled: false config :plausible, Oban, testing: :manual -config :plausible, Plausible.Verification.Checks.FetchBody, +config :plausible, Plausible.InstallationSupport.Checks.FetchBody, req_opts: [ - plug: {Req.Test, Plausible.Verification.Checks.FetchBody} + plug: {Req.Test, Plausible.InstallationSupport.Checks.FetchBody} ] -config :plausible, Plausible.Verification.Checks.Installation, +config :plausible, Plausible.InstallationSupport.Checks.Installation, req_opts: [ - plug: {Req.Test, Plausible.Verification.Checks.Installation} + plug: {Req.Test, Plausible.InstallationSupport.Checks.Installation} ] config :plausible, Plausible.HelpScout, req_opts: [ plug: {Req.Test, Plausible.HelpScout} ] + +config :plausible, Plausible.InstallationSupport.Checks.Detection, + req_opts: [ + plug: {Req.Test, Plausible.InstallationSupport.Checks.Detection} + ] + +config :plausible, Plausible.InstallationSupport.Checks.VerifyInstallation, + req_opts: [ + plug: {Req.Test, Plausible.InstallationSupport.Checks.VerifyInstallation} + ] + +config :plausible, Plausible.Session.Salts, interval: :timer.hours(1) + +config :plausible, max_goals_per_site: 10 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000000..99a51372c2ed --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,9 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/output/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/e2e/.prettierignore b/e2e/.prettierignore new file mode 100644 index 000000000000..c2658d7d1b31 --- /dev/null +++ b/e2e/.prettierignore @@ -0,0 +1 @@ +node_modules/ diff --git a/e2e/.prettierrc.json b/e2e/.prettierrc.json new file mode 100644 index 000000000000..81c51f9894bd --- /dev/null +++ b/e2e/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "none", + "semi": false +} diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs new file mode 100644 index 000000000000..c56da44ee99e --- /dev/null +++ b/e2e/eslint.config.mjs @@ -0,0 +1,46 @@ +import eslint from '@eslint/js' +import pluginPlaywright from 'eslint-plugin-playwright' +import prettierEslintInteroperabilityConfig from 'eslint-config-prettier/flat' +import tseslint from 'typescript-eslint' + +export default tseslint.config([ + // shared config for all files + eslint.configs.recommended, + tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ], + '@typescript-eslint/no-unused-expressions': [ + 'error', + { + allowShortCircuit: true + } + ] + } + }, + // config for tests of tracker scripts and installation support scripts + { + files: ['tests/**/*.ts'], + ...pluginPlaywright.configs['flat/recommended'], + languageOptions: { + ecmaVersion: 'latest' + }, + rules: { + ...pluginPlaywright.configs['flat/recommended'].rules, + 'playwright/expect-expect': [ + 'error', + { + assertFunctionNames: ['expectLiveViewConnected'] + } + ] + } + }, + prettierEslintInteroperabilityConfig +]) diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 000000000000..af526f444f1e --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,2527 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e", + "license": "MIT", + "devDependencies": { + "@eslint/js": "^9.39.2", + "@js-joda/core": "^5.7.0", + "@js-joda/locale": "^4.15.3", + "@playwright/test": "^1.58.0", + "@types/node": "^25.1.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-playwright": "^2.5.1", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.55.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@js-joda/core": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", + "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@js-joda/locale": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/@js-joda/locale/-/locale-4.15.3.tgz", + "integrity": "sha512-JsK0y/BXDdxpsiSZUh1fNjBGgkksp4oYNcjiDySmOUkPmbE9s/a/aZ2OlLHHmEOiPBppN2T5MHCaa/0Hf+z2fA==", + "dev": true, + "license": "BSD-3-Clause", + "peerDependencies": { + "@js-joda/core": ">=3.2.0", + "@js-joda/timezone": "^2.3.0", + "cldr-data": "*", + "cldrjs": "^0.5.4" + } + }, + "node_modules/@js-joda/timezone": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/@js-joda/timezone/-/timezone-2.23.0.tgz", + "integrity": "sha512-33rPV8ORT66Httd/IHQaymTZ//MbjF0WRB58JOUT0G04/a9cB5Q0RFTV1+T4XjIjHr+nY5QkO6KppqgogsJs+Q==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "peerDependencies": { + "@js-joda/core": ">=1.11.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", + "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", + "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cldr-data": { + "version": "36.0.5", + "resolved": "https://registry.npmjs.org/cldr-data/-/cldr-data-36.0.5.tgz", + "integrity": "sha512-+SVRkiHCKQcd1Qp3XgIMrwR35TFuExw/BCeAVMmcN9wNH8gmgl+kGL2Pho8jaoNGgr9A4oNUAMJOG4cXQxr0Og==", + "dev": true, + "hasInstallScript": true, + "license": [ + { + "type": "MIT", + "url": "https://github.com/rxaviers/cldr-data-npm/blob/master/LICENSE" + } + ], + "peer": true, + "dependencies": { + "cldr-data-downloader": "1.1.0", + "glob": "10.5.0" + } + }, + "node_modules/cldr-data-downloader": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cldr-data-downloader/-/cldr-data-downloader-1.1.0.tgz", + "integrity": "sha512-xg1GKFP4FOe4GEDkANb8ATz67e1tqJ6GGaRMTYJNNgRwr/9WL+qvlDU4nW9/Iw8gA6NISEfd/+XFNOFkuimaOQ==", + "dev": true, + "peer": true, + "dependencies": { + "axios": "^1.7.2", + "mkdirp": "^1.0.4", + "nopt": "3.0.x", + "q": "1.0.1", + "yauzl": "^2.10.0" + }, + "bin": { + "cldr-data-downloader": "bin/download.sh" + } + }, + "node_modules/cldrjs": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz", + "integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA==", + "dev": true, + "peer": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.5.1.tgz", + "integrity": "sha512-q7oqVQTTfa3VXJQ8E+ln0QttPGrs/XmSO1FjOMzQYBMYF3btih4FIrhEYh34JF184GYDmq3lJ/n7CMa49OHBvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^16.4.0" + }, + "engines": { + "node": ">=16.9.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-plugin-playwright/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/q": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.0.1.tgz", + "integrity": "sha512-18MnBaCeBX9sLRUdtxz/6onlb7wLzFxCylklyO8n27y5JxJYaGLPu4ccyc5zih58SpEzY8QmfwaWqguqXU6Y+A==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000000..ab45435c7daa --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,23 @@ +{ + "name": "e2e", + "scripts": { + "lint": "eslint", + "check-format": "prettier --check \"**/*.{js,mjs,ts,html,json,md}\"", + "typecheck": "tsc --noEmit --pretty", + "test": "npx playwright test" + }, + "license": "MIT", + "devDependencies": { + "@eslint/js": "^9.39.2", + "@js-joda/core": "^5.7.0", + "@js-joda/locale": "^4.15.3", + "@playwright/test": "^1.58.0", + "@types/node": "^25.1.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-playwright": "^2.5.1", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.55.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000000..2c59484de51b --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,76 @@ +import { defineConfig, devices } from '@playwright/test' + +const baseURL: string = process.env.BASE_URL! +const isCI: boolean = !!process.env.CI + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: isCI, + /* Retry on CI only */ + retries: isCI ? 2 : 0, + /* Make test timeout shorter when running tests in local dev env. */ + timeout: isCI ? 30_000 : 15_000, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry' + }, + /* Opt out of parallel tests on CI. */ + ...(isCI && { workers: 1 }), + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + webServer: { + cwd: '..', + command: 'mix phx.server', + env: { ...(process.env as Record), MIX_ENV: 'e2e_test' }, + gracefulShutdown: { signal: 'SIGTERM', timeout: 500 }, + url: `${baseURL}/api/system/health/ready`, + reuseExistingServer: !isCI + } +}) diff --git a/e2e/tests/dashboard/behaviours.spec.ts b/e2e/tests/dashboard/behaviours.spec.ts new file mode 100644 index 000000000000..96fbe6a191e6 --- /dev/null +++ b/e2e/tests/dashboard/behaviours.spec.ts @@ -0,0 +1,470 @@ +import { test, expect, Page } from '@playwright/test' +import { + setupSite, + populateStats, + addCustomGoal, + addPageviewGoal, + addScrollDepthGoal, + addAllCustomProps, + addFunnel +} from '../fixtures' +import { + tabButton, + expectHeaders, + expectRows, + rowLink, + expectMetricValues, + dropdown, + detailsLink, + modal, + closeModalButton, + searchInput +} from '../test-utils' + +const getReport = (page: Page) => page.getByTestId('report-behaviours') + +test('goals breakdown', async ({ page, request }) => { + const report = getReport(page) + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 123, + name: 'pageview', + pathname: '/page1', + timestamp: { minutesAgo: 60 } + }, + { + user_id: 124, + name: 'pageview', + pathname: '/page1', + timestamp: { minutesAgo: 60 } + }, + { + user_id: 125, + name: 'pageview', + pathname: '/page1', + timestamp: { minutesAgo: 60 } + }, + { + user_id: 126, + name: 'pageview', + pathname: '/page1', + timestamp: { minutesAgo: 60 } + }, + { + user_id: 123, + name: 'engagement', + pathname: '/page1', + scroll_depth: 80, + timestamp: { minutesAgo: 59 } + }, + { + user_id: 124, + name: 'engagement', + pathname: '/page1', + scroll_depth: 80, + timestamp: { minutesAgo: 59 } + }, + { + user_id: 125, + name: 'engagement', + pathname: '/page1', + scroll_depth: 80, + timestamp: { minutesAgo: 59 } + }, + { + user_id: 123, + name: 'purchase', + pathname: '/buy', + revenue_reporting_amount: '23', + revenue_reporting_currency: 'EUR', + timestamp: { minutesAgo: 59 } + }, + { user_id: 124, name: 'add_site', timestamp: { minutesAgo: 50 } }, + { user_id: 125, name: 'add_site', timestamp: { minutesAgo: 50 } } + ] + }) + + await addCustomGoal({ + page, + domain, + name: 'add_site', + displayName: 'Add a site' + }) + await addCustomGoal({ page, domain, name: 'purchase', currency: 'EUR' }) + await addPageviewGoal({ page, domain, pathname: '/page1' }) + await addScrollDepthGoal({ + page, + domain, + pathname: '/page1', + scrollPercentage: 75 + }) + + await page.goto('/' + domain) + + const goalsTabButton = tabButton(report, 'Goals') + + await test.step('listing all goals', async () => { + await goalsTabButton.scrollIntoViewIfNeeded() + await expect(goalsTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, [ + 'Goal', + 'Uniques', + 'Total', + 'CR', + 'Revenue', + 'Average' + ]) + + await expectRows(report, [ + 'Visit /page1', + 'Scroll 75% on /page1', + 'Add a site', + 'purchase' + ]) + await expectMetricValues(report, 'Visit /page1', [ + '4', + '4', + '100%', + '-', + '-' + ]) + await expectMetricValues(report, 'Scroll 75% on /page1', [ + '3', + '-', + '75%', + '-', + '-' + ]) + await expectMetricValues(report, 'Add a site', ['2', '2', '50%', '-', '-']) + await expectMetricValues(report, 'purchase', [ + '1', + '1', + '25%', + '€23.0', + '€23.0' + ]) + }) + + await test.step('goals modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Goal conversions' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Goal', + /Uniques/, + /Total/, + /CR/, + /Average/, + /Revenue/ + ]) + + await expectRows(modal(page), [ + 'Visit /page1', + 'Scroll 75% on /page1', + 'Add a site', + 'purchase' + ]) + + await expectMetricValues(modal(page), 'Visit /page1', [ + '4', + '4', + '100%', + '-', + '-' + ]) + + await closeModalButton(page).click() + }) + + await test.step('listing goals without revenue', async () => { + await page.goto('/' + domain + '?f=has_not_done,goal,purchase') + + await goalsTabButton.scrollIntoViewIfNeeded() + await expect(goalsTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Goal', 'Uniques', 'Total', 'CR']) + + await expectRows(report, [ + 'Visit /page1', + 'Add a site', + 'Scroll 75% on /page1' + ]) + + await expectMetricValues(report, 'Visit /page1', ['3', '3', '100%']) + await expectMetricValues(report, 'Add a site', ['2', '2', '66.7%']) + await expectMetricValues(report, 'Scroll 75% on /page1', [ + '2', + '-', + '66.7%' + ]) + }) + + await test.step('goals modal without revenue', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Goal conversions' }) + ).toBeVisible() + + await expectHeaders(modal(page), ['Goal', /Uniques/, /Total/, /CR/]) + + await expectRows(modal(page), [ + 'Visit /page1', + 'Add a site', + 'Scroll 75% on /page1' + ]) + + await expectMetricValues(modal(page), 'Visit /page1', ['3', '3', '100%']) + + await closeModalButton(page).click() + }) +}) + +test('props breakdown', async ({ page, request }) => { + const report = getReport(page) + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + name: 'pageview', + pathname: '/page', + 'meta.key': [ + 'logged_in', + 'browser_language', + 'prop3', + 'prop4', + 'prop5', + 'prop6', + 'prop7', + 'prop8', + 'prop9', + 'prop10', + 'prop11' + ], + 'meta.value': [ + 'false', + 'en_US', + 'val3', + 'val4', + 'val5', + 'val6', + 'val7', + 'val8', + 'val9', + 'val10', + 'val11' + ] + }, + { + name: 'pageview', + pathname: '/page', + 'meta.key': ['logged_in', 'browser_language'], + 'meta.value': ['false', 'en_US'] + }, + { + name: 'pageview', + pathname: '/page', + 'meta.key': ['logged_in', 'browser_language'], + 'meta.value': ['true', 'es'] + } + ] + }) + + await addPageviewGoal({ page, domain, pathname: '/page' }) + + await addAllCustomProps({ page, domain }) + + await page.goto('/' + domain) + + const propsTabButton = tabButton(report, 'Properties') + + await test.step('listing props', async () => { + await propsTabButton.scrollIntoViewIfNeeded() + await propsTabButton.click() + await dropdown(report) + .getByRole('button', { name: 'browser_language' }) + .click() + + await expect(propsTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['browser_language', 'Visitors', 'Events', '%']) + + await expectRows(report, ['en_US', 'es']) + + await expectMetricValues(report, 'en_US', ['2', '2', '66.7%']) + await expectMetricValues(report, 'es', ['1', '1', '33.3%']) + }) + + await test.step('loading more', async () => { + await propsTabButton.click() + const showMoreButton = dropdown(report).getByRole('button', { + name: 'Show 1 more' + }) + await showMoreButton.click() + await expect(showMoreButton).toBeHidden() + await expect(dropdown(report).getByRole('button')).toHaveCount(11) + }) + + await test.step('searching', async () => { + await searchInput(report).fill('prop1') + await expect(dropdown(report).getByRole('button')).toHaveCount(2) + }) + + await test.step('props modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Custom property breakdown' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'browser_language', + /Visitors/, + /Events/, + /%/ + ]) + + await expectRows(modal(page), ['en_US', 'es']) + + await expectMetricValues(modal(page), 'en_US', ['2', '2', '66.7%']) + + await closeModalButton(page).click() + }) + + await test.step('clicking goal opens props', async () => { + const goalsTabButton = tabButton(report, 'Goals') + goalsTabButton.click() + + await expect(goalsTabButton).toHaveAttribute('data-active', 'true') + + await rowLink(report, 'Visit /page').click() + + await expect(propsTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, [ + 'browser_language', + 'Visitors', + 'Events', + 'CR' + ]) + + await expectRows(report, ['en_US', 'es']) + + await expectMetricValues(report, 'en_US', ['2', '2', '66.7%']) + await expectMetricValues(report, 'es', ['1', '1', '33.3%']) + }) +}) + +test('funnels', async ({ page, request }) => { + const report = getReport(page) + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 123, + name: 'pageview', + pathname: '/products', + timestamp: { minutesAgo: 60 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/cart', + timestamp: { minutesAgo: 55 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/checkout', + timestamp: { minutesAgo: 50 } + }, + { + user_id: 124, + name: 'pageview', + pathname: '/products', + timestamp: { minutesAgo: 55 } + }, + { + user_id: 124, + name: 'pageview', + pathname: '/cart', + timestamp: { minutesAgo: 50 } + }, + { + user_id: 125, + name: 'pageview', + pathname: '/products', + timestamp: { minutesAgo: 50 } + } + ] + }) + + await addPageviewGoal({ page, domain, pathname: '/products' }) + await addPageviewGoal({ page, domain, pathname: '/cart' }) + await addPageviewGoal({ page, domain, pathname: '/checkout' }) + + for (let idx = 0; idx < 11; idx++) { + await addFunnel({ + request, + domain, + name: `Shopping ${idx + 1} Funnel`, + steps: ['Visit /products', 'Visit /cart', 'Visit /checkout'] + }) + } + + await page.goto('/' + domain) + + const funnelsTabButton = tabButton(report, 'Funnels') + + await test.step('rendering funnels', async () => { + await funnelsTabButton.scrollIntoViewIfNeeded() + await funnelsTabButton.click() + await dropdown(report) + .getByRole('button', { name: 'Shopping 11 Funnel' }) + .click() + + await expect(funnelsTabButton).toHaveAttribute('data-active', 'true') + + await expect(report.getByRole('heading')).toHaveText('Shopping 11 Funnel') + + await expect(report.getByText('3-step funnel')).toBeVisible() + + await expect(report.getByText('33.33% conversion rate')).toBeVisible() + }) + + await test.step('loading more', async () => { + await funnelsTabButton.click() + await dropdown(report).getByRole('button', { name: 'Show 1 more' }).click() + await dropdown(report) + .getByRole('button', { name: 'Shopping 1 Funnel' }) + .click() + + await expect(report.getByRole('heading')).toHaveText('Shopping 1 Funnel') + }) + + await test.step('searching', async () => { + await funnelsTabButton.click() + await searchInput(report).fill('Shopping 1') + + await expect(dropdown(report).getByRole('button')).toHaveText([ + 'Shopping 11 Funnel', + 'Shopping 10 Funnel', + 'Shopping 1 Funnel' + ]) + }) +}) diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts new file mode 100644 index 000000000000..1e25d052c9d0 --- /dev/null +++ b/e2e/tests/dashboard/breakdowns.spec.ts @@ -0,0 +1,1142 @@ +import { test, expect } from '@playwright/test' +import { setupSite, populateStats, addCustomGoal } from '../fixtures' +import { + tabButton, + expectHeaders, + expectRows, + rowLink, + expectMetricValues, + dropdown, + modal, + detailsLink, + closeModalButton, + header, + searchInput +} from '../test-utils' + +test('sources breakdown', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + name: 'pageview', + referrer_source: 'DuckDuckGo', + + referrer: 'https://duckduckgo.com/a1', + utm_medium: 'paid' + }, + { + name: 'pageview', + referrer_source: 'DuckDuckGo', + referrer: 'https://duckduckgo.com/a2', + click_id_param: 'gclid' + }, + { name: 'pageview', referrer_source: 'Facebook', utm_source: 'fb' }, + { name: 'pageview', referrer_source: 'theguardian.com' }, + { name: 'pageview', referrer_source: 'ablog.example.com' }, + { + name: 'pageview', + utm_medium: 'SomeUTMMedium', + utm_source: 'SomeUTMSource', + utm_campaign: 'SomeUTMCampaign', + utm_content: 'SomeUTMContent', + utm_term: 'SomeUTMTerm' + } + ] + }) + + await page.goto('/' + domain) + + const report = page.getByTestId('report-sources') + + await test.step('sources tab', async () => { + const sourcesTabButton = tabButton(report, 'Sources') + await sourcesTabButton.scrollIntoViewIfNeeded() + await expect(sourcesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Source', 'Visitors']) + + await expectRows(report, [ + 'DuckDuckGo', + 'Direct / None', + 'Facebook', + 'ablog.example.com', + 'theguardian.com' + ]) + + await expectMetricValues(report, 'DuckDuckGo', ['2', '33.3%']) + await expectMetricValues(report, 'Direct / None', ['1', '16.7%']) + await expectMetricValues(report, 'Facebook', ['1', '16.7%']) + await expectMetricValues(report, 'ablog.example.com', ['1', '16.7%']) + await expectMetricValues(report, 'theguardian.com', ['1', '16.7%']) + }) + + await test.step('sources modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top sources' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Source', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), [ + 'DuckDuckGo', + 'Direct / None', + 'Facebook', + 'ablog.example.com', + 'theguardian.com' + ]) + + await expectMetricValues(modal(page), 'DuckDuckGo', ['2', '100%', '0s']) + + await closeModalButton(page).click() + }) + + const referrersReport = page.getByTestId('report-referrers') + + await test.step('clicking sources entry shows referrers', async () => { + await rowLink(report, 'DuckDuckGo').click() + await expect(page).toHaveURL(/f=is,source,DuckDuckGo/) + + await expect(tabButton(referrersReport, 'Top referrers')).toHaveAttribute( + 'data-active', + 'true' + ) + + // Move mouse away from report rows + await tabButton(referrersReport, 'Top referrers').hover() + + await expectHeaders(referrersReport, ['Referrer', 'Visitors']) + + await expectRows(referrersReport, [ + 'https://duckduckgo.com/a1', + 'https://duckduckgo.com/a2' + ]) + }) + + await test.step('referrers modal', async () => { + await detailsLink(referrersReport).click() + + await expect( + modal(page).getByRole('heading', { name: 'Referrer drilldown' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Referrer', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), [ + 'https://duckduckgo.com/a1', + 'https://duckduckgo.com/a2' + ]) + + await closeModalButton(page).click() + + await page + .getByRole('button', { name: 'Remove filter: Source is DuckDuckGo' }) + .click() + }) + + await test.step('channels tab', async () => { + const channelsTabButton = tabButton(report, 'Channels') + await channelsTabButton.click() + await expect(channelsTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Channel', 'Visitors']) + + await expectRows(report, [ + 'Referral', + 'Direct', + 'Organic Search', + 'Organic Social', + 'Paid Search' + ]) + + await expectMetricValues(report, 'Referral', ['2', '33.3%']) + await expectMetricValues(report, 'Direct', ['1', '16.7%']) + await expectMetricValues(report, 'Organic Search', ['1', '16.7%']) + await expectMetricValues(report, 'Organic Social', ['1', '16.7%']) + await expectMetricValues(report, 'Paid Search', ['1', '16.7%']) + }) + + await test.step('channels modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top acquisition channels' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Channel', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), [ + 'Referral', + 'Direct', + 'Organic Search', + 'Organic Social', + 'Paid Search' + ]) + + await expectMetricValues(modal(page), 'Referral', ['2', '100%', '0s']) + + await closeModalButton(page).click() + }) + + await test.step('campaigns > UTM mediums tab', async () => { + await tabButton(report, 'Campaigns').click() + await dropdown(report).getByRole('button', { name: 'UTM mediums' }).click() + + await expect(tabButton(report, 'UTM mediums')).toHaveAttribute( + 'data-active', + 'true' + ) + + await expectHeaders(report, ['Medium', 'Visitors']) + + await expectRows(report, ['SomeUTMMedium', 'paid']) + + await expectMetricValues(report, 'SomeUTMMedium', ['1', '50%']) + await expectMetricValues(report, 'paid', ['1', '50%']) + }) + + await test.step('UTM mediums modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top UTM mediums' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'UTM medium', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['SomeUTMMedium', 'paid']) + + await expectMetricValues(modal(page), 'SomeUTMMedium', ['1', '100%', '0s']) + + await closeModalButton(page).click() + }) + + await test.step('campaigns > UTM sources tab', async () => { + await tabButton(report, 'UTM mediums').click() + await dropdown(report).getByRole('button', { name: 'UTM sources' }).click() + + await expect(tabButton(report, 'UTM sources')).toHaveAttribute( + 'data-active', + 'true' + ) + + await expectHeaders(report, ['Source', 'Visitors']) + + await expectRows(report, ['SomeUTMSource', 'fb']) + + await expectMetricValues(report, 'SomeUTMSource', ['1', '50%']) + await expectMetricValues(report, 'fb', ['1', '50%']) + }) + + await test.step('UTM sources modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top UTM sources' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'UTM source', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['SomeUTMSource', 'fb']) + + await expectMetricValues(modal(page), 'SomeUTMSource', ['1', '100%', '0s']) + + await closeModalButton(page).click() + }) + + await test.step('campaigns > UTM campaigns tab', async () => { + await tabButton(report, 'UTM sources').click() + await dropdown(report) + .getByRole('button', { name: 'UTM campaigns' }) + .click() + + await expect(tabButton(report, 'UTM campaigns')).toHaveAttribute( + 'data-active', + 'true' + ) + + await expectHeaders(report, ['Campaign', 'Visitors']) + + await expectRows(report, ['SomeUTMCampaign']) + + await expectMetricValues(report, 'SomeUTMCampaign', ['1', '100%']) + }) + + await test.step('UTM campaigns modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top UTM campaigns' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'UTM campaign', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['SomeUTMCampaign']) + + await expectMetricValues(modal(page), 'SomeUTMCampaign', [ + '1', + '100%', + '0s' + ]) + + await closeModalButton(page).click() + }) + + await test.step('campaigns > UTM contents tab', async () => { + await tabButton(report, 'UTM campaigns').click() + await dropdown(report).getByRole('button', { name: 'UTM contents' }).click() + + await expect(tabButton(report, 'UTM contents')).toHaveAttribute( + 'data-active', + 'true' + ) + + await expectHeaders(report, ['Content', 'Visitors']) + + await expectRows(report, ['SomeUTMContent']) + + await expectMetricValues(report, 'SomeUTMContent', ['1', '100%']) + }) + + await test.step('UTM contents modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top UTM contents' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'UTM content', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['SomeUTMContent']) + + await expectMetricValues(modal(page), 'SomeUTMContent', ['1', '100%', '0s']) + + await closeModalButton(page).click() + }) + + await test.step('campaigns > UTM terms tab', async () => { + await tabButton(report, 'UTM contents').click() + await dropdown(report).getByRole('button', { name: 'UTM terms' }).click() + + await expect(tabButton(report, 'UTM terms')).toHaveAttribute( + 'data-active', + 'true' + ) + + await expectHeaders(report, ['Term', 'Visitors']) + + await expectRows(report, ['SomeUTMTerm']) + + await expectMetricValues(report, 'SomeUTMTerm', ['1', '100%']) + }) + + await test.step('UTM terms modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top UTM terms' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'UTM term', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['SomeUTMTerm']) + + await expectMetricValues(modal(page), 'SomeUTMTerm', ['1', '100%', '0s']) + + await closeModalButton(page).click() + }) +}) + +test('pages breakdown', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { user_id: 123, name: 'pageview', pathname: '/page1' }, + { user_id: 123, name: 'pageview', pathname: '/page2' }, + { user_id: 123, name: 'pageview', pathname: '/page3' }, + { user_id: 124, name: 'pageview', pathname: '/page1' }, + { user_id: 124, name: 'pageview', pathname: '/page2' }, + { name: 'pageview', pathname: '/page1' }, + { name: 'pageview', pathname: '/other' } + ] + }) + + await page.goto('/' + domain) + + const report = page.getByTestId('report-pages') + + await test.step('top pages tab', async () => { + const pagesTabButton = tabButton(report, 'Top pages') + await pagesTabButton.scrollIntoViewIfNeeded() + await expect(pagesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Page', 'Visitors']) + + await expectRows(report, ['/page1', '/page2', '/other', '/page3']) + + await expectMetricValues(report, '/page1', ['3', '75%']) + await expectMetricValues(report, '/page2', ['2', '50%']) + await expectMetricValues(report, '/other', ['1', '25%']) + await expectMetricValues(report, '/page3', ['1', '25%']) + }) + + await test.step('entry pages tab', async () => { + const entryPagesTabButton = tabButton(report, 'Entry pages') + entryPagesTabButton.click() + await expect(entryPagesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Entry page', 'Unique entrances']) + + await expectRows(report, ['/page1', '/other']) + + await expectMetricValues(report, '/page1', ['3', '75%']) + await expectMetricValues(report, '/other', ['1', '25%']) + }) + + await test.step('Entry pages modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Entry pages' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Entry page', + /Visitors/, + /Total entrances/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['/page1', '/other']) + + await expectMetricValues(modal(page), '/page1', ['3', '3', '33%', '0s']) + + await closeModalButton(page).click() + }) + + await test.step('exit pages tab', async () => { + const exitPagesTabButton = tabButton(report, 'Exit pages') + exitPagesTabButton.click() + await expect(exitPagesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Exit page', 'Unique exits']) + + await expectRows(report, ['/other', '/page1', '/page2', '/page3']) + + await expectMetricValues(report, '/other', ['1', '25%']) + await expectMetricValues(report, '/page1', ['1', '25%']) + await expectMetricValues(report, '/page2', ['1', '25%']) + await expectMetricValues(report, '/page3', ['1', '25%']) + }) + + await test.step('Exit pages modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Exit pages' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Page url', + /Visitors/, + /Total exits/, + /Exit rate/ + ]) + + await expectRows(modal(page), ['/other', '/page1', '/page2', '/page3']) + + await expectMetricValues(modal(page), '/other', ['1', '1', '100%']) + + await closeModalButton(page).click() + }) +}) + +test('pages breakdown modal', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + const pagesCount = 110 + + // We generate unique page entries, each with a different number of visits + const pageEvents = Array(pagesCount) + .fill(null) + .map((_, idx) => { + return Array(idx + 1) + .fill(null) + .map(() => { + return { name: 'pageview', pathname: `/page${idx + 1}/foo` } + }) + }) + .flat() + + await populateStats({ + request, + domain, + events: pageEvents + }) + + await page.goto('/' + domain) + + const report = page.getByTestId('report-pages') + + const pagesTabButton = tabButton(report, 'Top pages') + await pagesTabButton.scrollIntoViewIfNeeded() + await expect(pagesTabButton).toHaveAttribute('data-active', 'true') + + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top pages' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Page url', + /Visitors/, + /Pageviews/, + /Bounce rate/, + /Time on page/, + /Scroll depth/ + ]) + + await test.step('displays 100 entries on a single page', async () => { + const pageRows = Array(100) + .fill(null) + .map((_, idx) => { + return `/page${pagesCount - idx}/foo` + }) + + await expectRows(modal(page), pageRows) + + await expectMetricValues(modal(page), '/page110/foo', [ + '110', + '110', + '100%', + '-', + '-' + ]) + + await expectMetricValues(modal(page), '/page11/foo', [ + '11', + '11', + '100%', + '-', + '-' + ]) + }) + + await test.step('loads more when requested', async () => { + const loadMoreButton = modal(page).getByRole('button', { + name: 'Load more' + }) + + await loadMoreButton.scrollIntoViewIfNeeded() + await loadMoreButton.click() + + await expectMetricValues(modal(page), '/page10/foo', [ + '10', + '10', + '100%', + '-', + '-' + ]) + + await expectMetricValues(modal(page), '/page1/foo', [ + '1', + '1', + '100%', + '-', + '-' + ]) + }) + + await test.step('sorts when clicking on column header', async () => { + await header(modal(page), 'Visitors').click() + + const pageRows = Array(100) + .fill(null) + .map((_, idx) => { + return `/page${idx + 1}/foo` + }) + + await expectRows(modal(page), pageRows) + }) + + await test.step('filters when using search', async () => { + await searchInput(modal(page)).fill('page9') + + await expectRows(modal(page), [ + '/page9/foo', + '/page90/foo', + '/page91/foo', + '/page92/foo', + '/page93/foo', + '/page94/foo', + '/page95/foo', + '/page96/foo', + '/page97/foo', + '/page98/foo', + '/page99/foo' + ]) + }) + + await test.step('close button closes the modal', async () => { + await closeModalButton(page).click() + + await expect(modal(page)).toBeHidden() + }) + + await test.step('reopening the modal resets the search state but preserves', async () => { + await detailsLink(report).click() + + await expect(modal(page)).toContainClass('is-open') + + await expect(searchInput(modal(page))).toHaveValue('') + + const pageRows = Array(100) + .fill(null) + .map((_, idx) => { + return `/page${idx + 1}/foo` + }) + + await expectRows(modal(page), pageRows) + }) +}) + +test('pages breakdown with a pageview goal filter applied', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { user_id: 123, name: 'pageview', pathname: '/page1' }, + { user_id: 123, name: 'pageview', pathname: '/page2' }, + { user_id: 123, name: 'pageview', pathname: '/page3' }, + { + user_id: 123, + name: 'purchase', + revenue_reporting_amount: '23', + revenue_reporting_currency: 'EUR' + }, + { user_id: 124, name: 'pageview', pathname: '/page1' }, + { user_id: 124, name: 'pageview', pathname: '/page2' }, + { user_id: 124, name: 'create_site' }, + { name: 'pageview', pathname: '/page1' }, + { name: 'pageview', pathname: '/other' } + ] + }) + + await addCustomGoal({ page, domain, name: 'create_site' }) + await addCustomGoal({ page, domain, name: 'purchase', currency: 'EUR' }) + + const report = page.getByTestId('report-pages') + + await test.step('custom goal filter applied', async () => { + await page.goto('/' + domain + '?f=is,goal,create_site') + + const pagesTabButton = tabButton(report, 'Conversion pages') + await pagesTabButton.scrollIntoViewIfNeeded() + await expect(pagesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Page', 'Conversions', 'CR']) + + await expectRows(report, ['/']) + + await expectMetricValues(report, '/', ['1', '50%']) + }) + + await test.step('details modal after custom goal filter applied', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top pages' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Page url', + /Total visitors/, + /Conversions/, + /CR/ + ]) + + await expectRows(modal(page), ['/']) + + await expectMetricValues(modal(page), '/', ['2', '1', '50%']) + + await closeModalButton(page).click() + }) + + await test.step('revenue goal filter applied', async () => { + await page.goto('/' + domain + '?f=is,goal,purchase') + + const pagesTabButton = tabButton(report, 'Conversion pages') + await pagesTabButton.scrollIntoViewIfNeeded() + await expect(pagesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Page', 'Conversions', 'CR']) + + await expectRows(report, ['/']) + + await expectMetricValues(report, '/', ['1', '50%']) + }) + + await test.step('details modal after revenue goal filter applied', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top pages' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Page url', + /Total visitors/, + /Conversions/, + /CR/, + /Revenue/, + /Average/ + ]) + + await expectRows(modal(page), ['/']) + + await expectMetricValues(modal(page), '/', [ + '2', + '1', + '50%', + '€23.0', + '€23.0' + ]) + + await closeModalButton(page).click() + }) +}) + +test('locations breakdown', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + name: 'pageview', + country_code: 'EE', + subdivision1_code: 'EE-37', + city_geoname_id: 588_409 + }, + { + name: 'pageview', + country_code: 'EE', + subdivision1_code: 'EE-79', + city_geoname_id: 588_335 + }, + { + name: 'pageview', + country_code: 'PL', + subdivision1_code: 'PL-14', + city_geoname_id: 756_135 + } + ] + }) + + await page.goto('/' + domain) + + const report = page.getByTestId('report-locations') + + await test.step('map tab', async () => { + const mapTabButton = tabButton(report, 'Map') + await mapTabButton.scrollIntoViewIfNeeded() + await expect(mapTabButton).toHaveAttribute('data-active', 'true') + + // NOTE: We only check that the map is there + await expect(report.locator('svg path.country').first()).toBeVisible() + }) + + await test.step('countries tab', async () => { + const countriesTabButton = tabButton(report, 'Countries') + await countriesTabButton.click() + await expect(countriesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Country', 'Visitors']) + + await expectRows(report, [/Estonia/, /Poland/]) + + await expectMetricValues(report, 'Estonia', ['2', '66.7%']) + await expectMetricValues(report, 'Poland', ['1', '33.3%']) + }) + + await test.step('countries modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top countries' }) + ).toBeVisible() + + await expectHeaders(modal(page), ['Country', /Visitors/]) + + await expectRows(modal(page), [/Estonia/, /Poland/]) + + await expectMetricValues(modal(page), 'Estonia', ['2']) + + await closeModalButton(page).click() + }) + + const regionsTabButton = tabButton(report, 'Regions') + + await test.step('clicking country entry shows regions', async () => { + await rowLink(report, 'Estonia').click() + await expect(page).toHaveURL(/f=is,country,EE/) + + await expect(regionsTabButton).toHaveAttribute('data-active', 'true') + + await page + .getByRole('button', { name: 'Remove filter: Country is Estonia' }) + .click() + }) + + await test.step('regions tab', async () => { + await regionsTabButton.click() + await expect(regionsTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Region', 'Visitors']) + + await expectRows(report, [/Harjumaa/, /Tartumaa/, /Mazovia/]) + + await expectMetricValues(report, 'Harjumaa', ['1', '33.3%']) + await expectMetricValues(report, 'Tartumaa', ['1', '33.3%']) + await expectMetricValues(report, 'Mazovia', ['1', '33.3%']) + }) + + await test.step('regions modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top regions' }) + ).toBeVisible() + + await expectHeaders(modal(page), ['Region', /Visitors/]) + + await expectRows(modal(page), [/Harjumaa/, /Tartumaa/, /Mazovia/]) + + await expectMetricValues(modal(page), 'Harjumaa', ['1']) + + await closeModalButton(page).click() + }) + + const citiesTabButton = tabButton(report, 'Cities') + + await test.step('clicking region entry shows cities', async () => { + await rowLink(report, 'Harjumaa').click() + await expect(page).toHaveURL(/f=is,region,EE-37/) + + await expect(citiesTabButton).toHaveAttribute('data-active', 'true') + + await page + .getByRole('button', { name: 'Remove filter: Region is Harjumaa' }) + .click() + }) + + await test.step('cities tab', async () => { + await citiesTabButton.click() + await expect(citiesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['City', 'Visitors']) + + await expectRows(report, [/Tartu/, /Tallinn/, /Warsaw/]) + + await expectMetricValues(report, 'Tartu', ['1', '33.3%']) + await expectMetricValues(report, 'Tallinn', ['1', '33.3%']) + await expectMetricValues(report, 'Warsaw', ['1', '33.3%']) + }) + + await test.step('cities modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top cities' }) + ).toBeVisible() + + await expectHeaders(modal(page), ['City', /Visitors/]) + + await expectRows(modal(page), [/Tartu/, /Tallinn/, /Warsaw/]) + + await expectMetricValues(modal(page), 'Tartu', ['1']) + + await closeModalButton(page).click() + }) +}) + +test('devices breakdown', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + name: 'pageview', + screen_size: 'Desktop', + browser: 'Chrome', + browser_version: '14.0.7', + operating_system: 'Windows', + operating_system_version: '11' + }, + { + name: 'pageview', + screen_size: 'Desktop', + browser: 'Firefox', + browser_version: '98', + operating_system: 'MacOS', + operating_system_version: '10.15' + }, + { + name: 'pageview', + screen_size: 'Mobile', + browser: 'Safari', + browser_version: '123', + operating_system: 'iOS', + operating_system_version: '16.15' + } + ] + }) + + await page.goto('/' + domain) + + const report = page.getByTestId('report-devices') + + const browsersTabButton = tabButton(report, 'Browsers') + + await test.step('browsers tab', async () => { + await browsersTabButton.scrollIntoViewIfNeeded() + await expect(browsersTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Browser', 'Visitors']) + + await expectRows(report, ['Chrome', 'Firefox', 'Safari']) + + await expectMetricValues(report, 'Chrome', ['1', '33.3%']) + await expectMetricValues(report, 'Firefox', ['1', '33.3%']) + await expectMetricValues(report, 'Safari', ['1', '33.3%']) + }) + + await test.step('browsers modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Browsers' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Browser', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['Chrome', 'Firefox', 'Safari']) + + await expectMetricValues(modal(page), 'Chrome', ['1', '100%', '0s']) + + await closeModalButton(page).click() + }) + + await test.step('browser versions', async () => { + await rowLink(report, 'Firefox').click() + + await expect(page).toHaveURL(/f=is,browser,Firefox/) + + await expect(browsersTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Browser version', 'Visitors']) + + await expectRows(report, ['Firefox 98']) + + await expectMetricValues(report, 'Firefox 98', ['1', '100%']) + }) + + await test.step('browser versions modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Browser versions' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Browser version', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['98']) + + await expectMetricValues(modal(page), '98', ['1', '100%', '0s']) + + await closeModalButton(page).click() + + await page + .getByRole('button', { name: 'Remove filter: Browser is Firefox' }) + .click() + }) + + const osTabButton = tabButton(report, 'Operating systems') + + await test.step('operating systems tab', async () => { + await osTabButton.click() + await expect(osTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Operating system', 'Visitors']) + + await expectRows(report, ['MacOS', 'Windows', 'iOS']) + + await expectMetricValues(report, 'MacOS', ['1', '33.3%']) + await expectMetricValues(report, 'Windows', ['1', '33.3%']) + await expectMetricValues(report, 'iOS', ['1', '33.3%']) + }) + + await test.step('operating systems modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Operating systems' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Operating system', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['MacOS', 'Windows', 'iOS']) + + await expectMetricValues(modal(page), 'MacOS', ['1', '100%', '0s']) + + await closeModalButton(page).click() + }) + + await test.step('operating system versions', async () => { + await rowLink(report, 'Windows').click() + + await expect(page).toHaveURL(/f=is,os,Windows/) + + await expect(osTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Operating system version', 'Visitors']) + + await expectRows(report, ['Windows 11']) + + await expectMetricValues(report, 'Windows 11', ['1', '100%']) + }) + + await test.step('operating system versions modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Operating system versions' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Operating system version', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['11']) + + await expectMetricValues(modal(page), '11', ['1', '100%', '0s']) + + await closeModalButton(page).click() + + await page + .getByRole('button', { + name: 'Remove filter: Operating system is Windows' + }) + .click() + }) + + await test.step('devices tab', async () => { + const devicesTabButton = tabButton(report, 'Devices') + await devicesTabButton.click() + await expect(devicesTabButton).toHaveAttribute('data-active', 'true') + + await expectHeaders(report, ['Device', 'Visitors']) + + await expectRows(report, ['Desktop', 'Mobile']) + + await expectMetricValues(report, 'Desktop', ['2', '66.7%']) + await expectMetricValues(report, 'Mobile', ['1', '33.3%']) + }) + + await test.step('devices modal', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Devices' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Device', + /Visitors/, + /Bounce rate/, + /Visit duration/ + ]) + + await expectRows(modal(page), ['Desktop', 'Mobile']) + + await expectMetricValues(modal(page), 'Desktop', ['2', '100%', '0s']) + + await closeModalButton(page).click() + }) +}) diff --git a/e2e/tests/dashboard/filtering.spec.ts b/e2e/tests/dashboard/filtering.spec.ts new file mode 100644 index 000000000000..fd2a7ab1de5d --- /dev/null +++ b/e2e/tests/dashboard/filtering.spec.ts @@ -0,0 +1,982 @@ +import { test, expect, Page } from '@playwright/test' +import { setupSite, populateStats, addPageviewGoal } from '../fixtures' +import { + filterButton, + filterItemButton, + applyFilterButton, + filterRow, + suggestedItem, + filterOperator, + filterOperatorOption +} from '../test-utils' + +test.describe('page filtering tests', () => { + const pageFilterButton = (page: Page) => filterItemButton(page, 'Page') + + test('filtering by page with detailed behavior test', async ({ + page, + request + }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', pathname: '/page1' }, + { name: 'pageview', pathname: '/page2' }, + { name: 'pageview', pathname: '/page3' }, + { name: 'pageview', pathname: '/other' } + ] + }) + + await page.goto('/' + domain) + + const pageFilterRow = filterRow(page, 'page') + const pageInput = page.getByPlaceholder('Select a Page') + + await filterButton(page).click() + await pageFilterButton(page).click() + + await expect( + page.getByRole('heading', { name: 'Filter by Page' }) + ).toBeVisible() + + await expect(applyFilterButton(page, { disabled: true })).toBeVisible() + await pageInput.fill('page') + + await expect(suggestedItem(pageFilterRow, '/page1')).toBeVisible() + await expect(suggestedItem(pageFilterRow, '/page2')).toBeVisible() + await expect(suggestedItem(pageFilterRow, '/page3')).toBeVisible() + await expect(applyFilterButton(page, { disabled: true })).toBeVisible() + + await pageInput.fill('/page1') + + await expect(suggestedItem(pageFilterRow, '/page1')).toBeVisible() + await expect(applyFilterButton(page, { disabled: true })).toBeVisible() + + await suggestedItem(pageFilterRow, '/page1').click() + await expect(applyFilterButton(page)).toBeVisible() + + await applyFilterButton(page).click() + + await expect(page).toHaveURL(/f=is,page,\/page1/) + + await expect( + page.getByRole('link', { name: 'Page is /page1' }) + ).toHaveAttribute('title', 'Edit filter: Page is /page1') + }) + + test('filtering by page using different operators', async ({ + page, + request + }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', pathname: '/page1' }, + { name: 'pageview', pathname: '/page2' }, + { name: 'pageview', pathname: '/page3' }, + { name: 'pageview', pathname: '/other' } + ] + }) + + await page.goto('/' + domain) + + const pageFilterRow = filterRow(page, 'page') + const pageInput = page.getByPlaceholder('Select a Page') + + await test.step("'is not' operator", async () => { + await filterButton(page).click() + await pageFilterButton(page).click() + + await filterOperator(pageFilterRow).click() + await filterOperatorOption(pageFilterRow, 'is not').click() + await pageInput.fill('page') + await suggestedItem(pageFilterRow, '/page1').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Page is not /page1' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is_not,page,\/page1/) + + await page + .getByRole('button', { name: 'Remove filter: Page is not /page1' }) + .click() + + await expect(page).not.toHaveURL(/f=is_not,page,\/page1/) + }) + + await test.step("'contains' operator", async () => { + await filterButton(page).click() + await pageFilterButton(page).click() + + await filterOperator(pageFilterRow).click() + await filterOperatorOption(pageFilterRow, 'contains').click() + await pageInput.fill('page1') + await suggestedItem(pageFilterRow, "Filter by 'page1'").click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Page contains page1' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=contains,page,page1/) + + await page + .getByRole('button', { name: 'Remove filter: Page contains page1' }) + .click() + + await expect(page).not.toHaveURL(/f=contains,page,page1/) + }) + + await test.step("'does not contain' operator", async () => { + await filterButton(page).click() + await pageFilterButton(page).click() + + await filterOperator(pageFilterRow).click() + await filterOperatorOption(pageFilterRow, 'does not contain').click() + await pageInput.fill('page1') + await suggestedItem(pageFilterRow, "Filter by 'page1'").click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Page does not contain page1' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=contains_not,page,page1/) + + await page + .getByRole('button', { + name: 'Remove filter: Page does not contain page1' + }) + .click() + + await expect(page).not.toHaveURL(/f=contains_not,page,page1/) + }) + + await test.step("'is' operator with multiple choices", async () => { + await filterButton(page).click() + await pageFilterButton(page).click() + + await pageInput.fill('page') + await suggestedItem(pageFilterRow, '/page2').click() + await pageInput.fill('page') + await suggestedItem(pageFilterRow, '/page3').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Page is /page2 or /page3' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,page,\/page2,\/page3/) + }) + }) + + test('filtering by entry page', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { user_id: 123, name: 'pageview', pathname: '/page1' }, + { user_id: 123, name: 'pageview', pathname: '/page2' }, + { user_id: 123, name: 'pageview', pathname: '/page3' }, + { user_id: 124, name: 'pageview', pathname: '/page1' }, + { user_id: 124, name: 'pageview', pathname: '/page2' }, + { name: 'pageview', pathname: '/page1' }, + { name: 'pageview', pathname: '/other' } + ] + }) + + await page.goto('/' + domain) + + const entryPageFilterRow = filterRow(page, 'entry_page') + const entryPageInput = page.getByPlaceholder('Select an Entry Page') + + await filterButton(page).click() + await pageFilterButton(page).click() + + await entryPageInput.fill('page') + await suggestedItem(entryPageFilterRow, '/page1').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Entry page is /page1' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,entry_page,\/page1/) + }) + + test('filtering by exit page', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { user_id: 123, name: 'pageview', pathname: '/page1' }, + { user_id: 123, name: 'pageview', pathname: '/page2' }, + { user_id: 123, name: 'pageview', pathname: '/page3' }, + { user_id: 124, name: 'pageview', pathname: '/page1' }, + { user_id: 124, name: 'pageview', pathname: '/page2' }, + { name: 'pageview', pathname: '/page1' }, + { name: 'pageview', pathname: '/other' } + ] + }) + + await page.goto('/' + domain) + + const exitPageFilterRow = filterRow(page, 'exit_page') + const exitPageInput = page.getByPlaceholder('Select an Exit Page') + + await filterButton(page).click() + await pageFilterButton(page).click() + + await exitPageInput.fill('page') + await suggestedItem(exitPageFilterRow, '/page3').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Exit page is /page3' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,exit_page,\/page3/) + }) +}) + +test.describe('hostname filtering tests', () => { + const hostnameFilterButton = (page: Page) => + filterItemButton(page, 'Hostname') + + test('filtering by hostname', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', hostname: 'one.example.com' }, + { name: 'pageview', hostname: 'two.example.com' } + ] + }) + + await page.goto('/' + domain) + + const hostnameFilterRow = filterRow(page, 'hostname') + const hostnameInput = page.getByPlaceholder('Select a Hostname') + + await filterButton(page).click() + await hostnameFilterButton(page).click() + + await hostnameInput.fill('one') + await suggestedItem(hostnameFilterRow, 'one.example.com').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Hostname is one.example.com' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,hostname,one.example.com/) + }) +}) + +test.describe('acquisition filtering tests', () => { + const sourceFilterButton = (page: Page) => filterItemButton(page, 'Source') + + test('filtering by source information', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', referrer_source: 'Google', utm_source: 'Adwords' }, + { name: 'pageview', referrer_source: 'Facebook', utm_source: 'fb' }, + { name: 'pageview', referrer: 'https://theguardian.com' } + ] + }) + + await page.goto('/' + domain) + + await test.step('filtering by source', async () => { + const sourceFilterRow = filterRow(page, 'source') + const sourceInput = page.getByPlaceholder('Select a Source') + + await filterButton(page).click() + await sourceFilterButton(page).click() + + await sourceInput.fill('goog') + await suggestedItem(sourceFilterRow, 'Google').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Source is Google' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,source,Google/) + + await page + .getByRole('button', { + name: 'Remove filter: Source is Google' + }) + .click() + + await expect(page).not.toHaveURL(/f=is,source,Google/) + }) + + await test.step('filtering by channel', async () => { + const channelFilterRow = filterRow(page, 'channel') + const channelInput = page.getByPlaceholder('Select a Channel') + + await filterButton(page).click() + await sourceFilterButton(page).click() + + await channelInput.fill('paid') + await suggestedItem(channelFilterRow, 'Paid Search').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Channel is Paid Search' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,channel,Paid%20Search/) + + await page + .getByRole('button', { + name: 'Remove filter: Channel is Paid Search' + }) + .click() + + await expect(page).not.toHaveURL(/f=is,channel,Paid%20Search/) + }) + + await test.step('filtering by referrer URL', async () => { + const referrerFilterRow = filterRow(page, 'referrer') + const referrerInput = page.getByPlaceholder('Select a Referrer URL') + + await filterButton(page).click() + await sourceFilterButton(page).click() + + await referrerInput.fill('guard') + await suggestedItem(referrerFilterRow, 'https://theguardian.com').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { + name: 'Referrer URL is https://theguardian.com' + }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,referrer,https:\/\/theguardian\.com/) + + await page + .getByRole('button', { + name: 'Remove filter: Referrer URL is https://theguardian.com' + }) + .click() + + await expect(page).not.toHaveURL( + /f=is,referrer,https:\/\/theguardian\.com/ + ) + }) + }) + + const utmTagsFilterButton = (page: Page) => filterItemButton(page, 'UTM tags') + + test('filtering by UTM tags', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', utm_medium: 'social' }, + { name: 'pageview', utm_source: 'producthunt' }, + { name: 'pageview', utm_campaign: 'ads' }, + { name: 'pageview', utm_term: 'post' }, + { name: 'pageview', utm_content: 'website' } + ] + }) + + await page.goto('/' + domain) + + await test.step('filtering by UTM medium', async () => { + const utmMediumFilterRow = filterRow(page, 'utm_medium') + const utmMediumInput = page.getByPlaceholder('Select a UTM Medium') + + await filterButton(page).click() + await utmTagsFilterButton(page).click() + + await utmMediumInput.fill('soc') + await suggestedItem(utmMediumFilterRow, 'social').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'UTM Medium is social' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,utm_medium,social/) + + await page + .getByRole('button', { + name: 'Remove filter: UTM Medium is social' + }) + .click() + + await expect(page).not.toHaveURL(/f=is,utm_medium,social/) + }) + + await test.step('filtering by UTM source', async () => { + const utmSourceFilterRow = filterRow(page, 'utm_source') + const utmSourceInput = page.getByPlaceholder('Select a UTM Source') + + await filterButton(page).click() + await utmTagsFilterButton(page).click() + + await utmSourceInput.fill('hunt') + await suggestedItem(utmSourceFilterRow, 'producthunt').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'UTM Source is producthunt' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,utm_source,producthunt/) + + await page + .getByRole('button', { + name: 'Remove filter: UTM Source is producthunt' + }) + .click() + + await expect(page).not.toHaveURL(/f=is,utm_source,producthunt/) + }) + + await test.step('filtering by UTM campaign', async () => { + const utmCampaignFilterRow = filterRow(page, 'utm_campaign') + const utmCampaignInput = page.getByPlaceholder('Select a UTM Campaign') + + await filterButton(page).click() + await utmTagsFilterButton(page).click() + + await utmCampaignInput.fill('ads') + await suggestedItem(utmCampaignFilterRow, 'ads').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'UTM Campaign is ads' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,utm_campaign,ads/) + + await page + .getByRole('button', { + name: 'Remove filter: UTM Campaign is ads' + }) + .click() + + await expect(page).not.toHaveURL(/f=is,utm_campaign,ads/) + }) + + await test.step('filtering by UTM term', async () => { + const utmTermFilterRow = filterRow(page, 'utm_term') + const utmTermInput = page.getByPlaceholder('Select a UTM Term') + + await filterButton(page).click() + await utmTagsFilterButton(page).click() + + await utmTermInput.fill('pos') + await suggestedItem(utmTermFilterRow, 'post').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'UTM Term is post' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,utm_term,post/) + + await page + .getByRole('button', { + name: 'Remove filter: UTM Term is post' + }) + .click() + + await expect(page).not.toHaveURL(/f=is,utm_term,post/) + }) + + await test.step('filtering by UTM content', async () => { + const utmContentFilterRow = filterRow(page, 'utm_content') + const utmContentInput = page.getByPlaceholder('Select a UTM Content') + + await filterButton(page).click() + await utmTagsFilterButton(page).click() + + await utmContentInput.fill('web') + await suggestedItem(utmContentFilterRow, 'website').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'UTM Content is website' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,utm_content,website/) + + await page + .getByRole('button', { + name: 'Remove filter: UTM Content is website' + }) + .click() + + await expect(page).not.toHaveURL(/f=is,utm_content,website/) + }) + }) +}) + +test.describe('location filtering tests', () => { + const locationFilterButton = (page: Page) => + filterItemButton(page, 'Location') + + test('filtering by location', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + name: 'pageview', + country_code: 'EE', + subdivision1_code: 'EE-37', + city_geoname_id: 588_409 + } + ] + }) + + await page.goto('/' + domain) + + await test.step('filtering by country', async () => { + const countryFilterRow = filterRow(page, 'country') + const countryInput = page.getByPlaceholder('Select a Country') + + await filterButton(page).click() + await locationFilterButton(page).click() + + await countryInput.fill('est') + await suggestedItem(countryFilterRow, 'Estonia').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Country is Estonia' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,country,EE/) + }) + + await test.step('filtering by region', async () => { + const regionFilterRow = filterRow(page, 'region') + const regionInput = page.getByPlaceholder('Select a Region') + + await filterButton(page).click() + await locationFilterButton(page).click() + + await regionInput.fill('har') + await suggestedItem(regionFilterRow, 'Harjumaa').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Region is Harjumaa' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,region,EE-37/) + await expect(page).toHaveURL(/f=is,country,EE/) + }) + + await test.step('filtering by city', async () => { + const cityFilterRow = filterRow(page, 'city') + const cityInput = page.getByPlaceholder('Select a City') + + await filterButton(page).click() + await locationFilterButton(page).click() + + await cityInput.click() + await suggestedItem(cityFilterRow, 'Tallinn').click() + + await applyFilterButton(page).click() + + await page + .getByRole('button', { name: 'See 1 more filter and actions' }) + .click() + + await expect( + page.getByRole('link', { name: 'City is Tallinn' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,city,588409/) + await expect(page).toHaveURL(/f=is,region,EE-37/) + await expect(page).toHaveURL(/f=is,country,EE/) + }) + }) +}) + +test.describe('screen size filtering tests', () => { + const screenSizeFilterButton = (page: Page) => + filterItemButton(page, 'Screen size') + + test('filtering by screen size', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', screen_size: 'Desktop' }, + { name: 'pageview', screen_size: 'Mobile' } + ] + }) + + await page.goto('/' + domain) + + const screenSizeFilterRow = filterRow(page, 'screen') + const screenSizeInput = page.getByPlaceholder('Select a Screen size') + + await filterButton(page).click() + await screenSizeFilterButton(page).click() + + // When testing via test.e2e.ui, it shows there are no + // suggestions found but there are 2 pageview in the top stats. + // When navigating live via `MIX_ENV=e2e_test iex -S mix`, + // all works fine. Puzzling. + await screenSizeInput.fill('mob') + await suggestedItem(screenSizeFilterRow, 'Mobile').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Screen size is Mobile' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,screen,Mobile/) + }) +}) + +test.describe('browser filtering tests', () => { + const browserFilterButton = (page: Page) => filterItemButton(page, 'Browser') + + test('filtering by browser', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', browser: 'Chrome', browser_version: '14.0.7' }, + { name: 'pageview', browser: 'Firefox', browser_version: '98' } + ] + }) + + await page.goto('/' + domain) + + await test.step('filtering by browser type', async () => { + const browserFilterRow = filterRow(page, 'browser') + const browserInput = page.getByPlaceholder('Select a Browser', { + exact: true + }) + + await filterButton(page).click() + await browserFilterButton(page).click() + + await browserInput.fill('chrom') + await suggestedItem(browserFilterRow, 'Chrome').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Browser is Chrome' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,browser,Chrome/) + }) + + await test.step('filtering by browser version', async () => { + const browserVersionFilterRow = filterRow(page, 'browser_version') + const browserVersionInput = page.getByPlaceholder( + 'Select a Browser Version' + ) + + await filterButton(page).click() + await browserFilterButton(page).click() + + await browserVersionInput.fill('14') + await suggestedItem(browserVersionFilterRow, '14.0.7').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Browser version is 14.0.7' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,browser_version,14\.0\.7/) + await expect(page).toHaveURL(/f=is,browser,Chrome/) + }) + }) +}) + +test.describe('operating system filtering tests', () => { + const operatingSystemFilterButton = (page: Page) => + filterItemButton(page, 'Operating system') + + test('filtering by operating system', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + name: 'pageview', + operating_system: 'Windows', + operating_system_version: '11' + }, + { + name: 'pageview', + operating_system: 'MacOS', + operating_system_version: '10.15' + } + ] + }) + + await page.goto('/' + domain) + + await test.step('filtering by operating system type', async () => { + const operatingSystemFilterRow = filterRow(page, 'os') + const operatingSystemInput = page.getByPlaceholder( + 'Select an Operating system', + { exact: true } + ) + + await filterButton(page).click() + await operatingSystemFilterButton(page).click() + + // The same problem as in the case of screen size filter test. + await operatingSystemInput.click() + await suggestedItem(operatingSystemFilterRow, 'Windows').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Operating System is Windows' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,os,Windows/) + }) + + await test.step('filtering by operating system version', async () => { + const operatingSystemVersionFilterRow = filterRow(page, 'os_version') + const operatingSystemVersionInput = page.getByPlaceholder( + 'Select an Operating system version' + ) + + await filterButton(page).click() + await operatingSystemFilterButton(page).click() + + await operatingSystemVersionInput.click() + await suggestedItem(operatingSystemVersionFilterRow, '11').click() + + await applyFilterButton(page).click() + + await page + .getByRole('button', { name: 'See 1 more filter and actions' }) + .click() + + await expect( + page.getByRole('link', { name: 'Operating system version is 11' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,os_version,11/) + await expect(page).toHaveURL(/f=is,os,Windows/) + }) + }) +}) + +test.describe('goal filtering tests', () => { + const goalFilterButton = (page: Page) => filterItemButton(page, 'Goal') + + test('filtering by goals', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', pathname: '/page1' }, + { name: 'pageview', pathname: '/page2' } + ] + }) + + await addPageviewGoal({ page, domain, pathname: '/page1' }) + await addPageviewGoal({ page, domain, pathname: '/page2' }) + + await page.goto('/' + domain) + + const goalFilterRow = filterRow(page, 'goal') + const goalInput = goalFilterRow.getByPlaceholder('Select a Goal') + + await test.step('single goal filter', async () => { + await filterButton(page).click() + await goalFilterButton(page).click() + + await goalInput.fill('page1') + await suggestedItem(goalFilterRow, 'Visit /page1').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Goal is Visit /page1' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,goal,Visit%20\/page1/) + }) + + const goalFilterRow2 = filterRow(page, 'goal1') + const goalInput2 = goalFilterRow2.getByPlaceholder('Select a Goal') + + await test.step('multiple goal filters', async () => { + await page.getByRole('link', { name: 'Goal is Visit /page1' }).click() + + await page.getByText('+ Add another').click() + + await filterOperator(goalFilterRow2).click() + await filterOperatorOption(goalFilterRow2, 'is not').click() + + await goalInput2.fill('page2') + await suggestedItem(goalFilterRow2, 'Visit /page2').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Goal is Visit /page1' }) + ).toBeVisible() + + await expect( + page.getByRole('link', { name: 'Goal is not Visit /page2' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,goal,Visit%20\/page1/) + await expect(page).toHaveURL(/f=has_not_done,goal,Visit%20\/page2/) + }) + }) +}) + +test.describe('property filtering tests', () => { + const propFilterButton = (page: Page) => + page.getByTestId('filtermenu').getByRole('link', { name: 'Property' }) + + test('filtering by properties', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + name: 'pageview', + 'meta.key': ['logged_in', 'browser_language'], + 'meta.value': ['false', 'en_US'] + }, + { + name: 'pageview', + 'meta.key': ['logged_in', 'browser_language'], + 'meta.value': ['true', 'es'] + } + ] + }) + + await page.goto('/' + domain) + + const propFilterRow = filterRow(page, 'props') + const propNameInput = propFilterRow.getByPlaceholder('Property') + const propValueInput = propFilterRow.getByPlaceholder('Value') + + await test.step('single property filter', async () => { + await filterButton(page).click() + await propFilterButton(page).click() + + await propNameInput.fill('logged') + await suggestedItem(propFilterRow, 'logged_in').click() + await propValueInput.fill('false') + await suggestedItem(propFilterRow, 'false').click() + + await applyFilterButton(page).click() + + await expect( + page.getByRole('link', { name: 'Property logged_in is false' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,props:logged_in,false/) + }) + + const propFilterRow2 = filterRow(page, 'props1') + const propNameInput2 = propFilterRow2.getByPlaceholder('Property') + const propValueInput2 = propFilterRow2.getByPlaceholder('Value') + + await test.step('multiple property filters', async () => { + await page + .getByRole('link', { name: 'Property logged_in is false' }) + .click() + + await page.getByText('+ Add another').click() + + await propNameInput2.fill('browser') + await suggestedItem(propFilterRow2, 'browser_language').click() + await filterOperator(propFilterRow2).click() + await filterOperatorOption(propFilterRow2, 'is not').click() + await propValueInput2.fill('US') + await suggestedItem(propFilterRow2, 'en_US').click() + + await applyFilterButton(page).click() + + await page + .getByRole('button', { name: 'See 1 more filter and actions' }) + .click() + + await expect( + page.getByRole('link', { + name: 'Property logged_in is false' + }) + ).toBeVisible() + + await expect( + page.getByRole('link', { + name: 'Property browser_language is not en_US' + }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,props:logged_in,false/) + await expect(page).toHaveURL(/f=is_not,props:browser_language,en_US/) + }) + }) +}) diff --git a/e2e/tests/dashboard/general.spec.ts b/e2e/tests/dashboard/general.spec.ts new file mode 100644 index 000000000000..47740363d32e --- /dev/null +++ b/e2e/tests/dashboard/general.spec.ts @@ -0,0 +1,156 @@ +import { test, expect } from '@playwright/test' +import { + setupSite, + logout, + makeSitePublic, + populateStats, + createSharedLink +} from '../fixtures' + +test('dashboard renders for logged in user', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + await populateStats({ request, domain, events: [{ name: 'pageview' }] }) + + await page.goto('/' + domain) + + await expect(page).toHaveTitle(/Plausible/) + + await expect(page.getByRole('button', { name: domain })).toBeVisible() +}) + +test('dashboard renders for anonymous viewer', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + await makeSitePublic({ page, domain }) + await populateStats({ request, domain, events: [{ name: 'pageview' }] }) + await logout(page) + + await page.goto('/' + domain) + + await expect(page).toHaveTitle(/Plausible/) + + await expect(page.getByRole('button', { name: domain })).toBeVisible() +}) + +test('dashboard renders via shared link', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + await populateStats({ request, domain, events: [{ name: 'pageview' }] }) + const link = await createSharedLink({ page, domain, name: 'public_link' }) + const passwordLink = await createSharedLink({ + page, + domain, + name: 'password_link', + password: 'secret' + }) + await logout(page) + + await test.step('public link', async () => { + await page.goto(link) + + await expect(page.getByRole('button', { name: domain })).toBeVisible() + + await expect(page.locator('#visitors')).toHaveText('1') + }) + + await test.step('password protected link', async () => { + await page.goto(passwordLink) + + await page.locator('input#password').fill('secret') + + await page.getByRole('button', { name: 'Continue' }).click() + + await expect(page.getByRole('button', { name: domain })).toBeVisible() + + await expect(page.locator('#visitors')).toHaveText('1') + }) +}) + +test('dashboard renders with imported data', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + await populateStats({ + request, + domain, + events: [ + { name: 'pageview' }, + { + type: 'imported_visitors', + visitors: 3, + visits: 4, + pageviews: 6, + bounces: 1 + } + ] + }) + + await page.goto('/' + domain) + + await test.step('with imported data included', async () => { + await expect(page.locator('#visitors')).toHaveText('4') + await expect(page.locator('#visits')).toHaveText('5') + await expect(page.locator('#pageviews')).toHaveText('7') + await expect(page.locator('#bounce_rate')).toHaveText('40%') + }) + + await test.step('with imported data excluded', async () => { + await page.getByTestId('import-switch').click() + + await expect(page).toHaveURL(/with_imported=false/) + + await expect(page.locator('#visitors')).toHaveText('1') + await expect(page.locator('#visits')).toHaveText('1') + await expect(page.locator('#pageviews')).toHaveText('1') + await expect(page.locator('#bounce_rate')).toHaveText('100%') + }) +}) + +test('tab selection user preferences are preserved across reloads', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + await populateStats({ request, domain, events: [{ name: 'pageview' }] }) + + await page.goto('/' + domain) + + await page.getByRole('button', { name: 'Entry pages' }).click() + + await page.goto('/' + domain) + + let currentTab = await page.evaluate( + (domain) => localStorage.getItem('pageTab__' + domain), + domain + ) + + expect(currentTab).toEqual('entry-pages') + + await page.getByRole('button', { name: 'Exit pages' }).click() + + await page.goto('/' + domain) + + currentTab = await page.evaluate( + (domain) => localStorage.getItem('pageTab__' + domain), + domain + ) + + expect(currentTab).toEqual('exit-pages') +}) + +test('back navigation closes the modal', async ({ page, request, baseURL }) => { + const { domain } = await setupSite({ page, request }) + await populateStats({ + request, + domain, + events: [{ name: 'pageview' }] + }) + + await page.goto('/' + domain) + + await page.getByRole('button', { name: 'Filter' }).click() + + await page.getByRole('link', { name: 'Page' }).click() + + await expect(page).toHaveURL(baseURL + '/' + domain + '/filter/page') + + await page.goBack() + + await expect(page).toHaveURL(baseURL + '/' + domain) +}) diff --git a/e2e/tests/dashboard/segments.spec.ts b/e2e/tests/dashboard/segments.spec.ts new file mode 100644 index 000000000000..157fe0bb55ff --- /dev/null +++ b/e2e/tests/dashboard/segments.spec.ts @@ -0,0 +1,407 @@ +import { test, expect, APIRequestContext, Page } from '@playwright/test' +import { setupSite, populateStats } from '../fixtures' +import { + filterButton, + filterItemButton, + applyFilterButton, + filterRow, + suggestedItem, + modal +} from '../test-utils' + +const setupSiteAndStats = async ({ + page, + request +}: { + page: Page + request: APIRequestContext +}) => { + const context = await setupSite({ page, request }) + + await populateStats({ + request, + domain: context.domain, + events: [ + { name: 'pageview', referrer_source: 'Google', utm_source: 'Adwords' }, + { name: 'pageview', referrer_source: 'Facebook', utm_source: 'fb' }, + { name: 'pageview', referrer: 'https://theguardian.com' } + ] + }) + + return context +} + +const segmentMenu = (page: Page) => page.getByTestId('segment-menu') + +const sourceFilterButton = (page: Page) => filterItemButton(page, 'Source') +const utmTagsFilterButton = (page: Page) => filterItemButton(page, 'UTM tags') + +const addSourceFilter = async (page: Page, sourceLabel: string) => { + const sourceFilterRow = filterRow(page, 'source') + const sourceInput = page.getByPlaceholder('Select a Source') + + await filterButton(page).click() + await sourceFilterButton(page).click() + + await sourceInput.click() + await suggestedItem(sourceFilterRow, sourceLabel).click() + + await applyFilterButton(page).click() + + const url = new RegExp(`f=is,source,${sourceLabel}`) + await expect(page).toHaveURL(url) +} + +const addUtmSourceFilter = async (page: Page, utmSource: string) => { + const utmSourceFilterRow = filterRow(page, 'utm_source') + const utmSourceInput = page.getByPlaceholder('Select a UTM Source') + + await filterButton(page).click() + await utmTagsFilterButton(page).click() + + await utmSourceInput.click() + await suggestedItem(utmSourceFilterRow, utmSource).click() + + await applyFilterButton(page).click() + + const url = new RegExp(`f=is,utm_source,${utmSource}`) + await expect(page).toHaveURL(url) +} + +const createPersonalSegment = async (page: Page, name: string) => { + await page.getByRole('button', { name: 'See actions' }).click() + + await page.getByRole('link', { name: 'Save as segment' }).click() + + await modal(page).getByLabel('Segment name').fill(name) + + await modal(page).getByRole('button', { name: 'Save' }).click() + + await expect(page).toHaveURL(/f=is,segment,[0-9]+/) +} + +test('saving a segment', async ({ page, request }) => { + const { domain } = await setupSiteAndStats({ page, request }) + + await page.goto('/' + domain) + + await test.step('creating personal segment using defaults', async () => { + await addSourceFilter(page, 'Facebook') + + await page.getByRole('button', { name: 'See actions' }).click() + + await page.getByRole('link', { name: 'Save as segment' }).click() + + await expect( + modal(page).getByRole('heading', { name: 'Create segment' }) + ).toBeVisible() + + await expect( + modal(page).getByPlaceholder('Source is Facebook') + ).toHaveAccessibleName('Segment name') + + await expect( + modal(page).getByRole('radio', { name: 'Personal segment' }) + ).toBeChecked() + + await modal(page).getByRole('button', { name: 'Save' }).click() + + await expect(page).toHaveURL(/f=is,segment,[0-9]+/) + + await expect( + page.getByRole('link', { name: 'Segment is Source is Facebook' }) + ).toBeVisible() + + await page + .getByRole('button', { + name: 'Remove filter: Segment is Source is Facebook' + }) + .click() + + await filterButton(page).click() + + await expect(filterItemButton(page, 'Source is Facebook')).toBeVisible() + + await filterButton(page).click() + }) + + await test.step('creating a personal segment with a custom name', async () => { + await addSourceFilter(page, 'Google') + + await page.getByRole('button', { name: 'See actions' }).click() + + await page.getByRole('link', { name: 'Save as segment' }).click() + + await expect( + modal(page).getByRole('heading', { name: 'Create segment' }) + ).toBeVisible() + + await modal(page).getByLabel('Segment name').fill('Traffic from Google') + + await expect( + modal(page).getByRole('radio', { name: 'Personal segment' }) + ).toBeChecked() + + await modal(page).getByRole('button', { name: 'Save' }).click() + + await expect(page).toHaveURL(/f=is,segment,[0-9]+/) + + await expect( + page.getByRole('link', { name: 'Segment is Traffic from Google' }) + ).toBeVisible() + + await page + .getByRole('button', { + name: 'Remove filter: Segment is Traffic from Google' + }) + .click() + + await filterButton(page).click() + + await expect(filterItemButton(page, 'Traffic from Google')).toBeVisible() + await expect(filterItemButton(page, 'Source is Facebook')).toBeVisible() + + await filterButton(page).click() + }) + + await test.step('creating a site segment from more than one filter', async () => { + await addSourceFilter(page, 'Google') + await addUtmSourceFilter(page, 'Adwords') + + await page.getByRole('button', { name: 'See actions' }).click() + + await page.getByRole('link', { name: 'Save as segment' }).click() + + await expect( + modal(page).getByRole('heading', { name: 'Create segment' }) + ).toBeVisible() + + await expect( + modal(page).getByPlaceholder('UTM source is Adwords and Source is Google') + ).toHaveAccessibleName('Segment name') + + await modal(page).getByLabel('Segment name').fill('Ads from Google') + + const siteSegmentRadio = modal(page).getByRole('radio', { + name: 'Site segment' + }) + + await siteSegmentRadio.click() + + await expect(siteSegmentRadio).toBeChecked() + + await modal(page).getByRole('button', { name: 'Save' }).click() + + await expect(page).toHaveURL(/f=is,segment,[0-9]+/) + + await expect( + page.getByRole('link', { name: 'Segment is Ads from Google' }) + ).toBeVisible() + + await page + .getByRole('button', { + name: 'Remove filter: Segment is Ads from Google' + }) + .click() + + await filterButton(page).click() + + await expect(filterItemButton(page, 'Ads from Google')).toBeVisible() + await expect(filterItemButton(page, 'Traffic from Google')).toBeVisible() + await expect(filterItemButton(page, 'Source is Facebook')).toBeVisible() + + await filterButton(page).click() + }) +}) + +test('creating a segment from a combination of segment and a filter is not allowed', async ({ + page, + request +}) => { + const { domain } = await setupSiteAndStats({ page, request }) + + await page.goto('/' + domain) + + await addSourceFilter(page, 'Google') + await createPersonalSegment(page, 'Traffic from Google') + await addUtmSourceFilter(page, 'Adwords') + + await expect( + page.getByRole('link', { name: 'UTM source is Adwords' }) + ).toBeVisible() + + await page + .getByRole('button', { name: 'See 1 more filter and actions' }) + .click() + + await expect( + page.getByRole('link', { name: 'Segment is Traffic from Google' }) + ).toBeVisible() + + await expect(page).toHaveURL(/f=is,segment,[0-9]+/) + await expect(page).toHaveURL(/f=is,utm_source,Adwords/) + + await expect( + modal(page).getByRole('heading', { name: 'Create segment' }) + ).toBeHidden() +}) + +test('editing an existing segment', async ({ page, request }) => { + const { domain } = await setupSiteAndStats({ page, request }) + + await page.goto('/' + domain) + + await addSourceFilter(page, 'Google') + await createPersonalSegment(page, 'Traffic from Google') + + await page + .getByRole('link', { name: 'Segment is Traffic from Google' }) + .click() + + await expect( + modal(page).getByRole('heading', { name: 'Traffic from Google' }) + ).toBeVisible() + + await modal(page).getByRole('link', { name: 'Edit segment' }).click() + + await addUtmSourceFilter(page, 'Adwords') + + await page.getByRole('link', { name: 'Update segment' }).click() + + await expect( + modal(page).getByRole('heading', { name: 'Update segment' }) + ).toBeVisible() + + await modal(page).getByLabel('Segment name').fill('Ads from Google') + + await modal(page).getByRole('button', { name: 'Save' }).click() + + await page.getByRole('link', { name: 'Segment is Ads from Google' }).click() + + await expect(modal(page)).toContainText('UTM source is Adwords') + await expect(modal(page)).toContainText('Source is Google') + + await modal(page).getByRole('button', { name: 'Remove filter' }).click() + + await expect(page).not.toHaveURL(/f=is,segment,[0-9]+/) + + await filterButton(page).click() + + await expect(filterItemButton(page, 'Ads from Google')).toBeVisible() + await expect(filterItemButton(page, 'Traffic from Google')).toBeHidden() +}) + +test('saving edited segment as new', async ({ page, request }) => { + const { domain } = await setupSiteAndStats({ page, request }) + + await page.goto('/' + domain) + + await addSourceFilter(page, 'Google') + await createPersonalSegment(page, 'Traffic from Google') + + await page + .getByRole('link', { name: 'Segment is Traffic from Google' }) + .click() + + await modal(page).getByRole('link', { name: 'Edit segment' }).click() + + await addUtmSourceFilter(page, 'Adwords') + + await segmentMenu(page).click() + + await page.getByRole('link', { name: 'Save as a new segment' }).click() + + await expect( + modal(page).getByRole('heading', { name: 'Create segment' }) + ).toBeVisible() + + await expect(modal(page).getByLabel('Segment name')).toHaveValue( + 'Copy of Traffic from Google' + ) + + await modal(page).getByLabel('Segment name').fill('Ads from Google') + + await modal(page).getByRole('button', { name: 'Save' }).click() + + await page.getByRole('link', { name: 'Segment is Ads from Google' }).click() + + await expect(modal(page)).toContainText('UTM source is Adwords') + await expect(modal(page)).toContainText('Source is Google') + + await modal(page).getByRole('button', { name: 'Remove filter' }).click() + + await filterButton(page).click() + + await expect(filterItemButton(page, 'Ads from Google')).toBeVisible() + await expect(filterItemButton(page, 'Traffic from Google')).toBeVisible() + + await filterItemButton(page, 'Traffic from Google').click() + + await page + .getByRole('link', { name: 'Segment is Traffic from Google' }) + .click() + + await expect(modal(page)).not.toContainText('UTM source is Adwords') + await expect(modal(page)).toContainText('Source is Google') +}) + +test('deleting segment', async ({ page, request }) => { + const { domain } = await setupSiteAndStats({ page, request }) + + await page.goto('/' + domain) + + await addSourceFilter(page, 'Google') + await createPersonalSegment(page, 'Traffic from Google') + + await page + .getByRole('link', { name: 'Segment is Traffic from Google' }) + .click() + + await modal(page).getByRole('link', { name: 'Edit segment' }).click() + + await segmentMenu(page).click() + + await page.getByRole('link', { name: 'Delete segment' }).click() + + await expect( + modal(page).getByRole('heading', { name: 'Delete personal segment' }) + ).toBeVisible() + + await modal(page).getByRole('button', { name: 'Delete' }).click() + + await filterButton(page).click() + + await expect(filterItemButton(page, 'Traffic from Google')).toBeHidden() +}) + +test('closing edited segment without saving', async ({ page, request }) => { + const { domain } = await setupSiteAndStats({ page, request }) + + await page.goto('/' + domain) + + await addSourceFilter(page, 'Google') + await createPersonalSegment(page, 'Traffic from Google') + + await page + .getByRole('link', { name: 'Segment is Traffic from Google' }) + .click() + + await modal(page).getByRole('link', { name: 'Edit segment' }).click() + + await addUtmSourceFilter(page, 'Adwords') + + await segmentMenu(page).click() + + await page.getByRole('link', { name: 'Close without saving' }).click() + + await filterButton(page).click() + + await filterItemButton(page, 'Traffic from Google').click() + + await page + .getByRole('link', { name: 'Segment is Traffic from Google' }) + .click() + + await expect(modal(page)).not.toContainText('UTM source is Adwords') + await expect(modal(page)).toContainText('Source is Google') +}) diff --git a/e2e/tests/dashboard/team-setup.spec.ts b/e2e/tests/dashboard/team-setup.spec.ts new file mode 100644 index 000000000000..4399144ebfe9 --- /dev/null +++ b/e2e/tests/dashboard/team-setup.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test' +import { setupSite } from '../fixtures' +import { expectLiveViewConnected } from '../test-utils' + +test('submitting team name via Enter key does not crash', async ({ + page, + request +}) => { + await setupSite({ page, request }) + await page.goto('/team/setup') + + await expectLiveViewConnected(page) + + await expect(page.getByRole('button', { name: 'Create Team' })).toBeVisible() + + const nameInput = page.locator('input[name="team[name]"]') + + await nameInput.clear() + await nameInput.fill('My New Team') + + await nameInput.press('Enter') + + await expect(nameInput).toHaveValue('My New Team') + + // the form had no phx-submit handler and plain HTTP POST fallback was made + await page.getByRole('button', { name: 'Create Team' }).click() + + await expect(page).toHaveURL(/\/settings\/team\/general/) + + await expectLiveViewConnected(page) + + const nameInput2 = page.locator('input[name="team[name]"]') + + await expect(nameInput2).toHaveValue('My New Team') +}) diff --git a/e2e/tests/dashboard/top-stats.spec.ts b/e2e/tests/dashboard/top-stats.spec.ts new file mode 100644 index 000000000000..9ade45c0b8de --- /dev/null +++ b/e2e/tests/dashboard/top-stats.spec.ts @@ -0,0 +1,447 @@ +import { test, expect } from '@playwright/test' +import { + ZonedDateTime, + ZoneOffset, + ChronoUnit, + DateTimeFormatter +} from '@js-joda/core' +import { Locale } from '@js-joda/locale' +import { setupSite, populateStats, StatsEntry } from '../fixtures' + +function currentTime(): ZonedDateTime { + return ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) +} + +function timeToISO(ts: ZonedDateTime): string { + return ts.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) +} + +test('site switcher allows switching between different sites', async ({ + page, + request +}) => { + const { domain: domain1, user } = await setupSite({ page, request }) + const { domain: domain2 } = await setupSite({ page, request, user }) + const { domain: domain3 } = await setupSite({ page, request, user }) + + await populateStats({ + request, + domain: domain1, + events: [{ name: 'pageview' }] + }) + await populateStats({ + request, + domain: domain2, + events: [{ name: 'pageview' }] + }) + await populateStats({ + request, + domain: domain3, + events: [{ name: 'pageview' }] + }) + + await page.goto('/' + domain1) + + const switcherButton = page.getByTestId('site-switcher-current-site') + + await expect(switcherButton).toHaveText(domain1) + + await switcherButton.click() + + const domain1Link = page.getByRole('link', { name: domain1 }) + const domain2Link = page.getByRole('link', { name: domain2 }) + const domain3Link = page.getByRole('link', { name: domain3 }) + + const domain1Key = await domain1Link.locator('kbd').textContent() + const domain3Key = await domain3Link.locator('kbd').textContent() + + await expect(domain1Link).toBeVisible() + await expect(domain2Link).toBeVisible() + await expect(domain3Link).toBeVisible() + + await page.getByRole('link', { name: domain2 }).click() + + await expect(page).toHaveURL(`/${domain2}`) + await expect(switcherButton).toHaveText(domain2) + + await page.keyboard.press(domain3Key!) + + await expect(page).toHaveURL(`/${domain3}`) + await expect(switcherButton).toHaveText(domain3) + + await page.keyboard.press(domain1Key!) + + await expect(page).toHaveURL(`/${domain1}`) + await expect(switcherButton).toHaveText(domain1) +}) + +test('current visitors counter shows number of active visitors', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', timestamp: { minutesAgo: 2 } }, + { name: 'pageview', timestamp: { minutesAgo: 3 } }, + { name: 'pageview', timestamp: { minutesAgo: 4 } }, + { name: 'pageview', timestamp: { minutesAgo: 4 } }, + { name: 'pageview', timestamp: { minutesAgo: 20 } }, + { name: 'pageview', timestamp: { minutesAgo: 50 } } + ] + }) + + await page.goto('/' + domain) + + await expect(page.getByText('4 current visitors')).toBeVisible() +}) + +test('top stats show relevant metrics', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + await populateStats({ + request, + domain, + events: [ + { + user_id: 123, + name: 'pageview', + pathname: '/', + timestamp: { minutesAgo: 120 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/', + timestamp: { minutesAgo: 60 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/page1', + timestamp: { minutesAgo: 50 } + }, + { + user_id: 456, + name: 'pageview', + pathname: '/', + timestamp: { minutesAgo: 80 } + } + ] + }) + + await page.goto('/' + domain) + + await expect(page).toHaveTitle(/Plausible/) + + await expect(page.getByRole('button', { name: domain })).toBeVisible() + + await expect(page.locator('#visitors')).toHaveText('2') + await expect(page.locator('#visits')).toHaveText('3') + await expect(page.locator('#pageviews')).toHaveText('4') + await expect(page.locator('#views_per_visit')).toHaveText('1.33') + await expect(page.locator('#bounce_rate')).toHaveText('67%') + await expect(page.locator('#visit_duration')).toHaveText('3m 20s') +}) + +test('different time ranges are supported', async ({ page, request }) => { + const now = currentTime() + const startOfDay = now.truncatedTo(ChronoUnit.DAYS) + const startOfYesterday = startOfDay.minusDays(1) + const startOfMonth = startOfDay.withDayOfMonth(1) + const startOfLastMonth = startOfMonth.minusMonths(1) + const startOfYear = now.withDayOfYear(1) + + const expectedCounts = [ + { from: startOfDay, to: now, key: 'd', value: 0 }, + { from: startOfYesterday, to: startOfDay, key: 'e', value: 0 }, + { from: now.minusMinutes(30), to: now, key: 'r', value: 0 }, + { from: now.minusHours(24), to: now, key: 'h', value: 0 }, + { from: startOfDay.minusDays(7), to: startOfDay, key: 'w', value: 0 }, + { from: startOfDay.minusDays(28), to: startOfDay, key: 'f', value: 0 }, + { from: startOfDay.minusDays(91), to: startOfDay, key: 'n', value: 0 }, + { from: startOfMonth, to: now, key: 'm', value: 0 }, + { from: startOfLastMonth, to: startOfMonth, key: 'p', value: 0 }, + { from: startOfYear, to: now, key: 'y', value: 0 }, + { from: startOfMonth.minusMonths(12), to: startOfMonth, key: 'l', value: 0 } + ] + + const eventTimes = [ + now.minusMinutes(20), + now.minusHours(12), + now.minusHours(26), + now.minusHours(30), + now.minusHours(35), + now.minusDays(5), + now.minusDays(17), + now.minusDays(54), + now.minusDays(120), + now.minusDays(720) + ] + + const events: StatsEntry[] = [] + + eventTimes.forEach((ts, idx) => { + expectedCounts.forEach((expected) => { + if (ts.compareTo(expected.from) >= 0 && ts.compareTo(expected.to) < 0) { + expected.value += 1 + } + }) + + events.push({ + user_id: idx + 1, + name: 'pageview', + timestamp: timeToISO(ts) + }) + }) + + const { domain } = await setupSite({ page, request }) + + await populateStats({ request, domain, events }) + + await page.goto('/' + domain) + await expect(page.getByRole('button', { name: domain })).toBeVisible() + + await expect(page.getByTestId('current-query-period')).toHaveText( + 'Last 28 days' + ) + + const visitors = page.locator('#visitors') + + for (const expected of expectedCounts) { + await page.keyboard.press(expected.key) + await expect(visitors).toHaveText(`${expected.value}`) + } + + // Realtime + await page.keyboard.press('r') + await expect(visitors).toHaveText('1') + + // All time + await page.keyboard.press('a') + await expect(visitors).toHaveText(`${events.length}`) +}) + +test('different graph time intervals are available', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', timestamp: { minutesAgo: 60 } }, + { name: 'pageview', timestamp: { daysAgo: 5 } } + ] + }) + + await page.goto('/' + domain) + + await expect(page.getByTestId('current-query-period')).toHaveText( + 'Last 28 days' + ) + + const intervalButton = page.getByTestId('current-graph-interval') + const intervalOptions = page.getByTestId('graph-interval') + await expect(intervalButton).toHaveText('Days') + await intervalButton.click() + const intervalOptions28Days = await intervalOptions.allTextContents() + + expect(intervalOptions28Days.indexOf('Days') > -1).toBeTruthy() + expect(intervalOptions28Days.indexOf('Weeks') > -1).toBeTruthy() + + await page.getByTestId('current-query-period').click() + await page + .getByTestId('query-period-picker') + .getByRole('link', { name: 'Today' }) + .click() + + await expect(intervalButton).toHaveText('Hours') + await intervalButton.click() + // The popover does not appear right away + await expect(intervalOptions).toHaveCount(2) + const intervalOptionsToday = await intervalOptions.allTextContents() + + expect(intervalOptionsToday.indexOf('Hours') > -1).toBeTruthy() + expect(intervalOptionsToday.indexOf('Minutes') > -1).toBeTruthy() +}) + +test('navigating dates previous next time periods', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + + const now = currentTime() + const startOfDay = now.truncatedTo(ChronoUnit.DAYS) + const startOfYesterday = startOfDay.minusDays(1) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', timestamp: timeToISO(now) }, + { name: 'pageview', timestamp: timeToISO(startOfDay.minusHours(3)) }, + { name: 'pageview', timestamp: timeToISO(startOfDay.minusHours(4)) }, + { + name: 'pageview', + timestamp: timeToISO(startOfYesterday.minusHours(3)) + }, + { + name: 'pageview', + timestamp: timeToISO(startOfYesterday.minusHours(4)) + }, + { + name: 'pageview', + timestamp: timeToISO(startOfYesterday.minusHours(5)) + } + ] + }) + + await page.goto('/' + domain) + + const currentQueryPeriod = page.getByTestId('current-query-period') + const queryPeriodPicker = page.getByTestId('query-period-picker') + const backButton = queryPeriodPicker.getByTestId('period-move-back') + const forwardButton = queryPeriodPicker.getByTestId('period-move-forward') + const visitors = page.locator('#visitors') + + await currentQueryPeriod.click() + await queryPeriodPicker.getByRole('link', { name: 'Today' }).click() + + await expect(currentQueryPeriod).toHaveText('Today') + await expect(visitors).toHaveText('1') + await expect(backButton).not.toHaveCSS('cursor', 'not-allowed') + await expect(forwardButton).toHaveCSS('cursor', 'not-allowed') + + await backButton.click() + + const yesterdayLabel = startOfYesterday.format( + DateTimeFormatter.ofPattern('EEE, dd MMM').withLocale(Locale.ENGLISH) + ) + + await expect(currentQueryPeriod).toHaveText(yesterdayLabel) + await expect(backButton).not.toHaveCSS('cursor', 'not-allowed') + await expect(forwardButton).not.toHaveCSS('cursor', 'not-allowed') + await expect(visitors).toHaveText('2') + + await backButton.click() + + const beforeYesterdayLabel = startOfYesterday + .minusDays(1) + .format( + DateTimeFormatter.ofPattern('EEE, dd MMM').withLocale(Locale.ENGLISH) + ) + + await expect(currentQueryPeriod).toHaveText(beforeYesterdayLabel) + await expect(backButton).toHaveCSS('cursor', 'not-allowed') + await expect(forwardButton).not.toHaveCSS('cursor', 'not-allowed') + await expect(visitors).toHaveText('3') + + await forwardButton.click() + + await expect(currentQueryPeriod).toHaveText(yesterdayLabel) + await expect(backButton).not.toHaveCSS('cursor', 'not-allowed') + await expect(forwardButton).not.toHaveCSS('cursor', 'not-allowed') + await expect(visitors).toHaveText('2') + + await forwardButton.click() + + await expect(currentQueryPeriod).toHaveText('Today') + await expect(backButton).not.toHaveCSS('cursor', 'not-allowed') + await expect(forwardButton).toHaveCSS('cursor', 'not-allowed') + await expect(visitors).toHaveText('1') +}) + +test('selecting a custom date range', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + // NOTE: As the calendar renders contents dynamically, we cannot tell for sure + // whether the day before today will be visible without switching month. + // To make things simpler, we only test a single-day range of today. + + const now = currentTime() + const startOfDay = now.truncatedTo(ChronoUnit.DAYS) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', timestamp: timeToISO(now) }, + { name: 'pageview', timestamp: timeToISO(startOfDay.minusDays(3)) } + ] + }) + + await page.goto('/' + domain) + + const currentQueryPeriod = page.getByTestId('current-query-period') + const queryPeriodPicker = page.getByTestId('query-period-picker') + const visitors = page.locator('#visitors') + + currentQueryPeriod.click() + await queryPeriodPicker.getByRole('link', { name: 'Custom range' }).click() + + const todayLabel = startOfDay.format( + DateTimeFormatter.ofPattern('MMMM d, YYYY').withLocale(Locale.ENGLISH) + ) + + await page.getByLabel(todayLabel).click() + await page.getByLabel(todayLabel).click() + + await expect(currentQueryPeriod).toHaveText('Today') + await expect(visitors).toHaveText('1') +}) + +test('comparing stats over time is supported', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { name: 'pageview', timestamp: { daysAgo: 2 } }, + { name: 'pageview', timestamp: { daysAgo: 4 } }, + { name: 'pageview', timestamp: { daysAgo: 30 } }, + { name: 'pageview', timestamp: { daysAgo: 30 } }, + { name: 'pageview', timestamp: { daysAgo: 31 } }, + { name: 'pageview', timestamp: { daysAgo: 370 } } + ] + }) + + await page.goto('/' + domain) + + await expect(page.getByTestId('current-query-period')).toHaveText( + 'Last 28 days' + ) + + await page.getByTestId('query-period-picker').click() + await page + .getByTestId('query-period-picker') + .getByRole('link', { name: 'Compare' }) + .click() + + const previousPeriodButton = page.getByRole('button', { + name: 'Previous period' + }) + + await expect(previousPeriodButton).toBeVisible() + + const visitors = page.locator('#visitors') + const previousVisitors = page.locator('#previous-visitors') + + await expect(visitors).toHaveText('2') + await expect(previousVisitors).toHaveText('3') + + await previousPeriodButton.click() + await page.getByRole('link', { name: 'Year over year' }).click() + + await expect( + page.getByRole('button', { name: 'Year over year' }) + ).toBeVisible() + + await expect(visitors).toHaveText('2') + await expect(previousVisitors).toHaveText('1') +}) diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts new file mode 100644 index 000000000000..679e304de581 --- /dev/null +++ b/e2e/tests/fixtures.ts @@ -0,0 +1,501 @@ +import type { APIRequestContext, Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { expectLiveViewConnected, randomID } from './test-utils' + +type User = { + name: string + email: string + password: string +} + +type EventTimestamp = + | { minutesAgo: number } + | { hoursAgo: number } + | { daysAgo: number } + | string + +type EventDate = { daysAgo: number } | string + +type Event = { + type?: 'event' + name: string + user_id?: number + scroll_depth?: number + revenue_reporting_amount?: string + revenue_reporting_currency?: string + pathname?: string + hostname?: string + country_code?: string + subdivision1_code?: string + city_geoname_id?: number + referrer_source?: string + referrer?: string + utm_medium?: string + utm_source?: string + utm_campaign?: string + utm_term?: string + utm_content?: string + click_id_param?: string + screen_size?: string + browser?: string + browser_version?: string + operating_system?: string + operating_system_version?: string + 'meta.key'?: string[] + 'meta.value'?: string[] + timestamp?: EventTimestamp +} + +type ImportedVisitors = { + type: 'imported_visitors' + visitors?: number + pageviews?: number + bounces?: number + visits?: number + visit_duration?: number + date?: EventDate +} + +export type StatsEntry = Event | ImportedVisitors + +export async function register({ + page, + request, + user +}: { + page: Page + request: APIRequestContext + user: User +}) { + await page.goto('/register') + + await expectLiveViewConnected(page) + + await expect( + page.getByRole('button', { name: 'Start my free trial' }) + ).toBeVisible() + + await page.getByLabel('Full name').fill(user.name) + await page.getByLabel('Email').fill(user.email) + await page.getByLabel('Password', { exact: true }).fill(user.password) + await page.getByLabel('Confirm password', { exact: true }).fill(user.password) + await expect( + page.getByRole('button', { name: 'Start my free trial' }) + ).toBeEnabled() + await page.getByRole('button', { name: 'Start my free trial' }).click() + + await expect( + page.getByRole('heading', { name: 'Activate your account' }) + ).toBeVisible() + + const response = await request.get('/sent-emails-api/emails.json') + + const emailData: Array<{ to: string[][]; subject: string }> = + await response.json() + + const emails = emailData.filter( + (e) => + e.to![0]![0] === user.name && + e.subject.indexOf('is your Plausible email verification code') > -1 + ) + + expect(emails.length).toEqual(1) + + const [code] = emails[0]!.subject.split(' ') + + await page.locator('input[name=code]').fill(code!) + + await page.getByRole('button', { name: 'Activate' }).click() + + await expect( + page.getByRole('button', { name: 'Install Plausible' }) + ).toBeVisible() +} + +export async function login({ page, user }: { page: Page; user: User }) { + await page.goto('/login') + + await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible() + + await page.getByLabel('Email').fill(user.email) + await page.getByLabel('Password').fill(user.password) + await page.getByRole('button', { name: 'Log in' }).click() + + await expect(page.getByRole('button', { name: user.name })).toBeVisible() +} + +export async function logout(page: Page) { + await page.goto('/logout') + + await expect( + page.getByRole('heading', { name: 'Welcome to Plausible!' }) + ).toBeVisible() +} + +export async function addSite({ + page, + domain +}: { + page: Page + domain: string +}) { + await page.goto('/sites/new') + + await expect( + page.getByRole('button', { name: 'Install Plausible' }) + ).toBeVisible() + + await page.getByLabel('Domain').fill(domain) + await page.getByLabel('Reporting timezone').selectOption('Etc/UTC') + + await page.getByRole('button', { name: 'Install Plausible' }).click() + + await expect( + page.getByRole('button', { name: 'Verify Script installation' }) + ).toBeVisible() +} + +export async function makeSitePublic({ + page, + domain +}: { + page: Page + domain: string +}) { + await page.goto(`/${domain}/settings/visibility`) + + await page + .getByRole('form', { name: 'Make stats publicly available' }) + .getByRole('button') + .click() + + await expect(page.locator('body')).toContainText('are now public') +} + +export async function createSharedLink({ + page, + domain, + name, + password +}: { + page: Page + domain: string + name: string + password?: string +}): Promise { + const modal = page.locator('#shared-links-form-modal') + const table = page.locator('#shared-links-table') + + await page.goto(`/${domain}/settings/visibility`) + + await page.getByRole('button', { name: 'Add shared link' }).click() + + await modal.getByLabel('Name').fill(name) + + if (password) { + await modal.locator('button#password-protect-').click() + + await modal.locator('input#shared_link_password').fill(password) + } + + await page.getByRole('button', { name: 'Create shared link' }).click() + + await expect(page.locator('body')).toContainText('Shared link saved') + + const link = await table + .locator('tr') + .filter({ hasText: name }) + .locator('input') + .inputValue() + + return link +} + +export async function populateStats({ + request, + domain, + events +}: { + request: APIRequestContext + domain: string + events: StatsEntry[] +}) { + const response = await request.post('/e2e-tests/stats', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + data: { domain: domain, events: events } + }) + + expect(response.ok()).toBeTruthy() +} + +export async function addCustomGoal({ + page, + domain, + name, + displayName, + currency, + // Useful when adding a goal for which there's no matching stat yet + clickManually = true +}: { + page: Page + domain: string + name: string + displayName?: string + currency?: string + clickManually?: boolean +}) { + await page.goto(`/${domain}/settings/goals`) + + await expectLiveViewConnected(page) + + await page.getByRole('button', { name: 'Add goal' }).click() + const customEventButton = page.locator( + 'button[phx-value-goal-type="custom_events"]' + ) + + await customEventButton.click() + + if (clickManually) { + await page.getByRole('button', { name: 'Add manually' }).click() + } + + await expect( + page.getByRole('heading', { name: `Add goal for ${domain}` }) + ).toBeVisible() + // NOTE: Locating inputs by role and label does not work in this case + // for some reason. + const nameInput = page.locator('input[placeholder="e.g. Signup"]') + await nameInput.fill(name) + await page.locator(`a[data-display-value="${name}"]`).click() + await expect(nameInput).toHaveAttribute('value', name) + + if (displayName) { + await page + .locator('input#custom_event_display_name_input') + .fill(displayName) + } + + if (currency) { + await page + .locator('button[aria-labelledby="enable-revenue-tracking"]') + .click() + const currencyInput = page.locator('input[id^=currency_input_]') + await currencyInput.fill(currency) + await page.locator(`a[phx-value-submit-value="${currency}"]`).click() + await expect(page.locator('input[name="goal[currency]"]')).toHaveAttribute( + 'value', + currency + ) + } + + await page + .locator('form[phx-submit="save-goal"]') + .getByRole('button', { name: 'Add goal' }) + .click() + + await expect(page.locator('body')).toContainText('Goal saved successfully') +} + +export async function addPageviewGoal({ + page, + domain, + pathname, + displayName +}: { + page: Page + domain: string + pathname: string + displayName?: string +}) { + await page.goto(`/${domain}/settings/goals`) + + await expectLiveViewConnected(page) + + await page.getByRole('button', { name: 'Add goal' }).click() + const pageviewEventButton = page.locator( + 'button[phx-value-goal-type="pageviews"]' + ) + + await pageviewEventButton.click() + + await expect( + page.getByRole('heading', { name: `Add goal for ${domain}` }) + ).toBeVisible() + + const pathnameInput = page.locator('input[id^="page_path_input"]') + await pathnameInput.fill(pathname) + await page.locator(`a[data-display-value="${pathname}"]`).click() + await expect(pathnameInput).toHaveAttribute('value', pathname) + if (displayName) { + await page.locator('input#pageview_display_name_input').fill(displayName) + } + + await page + .locator('form[phx-submit="save-goal"]') + .getByRole('button', { name: 'Add goal' }) + .click() + + await expect(page.locator('body')).toContainText('Goal saved successfully') +} + +export async function addScrollDepthGoal({ + page, + domain, + pathname, + displayName, + scrollPercentage +}: { + page: Page + domain: string + pathname: string + displayName?: string + scrollPercentage?: number +}) { + await page.goto(`/${domain}/settings/goals`) + + await expectLiveViewConnected(page) + + await page.getByRole('button', { name: 'Add goal' }).click() + const scrollDepthEventButton = page.locator( + 'button[phx-value-goal-type="scroll"]' + ) + + await scrollDepthEventButton.click() + + await expect( + page.getByRole('heading', { name: `Add goal for ${domain}` }) + ).toBeVisible() + + if (scrollPercentage) { + await page + .locator('input[name="goal[scroll_threshold]"]') + .fill(scrollPercentage.toString()) + } + + const pathnameInput = page.locator('input[id^="scroll_page_path_input"]') + await pathnameInput.fill(pathname) + await page.locator(`a[data-display-value="${pathname}"]`).click() + await expect(pathnameInput).toHaveAttribute('value', pathname) + + if (displayName) { + await page.locator('input#scroll_display_name_input').fill(displayName) + } + + await page + .locator('form[phx-submit="save-goal"]') + .getByRole('button', { name: 'Add goal' }) + .click() + + await expect(page.locator('body')).toContainText('Goal saved successfully') +} + +export async function addCustomProp({ + page, + domain, + name +}: { + page: Page + domain: string + name: string +}) { + await page.goto(`/${domain}/settings/properties`) + + await expectLiveViewConnected(page) + + await page.getByRole('button', { name: 'Add property' }).click() + + await expect( + page.getByRole('heading', { name: `Add property for ${domain}` }) + ).toBeVisible() + + const propInput = page.locator('input#prop_input') + await propInput.fill(name) + await page.locator(`a[data-display-value="${name}"]`).click() + await expect(propInput).toHaveAttribute('value', name) + + await page + .locator('form[phx-submit="allow-prop"]') + .getByRole('button', { name: 'Add property' }) + .click() + + await expect(page.locator('body')).toContainText( + 'Property added successfully' + ) +} + +export async function addAllCustomProps({ + page, + domain +}: { + page: Page + domain: string +}) { + await page.goto(`/${domain}/settings/properties`) + + await expectLiveViewConnected(page) + + await page.getByRole('button', { name: 'Add property' }).click() + + await expect( + page.getByRole('heading', { name: `Add property for ${domain}` }) + ).toBeVisible() + + await page.getByText(/Click to add [0-9]+ existing properties/).click() + + await expect(page.locator('body')).toContainText( + 'Properties added successfully' + ) +} + +export async function addFunnel({ + request, + domain, + name, + steps +}: { + request: APIRequestContext + domain: string + name: string + steps: string[] +}) { + const response = await request.post('/e2e-tests/funnel', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + data: { domain: domain, name: name, steps: steps } + }) + + expect(response.ok()).toBeTruthy() +} + +export async function setupSite({ + user, + page, + request +}: { + user?: User + page: Page + request: APIRequestContext +}): Promise<{ domain: string; user: User }> { + const domain = `${randomID()}.example.com` + + if (!user) { + const userID = randomID() + + user = { + name: `User ${userID}`, + email: `email-${userID}@example.com`, + password: 'VeryStrongVerySecret' + } + + await register({ page, request, user }) + } + + await addSite({ page, domain }) + + return { domain, user } +} diff --git a/e2e/tests/test-utils.ts b/e2e/tests/test-utils.ts new file mode 100644 index 000000000000..e6fe78d969c8 --- /dev/null +++ b/e2e/tests/test-utils.ts @@ -0,0 +1,83 @@ +import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export async function expectLiveViewConnected(page: Page) { + await expect + .poll(() => page.locator('.phx-connected').count()) + .toBeGreaterThan(0) +} + +export function randomID() { + return Math.random().toString(16).slice(2) +} + +export const tabButton = (page: Page | Locator, label: HasTextArg) => + page.getByTestId('tab-button').filter({ hasText: label }) + +export const header = (report: Locator, label: HasTextArg) => + report + .getByTestId('report-header') + .filter({ hasText: label }) + .getByRole('button') + +export const expectHeaders = async (report: Locator, headers: HaveTextArg) => + expect(report.getByTestId('report-header')).toHaveText(headers) + +export const expectRows = async (report: Locator, labels: HaveTextArg) => + expect(report.getByTestId('report-row').getByRole('link')).toHaveText(labels) + +export const rowLink = (report: Locator, label: HasTextArg) => + report.getByTestId('report-row').filter({ hasText: label }).getByRole('link') + +export const expectMetricValues = async ( + report: Locator, + label: HasTextArg, + values: HaveTextArg +) => + expect( + report + .getByTestId('report-row') + .filter({ hasText: label }) + .getByTestId('metric-value') + ).toHaveText(values) + +export const dropdown = (report: Locator) => + report.getByTestId('dropdown-items') + +export const searchInput = (report: Locator) => + report.getByTestId('search-input') + +export const modal = (page: Page) => page.locator('.modal') + +export const detailsLink = (report: Locator) => + report.getByRole('link', { name: 'View details' }) + +export const closeModalButton = (page: Page) => + page.getByRole('button', { name: 'Close modal' }) + +export const filterButton = (page: Page) => + page.getByRole('button', { name: 'Filter', exact: true }) + +export const filterItemButton = (page: Page, label: HasTextArg) => + page.getByTestId('filtermenu').getByRole('link', { name: label, exact: true }) + +export const applyFilterButton = (page: Page, { disabled = false } = {}) => + page.getByRole('button', { + name: 'Apply filter', + disabled + }) + +export const filterRow = (page: Page, key: string) => + page.getByTestId(`filter-row-${key}`) + +export const suggestedItem = (scoped: Locator, url: string) => + scoped.getByRole('listitem').filter({ hasText: url }) + +export const filterOperator = (scoped: Locator) => + scoped.getByTestId('filter-operator') + +export const filterOperatorOption = (scoped: Locator, option: HasTextArg) => + scoped.getByTestId('filter-operator-option').filter({ hasText: option }) + +type HaveTextArg = string | RegExp | ReadonlyArray +type HasTextArg = string | RegExp diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000000..0c9a7250aa33 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,36 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + // "rootDir": "./src", + // "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + // For nodejs: + "lib": ["esnext"], + "types": ["node"], + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Recommended Options + "strict": true, + "verbatimModuleSyntax": false, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + + "noEmit": true, + "pretty": true + } +} diff --git a/extra/fixture/.gitignore b/extra/fixture/.gitignore new file mode 100644 index 000000000000..1d43f5366c2d --- /dev/null +++ b/extra/fixture/.gitignore @@ -0,0 +1 @@ +Corefile.gen.* diff --git a/extra/fixture/Corefile.template b/extra/fixture/Corefile.template new file mode 100644 index 000000000000..361d96bb704f --- /dev/null +++ b/extra/fixture/Corefile.template @@ -0,0 +1,9 @@ +. { + bind 0.0.0.0 + template IN TXT plausible.test { + answer "{{ .Name }} 60 IN TXT \"plausible-sso-verification=${domain_id}\"" + fallthrough + } + log + errors +} diff --git a/extra/fixture/assertion.xml b/extra/fixture/assertion.xml new file mode 100644 index 000000000000..860e969592b1 --- /dev/null +++ b/extra/fixture/assertion.xml @@ -0,0 +1,9 @@ +http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + UQLQNaShMv0BjLvgqJNJWz61XAHf+v5LFDUqvMgfWdU=R+oEKdanohpq7mKN3AyTDITBXGMjqxL3wnMciHuYo+WRrPOdL+fvNP8knTZuvRlrsQRg+K7bVUd+5V5t+H1RkC+hsYXcyRK0pIbX4GXSMmNhl9XS04R9sErFQ/Yuj3azXgv4YicUVORjG7ti0eqxUN3kzfiS1tcYz0G7lH0MCAx7AwbfLQX6GfoYG949sc5gj1AcC+J5Ql+tk7JT7PrWaqDRl2GpBlnDyAi+/dulgD9Vu41e//XAHkkwe6ZcpdA5rzrRlToeS6C6UbM9Mzx8mQZazFLVSHkzeWR/BmlnK6GfjJXNg/apCK/1PiUef9FFjxHe5KLHVY1/cbzjmlxteQ== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + TcSdGz0TtFf6X/r3IYj7/95AFBPcas7TIXPTW8hNBRs=aXlzemjbwl1UmcBKXNOtHv+TFU8tWQGrJOGg3OUOeDvgBz1YCSq3iMSNGMadMU8WwlJjiZbGww6Jz+zAwNmhHKNvQpoMoV+v0S0Kwpy4uO5VynrD+M4PM0y8xTn8S3izEKaVQiTFHcgUYx9QBYOprBD2U2hIv4Y6hNgwIbuLg+D9qlkWo70xwn5uL1DZel4FxZOuerZfS7tSu5d48s1PcP4ZDWqsO5TUmJ4m6n7/XK17ROIQOccPLvwkVHWXB8g/r/R+lpFedsIh6cFO7Rx6urIjqz5k+ue7jFpuS+eVy/8Oq43q6XVvcOfb3Y8Qu41GkWEHSZp2wKWQVDY/jr12Uw== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==_ddd0b91c6b00942964e35238a4c9d482e9bdb9b4adhttp://localhost:8000/sso/d0a0ff3d-0754-498c-a90b-f3a20e9371f8urn:oasis:names:tc:SAML:2.0:ac:classes:Passworduser@plausible.testJaneSmith \ No newline at end of file diff --git a/extra/fixture/assertion_invalid_email.xml b/extra/fixture/assertion_invalid_email.xml new file mode 100644 index 000000000000..01e19fbf2fff --- /dev/null +++ b/extra/fixture/assertion_invalid_email.xml @@ -0,0 +1,9 @@ +http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + kx1GcbtUDzT+NUl/1cuwCpQqhQGRYaFN4YBUzhwqOb4=DVimuduQ1ghcpmEa+yWFDQ7CZBkhYa1omrbFeseap8fh98hIipB5xOcHUhNBWu4mZ2rV4GzFTAmBUJtJxElDTtatUiKzLWlfxRyALcH0rQMFnq9wpU6IKTkA9MNX6BbhBPyP8SiLtE5dW8PUeA2QrUz4bheqOOphAuXCHu2xix31CnsOpJRlttaxyq+ZJhdX8XzN1M6O7uTYNpkBU0oSG4/JWC8qjvWgg45WnB97f1+YbfiVPkB+gHjjY7tQ/2UkI8LVtQ9YD6a6sdikaAp9IyX742z4GCVtjgH6EqChOj5pP0QkZTNEOPice+kX1NVwYuzSDhUfik4UGGVYWU8A5Q== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + KEWH5FrqiOeaNEc2o92eL7izusA68qCAt9b8Ge/qAuM=ZiuXxxrxxaD0bSD0QyPPVvvW+arVZEH2TMl/rF7F/xwJHbrXLRkEKCFuY6iQJpOAk+3t5i1edzhQt4YVddWg3TKnr8IMVumUFITd0qdPlcRpyEELyAjfBVBrVYe7GAuI602zAkmiZGiEqUp7HK9y+XWC97bk/QFRGxxgiFL5zh6iN1MU8GpEaFh2OUyEQKGj3Bq7lvuNOatILMkcwmkWfg9BzzQ2/ZcjwalKdd7Iv1oxBmlfrUlD0OYOBwu3rUtYutWcOsUrbRc3lhkS6E0BZ2tVzHIR+qoS9zcA25uqQ+SCQMQUDG1MiUMv06XscPp3Vr/4a+5PDIft9eX19PSAEA== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==_9f26cf8c1e7fe26bc432975d3689dc53f3d82ca92ahttp://localhost:8000/sso/d0a0ff3d-0754-498c-a90b-f3a20e9371f8urn:oasis:names:tc:SAML:2.0:ac:classes:Passworduser1LennyCarr \ No newline at end of file diff --git a/extra/fixture/assertion_missing_email.xml b/extra/fixture/assertion_missing_email.xml new file mode 100644 index 000000000000..9ab4c8d240ce --- /dev/null +++ b/extra/fixture/assertion_missing_email.xml @@ -0,0 +1,9 @@ +http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + fYVp1zcpNUpQasg8YpE+0SaLpNtWieUZd7AH2LfEMjo=u69XMTKduXFPY7eWEnZi1rUBRNgCruSdAwiXDJ9/kut5pUMiL/J7VZ7wsQSj10aePxN/tH36gJFTs2SClEeO1liej6Ef/rvvQFctKKZW0F7Aw9f1EFd11V5RunVaGfhnLDI9l6HSK+39xmA3Jkw4jGU46lfV1ljAt8nuoXosfstos4rloSMAFtDLey2MOBIio7j6iHjsXRKHQsJsth2qtZI1oVBwmkl21u2i5aWujf8cD2Lw54VEi7l8nlWkCuH1tzcOqNBTyxyoMbIuARHUlek8hy19bI4GOdGlgesLDk1sxlBgYEOeZxabn98AuRxB0kUCk0qkJkcoY41qASWEyA== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + RlRAKhymDH0xAYJyF7X/txY+sZr6PHexWbMN7ctSC20=NfLq5ZWY4r0hpK7LOigqcDjSK2K5wxl6AHRCP0F3rstUPCsp8YWuC0PBGjGm4z8m47y2mdbWyDI+YcrN283obdAagFJcpPAROM8Ectw63VXpzMl44vIgSjJipU3fOzIdYkaJSuJG2FzT1xiByKzlth/U90W32h5yUkaSCjZUKflbXAtkZM7FOzF7QnZNXhNxZqPx398qt+21n/5mcMwSf55m5Q9igTYH3D36qflrJgD5R0W1cuks3l6181EJSYPnmPzxULk0erJ94CfjOOUHtGc70jQ4ux76XRym20dmRnPvC4RG+F2cDBkCJYvgHYWxDsW3HiNET+8FEc2FO9Bqhg== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==_1d89d8faa809ed9d2b547362e98fb23cabff28aa8fhttp://localhost:8000/sso/d0a0ff3d-0754-498c-a90b-f3a20e9371f8urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordLennyCarr \ No newline at end of file diff --git a/extra/fixture/assertion_missing_name.xml b/extra/fixture/assertion_missing_name.xml new file mode 100644 index 000000000000..7034a52d5389 --- /dev/null +++ b/extra/fixture/assertion_missing_name.xml @@ -0,0 +1,9 @@ +http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + Ug1YcZ+jIMiYCixQMD5QPzLY2ZAy7czkRduM40NHtzU=AcKB/i3IQnhttssZh7VW56jv9jFml5aq1/CTEioKU7nZl+WeaD4gdzDbIlnxb+onmJRMCdsz5brez5hoHa56cI+a00fFZXENQZdegao04q5hKweXlJ7Rz6XuvaIdmnNJIhVasDS45t3pKVlEwTEQ3Zl0mwM4Tq2dl8sTVTihJOgr4pMtsAcmVOBU0Zb1JYakpoFXoJpa20VuiNuxVZsEhBbJpMCRBaMfEMDufQwywWXna7CbJqxCt8T5ibxERELVY42PAYh7GFSFG/aB3NGq4TwplNuLwKRN3oAsUYprHY/HHYoyOF79ULYCRfqfa3uPdH8eAAnUH4jYYGOF6PqNIQ== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + X0lSSqiuayRcxeOLi+dpwpXKLDnJWw0d3akUHWaeizU=iBVTipxjKTT338EnWeA9y6SOMT2VTmTpi0Mft5SQaA4bM6U3pBHJ3FYhEIvZbH4qc6jtDJVo3c0Otm2vJw6sDAslWmdNIsOePRDaMi5sgya6sqm9VXef3NIu71lbUEdhE7iwR8y9qBzd7Cm9IYxR+wvFXnIh45WFXfuHaSHcu+k9ZzpE75CvK7A2cgXfpv7sMUGG0Kz9IhkolkbuKDxkhU3sKGlDF7FByz2TJiiuyOqz6O+4rMBgmRZ94TCpkwy2X7fdz7kvWPLyfyrp24l/qHBj1ObN+dwLu8GRYPXUlsQgwMzQLMRNfDfH5PULsB7YN5swMvmCPc9bswhcsZfoQA== +MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLfsdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7YS+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xTL/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyhuF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0ifXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw==_12466d6da890e3a8c5616b8b65c07eb58de7365a28http://localhost:8000/sso/d0a0ff3d-0754-498c-a90b-f3a20e9371f8urn:oasis:names:tc:SAML:2.0:ac:classes:Passworduser2@plausible.test \ No newline at end of file diff --git a/extra/fixture/authsources.php b/extra/fixture/authsources.php new file mode 100644 index 000000000000..35d8649e1b9e --- /dev/null +++ b/extra/fixture/authsources.php @@ -0,0 +1,25 @@ + [ + 'core:AdminPassword', + ], + 'example-userpass' => [ + 'exampleauth:UserPass', + 'user@plausible.test:plausible' => [ + 'email' => 'user@plausible.test', + 'first_name' => 'Jane', + 'last_name' => 'Smith' + ], + 'user1@plausible.test:plausible' => [ + 'email' => 'user1@plausible.test', + 'first_name' => 'Lenny', + 'last_name' => 'Carr' + ], + 'user2@plausible.test:plausible' => [ + 'email' => 'user2@plausible.test', + 'first_name' => 'Jane', + 'last_name' => 'Doorwell' + ], + ], +]; diff --git a/extra/lib/plausible/audit.ex b/extra/lib/plausible/audit.ex new file mode 100644 index 000000000000..7c7c2e1efd99 --- /dev/null +++ b/extra/lib/plausible/audit.ex @@ -0,0 +1,28 @@ +defmodule Plausible.Audit do + @moduledoc """ + Primary persistent Audit Entry interface + """ + + import Ecto.Query + + defdelegate encode(term, opts \\ []), to: Plausible.Audit.Encoder + defdelegate set_context(term), to: Plausible.Audit.Entry + + def list_entries(attrs) do + attrs + |> entries_query() + |> Plausible.Repo.all() + end + + def list_entries_paginated(attrs, params \\ %{}) do + attrs + |> entries_query() + |> Plausible.Pagination.paginate(params, cursor_fields: [{:datetime, :desc}]) + end + + defp entries_query(attrs) do + from ae in Plausible.Audit.Entry, + where: ^attrs, + order_by: [desc: :datetime] + end +end diff --git a/extra/lib/plausible/audit/encoder.ex b/extra/lib/plausible/audit/encoder.ex new file mode 100644 index 000000000000..7b8b1ceb1008 --- /dev/null +++ b/extra/lib/plausible/audit/encoder.ex @@ -0,0 +1,127 @@ +defmodule Plausible.Audit.EncoderError do + defexception [:message] +end + +defprotocol Plausible.Audit.Encoder do + def encode(x, opts \\ []) +end + +defimpl Plausible.Audit.Encoder, for: Ecto.Changeset do + def encode(changeset, opts) do + changes = + Enum.reduce(changeset.changes, %{}, fn {k, v}, acc -> + Map.put(acc, k, Plausible.Audit.Encoder.encode(v, opts)) + end) + + data = Plausible.Audit.Encoder.encode(changeset.data, opts) + + case {map_size(data), map_size(changes)} do + {n, 0} when n > 0 -> + data + + {0, n} when n > 0 -> + changes + + {0, 0} -> + %{} + + _ -> + %{before: data, after: changes} + end + end +end + +defimpl Plausible.Audit.Encoder, for: Map do + def encode(x, opts) do + {allow_not_loaded, data} = Map.pop(x, :__allow_not_loaded__) + raise_on_not_loaded? = Keyword.get(opts, :raise_on_not_loaded?, true) + + Enum.reduce(data, %{}, fn + {k, %Ecto.Association.NotLoaded{}}, acc -> + if k in allow_not_loaded or not raise_on_not_loaded? do + acc + else + raise Plausible.Audit.EncoderError, + message: + "#{k} association not loaded. Either preload, exclude or mark it as :allow_not_loaded in #{__MODULE__} options" + end + + {k, v}, acc -> + Map.put(acc, k, Plausible.Audit.Encoder.encode(v, opts)) + end) + end +end + +defimpl Plausible.Audit.Encoder, for: [Integer, BitString, Float] do + def encode(x, _opts), do: x +end + +defimpl Plausible.Audit.Encoder, for: [DateTime, Date, NaiveDateTime, Time] do + def encode(x, _opts), do: to_string(x) +end + +defimpl Plausible.Audit.Encoder, for: Atom do + def encode(nil, _opts), do: nil + def encode(true, _opts), do: true + def encode(false, _opts), do: false + def encode(x, _opts), do: Atom.to_string(x) +end + +defimpl Plausible.Audit.Encoder, for: List do + def encode(x, opts) do + Enum.map(x, &Plausible.Audit.Encoder.encode(&1, opts)) + end +end + +defimpl Plausible.Audit.Encoder, for: Any do + defmacro __deriving__(module, struct, options) do + deriving(module, struct, options) + end + + def deriving(module, _struct, options) do + only = options[:only] + except = options[:except] + allow_not_loaded = options[:allow_not_loaded] || [] + + extractor = + cond do + only -> + quote( + do: + struct + |> Map.take(unquote(only)) + |> Map.put(:__allow_not_loaded__, unquote(allow_not_loaded)) + ) + + except -> + except = [:__struct__ | except] + + quote( + do: + struct + |> Map.drop( + unquote(except) + |> Map.put(:__allow_not_loaded__, unquote(allow_not_loaded)) + ) + ) + + true -> + quote( + do: + struct + |> Map.delete(:__struct__) + |> Map.put(:__allow_not_loaded__, unquote(allow_not_loaded)) + ) + end + + quote do + defimpl Plausible.Audit.Encoder, for: unquote(module) do + def encode(struct, opts) do + Plausible.Audit.Encoder.encode(unquote(extractor), opts) + end + end + end + end + + def encode(_, _), do: raise("Implement me") +end diff --git a/extra/lib/plausible/audit/entry.ex b/extra/lib/plausible/audit/entry.ex new file mode 100644 index 000000000000..d978a8518dbc --- /dev/null +++ b/extra/lib/plausible/audit/entry.ex @@ -0,0 +1,96 @@ +defmodule Plausible.Audit.Entry do + @moduledoc """ + Persistent Audit Entry schema + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{ + name: String.t(), + entity: String.t(), + entity_id: String.t(), + meta: map(), + change: map(), + user_id: integer(), + team_id: integer(), + datetime: NaiveDateTime.t() + } + + @primary_key {:id, :binary_id, autogenerate: true} + schema "audit_entries" do + field :name, :string + field :entity, :string + field :entity_id, :string + field :meta, :map + field :change, :map, default: %{} + # default 0 is still useful in tests? + field :user_id, :integer, default: 0 + field :team_id, :integer, default: 0 + field :datetime, :naive_datetime_usec + field :actor_type, Ecto.Enum, default: :system, values: [:system, :user] + end + + def changeset(name, params) do + context = get_context() + + params = + Map.merge( + %{ + team_id: context[:current_team] && context.current_team.id, + user_id: context[:current_user] && context.current_user.id, + actor_type: if(context[:current_user], do: "user", else: "system") + }, + params + ) + + %__MODULE__{name: name} + |> cast(params, [:entity, :entity_id, :meta, :user_id, :team_id, :actor_type]) + |> validate_required([:name, :entity, :entity_id, :actor_type]) + |> put_change(:datetime, NaiveDateTime.utc_now()) + end + + def new(name, %{__struct__: struct, id: id}, params \\ %{}) do + changeset(name, Map.merge(%{entity: to_str(struct), entity_id: to_str(id)}, params)) + end + + def include_change(audit_entry, %Ecto.Changeset{} = related_changeset) do + audit_entry + |> change() + |> put_change(:change, Plausible.Audit.encode(related_changeset)) + end + + def include_change(audit_entry, %{__struct__: _} = struct) do + # inserts hardly ever preload associations, so raising on not loaded is not useful + audit_entry + |> change() + |> put_change(:change, Plausible.Audit.encode(struct, raise_on_not_loaded?: false)) + end + + def include_change(audit_entry, map) when is_map(map) do + # sometimes a hand-crafted map is passed; we don't need to encode then + audit_entry + |> change() + |> put_change(:change, map) + end + + def persist!(entry) do + Plausible.Repo.insert!(entry) + end + + defp get_context() do + case :logger.get_process_metadata() do + %{:__audit__ => audit_context} -> audit_context + %{} -> %{} + :undefined -> %{} + end + end + + def set_context(kv) when is_map(kv) do + :logger.update_process_metadata(%{:__audit__ => kv}) + end + + defp to_str(x) when is_binary(x), do: x + defp to_str(x) when is_atom(x), do: inspect(x) + defp to_str(x), do: to_string(x) +end diff --git a/extra/lib/plausible/audit/live_context.ex b/extra/lib/plausible/audit/live_context.ex new file mode 100644 index 000000000000..45147069fe8c --- /dev/null +++ b/extra/lib/plausible/audit/live_context.ex @@ -0,0 +1,22 @@ +defmodule Plausible.Audit.LiveContext do + @moduledoc """ + LiveView `on_mount` callback to provide audit context + """ + + defmacro __using__(_) do + quote do + on_mount Plausible.Audit.LiveContext + end + end + + def on_mount(:default, _params, _session, socket) do + if Phoenix.LiveView.connected?(socket) do + Plausible.Audit.set_context(%{ + current_user: socket.assigns[:current_user], + current_team: socket.assigns[:current_team] + }) + end + + {:cont, socket} + end +end diff --git a/extra/lib/plausible/auth/sso.ex b/extra/lib/plausible/auth/sso.ex new file mode 100644 index 000000000000..a92e3e97d855 --- /dev/null +++ b/extra/lib/plausible/auth/sso.ex @@ -0,0 +1,548 @@ +defmodule Plausible.Auth.SSO do + @moduledoc """ + API for SSO. + """ + + import Ecto.Changeset + import Ecto.Query + + alias Plausible.Auth + alias Plausible.Auth.SSO + alias Plausible.Billing.Subscription + alias Plausible.Repo + alias Plausible.Teams + + use Plausible.Auth.SSO.Domain.Status + + require Plausible.Billing.Subscription.Status + + @type policy_attr() :: + {:sso_default_role, Teams.Policy.sso_member_role()} + | {:sso_session_timeout_minutes, non_neg_integer()} + + @spec get_integration_for(Teams.Team.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found} + def get_integration_for(%Teams.Team{} = team) do + query = integration_query() |> where([i], i.team_id == ^team.id) + + if integration = Repo.one(query) do + {:ok, integration} + else + {:error, :not_found} + end + end + + @spec get_integration(String.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found} + def get_integration(identifier) when is_binary(identifier) do + query = integration_query() |> where([i], i.identifier == ^identifier) + + if integration = Repo.one(query) do + {:ok, integration} + else + {:error, :not_found} + end + end + + defp integration_query() do + from(i in SSO.Integration, + inner_join: t in assoc(i, :team), + as: :team, + left_join: d in assoc(i, :sso_domains), + as: :sso_domains, + preload: [team: t, sso_domains: d] + ) + end + + @spec initiate_saml_integration(Teams.Team.t()) :: SSO.Integration.t() + def initiate_saml_integration(team) do + changeset = SSO.Integration.init_changeset(team) + + Repo.insert_with_audit!( + changeset, + "saml_integration_initiated", + %{team_id: team.id}, + on_conflict: [set: [updated_at: NaiveDateTime.utc_now(:second)]], + conflict_target: :team_id, + returning: true + ) + end + + @spec update_integration(SSO.Integration.t(), map()) :: + {:ok, SSO.Integration.t()} | {:error, Ecto.Changeset.t()} + def update_integration(integration, params) do + changeset = SSO.Integration.update_changeset(integration, params) + + case Repo.update_with_audit(changeset, "sso_integration_updated", %{ + team_id: integration.team_id + }) do + {:ok, integration} -> {:ok, integration} + {:error, changeset} -> {:error, changeset.changes.config} + end + end + + @spec provision_user(SSO.Identity.t()) :: + {:ok, :standard | :sso | :integration, Teams.Team.t(), Auth.User.t()} + | {:error, :integration_not_found | :over_limit} + | {:error, :multiple_memberships | :active_personal_team, Teams.Team.t(), Auth.User.t()} + def provision_user(identity) do + case find_user(identity) do + {:ok, :standard, user, integration, domain} -> + provision_standard_user(user, identity, integration, domain) + + {:ok, :sso, user, integration, domain} -> + provision_sso_user(user, identity, integration, domain) + + {:ok, :integration, _, integration, domain} -> + provision_identity(identity, integration, domain) + + {:error, :not_found} -> + {:error, :integration_not_found} + end + end + + @spec deprovision_user!(Auth.User.t()) :: Auth.User.t() + def deprovision_user!(%{type: :standard} = user), do: user + + def deprovision_user!(user) do + user = Repo.preload(user, [:sso_integration, :sso_domain]) + + :ok = Auth.UserSessions.revoke_all(user) + + user + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(:type, :standard) + |> Ecto.Changeset.put_change(:sso_identity_id, nil) + |> Ecto.Changeset.put_assoc(:sso_integration, nil) + |> Ecto.Changeset.put_assoc(:sso_domain, nil) + |> Repo.update_with_audit!("sso_user_deprovioned", %{team_id: user.sso_integration.team_id}) + end + + @spec update_policy(Teams.Team.t(), [policy_attr()]) :: + {:ok, Teams.Team.t()} | {:error, Ecto.Changeset.t()} + def update_policy(team, attrs \\ []) do + params = Map.new(attrs) + + changeset = + team + |> Ecto.Changeset.cast(%{policy: params}, []) + |> Ecto.Changeset.cast_embed(:policy, with: &Teams.Policy.update_changeset/2) + + case Repo.update_with_audit(changeset, "sso_policy_updated", %{team_id: team.id}) do + {:ok, integration} -> {:ok, integration} + {:error, changeset} -> {:error, changeset.changes.policy} + end + end + + @spec set_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) :: + {:ok, Teams.Team.t()} + | {:error, + :no_integration + | :no_domain + | :no_verified_domain + | :owner_2fa_disabled + | :no_sso_user} + def set_force_sso(team, mode) do + with :ok <- check_force_sso(team, mode) do + params = %{policy: %{force_sso: mode}} + + team + |> Ecto.Changeset.cast(params, []) + |> Ecto.Changeset.cast_embed(:policy, + with: &Teams.Policy.force_sso_changeset(&1, &2.force_sso) + ) + |> Repo.update_with_audit("sso_force_mode_changed", %{team_id: team.id}) + end + end + + @spec check_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) :: + :ok + | {:error, + :no_integration + | :no_domain + | :no_verified_domain + | :owner_2fa_disabled + | :no_sso_user} + def check_force_sso(_team, :none), do: :ok + + def check_force_sso(team, :all_but_owners) do + with :ok <- check_integration_configured(team), + :ok <- check_sso_user_present(team) do + check_owners_2fa_enabled(team) + end + end + + @spec check_can_remove_integration(SSO.Integration.t()) :: + :ok | {:error, :force_sso_enabled | :sso_users_present} + def check_can_remove_integration(integration) do + team = Repo.preload(integration, :team).team + + cond do + team.policy.force_sso != :none -> + {:error, :force_sso_enabled} + + check_sso_user_present(integration) == :ok -> + {:error, :sso_users_present} + + true -> + :ok + end + end + + @spec remove_integration(SSO.Integration.t(), Keyword.t()) :: + :ok | {:error, :force_sso_enabled | :sso_users_present} + def remove_integration(integration, opts \\ []) do + force_deprovision? = Keyword.get(opts, :force_deprovision?, false) + check = check_can_remove_integration(integration) + + case {check, force_deprovision?} do + {:ok, _} -> + {:ok, :ok} = + Repo.transaction(fn -> + integration = Repo.preload(integration, :sso_domains) + Enum.each(integration.sso_domains, &SSO.Domains.cancel_verification(&1.domain)) + + Repo.delete_with_audit!(integration, "sso_integration_removed", %{ + team_id: integration.team_id + }) + + :ok + end) + + :ok + + {{:error, :sso_users_present}, true} -> + {:ok, :ok} = + Repo.transaction(fn -> + users = Repo.preload(integration, :users).users + integration = Repo.preload(integration, :sso_domains) + Enum.each(users, &deprovision_user!/1) + Enum.each(integration.sso_domains, &SSO.Domains.cancel_verification(&1.domain)) + + Repo.delete_with_audit!(integration, "sso_integration_removed", %{ + team_id: integration.team_id + }) + + :ok + end) + + :ok + + {{:error, error}, _} -> + {:error, error} + end + end + + @spec check_ready_to_provision(Auth.User.t(), Teams.Team.t()) :: + :ok | {:error, :not_a_member | :multiple_memberships | :active_personal_team} + def check_ready_to_provision(%{type: :sso} = _user, _team), do: :ok + + def check_ready_to_provision(user, team) do + result = + with :ok <- ensure_team_member(team, user), + :ok <- ensure_one_membership(user, team) do + ensure_empty_personal_team(user, team) + end + + case result do + :ok -> :ok + {:error, :integration_not_found} -> {:error, :not_a_member} + {:error, :multiple_memberships, _, _} -> {:error, :multiple_memberships} + {:error, :active_personal_team, _, _} -> {:error, :active_personal_team} + end + end + + defp check_integration_configured(team) do + integrations = + Repo.all( + from( + i in SSO.Integration, + left_join: d in assoc(i, :sso_domains), + where: i.team_id == ^team.id, + preload: [sso_domains: d] + ) + ) + + domains = Enum.flat_map(integrations, & &1.sso_domains) + no_verified_domains? = Enum.all?(domains, &(&1.status != Status.verified())) + + cond do + integrations == [] -> {:error, :no_integration} + domains == [] -> {:error, :no_domain} + no_verified_domains? -> {:error, :no_verified_domain} + true -> :ok + end + end + + defp check_sso_user_present(%Teams.Team{} = team) do + sso_user_count = + Repo.aggregate( + from( + tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id, + where: tm.role != :guest, + where: u.type == :sso + ), + :count + ) + + if sso_user_count > 0 do + :ok + else + {:error, :no_sso_user} + end + end + + defp check_sso_user_present(%SSO.Integration{} = integration) do + sso_user_count = + Repo.aggregate( + from( + i in SSO.Integration, + inner_join: u in assoc(i, :users), + inner_join: tm in assoc(u, :team_memberships), + on: tm.team_id == i.team_id, + where: i.id == ^integration.id, + where: tm.role != :guest, + where: u.type == :sso + ), + :count + ) + + if sso_user_count > 0 do + :ok + else + {:error, :no_sso_user} + end + end + + defp check_owners_2fa_enabled(team) do + disabled_2fa_count = + Repo.aggregate( + from( + tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id, + where: tm.role == :owner, + where: u.totp_enabled == false or is_nil(u.totp_secret) + ), + :count + ) + + if disabled_2fa_count == 0 do + :ok + else + {:error, :owner_2fa_disabled} + end + end + + defp find_user(identity) do + case find_user_with_fallback(identity) do + {:ok, type, user, integration, domain} -> + {:ok, type, Repo.preload(user, [:sso_integration, :sso_domain]), integration, domain} + + {:error, _} = error -> + error + end + end + + defp find_user_with_fallback(identity) do + with {:ok, integration} <- get_integration(identity.integration_id), + {:error, :not_found} <- find_by_identity(identity, integration) do + find_by_email(identity.email, integration) + end + end + + defp find_by_identity(identity, integration) do + if user = + Repo.get_by(Auth.User, + sso_integration_id: integration.id, + sso_identity_id: identity.id, + type: :sso + ) do + with {:ok, sso_domain} <- SSO.Domains.lookup(identity.email), + :ok <- check_domain_integration_match(sso_domain, integration) do + user = Repo.preload(user, sso_integration: :team) + + {:ok, user.type, user, user.sso_integration, sso_domain} + end + else + {:error, :not_found} + end + end + + defp find_by_email(email, integration) do + with {:ok, sso_domain} <- SSO.Domains.lookup(email), + :ok <- check_domain_integration_match(sso_domain, integration) do + case find_in_team_by_email(sso_domain.sso_integration.team, email) do + {:ok, user} -> + {:ok, user.type, user, sso_domain.sso_integration, sso_domain} + + {:error, :not_found} -> + {:ok, :integration, nil, sso_domain.sso_integration, sso_domain} + end + end + end + + defp find_in_team_by_email(team, email) do + result = + Repo.one( + from( + u in Auth.User, + inner_join: tm in assoc(u, :team_memberships), + where: u.email == ^email, + where: tm.team_id == ^team.id, + where: tm.role != :guest + ) + ) + + if result do + {:ok, result} + else + {:error, :not_found} + end + end + + defp check_domain_integration_match(sso_domain, integration) do + if sso_domain.sso_integration_id == integration.id do + :ok + else + {:error, :not_found} + end + end + + defp provision_sso_user(user, identity, integration, domain) do + changeset = + user + |> change() + |> put_change(:email, identity.email) + |> put_change(:name, identity.name) + |> put_change(:sso_identity_id, identity.id) + |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second)) + |> put_assoc(:sso_domain, domain) + + with {:ok, user} <- + Repo.update_with_audit(changeset, "sso_user_provisioned", %{ + team_id: integration.team_id + }) do + {:ok, :sso, integration.team, user} + end + end + + defp provision_standard_user(user, identity, integration, domain) do + changeset = + user + |> change() + |> put_change(:type, :sso) + |> put_change(:name, identity.name) + |> put_change(:sso_identity_id, identity.id) + |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second)) + |> put_assoc(:sso_integration, integration) + |> put_assoc(:sso_domain, domain) + + with :ok <- ensure_team_member(integration.team, user), + :ok <- ensure_one_membership(user, integration.team), + :ok <- ensure_empty_personal_team(user, integration.team), + :ok <- Auth.UserSessions.revoke_all(user), + {:ok, user} <- + Repo.update_with_audit(changeset, "sso_user_provisioned", %{ + team_id: integration.team_id + }) do + {:ok, :standard, integration.team, user} + end + end + + defp provision_identity(identity, integration, domain) do + random_password = + 64 + |> :crypto.strong_rand_bytes() + |> Base.encode64(padding: false) + + params = %{ + email: identity.email, + name: identity.name, + password: random_password, + password_confirmation: random_password + } + + changeset = + Auth.User.new(params) + |> put_change(:email_verified, true) + |> put_change(:type, :sso) + |> put_change(:sso_identity_id, identity.id) + |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second)) + |> put_assoc(:sso_integration, integration) + |> put_assoc(:sso_domain, domain) + + team = integration.team + role = integration.team.policy.sso_default_role + now = NaiveDateTime.utc_now(:second) + + result = + Repo.transaction(fn -> + with {:ok, user} <- + Repo.insert_with_audit(changeset, "sso_user_provisioned", %{team_id: team.id}), + :ok <- Teams.Invitations.check_team_member_limit(team, role, user.email), + {:ok, team_membership} <- + Teams.Invitations.create_team_membership(team, role, user, now) do + if team_membership.role != :guest do + {:identity, team, user} + else + Repo.rollback(:integration_not_found) + end + else + {:error, %{errors: [email: {_, attrs}]}} -> + true = {:constraint, :unique} in attrs + Repo.rollback(:integration_not_found) + + {:error, {:over_limit, _}} -> + Repo.rollback(:over_limit) + end + end) + + case result do + {:ok, {type, team, user}} -> + {:ok, type, team, user} + + {:error, _} = error -> + error + end + end + + defp ensure_team_member(team, user) do + case Teams.Memberships.team_role(team, user) do + {:ok, role} when role != :guest -> + :ok + + _ -> + {:error, :integration_not_found} + end + end + + defp ensure_one_membership(user, team) do + if Teams.Users.team_member?(user, except: [team.id], only_setup?: true) do + {:error, :multiple_memberships, team, user} + else + :ok + end + end + + defp ensure_empty_personal_team(user, team) do + case Teams.get_by_owner(user, only_not_setup?: true) do + {:ok, personal_team} -> + subscription = Teams.Billing.get_subscription(personal_team) + + no_subscription? = + is_nil(subscription) or subscription.status == Subscription.Status.deleted() + + zero_sites? = Teams.owned_sites_count(personal_team) == 0 + + if no_subscription? and zero_sites? do + :ok + else + {:error, :active_personal_team, team, user} + end + + {:error, :no_team} -> + :ok + end + end +end diff --git a/extra/lib/plausible/auth/sso/domain.ex b/extra/lib/plausible/auth/sso/domain.ex new file mode 100644 index 000000000000..572026eba564 --- /dev/null +++ b/extra/lib/plausible/auth/sso/domain.ex @@ -0,0 +1,133 @@ +defmodule Plausible.Auth.SSO.Domain do + @moduledoc """ + Once SSO integration is initiated, it's possible to start + allow-listing domains for it, in parallel with finalizing + the setup on IdP's end. + + Each pending domain should be periodically checked for + ownership verification by testing for presence of TXT record, meta tag + or URL. The moment whichever of them succeeds first, + the domain is marked as verified with method and timestamp + recorded. + """ + + use Ecto.Schema + + import Ecto.Changeset + + alias Plausible.Auth.SSO + + @type t() :: %__MODULE__{} + + @verification_methods [:dns_txt, :url, :meta_tag] + @type verification_method() :: unquote(Enum.reduce(@verification_methods, &{:|, [], [&1, &2]})) + + @spec verification_methods() :: list(verification_method()) + def verification_methods(), do: @verification_methods + + use Plausible.Auth.SSO.Domain.Status + + @derive {Plausible.Audit.Encoder, + only: [:id, :identifier, :domain, :verified_via, :status, :sso_integration], + allow_not_loaded: [:sso_integration]} + + schema "sso_domains" do + field :identifier, Ecto.UUID + field :domain, :string + field :verified_via, Ecto.Enum, values: @verification_methods + field :last_verified_at, :naive_datetime + + field :status, Ecto.Enum, + values: Status.all(), + default: Status.pending() + + belongs_to :sso_integration, Plausible.Auth.SSO.Integration + + timestamps() + end + + @spec create_changeset(SSO.Integration.t(), String.t() | nil) :: Ecto.Changeset.t() + def create_changeset(integration, domain) do + %__MODULE__{} + |> cast(%{domain: domain}, [:domain]) + |> validate_required(:domain) + |> normalize_domain(:domain) + |> validate_domain(:domain) + |> unique_constraint(:domain, message: "is already in use") + |> put_change(:identifier, Ecto.UUID.generate()) + |> put_assoc(:sso_integration, integration) + end + + @spec verified_changeset(t(), verification_method(), NaiveDateTime.t()) :: + Ecto.Changeset.t() + def verified_changeset(sso_domain, method, now \\ NaiveDateTime.utc_now(:second)) do + sso_domain + |> change() + |> put_change(:verified_via, method) + |> put_change(:last_verified_at, now) + |> put_change(:status, Status.verified()) + end + + @spec unverified_changeset(t(), atom(), NaiveDateTime.t()) :: Ecto.Changeset.t() + def unverified_changeset( + sso_domain, + status \\ Status.in_progress(), + now \\ NaiveDateTime.utc_now(:second) + ) do + sso_domain + |> change() + |> put_change(:verified_via, nil) + |> put_change(:last_verified_at, now) + |> put_change(:status, status) + end + + @spec valid_domain?(String.t()) :: boolean() + def valid_domain?(domain) do + # This is not a surefire way to ensure the domain is correct, + # but it should give a bit more confidence that it's at least + # resolvable. + case URI.new("https://" <> domain) do + {:ok, %{host: host, port: port, path: nil, query: nil, fragment: nil, userinfo: nil}} + when is_binary(host) and port in [80, 443] -> + true + + _ -> + false + end + end + + defp normalize_domain(changeset, field) do + if domain = get_change(changeset, field) do + # We try to clear the usual copy-paste prefixes. + normalized = + domain + |> String.trim() + |> String.downcase() + |> String.split("://", parts: 2) + |> List.last() + |> String.trim("/") + + case URI.new("https://" <> normalized) do + {:ok, %{host: host}} when is_binary(host) and host != "" -> + put_change(changeset, field, host) + + _ -> + put_change(changeset, field, normalized) + end + else + changeset + end + end + + defp validate_domain(changeset, field) do + if domain = get_change(changeset, field) do + if valid_domain?(domain) do + changeset + else + add_error(changeset, field, "invalid domain", validation: :domain) + end + else + changeset + end + end +end diff --git a/extra/lib/plausible/auth/sso/domain/status.ex b/extra/lib/plausible/auth/sso/domain/status.ex new file mode 100644 index 000000000000..730417f37a28 --- /dev/null +++ b/extra/lib/plausible/auth/sso/domain/status.ex @@ -0,0 +1,21 @@ +defmodule Plausible.Auth.SSO.Domain.Status do + @moduledoc false + + defmacro __using__(opts) do + as = Keyword.get(opts, :as, Status) + + quote do + require Plausible.Auth.SSO.Domain.Status + alias Plausible.Auth.SSO.Domain.Status, as: unquote(as) + end + end + + defmacro pending(), do: :pending + defmacro in_progress(), do: :in_progress + defmacro verified(), do: :verified + defmacro unverified(), do: :unverified + + defmacro all() do + [pending(), in_progress(), verified(), unverified()] + end +end diff --git a/extra/lib/plausible/auth/sso/domain/verification.ex b/extra/lib/plausible/auth/sso/domain/verification.ex new file mode 100644 index 000000000000..77b64102d6fa --- /dev/null +++ b/extra/lib/plausible/auth/sso/domain/verification.ex @@ -0,0 +1,143 @@ +defmodule Plausible.Auth.SSO.Domain.Verification do + @moduledoc """ + SSO domain ownership verification chain + + 1. DNS TXT `{domain}` record lookup. + Successful expectation contains `plausible-sso-verification={domain-identifier}` record. + + 2. HTTP GET lookup at `https://{domain}/plausible-sso-verification` + Successful expectation contains `{domain-identifier}` in the body. + + 3. META tag lookup at `https://{domain}` + Successful expectation contains: + + ```html + + ``` + + in the body of `text/html` type. + """ + + alias Plausible.Auth.SSO.Domain + require Domain + + @prefix "plausible-sso-verification" + + @spec run(String.t(), String.t(), Keyword.t()) :: + {:ok, Domain.verification_method()} | {:error, :unverified} + def run(sso_domain, domain_identifier, opts \\ []) do + available_methods = Domain.verification_methods() + methods = Keyword.get(opts, :methods, available_methods) + true = Enum.all?(methods, &(&1 in available_methods)) + + Enum.reduce_while(methods, {:error, :unverified}, fn method, acc -> + case apply(__MODULE__, method, [sso_domain, domain_identifier, opts]) do + true -> {:halt, {:ok, method}} + false -> {:cont, acc} + end + end) + end + + @spec url(String.t(), String.t(), Keyword.t()) :: boolean() + def url(sso_domain, domain_identifier, opts \\ []) do + url_override = Keyword.get(opts, :url_override) + resp = run_request(url_override || "https://" <> Path.join(sso_domain, @prefix)) + + case resp do + %Req.Response{body: body} + when is_binary(body) -> + String.trim(body) == domain_identifier + + _ -> + false + end + end + + @spec meta_tag(String.t(), String.t(), Keyword.t()) :: boolean() + def meta_tag(sso_domain, domain_identifier, opts \\ []) do + url_override = Keyword.get(opts, :url_override) + + with %Req.Response{body: body} = response when is_binary(body) <- + run_request(url_override || "https://#{sso_domain}"), + true <- html?(response), + html <- LazyHTML.from_document(body), + [_ | _] <- + LazyHTML.query(html, ~s|meta[name="#{@prefix}"][content="#{domain_identifier}"]|) + |> Enum.into([]) do + true + else + _ -> + false + end + end + + @spec dns_txt(String.t(), String.t()) :: boolean() + def dns_txt(sso_domain, domain_identifier, opts \\ []) do + record_value = to_charlist("#{@prefix}=#{domain_identifier}") + + timeout = Keyword.get(opts, :timeout, 5_000) + nameservers = Keyword.get(opts, :nameservers) + + lookup_opts = + case nameservers do + nil -> + [timeout: timeout] + + [_ | _] -> + [timeout: timeout, nameservers: nameservers] + end + + sso_domain + |> to_charlist() + |> :inet_res.lookup(:in, :txt, lookup_opts, timeout) + |> Enum.find_value(false, fn + [^record_value] -> true + _ -> false + end) + end + + defp html?(%Req.Response{headers: headers}) do + headers + |> Map.get("content-type", "") + |> List.wrap() + |> List.first() + |> String.contains?("text/html") + end + + defp run_request(base_url) do + fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] + + opts = + Keyword.merge( + [ + base_url: base_url, + max_redirects: 4, + max_retries: 3, + retry_log_level: :warning + ], + fetch_body_opts + ) + + {_req, resp} = opts |> Req.new() |> Req.Request.run_request() + resp + end + + @after_compile __MODULE__ + def __after_compile__(_env, _bytecode) do + available_methods = Domain.verification_methods() + + exported_funs = + :functions + |> __MODULE__.__info__() + |> Enum.map(&elem(&1, 0)) + + Enum.each( + available_methods, + fn method -> + if method not in exported_funs do + raise "#{method} must be implemented in #{__MODULE__}" + end + end + ) + end +end diff --git a/extra/lib/plausible/auth/sso/domain/verification/worker.ex b/extra/lib/plausible/auth/sso/domain/verification/worker.ex new file mode 100644 index 000000000000..be5b64007585 --- /dev/null +++ b/extra/lib/plausible/auth/sso/domain/verification/worker.ex @@ -0,0 +1,126 @@ +defmodule Plausible.Auth.SSO.Domain.Verification.Worker do + @moduledoc """ + Background service validating SSO domains ownership + + `bypass_checks` and `skip_checks` job args are for testing purposes only + (former will fail the verification, latter will succeed with `dns_txt` method) + """ + use Oban.Worker, + queue: :sso_domain_ownership_verification, + unique: true + + use Plausible.Auth.SSO.Domain.Status + + alias Plausible.Auth.SSO + alias Plausible.Repo + + # roughly around 34h, given the snooze back-off + @max_snoozes 14 + + @spec cancel(String.t()) :: :ok + def cancel(domain) do + {:ok, job} = + %{domain: domain} + |> new() + |> Oban.insert() + + Oban.cancel_job(job) + end + + @spec enqueue(String.t()) :: {:ok, Oban.Job.t()} + def enqueue(domain) do + {:ok, job} = + %{domain: domain} + |> new() + |> Oban.insert() + + :ok = Oban.retry_job(job) + {:ok, job} + end + + @impl true + def perform(%{ + attempt: attempt, + meta: meta, + args: %{"domain" => domain} + }) + when attempt <= @max_snoozes do + service_opts = [ + skip_checks?: meta["skip_checks"] == true, + verification_opts: [ + nameservers: Application.get_env(:plausible, :sso_verification_nameservers) + ] + ] + + service_opts = + if meta["bypass_checks"] == true do + Keyword.merge(service_opts, verification_opts: [methods: []]) + else + service_opts + end + + case SSO.Domains.get(domain) do + {:ok, sso_domain} -> + case SSO.Domains.verify(sso_domain, service_opts) do + %SSO.Domain{status: Status.verified()} = verified -> + verification_complete(sso_domain) + {:ok, verified} + + _ -> + {:snooze, snooze_backoff(attempt)} + end + + {:error, :not_found} -> + {:cancel, :domain_not_found} + end + end + + def perform(job) do + verification_failure(job.args["domain"]) + {:cancel, :max_snoozes} + end + + defp verification_complete(sso_domain) do + send_success_notification(sso_domain) + + :ok + end + + defp verification_failure(domain) do + with {:ok, sso_domain} <- SSO.Domains.get(domain) do + sso_domain + |> SSO.Domains.mark_verification_failure!() + |> send_failure_notification() + end + + :ok + end + + defp send_success_notification(sso_domain) do + owners = Repo.preload(sso_domain.sso_integration.team, :owners).owners + + Enum.each(owners, fn owner -> + sso_domain.domain + |> PlausibleWeb.Email.sso_domain_verification_success(owner) + |> Plausible.Mailer.send() + end) + + :ok + end + + defp send_failure_notification(sso_domain) do + owners = Repo.preload(sso_domain.sso_integration.team, :owners).owners + + Enum.each(owners, fn owner -> + sso_domain.domain + |> PlausibleWeb.Email.sso_domain_verification_failure(owner) + |> Plausible.Mailer.send() + end) + + :ok + end + + defp snooze_backoff(attempt) do + trunc(:math.pow(2, attempt - 1) * 15) + end +end diff --git a/extra/lib/plausible/auth/sso/domains.ex b/extra/lib/plausible/auth/sso/domains.ex new file mode 100644 index 000000000000..dee39c5c9336 --- /dev/null +++ b/extra/lib/plausible/auth/sso/domains.ex @@ -0,0 +1,241 @@ +defmodule Plausible.Auth.SSO.Domains do + @moduledoc """ + API for SSO domains. + """ + + import Ecto.Query + + alias Plausible.Auth + alias Plausible.Auth.SSO + alias Plausible.Auth.SSO.Domain.Verification + alias Plausible.Repo + + use Plausible.Auth.SSO.Domain.Status + + @spec add(SSO.Integration.t(), String.t()) :: + {:ok, SSO.Domain.t()} | {:error, Ecto.Changeset.t()} + def add(integration, domain) do + changeset = SSO.Domain.create_changeset(integration, domain) + + Repo.insert_with_audit(changeset, "sso_domain_added", %{team_id: integration.team_id}) + end + + @spec start_verification(String.t()) :: SSO.Domain.t() + def start_verification(domain) when is_binary(domain) do + {:ok, result} = + Repo.transaction(fn -> + with {:ok, sso_domain} <- get(domain) do + sso_domain = + sso_domain + |> SSO.Domain.unverified_changeset(Status.in_progress()) + |> Repo.update_with_audit!( + "sso_domain_verification_started", + %{team_id: sso_domain.sso_integration.team_id} + ) + + {:ok, _} = Verification.Worker.enqueue(domain) + {:ok, sso_domain} + end + end) + + result + end + + @spec cancel_verification(String.t()) :: :ok + def cancel_verification(domain) when is_binary(domain) do + {:ok, :ok} = + Repo.transaction(fn -> + with {:ok, sso_domain} <- get(domain) do + sso_domain + |> SSO.Domain.unverified_changeset(Status.unverified()) + |> Repo.update_with_audit("sso_domain_verification_cancelled", %{ + team_id: sso_domain.sso_integration.team_id + }) + end + + :ok = Verification.Worker.cancel(domain) + end) + + :ok + end + + @spec verify(SSO.Domain.t(), Keyword.t()) :: SSO.Domain.t() + def verify(%SSO.Domain{} = sso_domain, opts \\ []) do + skip_checks? = Keyword.get(opts, :skip_checks?, false) + verification_opts = Keyword.get(opts, :verification_opts, []) + now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second)) + + if skip_checks? do + mark_verified!(sso_domain, :dns_txt, now) + else + case SSO.Domain.Verification.run( + sso_domain.domain, + sso_domain.identifier, + verification_opts + ) do + {:ok, step} -> + mark_verified!(sso_domain, step, now) + + {:error, :unverified} -> + sso_domain + |> SSO.Domain.unverified_changeset(Status.in_progress(), now) + |> Repo.update!() + end + end + end + + @spec get(String.t()) :: {:ok, SSO.Domain.t()} | {:error, :not_found} + def get(domain) when is_binary(domain) do + result = + from( + d in SSO.Domain, + inner_join: i in assoc(d, :sso_integration), + inner_join: t in assoc(i, :team), + where: d.domain == ^domain, + preload: [sso_integration: {i, team: t}] + ) + |> Repo.one() + + if result do + {:ok, result} + else + {:error, :not_found} + end + end + + @spec lookup(String.t()) :: {:ok, SSO.Domain.t()} | {:error, :not_found} + def lookup(domain_or_email) when is_binary(domain_or_email) do + search = normalize_lookup(domain_or_email) + + result = + from( + d in SSO.Domain, + inner_join: i in assoc(d, :sso_integration), + inner_join: t in assoc(i, :team), + where: d.domain == ^search, + where: d.status == ^Status.verified(), + preload: [sso_integration: {i, team: t}] + ) + |> Repo.one() + + if result do + {:ok, result} + else + {:error, :not_found} + end + end + + @spec remove(SSO.Domain.t(), Keyword.t()) :: + :ok | {:error, :force_sso_enabled | :sso_users_present} + def remove(sso_domain, opts \\ []) do + sso_domain = Repo.preload(sso_domain, :sso_integration) + force_deprovision? = Keyword.get(opts, :force_deprovision?, false) + + check = check_can_remove(sso_domain) + + case {check, force_deprovision?} do + {:ok, _} -> + {:ok, :ok} = + Repo.transaction(fn -> + Repo.delete_with_audit!(sso_domain, "sso_domain_removed", %{ + team_id: sso_domain.sso_integration.team_id + }) + + :ok = cancel_verification(sso_domain.domain) + end) + + :ok + + {{:error, :sso_users_present}, true} -> + {:ok, :ok} = + Repo.transaction(fn -> + domain_users = users_by_domain(sso_domain) + Enum.each(domain_users, &SSO.deprovision_user!/1) + + Repo.delete_with_audit!(sso_domain, "sso_domain_removed", %{ + team_id: sso_domain.sso_integration.team_id + }) + + cancel_verification(sso_domain.domain) + end) + + :ok + + {{:error, error}, _} -> + {:error, error} + end + end + + @spec check_can_remove(SSO.Domain.t()) :: + :ok | {:error, :force_sso_enabled | :sso_users_present} + def check_can_remove(sso_domain) do + sso_domain = Repo.preload(sso_domain, sso_integration: [:team, :sso_domains]) + team = sso_domain.sso_integration.team + domain_users_count = sso_domain |> users_by_domain_query() |> Repo.aggregate(:count) + + integration_users_count = + sso_domain.sso_integration |> users_by_integration_query() |> Repo.aggregate(:count) + + only_domain_with_users? = + domain_users_count > 0 and integration_users_count == domain_users_count + + cond do + team.policy.force_sso != :none and only_domain_with_users? -> + {:error, :force_sso_enabled} + + domain_users_count > 0 -> + {:error, :sso_users_present} + + true -> + :ok + end + end + + @spec mark_verified!(SSO.Domain.t(), SSO.Domain.verification_method(), NaiveDateTime.t()) :: + SSO.Domain.t() + def mark_verified!(sso_domain, method, now \\ NaiveDateTime.utc_now(:second)) do + sso_domain + |> SSO.Domain.verified_changeset(method, now) + |> Repo.update_with_audit!("sso_domain_verification_success", %{ + team_id: sso_domain.sso_integration.team_id + }) + end + + @spec mark_verification_failure!(SSO.Domain.t()) :: SSO.Domain.t() + def mark_verification_failure!(sso_domain) do + sso_domain + |> SSO.Domain.unverified_changeset(Status.unverified()) + |> Repo.update_with_audit!("sso_domain_verification_failure", %{ + team_id: sso_domain.sso_integration.team_id + }) + end + + defp users_by_domain(sso_domain) do + sso_domain + |> users_by_domain_query() + |> Repo.all() + end + + defp users_by_domain_query(sso_domain) do + from( + u in Auth.User, + where: u.sso_domain_id == ^sso_domain.id + ) + end + + defp users_by_integration_query(sso_integration) do + from( + u in Auth.User, + where: u.sso_integration_id == ^sso_integration.id, + where: u.type == :sso + ) + end + + defp normalize_lookup(domain_or_email) do + domain_or_email + |> String.split("@", parts: 2) + |> List.last() + |> String.trim() + |> String.downcase() + end +end diff --git a/extra/lib/plausible/auth/sso/identity.ex b/extra/lib/plausible/auth/sso/identity.ex new file mode 100644 index 000000000000..1a0c3e538c41 --- /dev/null +++ b/extra/lib/plausible/auth/sso/identity.ex @@ -0,0 +1,11 @@ +defmodule Plausible.Auth.SSO.Identity do + @moduledoc """ + SSO Identity struct. + """ + + @type t() :: %__MODULE__{} + + @derive Plausible.Audit.Encoder + @enforce_keys [:id, :integration_id, :name, :email, :expires_at] + defstruct [:id, :integration_id, :name, :email, :expires_at] +end diff --git a/extra/lib/plausible/auth/sso/integration.ex b/extra/lib/plausible/auth/sso/integration.ex new file mode 100644 index 000000000000..885e31af8174 --- /dev/null +++ b/extra/lib/plausible/auth/sso/integration.ex @@ -0,0 +1,80 @@ +defmodule Plausible.Auth.SSO.Integration do + @moduledoc """ + Instance of particular SSO integration for a given team. + + Configuration is embedded and its type is dynamic, paving the + way for potentially supporting other SSO mechanisms in the future, + like OIDC. + + The UUID identifier can be used to uniquely identify the integration + when configuring external services like IdPs. + """ + + use Ecto.Schema + + import Ecto.Changeset + import PolymorphicEmbed + + alias Plausible.Auth.SSO + alias Plausible.Teams + + @type t() :: %__MODULE__{} + + @derive {Plausible.Audit.Encoder, only: [:id, :identifier]} + + schema "sso_integrations" do + field :identifier, Ecto.UUID + + polymorphic_embeds_one :config, + types: [ + saml: SSO.SAMLConfig + ], + on_type_not_found: :raise, + on_replace: :update + + belongs_to :team, Plausible.Teams.Team + has_many :users, Plausible.Auth.User, foreign_key: :sso_integration_id + has_many :sso_domains, SSO.Domain, foreign_key: :sso_integration_id + + timestamps() + end + + @spec configured?(t()) :: boolean() + def configured?(%__MODULE__{config: %config_mod{} = config}) do + config_mod.configured?(config) + end + + @spec init_changeset(Teams.Team.t()) :: Ecto.Changeset.t() + def init_changeset(team) do + params = %{config: %{__type__: :saml}} + + %__MODULE__{} + |> cast(params, []) + |> put_change(:identifier, Ecto.UUID.generate()) + |> cast_polymorphic_embed(:config) + |> put_assoc(:team, team) + end + + @spec update_changeset(t(), map()) :: Ecto.Changeset.t() + def update_changeset(integration, config_params) do + params = tag_params(:saml, config_params) + + integration + |> cast(params, []) + |> cast_polymorphic_embed(:config, + with: [ + saml: &SSO.SAMLConfig.update_changeset/2 + ] + ) + end + + defp tag_params(type, params) when is_atom(type) and is_map(params) do + case Enum.take(params, 1) do + [{key, _}] when is_binary(key) -> + %{"config" => Map.merge(%{"__type__" => Atom.to_string(type)}, params)} + + _ -> + %{config: Map.merge(%{__type__: type}, params)} + end + end +end diff --git a/extra/lib/plausible/auth/sso/saml_config.ex b/extra/lib/plausible/auth/sso/saml_config.ex new file mode 100644 index 000000000000..07071660d8a9 --- /dev/null +++ b/extra/lib/plausible/auth/sso/saml_config.ex @@ -0,0 +1,106 @@ +defmodule Plausible.Auth.SSO.SAMLConfig do + @moduledoc """ + SAML SSO can be configured in two ways - by either providing IdP + metadata XML or inputting required data one by one. + + If metadata is provided, the parameters are extracted but the + original metadata is preserved as well. This might be helpful + when updating configuration in the future to enable some other + feature like Single Logout without having to re-fetch metadata + from IdP again. + """ + + use Ecto.Schema + + alias Plausible.Auth.SSO + + import Ecto.Changeset + + @type t() :: %__MODULE__{} + + @fields [:idp_signin_url, :idp_entity_id, :idp_cert_pem, :idp_metadata] + @required_fields @fields -- [:idp_metadata] + + @derive {Plausible.Audit.Encoder, + only: [:id, :idp_signin_url, :idp_entity_id, :idp_cert_pem, :idp_metadata]} + + embedded_schema do + field :idp_signin_url, :string + field :idp_entity_id, :string + field :idp_cert_pem, :string + field :idp_metadata, :string + end + + @spec configured?(t()) :: boolean() + def configured?(config) do + !!(config.idp_signin_url && config.idp_entity_id && config.idp_cert_pem) + end + + @spec entity_id(SSO.Integration.t()) :: String.t() + def entity_id(integration) do + PlausibleWeb.Endpoint.url() <> "/sso/" <> integration.identifier + end + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(struct, params) do + struct + |> cast(params, @fields) + end + + @spec update_changeset(t(), map()) :: Ecto.Changeset.t() + def update_changeset(struct, params) do + struct + |> cast(params, @fields) + |> validate_required(@required_fields) + |> validate_url(:idp_signin_url) + |> validate_pem(:idp_cert_pem) + |> update_change(:idp_entity_id, &String.trim/1) + end + + defp validate_url(changeset, field) do + if url = get_change(changeset, field) do + case URI.new(url) do + {:ok, uri} when uri.scheme in ["http", "https"] -> changeset + _ -> add_error(changeset, field, "invalid URL", validation: :url) + end + else + changeset + end + end + + defp validate_pem(changeset, field) do + if pem = get_change(changeset, field) do + pem = clean_pem(pem) + + case parse_pem(pem) do + {:ok, _cert} -> put_change(changeset, field, pem) + {:error, _} -> add_error(changeset, field, "invalid certificate", validation: :cert_pem) + end + else + changeset + end + end + + defp parse_pem(pem) do + X509.Certificate.from_pem(pem) + catch + _, _ -> {:error, :failed_to_parse} + end + + defp clean_pem(pem) do + cleaned = + pem + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + |> String.trim() + + # Trying to account for PEM certificates without markers + if String.starts_with?(cleaned, "-----BEGIN CERTIFICATE-----") do + cleaned + else + "-----BEGIN CERTIFICATE-----\n" <> cleaned <> "\n-----END CERTIFICATE-----" + end + end +end diff --git a/extra/lib/plausible/consolidated_view.ex b/extra/lib/plausible/consolidated_view.ex new file mode 100644 index 000000000000..60d6c066fbe5 --- /dev/null +++ b/extra/lib/plausible/consolidated_view.ex @@ -0,0 +1,232 @@ +defmodule Plausible.ConsolidatedView do + @moduledoc """ + Contextual interface for consolidated views, + each implemented as Site object serving as + pointers to team's regular sites. + """ + + use Plausible + alias Plausible.ConsolidatedView + + import Ecto.Query + + alias Plausible.Teams + alias Plausible.Teams.Team + alias Plausible.{Repo, Site, Auth.User} + + import Ecto.Query + + @spec cta_dismissed?(User.t(), Team.t()) :: boolean() + def cta_dismissed?(%User{} = user, %Team{} = team) do + {:ok, team_membership} = Teams.Memberships.get_team_membership(team, user) + Teams.Memberships.get_preference(team_membership, :consolidated_view_cta_dismissed) + end + + @spec dismiss_cta(User.t(), Team.t()) :: :ok + def dismiss_cta(%User{} = user, %Team{} = team) do + {:ok, team_membership} = Teams.Memberships.get_team_membership(team, user) + Teams.Memberships.set_preference(team_membership, :consolidated_view_cta_dismissed, true) + + :ok + end + + @spec restore_cta(User.t(), Team.t()) :: :ok + def restore_cta(%User{} = user, %Team{} = team) do + {:ok, team_membership} = Teams.Memberships.get_team_membership(team, user) + + Teams.Memberships.set_preference( + team_membership, + :consolidated_view_cta_dismissed, + false + ) + + :ok + end + + @spec ok_to_display?(Team.t() | nil) :: boolean() + def ok_to_display?(team) do + is_struct(team, Team) and + view_enabled?(team) and + has_sites_to_consolidate?(team) and + Plausible.Billing.Feature.ConsolidatedView.check_availability(team) == :ok + end + + @spec reset_if_enabled(Team.t()) :: :ok + def reset_if_enabled(%Team{} = team) do + case get(team) do + nil -> + :skip + + consolidated_view -> + if has_sites_to_consolidate?(team) do + consolidated_view + |> change_stats_dates(team) + |> change_timezone(majority_sites_timezone(team)) + |> bump_updated_at() + |> Repo.update!() + else + disable(team) + end + end + + :ok + end + + @spec sites(Ecto.Query.t() | Site) :: Ecto.Query.t() + def sites(q \\ Site) do + from(s in q, where: s.consolidated == true) + end + + @spec enable(Team.t()) :: + {: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} + + true -> + do_enable(team) + end + end + + @spec disable(Team.t()) :: :ok + def disable(%Team{} = team) do + # consider `Plausible.Site.Removal.run/1` if we ever support memberships or invitations + Plausible.Repo.delete_all(from(s in sites(), where: s.domain == ^make_id(team))) + :ok + end + + @spec site_ids(Team.t() | String.t()) :: {:ok, [pos_integer()]} | {:error, :not_found} + def site_ids(consolidated_view_id) when is_binary(consolidated_view_id) do + case get(consolidated_view_id) do + nil -> {:error, :not_found} + view -> {:ok, Teams.owned_sites_ids(view.team)} + end + end + + def site_ids(%Team{} = team) do + site_ids(team.identifier) + end + + @spec get(Team.t() | String.t()) :: Site.t() | nil + def get(team_or_id) + + def get(%Team{} = team) do + team |> make_id() |> get() + end + + def get(id) when is_binary(id) do + Repo.one( + from(s in sites(), inner_join: assoc(s, :team), where: s.domain == ^id, preload: [:team]) + ) + end + + @spec change_stats_dates(Site.t() | Ecto.Changeset.t(), Team.t()) :: + Ecto.Changeset.t() | Site.t() + def change_stats_dates(site_or_changeset, %Team{} = team) do + native_stats_start_at = native_stats_start_at(team) + + if native_stats_start_at do + start_date = NaiveDateTime.to_date(native_stats_start_at) + + site_or_changeset + |> Site.set_native_stats_start_at(native_stats_start_at) + |> Site.set_stats_start_date(start_date) + else + site_or_changeset + end + end + + @spec can_manage?(User.t(), Team.t()) :: boolean() + def can_manage?(user, team) do + case Plausible.Teams.Memberships.team_role(team, user) do + {:ok, role} when role not in [:viewer, :guest] -> + true + + _ -> + false + end + end + + defp change_timezone(site_or_changeset, timezone) do + Ecto.Changeset.change(site_or_changeset, timezone: timezone) + end + + defp bump_updated_at(struct_or_changeset) do + Ecto.Changeset.change(struct_or_changeset, updated_at: NaiveDateTime.utc_now(:second)) + end + + defp do_enable(%Team{} = team) do + case get(team) do + nil -> + {:ok, consolidated_view} = + team + |> Site.new_for_team(%{ + consolidated: true, + domain: make_id(team) + }) + |> change_timezone(majority_sites_timezone(team)) + |> change_stats_dates(team) + |> Repo.insert() + + {:ok, site_ids} = site_ids(team) + :ok = ConsolidatedView.Cache.broadcast_put(consolidated_view.domain, site_ids) + {:ok, consolidated_view} + + consolidated_view -> + {:ok, consolidated_view} + end + end + + defp make_id(%Team{} = team) do + team.identifier + end + + defp native_stats_start_at(%Team{} = team) do + q = + from(sr in Site.regular(), + group_by: sr.team_id, + where: sr.team_id == ^team.id, + select: min(sr.native_stats_start_at) + ) + + Repo.one(q) + end + + defp has_sites_to_consolidate?(%Team{} = team) do + Teams.owned_sites_count(team) > 1 + end + + defp majority_sites_timezone(%Team{} = team) do + q = + from(sr in Site.regular(), + where: sr.team_id == ^team.id, + group_by: sr.timezone, + select: {sr.timezone, count(sr.id)}, + order_by: [desc: count(sr.id), asc: sr.timezone], + limit: 1 + ) + + case Repo.one(q) do + {"UTC", _count} -> "Etc/UTC" + {timezone, _count} -> timezone + nil -> "Etc/UTC" + end + end + + defp view_enabled?(%Team{} = team) do + not is_nil(get(team)) + end +end diff --git a/extra/lib/plausible/consolidated_view/cache.ex b/extra/lib/plausible/consolidated_view/cache.ex new file mode 100644 index 000000000000..970d2d6d62bb --- /dev/null +++ b/extra/lib/plausible/consolidated_view/cache.ex @@ -0,0 +1,104 @@ +defmodule Plausible.ConsolidatedView.Cache do + @moduledoc """ + Caching layer for consolidated views. + + Because of how they're modelled (on top of "sites" table currently), + we have to refresh the cache whenever any regular site changes within, + as well as when the consolidating site is updated itself. + """ + alias Plausible.ConsolidatedView + import Ecto.Query + + use Plausible.Cache + + @cache_name :consolidated_views + @large_view_alert_threshold 12_000 + @max_sites_per_view 14_000 + + @impl true + def name(), do: @cache_name + + @impl true + def child_id(), do: :cache_consolidated_views + + @impl true + def count_all() do + Plausible.Repo.aggregate( + from(s in ConsolidatedView.sites()), + :count + ) + end + + @impl true + def base_db_query() do + from sc in ConsolidatedView.sites(), + inner_join: sr in ^Plausible.Site.regular(), + on: sr.team_id == sc.team_id, + group_by: sc.id, + order_by: [desc: sc.id], + select: %{ + consolidated_view_id: sc.domain, + site_ids: fragment("array_agg(? ORDER BY ? DESC)", sr.id, sr.id) + } + end + + @spec refresh_updated_recently(Keyword.t()) :: :ok + def refresh_updated_recently(opts) do + recently_updated_site_ids = + from sc in ConsolidatedView.sites(), + join: sr in ^Plausible.Site.regular(), + on: sc.team_id == sr.team_id, + where: sr.updated_at > ago(^15, "minute") or sc.updated_at > ago(^15, "minute"), + select: sc.id + + query = + from sc in ConsolidatedView.sites(), + join: sr in ^Plausible.Site.regular(), + on: sr.team_id == sc.team_id, + where: sc.id in subquery(recently_updated_site_ids), + group_by: sc.id, + order_by: [desc: sc.id], + select: %{ + consolidated_view_id: sc.domain, + site_ids: fragment("array_agg(? ORDER BY ? DESC)", sr.id, sr.id) + } + + refresh( + :updated_recently, + query, + Keyword.put(opts, :delete_stale_items?, false) + ) + end + + @impl true + def get_from_source(consolidated_view_id) do + case ConsolidatedView.site_ids(consolidated_view_id) do + {:ok, some} -> some + {:error, :not_found} -> nil + end + end + + @impl true + def unwrap_cache_keys(items) do + Enum.reduce(items, [], fn row, acc -> + [{row.consolidated_view_id, row.site_ids} | acc] + end) + end + + def get(key, opts) do + case super(key, opts) do + nil -> + [] + + site_ids when length(site_ids) > @large_view_alert_threshold -> + Sentry.capture_message("Consolidated View crop warning", + extra: %{sites: length(site_ids), key: key} + ) + + Enum.take(site_ids, @max_sites_per_view) + + site_ids -> + site_ids + end + end +end diff --git a/extra/lib/plausible/customer_support/enterprise_plan.ex b/extra/lib/plausible/customer_support/enterprise_plan.ex new file mode 100644 index 000000000000..527a2d2077af --- /dev/null +++ b/extra/lib/plausible/customer_support/enterprise_plan.ex @@ -0,0 +1,90 @@ +defmodule Plausible.CustomerSupport.EnterprisePlan do + @moduledoc """ + Custom plan price estimation + """ + @spec estimate(Keyword.t()) :: Decimal.t() + def estimate(basis) do + basis = + Keyword.validate!(basis, [ + :billing_interval, + :pageviews_per_month, + :sites_limit, + :team_members_limit, + :api_calls_limit, + :features, + :managed_proxy_price_modifier + ]) + + pv_rate = + pv_rate(basis[:pageviews_per_month]) + + sites_rate = + sites_rate(basis[:sites_limit]) + + team_members_rate = team_members_rate(basis[:team_members_limit]) + + api_calls_rate = + api_calls_rate(basis[:api_calls_limit]) + + features_rate = + features_rate(basis[:features]) + + cost_per_month = + Decimal.from_float( + (pv_rate + + sites_rate + + team_members_rate + + api_calls_rate + + features_rate + managed_proxy_price_modifier(basis[:managed_proxy_price_modifier])) * + 1.0 + ) + |> Decimal.round(2) + + if basis[:billing_interval] == "monthly" do + cost_per_month + else + cost_per_month |> Decimal.mult(10) |> Decimal.round(2) + end + end + + def managed_proxy_price_modifier(true), do: 199.0 + def managed_proxy_price_modifier(_), do: 0 + + def pv_rate(pvs) when pvs <= 10_000, do: 19 + def pv_rate(pvs) when pvs <= 100_000, do: 39 + def pv_rate(pvs) when pvs <= 200_000, do: 59 + def pv_rate(pvs) when pvs <= 500_000, do: 99 + def pv_rate(pvs) when pvs <= 1_000_000, do: 139 + def pv_rate(pvs) when pvs <= 2_000_000, do: 179 + def pv_rate(pvs) when pvs <= 5_000_000, do: 259 + def pv_rate(pvs) when pvs <= 10_000_000, do: 339 + def pv_rate(pvs) when pvs <= 20_000_000, do: 639 + def pv_rate(pvs) when pvs <= 50_000_000, do: 1379 + def pv_rate(pvs) when pvs <= 100_000_000, do: 2059 + def pv_rate(pvs) when pvs <= 200_000_000, do: 3259 + def pv_rate(pvs) when pvs <= 300_000_000, do: 4739 + def pv_rate(pvs) when pvs <= 400_000_000, do: 5979 + def pv_rate(pvs) when pvs <= 500_000_000, do: 7459 + def pv_rate(pvs) when pvs <= 1_000_000_000, do: 14_439 + def pv_rate(_), do: 14_439 + + def sites_rate(n) when n <= 50, do: 0 + def sites_rate(n), do: n * 0.1 + + def team_members_rate(n) when n > 10, do: (n - 10) * 15 + def team_members_rate(_), do: 0 + + def api_calls_rate(n) when n <= 600, do: 0 + def api_calls_rate(n) when n > 600, do: round(n / 1_000) * 100 + + @feature_rates %{ + "sites_api" => 99, + "sso" => 299 + } + + def features_rate(features) do + features + |> Enum.map(&Map.get(@feature_rates, &1, 0)) + |> Enum.sum() + end +end diff --git a/extra/lib/plausible/customer_support/resource.ex b/extra/lib/plausible/customer_support/resource.ex new file mode 100644 index 000000000000..e50925eabec7 --- /dev/null +++ b/extra/lib/plausible/customer_support/resource.ex @@ -0,0 +1,48 @@ +defmodule Plausible.CustomerSupport.Resource do + @moduledoc """ + Generic behaviour for CS resources + """ + defstruct [:id, :type, :module, :object, :path] + + @type schema() :: map() + + @type t() :: %__MODULE__{ + id: pos_integer(), + module: atom(), + object: schema(), + type: String.t() + } + + @callback search(String.t(), Keyword.t()) :: list(schema()) + @callback get(pos_integer()) :: schema() + @callback path(any()) :: String.t() + @callback dump(schema()) :: t() + + defmacro __using__(type: type) do + quote do + @behaviour Plausible.CustomerSupport.Resource + alias Plausible.CustomerSupport.Resource + alias PlausibleWeb.Router.Helpers, as: Routes + + import Ecto.Query + alias Plausible.Repo + + @impl true + def dump(schema) do + new(__MODULE__, schema) + end + + defoverridable dump: 1 + + def new(module, schema) do + %Resource{ + id: schema.id, + type: unquote(type), + path: module.path(schema.id), + module: module, + object: schema + } + end + end + end +end diff --git a/extra/lib/plausible/customer_support/resource/site.ex b/extra/lib/plausible/customer_support/resource/site.ex new file mode 100644 index 000000000000..fb65bd3f4fc6 --- /dev/null +++ b/extra/lib/plausible/customer_support/resource/site.ex @@ -0,0 +1,61 @@ +defmodule Plausible.CustomerSupport.Resource.Site do + @moduledoc false + use Plausible.CustomerSupport.Resource, type: "site" + alias Plausible.Repo + + @impl true + def path(id) do + Routes.customer_support_site_path(PlausibleWeb.Endpoint, :show, id) + end + + @impl true + def search(input, opts \\ []) + + def search("", opts) do + limit = Keyword.fetch!(opts, :limit) + + q = + from s in Plausible.Site.regular(), + inner_join: t in assoc(s, :team), + inner_join: o in assoc(t, :owners), + order_by: [ + desc: :id + ], + limit: ^limit, + preload: [team: {t, owners: o}] + + Repo.all(q) + end + + def search(input, opts) do + limit = Keyword.fetch!(opts, :limit) + + q = + from s in Plausible.Site.regular(), + inner_join: t in assoc(s, :team), + 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}%") 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), + asc: s.domain + ], + limit: ^limit, + preload: [team: {t, owners: o}] + + Repo.all(q) + end + + @impl true + def get(id) do + Plausible.Site + |> Repo.get!(id) + |> Repo.preload(:team) + end +end diff --git a/extra/lib/plausible/customer_support/resource/team.ex b/extra/lib/plausible/customer_support/resource/team.ex new file mode 100644 index 000000000000..d8129323770c --- /dev/null +++ b/extra/lib/plausible/customer_support/resource/team.ex @@ -0,0 +1,101 @@ +defmodule Plausible.CustomerSupport.Resource.Team do + @moduledoc false + use Plausible.CustomerSupport.Resource, type: "team" + alias Plausible.Teams + alias Plausible.Repo + + @impl true + def path(id) do + Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, id) + end + + @impl true + def search(input, opts \\ []) + + def search("", opts) do + limit = Keyword.fetch!(opts, :limit) + + q = + from(t in Plausible.Teams.Team, + as: :team, + inner_join: o in assoc(t, :owners), + limit: ^limit, + where: not is_nil(t.trial_expiry_date), + left_lateral_join: s in subquery(Teams.last_subscription_join_query()), + on: true, + order_by: [desc: :id], + preload: [owners: o, subscription: s] + ) + + Plausible.Repo.all(q) + end + + def search(input, opts) do + limit = Keyword.fetch!(opts, :limit) + + q = + if opts[:uuid_provided?] do + from(t in Plausible.Teams.Team, + as: :team, + inner_join: o in assoc(t, :owners), + where: t.identifier == ^input, + preload: [owners: o] + ) + else + from(t in Plausible.Teams.Team, + as: :team, + inner_join: o in assoc(t, :owners), + where: + ilike(t.name, ^"%#{input}%") or + ilike(o.name, ^"%#{input}%") or + ilike(o.email, ^"%#{input}%"), + limit: ^limit, + order_by: [ + desc: fragment("?.name = ?", t, ^input), + desc: fragment("?.name = ?", o, ^input), + desc: fragment("?.email = ?", o, ^input), + asc: t.name + ], + preload: [owners: o] + ) + end + + q = + if opts[:with_subscription_only?] do + from(t in q, + inner_lateral_join: s in subquery(Teams.last_subscription_join_query()), + on: true, + preload: [subscription: s] + ) + else + 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, + 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 + + Plausible.Repo.all(q) + end + + @impl true + def get(id) do + Plausible.Teams.Team + |> Repo.get!(id) + |> Plausible.Teams.with_subscription() + |> Repo.preload(:owners) + end +end diff --git a/extra/lib/plausible/customer_support/resource/user.ex b/extra/lib/plausible/customer_support/resource/user.ex new file mode 100644 index 000000000000..758fa6c784e8 --- /dev/null +++ b/extra/lib/plausible/customer_support/resource/user.ex @@ -0,0 +1,50 @@ +defmodule Plausible.CustomerSupport.Resource.User do + @moduledoc false + use Plausible.CustomerSupport.Resource, type: "user" + alias Plausible.Repo + + @impl true + def path(id) do + Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, id) + end + + @impl true + def get(id) do + Repo.get!(Plausible.Auth.User, id) + |> Repo.preload(team_memberships: :team) + end + + @impl true + def search(input, opts \\ []) + + def search("", opts) do + limit = Keyword.fetch!(opts, :limit) + + q = + from u in Plausible.Auth.User, + order_by: [ + desc: :id + ], + preload: [:owned_teams], + limit: ^limit + + Repo.all(q) + end + + def search(input, opts) do + limit = Keyword.fetch!(opts, :limit) + + q = + from u in Plausible.Auth.User, + where: ilike(u.email, ^"%#{input}%") or ilike(u.name, ^"%#{input}%"), + order_by: [ + desc: fragment("?.name = ?", u, ^input), + desc: fragment("?.email = ?", u, ^input), + asc: u.name + ], + preload: [:owned_teams], + limit: ^limit + + Repo.all(q) + end +end diff --git a/extra/lib/plausible/help_scout.ex b/extra/lib/plausible/help_scout.ex index f0e7b7db5a90..c1bf99fd43f0 100644 --- a/extra/lib/plausible/help_scout.ex +++ b/extra/lib/plausible/help_scout.ex @@ -20,10 +20,14 @@ defmodule Plausible.HelpScout do @signature_errors [:missing_signature, :bad_signature] + @excluded_email_domains ["paddle.com"] + @type signature_error() :: unquote(Enum.reduce(@signature_errors, &{:|, [], [&1, &2]})) def signature_errors(), do: @signature_errors + def excluded_email_domains(), do: @excluded_email_domains + @doc """ Validates signature against secret key configured for the HelpScout application. @@ -72,18 +76,19 @@ defmodule Plausible.HelpScout do end end - @spec get_details_for_customer(String.t()) :: {:ok, map()} | {:error, any()} - def get_details_for_customer(customer_id) do - with {:ok, emails} <- get_customer_emails(customer_id) do - get_details_for_emails(emails, customer_id) + @spec get_details_for_customer(String.t(), String.t()) :: {:ok, map()} | {:error, any()} + def get_details_for_customer(customer_id, conversation_id) do + with {:ok, emails} <- get_customer_emails(customer_id, conversation_id) do + get_details_for_emails(emails, customer_id, conversation_id, nil) end end - @spec get_details_for_emails([String.t()], String.t(), String.t() | nil) :: + @spec get_details_for_emails([String.t()], String.t(), String.t(), String.t() | nil) :: {:ok, map()} | {:error, any()} - def get_details_for_emails(emails, customer_id, team_identifier \\ nil) do + def get_details_for_emails(emails, customer_id, conversation_id, team_identifier) do with {:ok, user} <- get_user(emails) do - set_mapping(customer_id, user.email) + set_customer_mapping(customer_id, user.email) + set_conversation_mapping(conversation_id, user.email) teams = Teams.Users.owned_teams(user) @@ -105,7 +110,12 @@ defmodule Plausible.HelpScout do } end) - user_link = Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id) + user_link = + Routes.customer_support_user_url( + PlausibleWeb.Endpoint, + :show, + user.id + ) {:ok, %{ @@ -129,19 +139,16 @@ defmodule Plausible.HelpScout do status_link = if team do - Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :teams, :team, team.id) - else - Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id) - end - - sites_link = - if team do - Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site, - custom_search: team.identifier + Routes.customer_support_team_url( + PlausibleWeb.Endpoint, + :show, + team.id ) else - Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site, - custom_search: user.email + Routes.customer_support_user_url( + PlausibleWeb.Endpoint, + :show, + user.id ) end @@ -156,21 +163,20 @@ defmodule Plausible.HelpScout do status_link: status_link, plan_label: plan_label(subscription, plan), plan_link: plan_link(subscription), - sites_count: Teams.owned_sites_count(team), - sites_link: sites_link + sites_count: Teams.owned_sites_count(team) }} end end end - @spec search_users(String.t(), String.t()) :: [map()] - def search_users(term, customer_id) do - clear_mapping(customer_id) + @spec search_users(String.t(), String.t(), String.t()) :: [map()] + def search_users(term, customer_id, conversation_id) do + clear_mappings(customer_id, conversation_id) search_term = "%#{term}%" domain_query = - from(s in Plausible.Site, + from(s in Plausible.Site.regular(), inner_join: t in assoc(s, :team), inner_join: tm in assoc(t, :team_memberships), where: tm.user_id == parent_as(:user).id and tm.role == :owner, @@ -222,7 +228,7 @@ defmodule Plausible.HelpScout do subscription.status == Subscription.Status.paused() -> "Paused" - Teams.owned_sites_locked?(team) -> + Teams.locked?(team) -> "Dashboard locked" subscription_active? -> @@ -295,18 +301,45 @@ defmodule Plausible.HelpScout do left_join: t in assoc(tm, :team), left_join: s in assoc(t, :sites), as: :sites, + where: is_nil(s) or not s.consolidated, group_by: u.id, order_by: [desc: count(s.id)] ) end - defp get_customer_emails(customer_id) do - case lookup_mapping(customer_id) do - {:ok, email} -> - {:ok, [email]} + defp get_customer_emails(customer_id, conversation_id) do + get_emails_with_conversation_mapping(conversation_id, customer_id) + end + + defp get_emails_with_conversation_mapping(conversation_id, customer_id) do + case lookup_conversation_mapping(conversation_id) do + {:ok, mapped_emails} -> + {:ok, mapped_emails} + + {:error, :not_found} -> + get_emails_with_customer_mapping(customer_id) + end + end - {:error, :mapping_not_found} -> - fetch_customer_emails(customer_id) + defp get_emails_with_customer_mapping(customer_id) do + # We want to explicitly reject customer emails from HS which + # are in one of excluded domains. That's why we fetch + # emails from HS first before checking the mapping. + case fetch_customer_emails(customer_id) do + {:ok, emails} -> + case lookup_customer_mapping(customer_id) do + {:ok, mapped_emails} -> + {:ok, mapped_emails} + + {:error, :not_found} -> + {:ok, emails} + end + + {:error, error} when error in [:not_found, :no_emails] -> + lookup_customer_mapping(customer_id) + + {:error, _} = error -> + error end end @@ -321,7 +354,13 @@ defmodule Plausible.HelpScout do case Req.get(url, opts) do {:ok, %{body: %{"_embedded" => %{"emails" => [_ | _] = emails}}}} -> - {:ok, Enum.map(emails, & &1["value"])} + emails = Enum.map(emails, & &1["value"]) + + if Enum.any?(emails, &email_excluded?/1) do + {:error, :excluded_email} + else + {:ok, emails} + end {:ok, %{status: 200}} -> {:error, :no_emails} @@ -348,25 +387,43 @@ defmodule Plausible.HelpScout do # Exposed for testing @doc false - def lookup_mapping(customer_id) do - email = + def lookup_customer_mapping(customer_id) do + email_row = "SELECT email FROM help_scout_mappings WHERE customer_id = $1" |> Repo.query!([customer_id]) |> Map.get(:rows) |> List.first() - case email do + case email_row do + [email] -> + {:ok, [email]} + + _ -> + {:error, :not_found} + end + end + + # Exposed for testing + @doc false + def lookup_conversation_mapping(conversation_id) do + email_row = + "SELECT email FROM help_scout_mappings WHERE conversation_id = $1" + |> Repo.query!([conversation_id]) + |> Map.get(:rows) + |> List.first() + + case email_row do [email] -> - {:ok, email} + {:ok, [email]} _ -> - {:error, :mapping_not_found} + {:error, :not_found} end end # Exposed for testing @doc false - def set_mapping(customer_id, email) do + def set_customer_mapping(customer_id, email) do now = NaiveDateTime.utc_now(:second) Repo.insert_all( @@ -377,8 +434,33 @@ defmodule Plausible.HelpScout do ) end - defp clear_mapping(customer_id) do - Repo.query!("DELETE FROM help_scout_mappings WHERE customer_id = $1", [customer_id]) + # Exposed for testing + @doc false + def set_conversation_mapping(conversation_id, email) do + now = NaiveDateTime.utc_now(:second) + + Repo.insert_all( + "help_scout_mappings", + [[conversation_id: conversation_id, email: email, inserted_at: now, updated_at: now]], + conflict_target: :conversation_id, + on_conflict: [set: [email: email, updated_at: now]] + ) + end + + defp email_excluded?(email) when is_binary(email) do + case String.split(email, "@") do + [_, domain] -> String.trim(domain) in @excluded_email_domains + _ -> false + end + end + + defp email_excluded?(_), do: false + + defp clear_mappings(customer_id, conversation_id) do + Repo.query!( + "DELETE FROM help_scout_mappings WHERE customer_id = $1 or conversation_id = $2", + [customer_id, conversation_id] + ) end defp get_token!() do diff --git a/extra/lib/plausible/installation_support/browserless_config.ex b/extra/lib/plausible/installation_support/browserless_config.ex new file mode 100644 index 000000000000..43532a1bf9c3 --- /dev/null +++ b/extra/lib/plausible/installation_support/browserless_config.ex @@ -0,0 +1,28 @@ +defmodule Plausible.InstallationSupport.BrowserlessConfig do + @moduledoc """ + Req options for browserless.io requests + """ + use Plausible + + @retry_policy %{ + # rate limit + 429 => {:delay, 1000}, + # even 400 are verified manually to sometimes succeed on retry + 400 => {:delay, 500} + } + + def retry_policy(), do: @retry_policy + + on_ee do + def browserless_function_api_endpoint() do + config = Application.fetch_env!(:plausible, __MODULE__) + token = Keyword.fetch!(config, :token) + endpoint = Keyword.fetch!(config, :endpoint) + Path.join(endpoint, "function?token=#{token}&stealth") + end + else + def browserless_function_api_endpoint() do + "Browserless API should not be called on Community Edition" + end + end +end diff --git a/extra/lib/plausible/installation_support/check.ex b/extra/lib/plausible/installation_support/check.ex new file mode 100644 index 000000000000..d08638f06575 --- /dev/null +++ b/extra/lib/plausible/installation_support/check.ex @@ -0,0 +1,61 @@ +defmodule Plausible.InstallationSupport.Check do + @moduledoc """ + Behaviour to be implemented by a specific installation support check. + + `report_progress_as()` doesn't necessarily reflect the actual check + description, it serves as a user-facing message grouping mechanism, + to prevent frequent message flashing when checks rotate often. + + Each check operates on `%Plausible.InstallationSupport.State{}` and is + expected to return it, optionally modified, by all means. `perform_safe/1` + is used to guarantee no exceptions are thrown by faulty implementations, + not to interrupt LiveView. + """ + @type state() :: Plausible.InstallationSupport.State.t() + @callback report_progress_as() :: String.t() + @callback perform(state(), Keyword.t()) :: state() + + defmacro __using__(_) do + quote do + import Plausible.InstallationSupport.State + alias Plausible.InstallationSupport.State + + require Logger + + @behaviour Plausible.InstallationSupport.Check + + def perform_safe(state, opts) do + timeout = Keyword.get(opts, :timeout, 10_000) + + task = + Task.async(fn -> + try do + perform(state, opts) + catch + _, e -> + Logger.error( + "Error running check #{inspect(__MODULE__)} on #{state.url}: #{inspect(e)}" + ) + + put_diagnostics(state, service_error: %{code: :internal_check_error, extra: e}) + end + end) + + try do + Task.await(task, timeout) + catch + :exit, {:timeout, _} -> + Task.shutdown(task, :brutal_kill) + check_name = __MODULE__ |> Atom.to_string() |> String.split(".") |> List.last() + + put_diagnostics(state, + service_error: %{ + code: :internal_check_timeout, + extra: "#{check_name} timed out after #{timeout}ms" + } + ) + end + end + end + end +end diff --git a/extra/lib/plausible/installation_support/check_runner.ex b/extra/lib/plausible/installation_support/check_runner.ex new file mode 100644 index 000000000000..73aa5f84555b --- /dev/null +++ b/extra/lib/plausible/installation_support/check_runner.ex @@ -0,0 +1,65 @@ +defmodule Plausible.InstallationSupport.CheckRunner do + @moduledoc """ + Takes two arguments: + + 1. A `%Plausible.InstallationSupport.State{}` struct - the `diagnostics` + field is a struct representing the set of diagnostics shared between + all the checks in this flow. + + 2. A list of modules implementing `Plausible.InstallationSupport.Check` + behaviour. + + Checks are normally run asynchronously, except when synchronous + execution is optionally required for tests. Slowdowns can be optionally + added, the user doesn't benefit from running the checks too quickly. + """ + + def run(state, checks, opts) do + async? = Keyword.get(opts, :async?, true) + slowdown = Keyword.get(opts, :slowdown, 500) + + if async? do + Task.start_link(fn -> do_run(state, checks, slowdown) end) + else + do_run(state, checks, slowdown) + end + end + + defp do_run(state, checks, slowdown) do + state = + Enum.reduce_while( + checks, + state, + fn {check, check_opts}, state -> + if state.skip_further_checks? do + {:halt, state} + else + {:cont, + state + |> notify_check_start(check, slowdown) + |> check.perform_safe(check_opts)} + end + end + ) + + notify_all_checks_done(state, slowdown) + end + + defp notify_check_start(state, check, slowdown) do + if is_pid(state.report_to) do + if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown) + send(state.report_to, {:check_start, {check, state}}) + end + + state + end + + defp notify_all_checks_done(state, slowdown) do + if is_pid(state.report_to) do + if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown) + send(state.report_to, {:all_checks_done, state}) + end + + state + end +end diff --git a/extra/lib/plausible/installation_support/checks/detection.ex b/extra/lib/plausible/installation_support/checks/detection.ex new file mode 100644 index 000000000000..be805fb07baf --- /dev/null +++ b/extra/lib/plausible/installation_support/checks/detection.ex @@ -0,0 +1,162 @@ +defmodule Plausible.InstallationSupport.Checks.Detection do + @moduledoc """ + Calls the browserless.io service (local instance can be spawned with `make browserless`) + and runs detector script via the [function API](https://docs.browserless.io/HTTP-APIs/function). + + * v1_detected (optional - detection can take up to @plausible_window_check_timeout_ms) + * gtm_likely + * wordpress_likely + * wordpress_plugin + + These diagnostics are used to determine what installation type to recommend, + and whether to provide a notice for upgrading an existing v1 integration to v2. + """ + + require Logger + use Plausible.InstallationSupport.Check + alias Plausible.InstallationSupport.BrowserlessConfig + + @detector_code_path "priv/tracker/installation_support/detector.js" + @external_resource @detector_code_path + + # On CI, the file might not be present for static checks so we default to empty string + @detector_code (case File.read(Application.app_dir(:plausible, @detector_code_path)) do + {:ok, content} -> content + {:error, _} -> "" + end) + + # Puppeteer wrapper function that executes the vanilla JS detector code + @puppeteer_wrapper_code """ + export default async function({ page, context: { url, userAgent, ...functionContext } }) { + try { + await page.setUserAgent(userAgent); + await page.goto(url); + + await page.evaluate(() => { + #{@detector_code} // injects window.scanPageBeforePlausibleInstallation + }); + + return await page.evaluate( + (c) => window.scanPageBeforePlausibleInstallation(c), + { ...functionContext } + ); + } catch (error) { + return { + data: { + completed: false, + error: { + message: error?.message ?? JSON.stringify(error) + } + } + } + } + } + """ + + # This timeout determines how long we wait for window.plausible to be initialized on the page, used for detecting whether v1 installed + @plausible_window_check_timeout_ms 1_500 + + # To support browserless API being unavailable or overloaded, we retry the endpoint call if it doesn't return a successful response + @max_retries 1 + + @impl true + def report_progress_as, do: "We're checking your site to recommend the best installation method" + + @impl true + def perform(%State{url: url, assigns: %{detect_v1?: detect_v1?}} = state, opts) do + check_timeout = Keyword.fetch!(opts, :timeout) + + # We rely on Req's `:receive_timeout` to avoid waiting too long for a response, but + # we also pass the same timeout (+ 1s) via a query param to the Browserless /function API + # to not waste resources and leave a redundant process running there. + req_timeout = check_timeout - 1000 + browserless_api_timeout = check_timeout + + opts = + [ + headers: %{content_type: "application/json"}, + body: + Jason.encode!(%{ + code: @puppeteer_wrapper_code, + context: %{ + url: Plausible.InstallationSupport.URL.bust_url(url), + userAgent: Plausible.InstallationSupport.user_agent(), + detectV1: detect_v1?, + timeoutMs: @plausible_window_check_timeout_ms, + debug: Application.get_env(:plausible, :environment) == "dev" + } + }), + params: %{timeout: browserless_api_timeout}, + retry: fn _request, response_or_error -> + case response_or_error do + %{status: status} -> Map.get(BrowserlessConfig.retry_policy(), status, false) + _ -> false + end + end, + receive_timeout: req_timeout, + retry_log_level: :warning, + max_retries: @max_retries + ] + |> Keyword.merge(Application.get_env(:plausible, __MODULE__)[:req_opts] || []) + + case Req.post(BrowserlessConfig.browserless_function_api_endpoint(), opts) do + {:ok, %{body: body, status: status}} -> + handle_browserless_response(state, body, status) + + {:error, %Req.TransportError{reason: :timeout}} -> + put_diagnostics(state, service_error: %{code: :browserless_timeout}) + + {:error, %{reason: reason}} -> + Logger.warning(warning_message("Browserless request error: #{inspect(reason)}", state)) + + put_diagnostics(state, service_error: %{code: :req_error, extra: reason}) + end + end + + defp handle_browserless_response( + state, + %{"data" => %{"completed" => completed} = data}, + _status + ) do + if completed do + put_diagnostics( + state, + parse_to_diagnostics(data) + ) + else + Logger.warning( + warning_message( + "Browserless function returned with completed: false, error.message: #{inspect(data["error"]["message"])}", + state + ) + ) + + put_diagnostics(state, + service_error: %{code: :browserless_client_error, extra: data["error"]["message"]} + ) + end + end + + defp handle_browserless_response(state, body, status) do + error = "Unhandled browserless response with status: #{status}" + + warning_message("#{error}; body: #{inspect(body)}", state) + |> Logger.warning() + + put_diagnostics(state, service_error: %{code: :bad_browserless_response, extra: status}) + end + + defp warning_message(message, state) do + "[DETECTION] #{message} (data_domain='#{state.data_domain}')" + end + + defp parse_to_diagnostics(data), + do: [ + v1_detected: data["v1Detected"], + gtm_likely: data["gtmLikely"], + npm: data["npm"], + wordpress_likely: data["wordpressLikely"], + wordpress_plugin: data["wordpressPlugin"], + service_error: nil + ] +end diff --git a/extra/lib/plausible/installation_support/checks/url.ex b/extra/lib/plausible/installation_support/checks/url.ex new file mode 100644 index 000000000000..95dbd56fea1c --- /dev/null +++ b/extra/lib/plausible/installation_support/checks/url.ex @@ -0,0 +1,103 @@ +defmodule Plausible.InstallationSupport.Checks.Url do + @moduledoc """ + Checks if site domain has an A record. + If not, checks if prepending `www.` helps, + because we have specifically requested customers to register the domain with `www.` prefix. + If not, skips all further checks. + """ + + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: "We're trying to reach your website" + + @impl true + @spec perform(State.t(), Keyword.t()) :: State.t() + def perform(%State{url: url} = state, _opts) when is_binary(url) do + with {:ok, %URI{scheme: scheme} = uri} when scheme in ["http", "https"] <- URI.new(url), + :ok <- check_domain(uri.host) do + stripped_url = URI.to_string(%URI{uri | query: nil, fragment: nil}) + %State{state | url: stripped_url} + else + {:error, :no_a_record} -> + put_diagnostics(%State{state | skip_further_checks?: true}, + service_error: %{code: :domain_not_found} + ) + + _ -> + put_diagnostics(%State{state | skip_further_checks?: true}, + service_error: %{code: :invalid_url} + ) + end + end + + def perform(%State{data_domain: domain} = state, _opts) when is_binary(domain) do + case find_working_url(domain) do + {:ok, working_url} -> + %State{state | url: working_url} + + {:error, :domain_not_found} -> + put_diagnostics(%State{state | url: nil, skip_further_checks?: true}, + service_error: %{code: :domain_not_found} + ) + end + end + + # Check A records of the the domains [domain, "www.#{domain}"] + # at this point, domain can contain path + @spec find_working_url(String.t()) :: {:ok, String.t()} | {:error, :domain_not_found} + defp find_working_url(domain) do + [domain_without_path | rest] = split_domain(domain) + + [ + domain_without_path, + "www.#{domain_without_path}" + ] + |> Enum.reduce_while({:error, :domain_not_found}, fn d, _acc -> + case dns_lookup(d) do + :ok -> {:halt, {:ok, "https://" <> unsplit_domain(d, rest)}} + {:error, :no_a_record} -> {:cont, {:error, :domain_not_found}} + end + end) + end + + @spec dns_lookup(String.t()) :: :ok | {:error, :no_a_record} + defp dns_lookup(domain) do + lookup_timeout = 1_000 + resolve_timeout = 1_000 + + case Plausible.DnsLookup.impl().lookup( + to_charlist(domain), + :in, + :a, + [timeout: resolve_timeout], + lookup_timeout + ) do + [{a, b, c, d} | _] + when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) -> + :ok + + # this may mean timeout or no DNS record + [] -> + {:error, :no_a_record} + end + end + + defp check_domain(domain) do + if Application.get_env(:plausible, :environment) == "dev" and domain == "localhost" do + :ok + else + dns_lookup(domain) + end + end + + @spec split_domain(String.t()) :: [String.t()] + defp split_domain(domain) do + String.split(domain, "/", parts: 2) + end + + @spec unsplit_domain(String.t(), [String.t()]) :: String.t() + defp unsplit_domain(domain_without_path, rest) do + Enum.join([domain_without_path] ++ rest, "/") + end +end diff --git a/extra/lib/plausible/installation_support/checks/verify_installation.ex b/extra/lib/plausible/installation_support/checks/verify_installation.ex new file mode 100644 index 000000000000..fafdecb70510 --- /dev/null +++ b/extra/lib/plausible/installation_support/checks/verify_installation.ex @@ -0,0 +1,189 @@ +defmodule Plausible.InstallationSupport.Checks.VerifyInstallation do + @moduledoc """ + Calls the browserless.io service (local instance can be spawned with `make browserless`) + and runs verifier script via the [function API](https://docs.browserless.io/HTTP-APIs/function). + """ + + require Logger + use Plausible.InstallationSupport.Check + alias Plausible.InstallationSupport.BrowserlessConfig + + @verifier_code_path "priv/tracker/installation_support/verifier.js" + @external_resource @verifier_code_path + + # On CI, the file might not be present for static checks so we default to empty string + @verifier_code (case File.read(Application.app_dir(:plausible, @verifier_code_path)) do + {:ok, content} -> content + {:error, _} -> "" + end) + + # Puppeteer wrapper function that executes the vanilla JS verifier code + @puppeteer_wrapper_code """ + export default async function({ page, context: { url, userAgent, maxAttempts, timeoutBetweenAttemptsMs, ...functionContext } }) { + try { + await page.setUserAgent(userAgent) + const response = await page.goto(url) + const responseStatus = response.status() + const responseHeaders = response.headers() + + async function verify() { + await page.evaluate(() => {#{@verifier_code}}) // injects window.verifyPlausibleInstallation + return await page.evaluate( + (c) => window.verifyPlausibleInstallation(c), + { ...functionContext, responseHeaders } + ); + } + + let lastError; + for (let attempts = 1; attempts <= maxAttempts; attempts++) { + try { + const output = await verify(); + return { + data: { + ...output.data, + attempts, + responseStatus + }, + }; + } catch (error) { + lastError = error; + if ( + typeof error?.message === "string" && + error.message.toLowerCase().includes("execution context") + ) { + await new Promise((resolve) => setTimeout(resolve, timeoutBetweenAttemptsMs)); + continue; + } + throw error + } + } + throw lastError; + } catch (error) { + return { + data: { + completed: false, + error: { + message: error?.message ?? JSON.stringify(error) + } + } + } + } + } + """ + + # To support browserless API being unavailable or overloaded, we retry the endpoint call if it doesn't return a successful response + @max_retries 1 + + # We rely on Req's `:receive_timeout` to avoid waiting too long for a response. We also pass the same timeout (+ 1s) via a query + # param to the Browserless /function API to not waste resources. + @req_timeout 15_000 + + # This timeout determines how long we wait for window.plausible to be initialized on the page, including sending the test event + @plausible_window_check_timeout_ms 4_000 + + # To handle navigation that happens immediately on the page, we attempt to verify the installation multiple times _within a single browserless endpoint call_ + @max_attempts 2 + @timeout_between_attempts_ms 500 + + @impl true + def report_progress_as, do: "We're verifying that your visitors are being counted correctly" + + @impl true + def perform(%State{url: url} = state, _opts) do + opts = [ + headers: %{content_type: "application/json"}, + body: + JSON.encode!(%{ + code: @puppeteer_wrapper_code, + context: %{ + maxAttempts: @max_attempts, + timeoutMs: @plausible_window_check_timeout_ms, + timeoutBetweenAttemptsMs: @timeout_between_attempts_ms, + cspHostToCheck: PlausibleWeb.Endpoint.host(), + url: url, + userAgent: Plausible.InstallationSupport.user_agent(), + debug: Application.get_env(:plausible, :environment) == "dev" + } + }), + params: %{timeout: @req_timeout + 1000}, + retry: fn _request, response_or_error -> + case response_or_error do + %{status: status} -> Map.get(BrowserlessConfig.retry_policy(), status, false) + %Req.TransportError{reason: :timeout} -> {:delay, 500} + _ -> false + end + end, + retry_log_level: :warning, + max_retries: @max_retries, + receive_timeout: @req_timeout + ] + + extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] + opts = Keyword.merge(opts, extra_opts) + + case Req.post(BrowserlessConfig.browserless_function_api_endpoint(), opts) do + {:ok, %{body: body, status: status}} -> + handle_browserless_response(state, body, status) + + {:error, %Req.TransportError{reason: :timeout}} -> + put_diagnostics(state, service_error: %{code: :browserless_timeout}) + + {:error, %{reason: reason}} -> + Logger.warning(warning_message("Browserless request error: #{inspect(reason)}", state)) + + put_diagnostics(state, service_error: %{code: :req_error, extra: reason}) + end + end + + defp handle_browserless_response( + state, + %{"data" => %{"completed" => completed} = data}, + _status + ) do + if completed do + put_diagnostics( + state, + parse_to_diagnostics(data) + ) + else + Logger.warning( + warning_message( + "Browserless function returned with completed: false, error.message: #{inspect(data["error"]["message"])}", + state + ) + ) + + put_diagnostics(state, + service_error: %{code: :browserless_client_error, extra: data["error"]["message"]} + ) + end + end + + defp handle_browserless_response(state, body, status) do + error = "Unhandled browserless response with status: #{status}" + + warning_message("#{error}; body: #{inspect(body)}", state) + |> Logger.warning() + + put_diagnostics(state, service_error: %{code: :bad_browserless_response, extra: status}) + end + + defp warning_message(message, state) do + "[VERIFICATION] #{message} (data_domain='#{state.data_domain}')" + end + + defp parse_to_diagnostics(data), + do: [ + disallowed_by_csp: data["disallowedByCsp"], + tracker_is_in_html: data["trackerIsInHtml"], + plausible_is_on_window: data["plausibleIsOnWindow"], + plausible_is_initialized: data["plausibleIsInitialized"], + plausible_version: data["plausibleVersion"], + plausible_variant: data["plausibleVariant"], + test_event: data["testEvent"], + cookies_consent_result: data["cookiesConsentResult"], + attempts: data["attempts"], + response_status: data["responseStatus"], + service_error: nil + ] +end diff --git a/extra/lib/plausible/installation_support/checks/verify_installation_cache_bust.ex b/extra/lib/plausible/installation_support/checks/verify_installation_cache_bust.ex new file mode 100644 index 000000000000..f8a84a492e4c --- /dev/null +++ b/extra/lib/plausible/installation_support/checks/verify_installation_cache_bust.ex @@ -0,0 +1,42 @@ +defmodule Plausible.InstallationSupport.Checks.VerifyInstallationCacheBust do + @moduledoc """ + If the output of previous checks can not be interpreted as successful, + as a last resort, we try to bust the cache of the site under test by adding a query parameter to the URL, + and running VerifyInstallation again. + + Whatever the result from the rerun, that is what we use to interpret the installation. + + The idea is to make sure that any issues we detect will be about the latest version of their website. + + We also want to avoid reporting a successful installation if it took a special cache-busting action to make it work. + """ + + require Logger + alias Plausible.InstallationSupport + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: "We're verifying that your visitors are being counted correctly" + + @impl true + def perform(%State{url: url} = state, _opts) do + case InstallationSupport.Verification.Checks.interpret_diagnostics(state, telemetry?: false) do + %InstallationSupport.Result{ok?: true} -> + state + + %InstallationSupport.Result{data: %{unhandled: true}} -> + state + + _known_installation_failure -> + reset_diagnostics = %InstallationSupport.Verification.Diagnostics{ + selected_installation_type: state.diagnostics.selected_installation_type + } + + state + |> struct!(diagnostics: reset_diagnostics) + |> struct!(url: InstallationSupport.URL.bust_url(url)) + |> InstallationSupport.Checks.VerifyInstallation.perform([]) + |> put_diagnostics(diagnostics_are_from_cache_bust: true) + end + end +end diff --git a/extra/lib/plausible/installation_support/detection/checks.ex b/extra/lib/plausible/installation_support/detection/checks.ex new file mode 100644 index 000000000000..1514f9a9fb96 --- /dev/null +++ b/extra/lib/plausible/installation_support/detection/checks.ex @@ -0,0 +1,114 @@ +defmodule Plausible.InstallationSupport.Detection.Checks do + @moduledoc """ + Checks that are performed pre-installation, providing recommended installation + methods and whether v1 is used on the site. + + In async execution, each check notifies the caller by sending a message to it. + """ + alias Plausible.InstallationSupport.Detection + alias Plausible.InstallationSupport.{State, CheckRunner, Checks} + + require Logger + + @detection_check_timeout 6000 + + def run(url, data_domain, opts \\ []) do + detection_check_timeout = + case Keyword.get(opts, :detection_check_timeout) do + int when is_integer(int) -> int + _ -> @detection_check_timeout + end + + report_to = Keyword.get(opts, :report_to, self()) + async? = Keyword.get(opts, :async?, true) + slowdown = Keyword.get(opts, :slowdown, 500) + detect_v1? = Keyword.get(opts, :detect_v1?, false) + + init_state = + %State{ + url: url, + data_domain: data_domain, + report_to: report_to, + diagnostics: %Detection.Diagnostics{}, + assigns: %{detect_v1?: detect_v1?} + } + + checks = [ + {Checks.Url, []}, + {Checks.Detection, [timeout: detection_check_timeout]} + ] + + CheckRunner.run(init_state, checks, + async?: async?, + report_to: report_to, + slowdown: slowdown + ) + end + + def telemetry_event_success(), do: [:plausible, :detection, :success] + def telemetry_event_failure(), do: [:plausible, :detection, :failure] + + def interpret_diagnostics(%State{ + diagnostics: diagnostics, + data_domain: data_domain, + url: url + }) do + result = Detection.Diagnostics.interpret(diagnostics, url) + + {failed?, trigger_sentry?, msg} = + case result do + %{ok?: true} -> + {false, false, nil} + + %{data: %{failure: :customer_website_issue}} -> + {true, false, "Failed due to an issue with the customer website"} + + %{data: %{failure: :browserless_issue}} -> + {true, true, "Failed due to a Browserless issue"} + + _unknown_failure -> + {true, true, "Unknown failure"} + end + + if failed? do + :telemetry.execute(telemetry_event_failure(), %{}) + Logger.warning("[DETECTION] #{msg} (data_domain='#{data_domain}'): #{inspect(diagnostics)}") + else + :telemetry.execute(telemetry_event_success(), %{}) + end + + if trigger_sentry? do + Sentry.capture_message("[DETECTION] #{msg}", + extra: %{ + message: inspect(diagnostics), + url: url, + hash: :erlang.phash2(diagnostics) + } + ) + end + + result + end + + @unthrottled_checks 3 + @first_slowdown_ms 1000 + def run_with_rate_limit(url, data_domain, opts \\ []) do + case Plausible.RateLimit.check_rate( + "site_detection:#{data_domain}", + :timer.minutes(60), + 10 + ) do + {:allow, count} when count <= @unthrottled_checks -> + {:ok, run(url, data_domain, opts)} + + {:allow, count} when count > @unthrottled_checks -> + # slowdown steps 1x, 4x, 9x, 16x, ... + slowdown_ms = @first_slowdown_ms * (count - @unthrottled_checks) ** 2 + :timer.sleep(slowdown_ms) + {:ok, run(url, data_domain, opts)} + + {:deny, limit} -> + {:error, {:rate_limit_exceeded, limit}} + end + end +end diff --git a/extra/lib/plausible/installation_support/detection/diagnostics.ex b/extra/lib/plausible/installation_support/detection/diagnostics.ex new file mode 100644 index 000000000000..4e4b40527057 --- /dev/null +++ b/extra/lib/plausible/installation_support/detection/diagnostics.ex @@ -0,0 +1,109 @@ +defmodule Plausible.InstallationSupport.Detection.Diagnostics do + @moduledoc """ + Module responsible for translating diagnostics to user-friendly errors and recommendations. + """ + require Logger + + # in this struct, nil means indeterminate + defstruct v1_detected: nil, + gtm_likely: nil, + wordpress_likely: nil, + wordpress_plugin: nil, + npm: nil, + service_error: nil + + @type t :: %__MODULE__{} + + alias Plausible.InstallationSupport.Result + + @spec interpret(t(), String.t()) :: Result.t() + def interpret( + %__MODULE__{ + gtm_likely: true, + service_error: nil + } = diagnostics, + _url + ) do + success("gtm", diagnostics) + end + + def interpret( + %__MODULE__{ + wordpress_likely: true, + service_error: nil + } = diagnostics, + _url + ) do + success( + "wordpress", + diagnostics + ) + end + + def interpret( + %__MODULE__{ + npm: true, + service_error: nil + } = diagnostics, + _url + ) do + success("npm", diagnostics) + end + + def interpret( + %__MODULE__{ + service_error: nil + } = diagnostics, + _url + ) do + success(PlausibleWeb.Tracker.fallback_installation_type(), diagnostics) + end + + def interpret(%__MODULE__{service_error: %{code: code}}, _url) + when code in [:domain_not_found, :invalid_url] do + failure(:customer_website_issue) + end + + def interpret(%__MODULE__{service_error: %{code: code}}, _url) + when code in [:bad_browserless_response, :browserless_timeout, :internal_check_timeout] do + failure(:browserless_issue) + end + + def interpret( + %__MODULE__{service_error: %{code: :browserless_client_error, extra: extra}}, + _url + ) do + cond do + String.contains?(extra, "net::") -> + failure(:customer_website_issue) + + String.contains?(String.downcase(extra), "execution context") -> + failure(:customer_website_issue) + + true -> + failure(:unknown_issue) + end + end + + def interpret(%__MODULE__{} = _diagnostics, _url), do: failure(:unknown_issue) + + defp failure(reason) do + %Result{ + ok?: false, + data: %{failure: reason}, + errors: [reason] + } + end + + defp success(suggested_technology, diagnostics) do + %Result{ + ok?: true, + data: %{ + v1_detected: diagnostics.v1_detected, + wordpress_plugin: diagnostics.wordpress_plugin, + npm: diagnostics.npm, + suggested_technology: suggested_technology + } + } + end +end diff --git a/lib/plausible/verification.ex b/extra/lib/plausible/installation_support/installation_support.ex similarity index 50% rename from lib/plausible/verification.ex rename to extra/lib/plausible/installation_support/installation_support.ex index 35bd85881d20..f724a96d28ea 100644 --- a/lib/plausible/verification.ex +++ b/extra/lib/plausible/installation_support/installation_support.ex @@ -1,6 +1,10 @@ -defmodule Plausible.Verification do +defmodule Plausible.InstallationSupport do @moduledoc """ - Module defining the user-agent used for site verification. + This top level module is the middle ground between pre-installation + site scans and verification of whether Plausible has been installed + correctly. + + Defines the user-agent used with checks. """ use Plausible diff --git a/extra/lib/plausible/installation_support/result.ex b/extra/lib/plausible/installation_support/result.ex new file mode 100644 index 000000000000..baa08d645ddd --- /dev/null +++ b/extra/lib/plausible/installation_support/result.ex @@ -0,0 +1,18 @@ +defmodule Plausible.InstallationSupport.Result do + @moduledoc """ + Diagnostics interpretation result. + + ## Example + ok?: false, + data: nil, + errors: [error.message], + recommendations: [%{text: error.recommendation, url: error.url}] + + ok?: true, + data: %{}, + errors: [], + recommendations: [] + """ + defstruct ok?: false, errors: [], recommendations: [], data: nil + @type t :: %__MODULE__{} +end diff --git a/extra/lib/plausible/installation_support/state.ex b/extra/lib/plausible/installation_support/state.ex new file mode 100644 index 000000000000..4072af9cf0b3 --- /dev/null +++ b/extra/lib/plausible/installation_support/state.ex @@ -0,0 +1,40 @@ +defmodule Plausible.InstallationSupport.State do + @moduledoc """ + The state to be shared across check during site installation support. + + Assigns are meant to be used to communicate between checks, while + `diagnostics` are specific to the check group being executed. + """ + + defstruct url: nil, + data_domain: nil, + report_to: nil, + assigns: %{}, + diagnostics: %{}, + skip_further_checks?: false + + @type diagnostics_type :: + Plausible.InstallationSupport.Verification.Diagnostics.t() + | Plausible.InstallationSupport.Detection.Diagnostics.t() + + @type t :: %__MODULE__{ + url: String.t() | nil, + data_domain: String.t() | nil, + report_to: pid() | nil, + assigns: map(), + diagnostics: diagnostics_type(), + skip_further_checks?: boolean() + } + + def assign(%__MODULE__{} = state, assigns) do + %{state | assigns: Map.merge(state.assigns, Enum.into(assigns, %{}))} + end + + def put_diagnostics(%__MODULE__{} = state, diagnostics) when is_list(diagnostics) do + %{state | diagnostics: struct!(state.diagnostics, diagnostics)} + end + + def put_diagnostics(%__MODULE__{} = state, diagnostics) do + put_diagnostics(state, List.wrap(diagnostics)) + end +end diff --git a/lib/plausible/verification/url.ex b/extra/lib/plausible/installation_support/url.ex similarity index 78% rename from lib/plausible/verification/url.ex rename to extra/lib/plausible/installation_support/url.ex index 1806c8d3b661..97ca4b39a842 100644 --- a/lib/plausible/verification/url.ex +++ b/extra/lib/plausible/installation_support/url.ex @@ -1,6 +1,6 @@ -defmodule Plausible.Verification.URL do +defmodule Plausible.InstallationSupport.URL do @moduledoc """ - Busting some caches by appending ?plausible_verification=12345 to it. + URL utilities for installation support, including cache busting functionality. """ def bust_url(url) do diff --git a/extra/lib/plausible/installation_support/verification/checks.ex b/extra/lib/plausible/installation_support/verification/checks.ex new file mode 100644 index 000000000000..2a3328d21bcb --- /dev/null +++ b/extra/lib/plausible/installation_support/verification/checks.ex @@ -0,0 +1,100 @@ +defmodule Plausible.InstallationSupport.Verification.Checks do + @moduledoc """ + Checks that are performed during tracker script installation verification. + + In async execution, each check notifies the caller by sending a message to it. + """ + alias Plausible.InstallationSupport.Verification + alias Plausible.InstallationSupport.{State, CheckRunner, Checks} + + require Logger + + @verify_installation_check_timeout 20_000 + + @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t() + def run(url, data_domain, installation_type, opts \\ []) do + # Timeout option for testing purposes + verify_installation_check_timeout = + case Keyword.get(opts, :verify_installation_check_timeout) do + int when is_integer(int) -> int + _ -> @verify_installation_check_timeout + end + + report_to = Keyword.get(opts, :report_to, self()) + async? = Keyword.get(opts, :async?, true) + slowdown = Keyword.get(opts, :slowdown, 500) + + init_state = + %State{ + url: url, + data_domain: data_domain, + report_to: report_to, + diagnostics: %Verification.Diagnostics{ + selected_installation_type: installation_type + } + } + + checks = [ + {Checks.Url, []}, + {Checks.VerifyInstallation, [timeout: verify_installation_check_timeout]}, + {Checks.VerifyInstallationCacheBust, [timeout: verify_installation_check_timeout]} + ] + + CheckRunner.run(init_state, checks, + async?: async?, + report_to: report_to, + slowdown: slowdown + ) + end + + def telemetry_event_handled(), do: [:plausible, :verification, :handled] + def telemetry_event_unhandled(), do: [:plausible, :verification, :unhandled] + + def interpret_diagnostics( + %State{ + diagnostics: diagnostics, + data_domain: data_domain, + url: url + }, + opts \\ [] + ) do + telemetry? = Keyword.get(opts, :telemetry?, true) + + result = + Verification.Diagnostics.interpret( + diagnostics, + data_domain, + url + ) + + case {telemetry?, result.data} do + {false, _} -> + :skip + + {_, %{unhandled: true, browserless_issue: browserless_issue}} -> + sentry_msg = + if browserless_issue, + do: "Browserless failure in verification", + else: "Unhandled case for site verification" + + Sentry.capture_message(sentry_msg, + extra: %{ + message: inspect(diagnostics), + url: url, + hash: :erlang.phash2(diagnostics) + } + ) + + Logger.warning( + "[VERIFICATION] Unhandled case (data_domain='#{data_domain}'): #{inspect(diagnostics)}" + ) + + :telemetry.execute(telemetry_event_unhandled(), %{}) + + _ -> + :telemetry.execute(telemetry_event_handled(), %{}) + end + + result + end +end diff --git a/extra/lib/plausible/installation_support/verification/diagnostics.ex b/extra/lib/plausible/installation_support/verification/diagnostics.ex new file mode 100644 index 000000000000..afe06b4012bd --- /dev/null +++ b/extra/lib/plausible/installation_support/verification/diagnostics.ex @@ -0,0 +1,390 @@ +defmodule Plausible.InstallationSupport.Verification.Diagnostics do + @moduledoc """ + Module responsible for translating diagnostics to user-friendly errors and recommendations. + """ + require Logger + + # In this struct + # - the default nil value for each field means that the value is indeterminate (e.g. we didn't even get to the part where response_status is set) + defstruct [ + :selected_installation_type, + :disallowed_by_csp, + :tracker_is_in_html, + :plausible_is_on_window, + :plausible_is_initialized, + :plausible_version, + :plausible_variant, + :diagnostics_are_from_cache_bust, + :test_event, + :cookies_consent_result, + :response_status, + :service_error, + :attempts + ] + + @type t :: %__MODULE__{} + + @verify_manually_url "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + + alias Plausible.InstallationSupport.Result + + defmodule Error do + @moduledoc """ + Error that has compile-time enforced checks for the attributes. + """ + + @enforce_keys [:message, :recommendation] + defstruct [:message, :recommendation, :url] + + def new!(attrs) do + message = Map.fetch!(attrs, :message) + + if String.ends_with?(message, ".") do + raise ArgumentError, "Error message must not end with a period: #{inspect(message)}" + end + + if String.ends_with?(attrs[:recommendation], ".") do + raise ArgumentError, + "Error recommendation must not end with a period: #{inspect(attrs[:recommendation])}" + end + + if is_binary(attrs[:url]) and not String.starts_with?(attrs[:url], "https://plausible.io") do + raise ArgumentError, + "Recommendation url must start with 'https://plausible.io': #{inspect(attrs[:url])}" + end + + struct!(__MODULE__, attrs) + end + end + + @error_succeeds_only_after_cache_bust Error.new!(%{ + message: "We detected an issue with your site's cache", + recommendation: + "Please clear the cache for your site to ensure that your visitors will load the latest version of your site that has Plausible correctly installed", + url: + "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + }) + + @spec interpret(t(), String.t(), String.t()) :: Result.t() + def interpret( + %__MODULE__{ + test_event: %{ + "normalizedBody" => %{ + "domain" => domain + }, + "responseStatus" => response_status + }, + service_error: nil, + diagnostics_are_from_cache_bust: true + }, + expected_domain, + _url + ) + when response_status in [200, 202] and + domain == expected_domain, + do: handled_error(@error_succeeds_only_after_cache_bust) + + def interpret( + %__MODULE__{ + test_event: %{ + "normalizedBody" => %{ + "domain" => domain + }, + "responseStatus" => response_status + }, + service_error: nil + }, + expected_domain, + _url + ) + when response_status in [200, 202] and + domain == expected_domain, + do: success() + + def interpret( + %__MODULE__{ + test_event: %{ + "normalizedBody" => %{ + "domain" => domain + }, + "responseStatus" => response_status + }, + service_error: nil, + selected_installation_type: selected_installation_type + }, + expected_domain, + _url + ) + when response_status in [200, 202] and + domain != expected_domain do + error_unexpected_domain(selected_installation_type) + |> handled_error() + end + + @error_proxy_network_error Error.new!(%{ + message: + "We got an unexpected response from the proxy you are using for Plausible", + recommendation: + "Please check that you've configured the proxied /event route correctly", + url: "https://plausible.io/docs/proxy/introduction" + }) + @error_plausible_network_error Error.new!(%{ + message: "We couldn't verify your website", + recommendation: + "Please try verifying again in a few minutes, or verify your installation manually", + url: @verify_manually_url + }) + + def interpret( + %__MODULE__{ + test_event: %{ + "requestUrl" => request_url, + "responseStatus" => response_status + }, + service_error: nil + }, + _expected_domain, + _url + ) + when response_status not in [200, 202] and is_binary(request_url) do + proxying? = not String.starts_with?(request_url, PlausibleWeb.Endpoint.url()) + + if proxying? do + handled_error(@error_proxy_network_error) + else + handled_error(@error_plausible_network_error) + end + end + + def interpret( + %__MODULE__{ + tracker_is_in_html: false, + selected_installation_type: "manual", + plausible_is_on_window: plausible_is_on_window, + plausible_is_initialized: plausible_is_initialized, + service_error: nil + }, + _expected_domain, + _url + ) + when plausible_is_on_window != true and + plausible_is_initialized != true do + error_plausible_not_found("manual") + |> handled_error() + end + + @error_csp_disallowed Error.new!(%{ + message: + "We encountered an issue with your site's Content Security Policy (CSP)", + recommendation: + "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP", + url: + "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + }) + def interpret( + %__MODULE__{ + disallowed_by_csp: true, + service_error: nil + }, + _expected_domain, + _url + ), + do: handled_error(@error_csp_disallowed) + + @error_domain_not_found Error.new!(%{ + message: "We couldn't find your website at <%= @attempted_url %>", + recommendation: + "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually", + url: @verify_manually_url + }) + + def interpret(%__MODULE__{service_error: %{code: code}}, expected_domain, url) + when code in [:domain_not_found, :invalid_url] do + attempted_url = if url, do: url, else: "https://#{expected_domain}" + + @error_domain_not_found + |> handled_error(attempted_url: attempted_url) + |> struct!(data: %{offer_custom_url_input: true}) + end + + @error_browserless_network Error.new!(%{ + message: + "We couldn't verify your website at <%= @attempted_url %>", + recommendation: + "Accessing the website resulted in a network error. Please verify your installation manually", + url: @verify_manually_url + }) + + def interpret( + %__MODULE__{service_error: %{code: :browserless_client_error, extra: "net::" <> _}}, + _expected_domain, + url + ) + when is_binary(url) do + attempted_url = shorten_url(url) + + @error_browserless_network + |> handled_error(attempted_url: attempted_url) + |> struct!(data: %{offer_custom_url_input: true}) + end + + @error_browserless_temporary Error.new!(%{ + message: + "Our verification tool encountered a temporary service error", + recommendation: + "Please try again in a few minutes or verify your installation manually", + url: @verify_manually_url + }) + + def interpret(%__MODULE__{service_error: %{code: code}}, _expected_domain, _url) + when code in [:bad_browserless_response, :browserless_timeout, :internal_check_timeout] do + unhandled_error(@error_browserless_temporary, browserless_issue: true) + end + + @error_unexpected_page_response Error.new!(%{ + message: + "We couldn't verify your website at <%= @attempted_url %>", + recommendation: + "Accessing the website resulted in an unexpected status code <%= @page_response_status %>. Please check for anything that might be blocking us from reaching your site, like a firewall, authentication requirements, or CDN rules. If you'd prefer, you can skip this and verify your installation manually", + url: @verify_manually_url + }) + + def interpret( + %__MODULE__{ + plausible_is_on_window: plausible_is_on_window, + plausible_is_initialized: plausible_is_initialized, + response_status: page_response_status + }, + _expected_domain, + url + ) + when is_binary(url) and not is_nil(page_response_status) and + (page_response_status < 200 or page_response_status >= 300) and + plausible_is_on_window != true and + plausible_is_initialized != true do + attempted_url = shorten_url(url) + + @error_unexpected_page_response + |> handled_error(attempted_url: attempted_url, page_response_status: page_response_status) + |> struct!(data: %{offer_custom_url_input: true}) + end + + def interpret( + %__MODULE__{ + selected_installation_type: selected_installation_type, + plausible_is_on_window: false, + service_error: nil + }, + _expected_domain, + _url + ) do + error_plausible_not_found(selected_installation_type) + |> handled_error() + end + + def interpret(%__MODULE__{} = diagnostics, _expected_domain, _url) do + error_plausible_not_found(diagnostics.selected_installation_type) + |> unhandled_error() + end + + @message_plausible_not_found "We couldn't detect Plausible on your site" + @error_plausible_not_found_for_manual Error.new!(%{ + message: @message_plausible_not_found, + recommendation: + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually", + url: @verify_manually_url + }) + @error_plausible_not_found_for_npm Error.new!(%{ + message: @message_plausible_not_found, + recommendation: + "Please make sure you've initialized Plausible on your site, or verify your installation manually", + url: @verify_manually_url + }) + @error_plausible_not_found_for_gtm Error.new!(%{ + message: @message_plausible_not_found, + recommendation: + "Please make sure you've configured the GTM template correctly, or verify your installation manually", + url: @verify_manually_url + }) + @error_plausible_not_found_for_wordpress Error.new!(%{ + message: @message_plausible_not_found, + recommendation: + "Please make sure you've enabled the plugin, or verify your installation manually", + url: @verify_manually_url + }) + defp error_plausible_not_found(selected_installation_type) do + case selected_installation_type do + "npm" -> @error_plausible_not_found_for_npm + "gtm" -> @error_plausible_not_found_for_gtm + "wordpress" -> @error_plausible_not_found_for_wordpress + _ -> @error_plausible_not_found_for_manual + end + end + + @unexpected_domain_message "Plausible test event is not for this site" + @error_unexpected_domain_for_manual Error.new!(%{ + message: @unexpected_domain_message, + recommendation: + "Please check that the snippet on your site matches the installation instructions exactly, or verify your installation manually", + url: @verify_manually_url + }) + + @error_unexpected_domain_for_npm Error.new!(%{ + message: @unexpected_domain_message, + recommendation: + "Please check that you've initialized Plausible with the correct domain, or verify your installation manually", + url: @verify_manually_url + }) + + @error_unexpected_domain_for_gtm Error.new!(%{ + message: @unexpected_domain_message, + recommendation: + "Please check that you've entered the ID in the GTM template correctly, or verify your installation manually", + url: @verify_manually_url + }) + + @error_unexpected_domain_for_wordpress Error.new!(%{ + message: @unexpected_domain_message, + recommendation: + "Please check that you've installed the WordPress plugin correctly, or verify your installation manually", + url: @verify_manually_url + }) + defp error_unexpected_domain(selected_installation_type) do + case selected_installation_type do + "npm" -> @error_unexpected_domain_for_npm + "gtm" -> @error_unexpected_domain_for_gtm + "wordpress" -> @error_unexpected_domain_for_wordpress + _ -> @error_unexpected_domain_for_manual + end + end + + defp shorten_url(url) do + String.split(url, "?") |> List.first() + end + + defp success() do + %Result{ok?: true} + end + + defp handled_error(%Error{} = error, assigns \\ []) do + message = EEx.eval_string(error.message, assigns: assigns) + recommendation = EEx.eval_string(error.recommendation, assigns: assigns) + + %Result{ + ok?: false, + errors: [message], + recommendations: [%{text: recommendation, url: error.url}] + } + end + + defp unhandled_error(%Error{} = error, opts \\ []) do + browserless_issue = Keyword.get(opts, :browserless_issue, false) + + %Result{ + ok?: false, + data: %{unhandled: true, browserless_issue: browserless_issue}, + errors: [error.message], + recommendations: [%{text: error.recommendation, url: error.url}] + } + end +end diff --git a/extra/lib/plausible/site/tracker_script_id_cache.ex b/extra/lib/plausible/site/tracker_script_id_cache.ex new file mode 100644 index 000000000000..9711a5756866 --- /dev/null +++ b/extra/lib/plausible/site/tracker_script_id_cache.ex @@ -0,0 +1,50 @@ +defmodule Plausible.Site.TrackerScriptIdCache do + @moduledoc """ + Cache for tracker script IDs to avoid excessive database lookups when the + script API endpoint is bombarded with random IDs. + """ + alias Plausible.Site.TrackerScriptConfiguration + alias PlausibleWeb.Tracker + + import Ecto.Query + use Plausible + use Plausible.Cache + + @cache_name :tracker_script_id_cache + + @impl true + def name(), do: @cache_name + + @impl true + def child_id(), do: :cache_tracker_script_id + + @impl true + def count_all() do + Plausible.Repo.aggregate(TrackerScriptConfiguration, :count) + end + + @impl true + def base_db_query(), do: Tracker.get_tracker_script_configuration_base_query() + + @impl true + def get_from_source(id) do + case Tracker.get_tracker_script_configuration_by_id(id) do + %TrackerScriptConfiguration{site: %{domain: _domain}} -> + true + + _ -> + nil + end + end + + @impl true + def unwrap_cache_keys(items) do + Enum.reduce(items, [], fn + tracker_script_configuration, acc -> + [ + {tracker_script_configuration.id, true} + | acc + ] + end) + end +end diff --git a/extra/lib/plausible/stats/funnel.ex b/extra/lib/plausible/stats/funnel.ex index a9fae4ca90d6..5614c640b6b0 100644 --- a/extra/lib/plausible/stats/funnel.ex +++ b/extra/lib/plausible/stats/funnel.ex @@ -13,7 +13,7 @@ defmodule Plausible.Stats.Funnel do import Plausible.Stats.SQL.Fragments alias Plausible.ClickhouseRepo - alias Plausible.Stats.Base + alias Plausible.Stats.{Base, Query} @spec funnel(Plausible.Site.t(), Plausible.Stats.Query.t(), Funnel.t() | pos_integer()) :: {:ok, map()} | {:error, :funnel_not_found} @@ -28,8 +28,11 @@ defmodule Plausible.Stats.Funnel do end def funnel(_site, query, %Funnel{} = funnel) do + goals = Enum.map(funnel.steps, & &1.goal) + funnel_data = query + |> Query.set(preloaded_goals: %{all: [], matching_toplevel_filters: goals}) |> Base.base_event_query() |> query_funnel(funnel) diff --git a/extra/lib/plausible/stats/sampling.ex b/extra/lib/plausible/stats/sampling.ex index fc78534743e5..a1172281d7e0 100644 --- a/extra/lib/plausible/stats/sampling.ex +++ b/extra/lib/plausible/stats/sampling.ex @@ -3,8 +3,6 @@ defmodule Plausible.Stats.Sampling do Sampling related functions """ @default_sample_threshold 10_000_000 - # 1 percent - @min_sample_rate 0.01 import Ecto.Query @@ -56,17 +54,25 @@ defmodule Plausible.Stats.Sampling do end defp decide_sample_rate(site, query) do - site.id - |> SamplingCache.get() - |> fractional_sample_rate(query) + if Plausible.Sites.consolidated?(site) and not Enum.empty?(query.consolidated_site_ids) do + query.consolidated_site_ids + |> SamplingCache.consolidated_get() + |> fractional_sample_rate(query) + else + site.id + |> SamplingCache.get() + |> fractional_sample_rate(query) + end end - def fractional_sample_rate(nil = _traffic_30_day, _query), do: :no_sampling + def fractional_sample_rate(nil = _traffic_30_day, _query), + do: :no_sampling def fractional_sample_rate(traffic_30_day, query) do date_range = Query.date_range(query) duration = Date.diff(date_range.last, date_range.first) - estimated_traffic = traffic_30_day / 30.0 * duration + + estimated_traffic = estimate_traffic(traffic_30_day, duration, query) fraction = if(estimated_traffic > 0, @@ -79,7 +85,21 @@ defmodule Plausible.Stats.Sampling do duration < 1 -> :no_sampling # If sampling doesn't have a significant effect, don't sample fraction > 0.4 -> :no_sampling - true -> max(fraction, @min_sample_rate) + true -> max(fraction, min_sample_rate()) end end + + defp min_sample_rate(), do: 0.013 + + defp estimate_traffic(traffic_30_day, duration, query) do + duration_adjusted_traffic = traffic_30_day / 30.0 * duration + + estimate_by_filters(duration_adjusted_traffic, query.filters) + end + + @filter_traffic_multiplier 1 / 4.0 + @max_filters 2 + + defp estimate_by_filters(estimation, filters), + do: estimation * @filter_traffic_multiplier ** min(length(filters), @max_filters) end diff --git a/extra/lib/plausible/stats/sampling_cache.ex b/extra/lib/plausible/stats/sampling_cache.ex index 7f91e394706a..98fea262d236 100644 --- a/extra/lib/plausible/stats/sampling_cache.ex +++ b/extra/lib/plausible/stats/sampling_cache.ex @@ -1,11 +1,10 @@ defmodule Plausible.Stats.SamplingCache do @moduledoc """ - Cache storing estimation for events ingested by a team in the past month. + Cache storing estimation for events ingested by a site in the past month. Used for sampling rate calculations in Plausible.Stats.Sampling. """ alias Plausible.Ingestion - alias Plausible.Stats.Sampling import Ecto.Query use Plausible.Cache @@ -36,8 +35,7 @@ defmodule Plausible.Stats.SamplingCache do selected_as(fragment("sumIf(value, metric = 'buffered')"), :events_ingested) }, where: fragment("toDate(event_timebucket) >= ?", ^thirty_days_ago()), - group_by: r.site_id, - having: selected_as(:events_ingested) > ^Sampling.default_sample_threshold() + group_by: r.site_id ) end @@ -49,7 +47,13 @@ defmodule Plausible.Stats.SamplingCache do |> Map.get(site_id) end - def thirty_days_ago() do + @spec consolidated_get(list(pos_integer()), Keyword.t()) :: pos_integer() | nil + def consolidated_get(site_ids, opts \\ []) when is_list(site_ids) do + events_ingested = Enum.sum_by(site_ids, &(get(&1, opts) || 0)) + if events_ingested > 0, do: events_ingested + end + + defp thirty_days_ago() do Date.shift(Date.utc_today(), day: -30) end end diff --git a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex index a1c0fa09426f..706121a06bdf 100644 --- a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex +++ b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex @@ -8,6 +8,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do alias Plausible.Sites alias Plausible.Goal alias Plausible.Goals + alias Plausible.Props alias Plausible.Teams alias PlausibleWeb.Api.Helpers, as: H @@ -23,7 +24,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do |> paginate(params, @pagination_opts) json(conn, %{ - sites: page.entries, + sites: page.entries |> Enum.map(&get_site_response_for_index/1), meta: pagination_meta(page.metadata) }) end @@ -33,8 +34,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), - {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do - opts = [cursor_fields: [inserted_at: :desc, id: :desc], limit: 100, maximum_limit: 1000] + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do + opts = [cursor_fields: [sort_index: :asc], limit: 100, maximum_limit: 1000] page = site |> Sites.list_guests_query() |> paginate(params, opts) json(conn, %{ @@ -83,7 +84,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), - {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do page = site |> Plausible.Goals.for_site_query() @@ -97,7 +98,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do display_name: goal.display_name, goal_type: Goal.type(goal), event_name: goal.event_name, - page_path: goal.page_path + page_path: goal.page_path, + custom_props: goal.custom_props } end), meta: pagination_meta(page.metadata) @@ -115,11 +117,28 @@ defmodule PlausibleWeb.Api.ExternalSitesController do user = conn.assigns.current_user team = conn.assigns.current_team || Teams.get(params["team_id"]) - case Sites.create(user, params, team) do - {:ok, %{site: site}} -> - json(conn, site) + case Repo.transact(fn -> + with {:ok, %{site: site}} <- Sites.create(user, params, team), + {:ok, tracker_script_configuration} <- + get_or_create_config(site, params["tracker_script_configuration"] || %{}) do + {:ok, + struct(site, + tracker_script_configuration: tracker_script_configuration + )} + else + # Translates Multi error format to Repo.transact error format + {:error, step_id, output, context} -> + {:error, {step_id, output, context}} + + # already in Repo.transact error format + {:error, reason} -> + {:error, reason} + end + end) do + {:ok, site} -> + json(conn, get_site_response(site)) - {:error, _, {:over_limit, limit}, _} -> + {:error, {_, {:over_limit, limit}, _}} -> conn |> put_status(402) |> json(%{ @@ -127,21 +146,26 @@ defmodule PlausibleWeb.Api.ExternalSitesController do "Your account has reached the limit of #{limit} sites. To unlock more sites, please upgrade your subscription." }) - {:error, _, :permission_denied, _} -> + {:error, {_, :permission_denied, _}} -> conn |> put_status(403) |> json(%{ error: "You can't add sites to the selected team." }) - {:error, _, :multiple_teams, _} -> + {:error, {_, :multiple_teams, _}} -> conn |> put_status(400) |> json(%{ error: "You must select a team with 'team_id' parameter." }) - {:error, _, changeset, _} -> + {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} -> + conn + |> put_status(400) + |> json(serialize_errors(changeset, "tracker_script_configuration.")) + + {:error, {_, %Ecto.Changeset{} = changeset, _}} -> conn |> put_status(400) |> json(serialize_errors(changeset)) @@ -152,14 +176,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do user = conn.assigns.current_user team = conn.assigns.current_team - case get_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do - {:ok, site} -> - json(conn, %{ - domain: site.domain, - timezone: site.timezone, - custom_properties: site.allowed_event_props || [] - }) + with {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]), + {:ok, tracker_script_configuration} <- get_or_create_config(site, %{}) do + site = struct(site, tracker_script_configuration: tracker_script_configuration) + json(conn, get_site_response(site)) + else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") end @@ -169,7 +191,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do user = conn.assigns.current_user team = conn.assigns.current_team - case get_site(user, team, site_id, [:owner]) do + case find_site(user, team, site_id, [:owner]) do {:ok, site} -> {:ok, _} = Plausible.Site.Removal.run(site) json(conn, %{"deleted" => true}) @@ -183,18 +205,66 @@ defmodule PlausibleWeb.Api.ExternalSitesController do user = conn.assigns.current_user team = conn.assigns.current_team - # for now this only allows to change the domain - with {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]), - {:ok, site} <- Plausible.Site.Domain.change(site, params["domain"]) do - json(conn, site) + with {:ok, params} <- validate_update_payload(params), + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), + {:ok, site} <- do_update_site(site, params) do + json(conn, get_site_response(site)) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") - {:error, %Ecto.Changeset{} = changeset} -> + {:error, :no_changes} -> + H.bad_request( + conn, + "Payload must contain at least one of the parameters 'domain', 'tracker_script_configuration'" + ) + + {:error, {:domain_change_invalid, %Ecto.Changeset{} = changeset}} -> conn |> put_status(400) |> json(serialize_errors(changeset)) + + {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} -> + conn + |> put_status(400) + |> json(serialize_errors(changeset, "tracker_script_configuration.")) + end + end + + defp validate_update_payload(params) do + params = params |> Map.take(["domain", "tracker_script_configuration"]) |> Map.drop([nil]) + + if map_size(params) == 0 do + {:error, :no_changes} + else + {:ok, params} + end + end + + defp do_update_site(site, params) do + Repo.transact(fn -> + with {:ok, site} <- + if(params["domain"], + do: change_domain(site, params["domain"]), + else: {:ok, site} + ), + {:ok, tracker_script_configuration} <- + if(params["tracker_script_configuration"], + do: update_config(site, params["tracker_script_configuration"]), + else: get_or_create_config(site, %{}) + ) do + {:ok, struct(site, tracker_script_configuration: tracker_script_configuration)} + end + end) + end + + defp change_domain(site, domain) do + case Plausible.Site.Domain.change(site, domain) do + {:ok, site} -> + {:ok, site} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, {:domain_change_invalid, changeset}} end end @@ -205,7 +275,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, email} <- expect_param_key(params, "email"), {:ok, role} <- expect_param_key(params, "role", ["viewer", "editor"]), - {:ok, site} <- get_site(user, team, site_id, [:owner, :admin]) do + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do existing = Repo.one(Sites.list_guests_query(site, email: email)) if existing do @@ -215,7 +285,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do status: existing.status }) else - case Plausible.Site.Memberships.CreateInvitation.create_invitation( + case Teams.Invitations.InviteToSite.invite( site, conn.assigns.current_user, email, @@ -250,7 +320,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, email} <- expect_param_key(params, "email"), - {:ok, site} <- get_site(user, team, site_id, [:owner, :admin]) do + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do existing = Repo.one(Sites.list_guests_query(site, email: email)) case existing do @@ -285,7 +355,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, link_name} <- expect_param_key(params, "name"), - {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]) do + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]) do shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name) shared_link = @@ -300,6 +370,13 @@ defmodule PlausibleWeb.Api.ExternalSitesController do name: link.name, url: Sites.shared_link_url(site, link) }) + + {:error, %Ecto.Changeset{} = changeset} -> + {msg, _} = changeset.errors[:name] + H.bad_request(conn, msg) + + {:error, :upgrade_required} -> + H.payment_required(conn, "Your current subscription plan does not include Shared Links") end else {:error, :site_not_found} -> @@ -322,7 +399,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, _} <- expect_param_key(params, "goal_type"), - {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]), + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), {:ok, goal} <- Goals.find_or_create(site, params) do json(conn, goal) else @@ -332,6 +409,17 @@ 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) + + {:error, :upgrade_required} -> + H.payment_required( + conn, + "Your current subscription plan does not include Custom Properties" + ) + e -> H.bad_request(conn, "Something went wrong: #{inspect(e)}") end @@ -343,7 +431,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, goal_id} <- expect_param_key(params, "goal_id"), - {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]), + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), :ok <- Goals.delete(goal_id, site) do json(conn, %{"deleted" => true}) else @@ -364,6 +452,92 @@ defmodule PlausibleWeb.Api.ExternalSitesController do end end + def custom_props_index(conn, params) do + user = conn.assigns.current_user + team = conn.assigns.current_team + + with {:ok, site_id} <- expect_param_key(params, "site_id"), + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do + properties = + (site.allowed_event_props || []) + |> Enum.sort() + |> Enum.map(fn prop -> %{property: prop} end) + + json(conn, %{custom_properties: properties}) + else + {:error, :site_not_found} -> + H.not_found(conn, "Site could not be found") + + {:missing, "site_id"} -> + H.bad_request(conn, "Parameter `site_id` is required to list custom properties") + end + end + + def add_custom_prop(conn, params) do + user = conn.assigns.current_user + team = conn.assigns.current_team + + with {:ok, site_id} <- expect_param_key(params, "site_id"), + {:ok, property} <- expect_param_key(params, "property"), + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), + {:ok, _} <- Props.allow(site, property) do + json(conn, %{"created" => true}) + else + {:error, :site_not_found} -> + H.not_found(conn, "Site could not be found") + + {:missing, param} -> + H.bad_request(conn, "Parameter `#{param}` is required to create a custom property") + + {:error, :upgrade_required} -> + H.payment_required( + conn, + "Your current subscription plan does not include Custom Properties" + ) + + {:error, changeset} -> + %{allowed_event_props: [error | _]} = + Ecto.Changeset.traverse_errors(changeset, fn {_msg, opts} -> + cond do + opts[:type] == :list and opts[:validation] == :length -> + "Can't add any more custom properties" + + opts[:type] == :string and opts[:validation] == :length -> + "Parameter `property` is too long" + + true -> + "Parameter `property` is invalid" + end + end) + + H.bad_request(conn, error) + end + end + + def delete_custom_prop(conn, params) do + user = conn.assigns.current_user + team = conn.assigns.current_team + + with {:ok, site_id} <- expect_param_key(params, "site_id"), + {:ok, property} <- expect_param_key(params, "property"), + # Property name is extracted from route URL via wildcard, + # which returns a list. + property = Path.join(property), + {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), + {:ok, _} <- Props.disallow(site, property) do + json(conn, %{"deleted" => true}) + else + {:error, :site_not_found} -> + H.not_found(conn, "Site could not be found") + + {:missing, param} -> + H.bad_request(conn, "Parameter `#{param}` is required to delete a custom property") + + e -> + H.bad_request(conn, "Something went wrong: #{inspect(e)}") + end + end + defp pagination_meta(meta) do %{ after: meta.after, @@ -372,8 +546,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do } end - defp get_site(user, team, site_id, roles) do - case Plausible.Sites.get_for_user(user, site_id, roles) do + defp find_site(user, team, site_id, roles) do + case Plausible.Sites.get_for_user(user, site_id, roles: roles) do nil -> {:error, :site_not_found} @@ -388,9 +562,9 @@ defmodule PlausibleWeb.Api.ExternalSitesController do end end - defp serialize_errors(changeset) do + defp serialize_errors(changeset, field_prefix \\ "") do {field, {msg, _opts}} = List.first(changeset.errors) - error_msg = Atom.to_string(field) <> ": " <> msg + error_msg = field_prefix <> Atom.to_string(field) <> ": " <> msg %{"error" => error_msg} end @@ -412,4 +586,41 @@ defmodule PlausibleWeb.Api.ExternalSitesController do {:missing, key} end end + + defp get_or_create_config(site, params) do + case PlausibleWeb.Tracker.get_or_create_tracker_script_configuration(site, params) do + {:ok, tracker_script_configuration} -> + {:ok, tracker_script_configuration} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, {:tracker_script_configuration_invalid, changeset}} + end + end + + defp update_config(site, params) do + case PlausibleWeb.Tracker.update_script_configuration(site, params, :installation) do + {:ok, tracker_script_configuration} -> + {:ok, tracker_script_configuration} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, {:tracker_script_configuration_invalid, changeset}} + end + end + + defp get_site_response_for_index(site) do + site |> Map.take([:domain, :timezone]) + end + + defp get_site_response(site) do + site + |> Map.take([:domain, :timezone, :tracker_script_configuration]) + # 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 diff --git a/extra/lib/plausible_web/controllers/help_scout_controller.ex b/extra/lib/plausible_web/controllers/help_scout_controller.ex index 5fb68287fd4a..63974267863a 100644 --- a/extra/lib/plausible_web/controllers/help_scout_controller.ex +++ b/extra/lib/plausible_web/controllers/help_scout_controller.ex @@ -12,7 +12,7 @@ defmodule PlausibleWeb.HelpScoutController do assigns = %{conversation_id: conversation_id, customer_id: customer_id, token: token} with :ok <- HelpScout.validate_signature(conn), - {:ok, details} <- HelpScout.get_details_for_customer(customer_id) do + {:ok, details} <- HelpScout.get_details_for_customer(customer_id, conversation_id) do conn |> render("callback.html", Map.merge(assigns, details)) else @@ -49,7 +49,12 @@ defmodule PlausibleWeb.HelpScoutController do with :ok <- match_conversation(token, conversation_id), {:ok, details} <- - HelpScout.get_details_for_emails([email], customer_id, params["team_identifier"]) do + HelpScout.get_details_for_emails( + [email], + customer_id, + conversation_id, + params["team_identifier"] + ) do render(conn, "callback.html", Map.merge(assigns, details)) else {:error, error} -> @@ -71,7 +76,7 @@ defmodule PlausibleWeb.HelpScoutController do case match_conversation(token, conversation_id) do :ok -> - users = HelpScout.search_users(term, customer_id) + users = HelpScout.search_users(term, customer_id, conversation_id) render(conn, "search.html", Map.merge(assigns, %{users: users, term: term})) {:error, error} -> @@ -110,6 +115,6 @@ defmodule PlausibleWeb.HelpScoutController do defp make_iframe_friendly(conn, _opts) do conn |> delete_resp_header("x-frame-options") - |> put_layout(false) + |> put_root_layout(html: {PlausibleWeb.HelpScoutView, :layout}) end end diff --git a/extra/lib/plausible_web/controllers/sso_controller.ex b/extra/lib/plausible_web/controllers/sso_controller.ex new file mode 100644 index 000000000000..dfad5d4f0b9c --- /dev/null +++ b/extra/lib/plausible_web/controllers/sso_controller.ex @@ -0,0 +1,131 @@ +defmodule PlausibleWeb.SSOController do + use PlausibleWeb, :controller + + require Logger + + alias Plausible.Auth + alias Plausible.Auth.SSO + alias PlausibleWeb.LoginPreference + + alias PlausibleWeb.Router.Helpers, as: Routes + + plug Plausible.Plugs.AuthorizeTeamAccess, + [:owner] when action in [:sso_settings] + + plug Plausible.Plugs.AuthorizeTeamAccess, + [:owner, :admin] when action in [:team_sessions, :delete_session] + + def login_form(conn, params) do + login_preference = LoginPreference.get(conn) + error = Phoenix.Flash.get(conn.assigns.flash, :login_error) + + case {login_preference, params["prefer"], error} do + {nil, nil, nil} -> + redirect(conn, to: Routes.auth_path(conn, :login_form, return_to: params["return_to"])) + + _ -> + render(conn, "login_form.html", autosubmit: params["autosubmit"] != nil) + end + end + + def login(conn, %{"email" => email} = params) do + with :ok <- Auth.rate_limit(:login_ip, conn), + {:ok, %{sso_integration: integration}} <- SSO.Domains.lookup(email) do + redirect(conn, + to: + Routes.sso_path( + conn, + :saml_signin, + integration.identifier, + email: email, + return_to: params["return_to"] + ) + ) + else + {:error, :not_found} -> + conn + |> put_flash(:login_error, "Wrong email.") + |> redirect(to: Routes.sso_path(conn, :login_form)) + + {:error, {:rate_limit, _}} -> + Auth.log_failed_login_attempt("too many login attempts for #{email}") + + render_error( + conn, + 429, + "Too many login attempts. Wait a minute before trying again." + ) + end + end + + def provision_notice(conn, _params) do + render(conn, "provision_notice.html") + end + + def provision_issue(conn, params) do + issue = + case params["issue"] do + "not_a_member" -> :not_a_member + "multiple_memberships" -> :multiple_memberships + "multiple_memberships_noforce" -> :multiple_memberships_noforce + "active_personal_team" -> :active_personal_team + "active_personal_team_noforce" -> :active_personal_team_noforce + _ -> :unknown + end + + render(conn, "provision_issue.html", issue: issue) + end + + def saml_signin(conn, params) do + saml_adapter().signin(conn, params) + end + + def saml_consume(conn, params) do + saml_adapter().consume(conn, params) + end + + def csp_report(conn, _params) do + {:ok, body, conn} = Plug.Conn.read_body(conn) + Logger.error(body) + conn |> send_resp(200, "OK") + end + + def cta(conn, _params) do + render(conn, :cta, layout: {PlausibleWeb.LayoutView, :settings}) + end + + def sso_settings(conn, _params) do + if Plausible.Teams.setup?(conn.assigns.current_team) and + Plausible.Billing.Feature.SSO.check_availability(conn.assigns.current_team) == :ok do + render(conn, :sso_settings, + layout: {PlausibleWeb.LayoutView, :settings}, + connect_live_socket: true + ) + else + conn + |> redirect(to: Routes.site_path(conn, :index)) + end + end + + def team_sessions(conn, _params) do + sso_sessions = Auth.UserSessions.list_sso_for_team(conn.assigns.current_team) + + render(conn, :team_sessions, + layout: {PlausibleWeb.LayoutView, :settings}, + sso_sessions: sso_sessions + ) + end + + def delete_session(conn, %{"session_id" => session_id}) do + current_team = conn.assigns.current_team + Auth.UserSessions.revoke_sso_by_id(current_team, session_id) + + conn + |> put_flash(:success, "Session logged out successfully") + |> redirect(to: Routes.sso_path(conn, :team_sessions)) + end + + defp saml_adapter() do + Application.fetch_env!(:plausible, :sso_saml_adapter) + end +end diff --git a/extra/lib/plausible_web/dogfood.ex b/extra/lib/plausible_web/dogfood.ex index ee82b071ff75..6881e4d060a1 100644 --- a/extra/lib/plausible_web/dogfood.ex +++ b/extra/lib/plausible_web/dogfood.ex @@ -3,29 +3,76 @@ defmodule PlausibleWeb.Dogfood do Plausible tracking itself functions """ - @doc """ - Temporary override to do more testing of the new ingest.plausible.io endpoint for accepting events. In staging and locally - will fall back to staging.plausible.io/api/event and localhost:8000/api/event respectively. - """ - def api_destination() do - if Application.get_env(:plausible, :environment) == "prod" do - "https://ingest.plausible.io/api/event" - end + def script_params(assigns) do + %{ + script_url: script_url(assigns), + domain_to_replace: domain_to_replace(assigns), + location_override: location_override(assigns), + custom_properties: custom_properties(assigns), + capture_on_localhost: Application.get_env(:plausible, :environment) == "dev" + } end - def script_url() do - if Application.get_env(:plausible, :environment) in ["prod", "staging"] do - "#{PlausibleWeb.Endpoint.url()}/js/script.manual.pageview-props.tagged-events.pageleave.js" - else - "#{PlausibleWeb.Endpoint.url()}/js/script.local.manual.pageview-props.tagged-events.pageleave.js" - end + defp script_url(assigns) do + env = Application.get_env(:plausible, :environment) + selfhost? = Application.get_env(:plausible, :is_selfhost) + + tracker_script_config_id = + cond do + env == "prod" and selfhost? -> + "pa-V5OUguy5m04s95qHnmGbH" + + env == "prod" and assigns[:embedded] -> + "pa-Qo3A7Ksnbn-wYQWMijuR3" + + env == "prod" -> + "pa-6_srOGVV9SLMWJ1ZpUAbG" + + env == "staging" -> + "pa-egYOCIzzYzPL9v6GHLc-7" + + env in ["dev", "ce_dev"] -> + # By default we're not letting the app track itself on localhost. + # The requested script will be `.js` and the server will respond with 404. + # If you wish to track the app itself, uncomment the following code + # and replace the site_id if necessary (1 stands for dummy.site). + + # Plausible.Repo.get(Plausible.Site, 1) + # |> PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!() + # |> Map.get(:id) + + "pa-invalid-script-id" + + env in ["test", "ce_test", "e2e_test"] -> + "" + end + + "#{PlausibleWeb.Endpoint.url()}/js/#{tracker_script_config_id}.js" end - def domain(conn) do - cond do - Application.get_env(:plausible, :is_selfhost) -> "ee.plausible.io" - conn.assigns[:embedded] -> "embed." <> PlausibleWeb.Endpoint.host() - true -> PlausibleWeb.Endpoint.host() + # TRICKY: The React dashboard uses history-based SPA navigation + # which triggers pageviews on routes that the backend is unaware + # of (e.g.`/:domain/pages`). As opposed to other site paths like + # `/:domain/settings/general` we cannot override the entire URL. + # To still make sure we're not capturing customer domains we run + # a string replace in `payload.u`. + defp domain_to_replace(assigns) do + if not is_nil(assigns[:site]) and assigns[:demo] != true do + URI.encode_www_form(assigns.site.domain) end end + + defp location_override(%{dogfood_page_path: path}) when is_binary(path) do + Path.join(PlausibleWeb.Endpoint.url(), path) + end + + defp location_override(_), do: nil + + defp custom_properties(%{current_user: user}) when is_map(user) do + %{logged_in: true, theme: user.theme} + end + + defp custom_properties(_) do + %{logged_in: false} + end end diff --git a/extra/lib/plausible_web/live/customer_support.ex b/extra/lib/plausible_web/live/customer_support.ex new file mode 100644 index 000000000000..d5de1e0ae7cc --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support.ex @@ -0,0 +1,74 @@ +defmodule PlausibleWeb.Live.CustomerSupport do + @moduledoc """ + Customer Support home page (search) + """ + use PlausibleWeb, :live_view + + alias PlausibleWeb.CustomerSupport.Components.{Layout, Search} + + @impl true + def mount(params, _session, socket) do + uri = + Routes.customer_support_path( + PlausibleWeb.Endpoint, + :index, + Map.take(params, ["filter_text"]) + ) + |> URI.new!() + + {:ok, + assign(socket, + uri: uri, + filter_text: params["filter_text"] || "" + )} + end + + @impl true + def handle_params(params, _uri, socket) do + filter_text = params["filter_text"] || "" + socket = assign(socket, filter_text: filter_text) + + send_update(Search, id: "search-component", filter_text: filter_text) + + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" + <.flash_messages flash={@flash} /> + + + <.live_component module={Search} filter_text={@filter_text} id="search-component" /> + + """ + end + + @impl true + def handle_event("filter", %{"filter-text" => input}, socket) do + socket = set_filter_text(socket, input) + {:noreply, socket} + end + + def handle_event("reset-filter-text", _params, socket) do + socket = set_filter_text(socket, "") + {:noreply, socket} + end + + defp set_filter_text(socket, filter_text) do + uri = socket.assigns.uri + + uri_params = + (uri.query || "") + |> URI.decode_query() + |> Map.put("filter_text", filter_text) + |> URI.encode_query() + + uri = %{uri | query: uri_params} + + socket + |> assign(:filter_text, filter_text) + |> assign(:uri, uri) + |> push_patch(to: URI.to_string(uri), replace: true) + end +end diff --git a/extra/lib/plausible_web/live/customer_support/components/layout.ex b/extra/lib/plausible_web/live/customer_support/components/layout.ex new file mode 100644 index 000000000000..fa6d6d605ec8 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/components/layout.ex @@ -0,0 +1,124 @@ +defmodule PlausibleWeb.CustomerSupport.Components.Layout do + @moduledoc """ + Base layout component for Customer Support UI + Provides common header, filter bar, and content area structure + """ + use PlausibleWeb, :component + import PlausibleWeb.Live.Flash + + attr :filter_text, :string, default: "" + attr :show_search, :boolean, default: true + attr :flash, :map, default: %{} + slot :inner_block, required: true + slot :filter_actions, required: false + + def layout(assigns) do + ~H""" +
+ <.help_overlay /> + +
+ <.header filter_text={@filter_text} /> + +
+ <.styled_link class="text-sm" onclick="window.history.go(-1); return false;"> + ← Go back + +
+ + <.search_bar :if={@show_search} filter_text={@filter_text}> + {render_slot(@filter_actions)} + + + <.flash_messages flash={@flash} /> + +
+ {render_slot(@inner_block)} +
+ +
+ <.styled_link class="text-sm" onclick="window.history.go(-1); return false;"> + ← Go back + +
+
+
+ """ + end + + defp help_overlay(assigns) do + ~H""" +
+
+ Prefix your searches with:

+
+ site:input
+

+ Search for sites exclusively. Input will be checked against site's domain, team's name, owners' names and e-mails. +

+ user:input
+

+ Search for users exclusively. Input will be checked against user's name and e-mail. +

+ team:input
+

+ Search for teams exclusively. Input will be checked against user/team name, e-mail or team identifier. Identifier must be provided complete, as is. +

+ + team:input +sub +
+

+ Like above, but only finds team(s) with subscription (in any status). +

+ + team:input +sso +
+

+ Like above, but only finds team(s) with SSO integrations (in any status). +

+
+
+
+ """ + end + + attr :filter_text, :string, required: true + + defp header(assigns) do + ~H""" +
+

+ <.link + replace + patch={ + Routes.customer_support_path(PlausibleWeb.Endpoint, :index, %{filter_text: @filter_text}) + } + > + 💬 Customer Support + +

+
+ """ + end + + attr :filter_text, :string, required: true + slot :inner_block, required: false + + defp search_bar(assigns) do + ~H""" +
+ <.filter_bar filter_text={@filter_text} placeholder="Search everything"> + + + + {render_slot(@inner_block)} + +
+ """ + end +end diff --git a/extra/lib/plausible_web/live/customer_support/components/search.ex b/extra/lib/plausible_web/live/customer_support/components/search.ex new file mode 100644 index 000000000000..53469cbc97eb --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/components/search.ex @@ -0,0 +1,125 @@ +defmodule PlausibleWeb.CustomerSupport.Components.Search do + @moduledoc """ + Dedicated search component for Customer Support + Handles search logic independently from other components + """ + use PlausibleWeb, :live_component + + alias Plausible.CustomerSupport.Resource + alias PlausibleWeb.CustomerSupport.Components.SearchResult + + @resources [Resource.Team, Resource.User, Resource.Site] + + def update(%{filter_text: filter_text} = assigns, socket) do + socket = assign(socket, assigns) + {:ok, perform_search(socket, filter_text)} + end + + def handle_event("search-updated", %{"filter_text" => filter_text}, socket) do + {:noreply, perform_search(socket, filter_text)} + end + + def render(assigns) do + ~H""" +
+
    +
  • + <.link patch={r.path} data-test-type={r.type} data-test-id={r.id}> +
    +
    + <.render_result resource={r} /> +
    +
    + +
  • +
+ +
+ No results found for "{@filter_text}" +
+ +
+ Enter a search term to find teams, users, or sites +
+
+ """ + end + + def render_result(assigns) do + SearchResult.render_result(assigns) + end + + defp perform_search(socket, filter_text) do + results = spawn_searches(filter_text) + assign(socket, :results, results) + end + + defp spawn_searches(input) do + input = String.trim(input) + + {resources, input, opts} = + maybe_focus_search(input) + + resources + |> Task.async_stream(fn resource -> + input + |> resource.search(opts) + |> Enum.map(&resource.dump/1) + end) + |> Enum.reduce([], fn {:ok, results}, acc -> + acc ++ results + end) + end + + defp maybe_focus_search(lone_modifier) when lone_modifier in ["site:", "team:", "user:"] do + {[], "", limit: 0} + end + + defp maybe_focus_search("site:" <> rest) do + {[Resource.Site], rest, limit: 90} + end + + defp maybe_focus_search("team:" <> rest) do + [input | mods] = String.split(rest, "+", trim: true) + input = String.trim(input) + + opts = + if "sub" in mods do + [limit: 90, with_subscription_only?: true] + else + [limit: 90] + end + + opts = + if "sso" in mods do + Keyword.merge(opts, with_sso_only?: true) + else + opts + end + + opts = + case Ecto.UUID.cast(input) do + {:ok, _uuid} -> + Keyword.merge(opts, uuid_provided?: true) + + _ -> + opts + end + + {[Resource.Team], input, opts} + end + + defp maybe_focus_search("user:" <> rest) do + {[Resource.User], rest, limit: 90} + end + + defp maybe_focus_search(input) do + {@resources, input, limit: 30} + end +end diff --git a/extra/lib/plausible_web/live/customer_support/components/search_result.ex b/extra/lib/plausible_web/live/customer_support/components/search_result.ex new file mode 100644 index 000000000000..5cb2c00a63bd --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/components/search_result.ex @@ -0,0 +1,105 @@ +defmodule PlausibleWeb.CustomerSupport.Components.SearchResult do + @moduledoc """ + Component responsible for rendering search result cards for customer support resources + """ + use PlausibleWeb, :live_component + + def favicon(assigns) do + ~H""" + + """ + end + + def render_result(%{resource: %{type: "team"}} = assigns) do + ~H""" +
+
+
+ T +
+

+ {@resource.object.name} +

+ + + Team + + + $ + +
+ +
+
+ Team identifier: + {@resource.object.identifier |> String.slice(0, 8)} +
+ Owned by: {@resource.object.owners + |> Enum.map(& &1.name) + |> Enum.join(", ")} +
+
+ """ + end + + def render_result(%{resource: %{type: "user"}} = assigns) do + ~H""" +
+
+ +

+ {@resource.object.name} +

+ + + User + +
+ +
+
+ {@resource.object.name} <{@resource.object.email}>
+ Owns {length(@resource.object.owned_teams)} team(s) +
+
+ """ + end + + def render_result(%{resource: %{type: "site"}} = assigns) do + ~H""" +
+
+ <.favicon class="w-5" domain={@resource.object.domain} /> +

+ {@resource.object.domain} +

+ + + Site + +
+ +
+
+ Part of {@resource.object.team.name} +
+ owned by {@resource.object.team.owners + |> Enum.map(& &1.name) + |> Enum.join(", ")} +
+
+ """ + end +end diff --git a/extra/lib/plausible_web/live/customer_support/live.ex b/extra/lib/plausible_web/live/customer_support/live.ex new file mode 100644 index 000000000000..fecf2f8b7743 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/live.ex @@ -0,0 +1,133 @@ +defmodule PlausibleWeb.CustomerSupport.Live do + @moduledoc """ + Shared module providing common LiveView functionality for Customer Support. + + Provides: + - Standard mount/3 and handle_info/2 implementations + - Tab navigation components and routing utilities + - Common aliases and imports for Customer Support LiveViews + - Convenience API for flashes and redirects + """ + + defmacro __using__(_opts) do + quote do + use PlausibleWeb, :live_view + + alias Plausible.CustomerSupport.Resource + alias PlausibleWeb.CustomerSupport.Components.Layout + import PlausibleWeb.CustomerSupport.Live + import PlausibleWeb.Components.Generic + alias PlausibleWeb.Router.Helpers, as: Routes + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def handle_info({:success, msg}, socket) do + {:noreply, put_flash(socket, :success, msg)} + end + + def handle_info({:error, msg}, socket) do + {:noreply, put_flash(socket, :error, msg)} + end + + def handle_info({:navigate, path, success_msg}, socket) do + socket = if success_msg, do: put_flash(socket, :success, success_msg), else: socket + {:noreply, push_navigate(socket, to: path)} + end + + def handle_params(%{"id" => _id} = params, _uri, socket) do + handle_params(Map.put(params, "tab", "overview"), nil, socket) + end + + defoverridable mount: 3, handle_info: 2, handle_params: 3 + end + end + + @spec success(String.t()) :: :ok + def success(msg) do + send(self(), {:success, msg}) + end + + @spec failure(String.t()) :: :ok + def failure(msg) do + send(self(), {:error, msg}) + end + + @spec navigate_with_success(String.t(), String.t()) :: :ok + def navigate_with_success(path, msg) do + send(self(), {:navigate, path, msg}) + end + + use Phoenix.Component + import Phoenix.LiveView + + attr :to, :string, required: true + attr :tab, :string, required: true + slot :inner_block, required: true + + def tab(assigns) do + current_class = "text-gray-900 dark:text-gray-100" + + inactive_class = + "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" + + assigns = + assign( + assigns, + :link_class, + if(assigns.to == assigns.tab, do: current_class, else: inactive_class) + ) + + ~H""" + <.link + patch={"?tab=#{@to}"} + class={[ + @link_class, + "group relative min-w-0 flex-1 overflow-hidden bg-white dark:bg-gray-800 py-4 px-6 text-center text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-750 focus:z-10 first:rounded-l-lg last:rounded-r-lg" + ]} + > + {render_slot(@inner_block)} + + """ + end + + attr :tab, :string, required: true + attr :extra_classes, :string, default: "" + slot :tabs, required: true + + def tab_navigation(assigns) do + ~H""" +
+ +
+ """ + end + + def go_to_tab(socket, tab, params, resource_key, component) do + tab_params = Map.drop(params, ["id", "tab"]) + resource = Map.get(socket.assigns, resource_key) + + update_params = [ + id: "#{resource_key}-#{resource.id}-#{tab}", + tab: tab, + tab_params: tab_params + ] + + update_params = Keyword.put(update_params, resource_key, resource) + + send_update(component, update_params) + + socket + end +end diff --git a/extra/lib/plausible_web/live/customer_support/site.ex b/extra/lib/plausible_web/live/customer_support/site.ex new file mode 100644 index 000000000000..88d450e2bd76 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/site.ex @@ -0,0 +1,115 @@ +defmodule PlausibleWeb.Live.CustomerSupport.Site do + @moduledoc """ + Site coordinator LiveView for Customer Support interface. + + Manages tab-based navigation and delegates rendering to specialized + components: Overview, People, and Rescue Zone. + """ + use PlausibleWeb.CustomerSupport.Live + + alias PlausibleWeb.CustomerSupport.Site.Components.{ + Overview, + People, + RescueZone + } + + def favicon(assigns) do + ~H""" + + """ + end + + def handle_params(%{"id" => site_id} = params, _uri, socket) do + tab = params["tab"] || "overview" + site = Resource.Site.get(site_id) + + if site do + socket = + socket + |> assign(:site, site) + |> assign(:tab, tab) + + {:noreply, go_to_tab(socket, tab, params, :site, tab_component(tab))} + else + {:noreply, redirect(socket, to: Routes.customer_support_path(socket, :index))} + end + end + + def render(assigns) do + ~H""" + + <.site_header site={@site} /> + <.site_tab_navigation site={@site} tab={@tab} /> + + <.live_component + module={tab_component(@tab)} + site={@site} + tab={@tab} + id={"site-#{@site.id}-#{@tab}"} + /> + + """ + end + + defp site_header(assigns) do + ~H""" +
+
+ <.favicon class="w-8" domain={@site.domain} /> +
+
+

+ {@site.domain} + + Rejecting traffic + + + + Traffic blocked + +

+

+ Timezone: {@site.timezone} +

+

+ Team: + <.styled_link patch={ + Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, @site.team.id) + }> + {@site.team.name} + +

+

+ (previously: {@site.domain_changed_from}) +

+
+
+ """ + end + + defp site_tab_navigation(assigns) do + ~H""" + <.tab_navigation tab={@tab}> + <:tabs> + <.tab to="overview" tab={@tab}>Overview + <.tab to="people" tab={@tab}>People + <.tab to="rescue-zone" tab={@tab}>Rescue Zone + + + """ + end + + defp tab_component("overview"), do: Overview + defp tab_component("people"), do: People + defp tab_component("rescue-zone"), do: RescueZone + defp tab_component(_), do: Overview +end diff --git a/extra/lib/plausible_web/live/customer_support/site/components/overview.ex b/extra/lib/plausible_web/live/customer_support/site/components/overview.ex new file mode 100644 index 000000000000..c67ee6cb937d --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/site/components/overview.ex @@ -0,0 +1,104 @@ +defmodule PlausibleWeb.CustomerSupport.Site.Components.Overview do + @moduledoc """ + Site overview component - handles site settings and management + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + + def update(%{site: site}, socket) do + changeset = Plausible.Site.crm_changeset(site, %{}) + form = to_form(changeset) + {:ok, assign(socket, site: site, form: form)} + end + + def render(assigns) do + ~H""" +
+ <.form :let={f} for={@form} phx-target={@myself} phx-submit="save-site"> +
+ Quick links: + + <.styled_link + new_tab={true} + href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, @site.domain, [])} + > + Dashboard + + + <.styled_link + new_tab={true} + href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, @site.domain, [])} + > + Settings + + + <.styled_link + new_tab={true} + href={"https://plausible.grafana.net/d/BClBG5b4k/ingest-counters-per-domain?orgId=1&from=now-24h&to=now&timezone=browser&var-domain=#{@site.domain}&refresh=10s"} + > + Ingest Overview + +
+ + <.input + type="select" + field={f[:timezone]} + label="Timezone" + options={Plausible.Timezones.options()} + /> + <.input type="checkbox" field={f[:public]} label="Public?" /> + <.input type="datetime-local" field={f[:native_stats_start_at]} label="Native Stats Start At" /> + <.input + type="text" + field={f[:ingest_rate_limit_threshold]} + label="Ingest Rate Limit Threshold" + /> + <.input + type="text" + field={f[:ingest_rate_limit_scale_seconds]} + label="Ingest Rate Limit Scale Seconds" + /> + +
+ <.button phx-target={@myself} type="submit"> + Save + + + <.button + phx-target={@myself} + phx-click="delete-site" + data-confirm="Are you sure you want to delete this site?" + theme="danger" + > + Delete Site + +
+ +
+ """ + end + + def handle_event("save-site", %{"site" => params}, socket) do + site = socket.assigns.site + + case Plausible.Site.crm_changeset(site, params) |> Plausible.Repo.update() do + {:ok, updated_site} -> + form = Plausible.Site.crm_changeset(updated_site, %{}) |> to_form() + success("Site updated successfully") + {:noreply, assign(socket, site: updated_site, form: form)} + + {:error, changeset} -> + form = changeset |> to_form() + failure("Failed to update site") + {:noreply, assign(socket, form: form)} + end + end + + def handle_event("delete-site", _, socket) do + site = socket.assigns.site + + {:ok, _} = Plausible.Site.Removal.run(site) + navigate_with_success(Routes.customer_support_path(socket, :index), "Site deleted") + {:noreply, socket} + end +end diff --git a/extra/lib/plausible_web/live/customer_support/site/components/people.ex b/extra/lib/plausible_web/live/customer_support/site/components/people.ex new file mode 100644 index 000000000000..f3d284ede60c --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/site/components/people.ex @@ -0,0 +1,70 @@ +defmodule PlausibleWeb.CustomerSupport.Site.Components.People do + @moduledoc """ + Site people component - handles site memberships and invitations + """ + use PlausibleWeb, :live_component + + def update(%{site: site}, socket) do + people = Plausible.Sites.list_people(site) + + people = + (people.invitations ++ people.memberships) + |> Enum.map(fn p -> + if Map.has_key?(p, :invitation_id) do + {:invitation, p.email, p.role} + else + {:membership, p.user, p.role} + end + end) + + {:ok, assign(socket, site: site, people: people)} + end + + def render(assigns) do + ~H""" +
+ <.table rows={@people}> + <:thead> + <.th>User + <.th>Kind + <.th>Role + + <:tbody :let={{kind, person, role}}> + <.td :if={kind == :membership}> + <.styled_link + class="flex items-center" + patch={Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, person.id)} + > + + {person.name} + + + + <.td :if={kind == :invitation}> +
+ + {person} +
+ + + <.td :if={kind == :membership}> + Membership + + + <.td :if={kind == :invitation}> + Invitation + + + <.td>{role} + + +
+ """ + end +end diff --git a/extra/lib/plausible_web/live/customer_support/site/components/rescue_zone.ex b/extra/lib/plausible_web/live/customer_support/site/components/rescue_zone.ex new file mode 100644 index 000000000000..31e7dcc4ea60 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/site/components/rescue_zone.ex @@ -0,0 +1,89 @@ +defmodule PlausibleWeb.CustomerSupport.Site.Components.RescueZone do + @moduledoc """ + Site rescue zone component - handles site transfer functionality + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + import PlausibleWeb.Components.Generic + import Ecto.Query + alias Plausible.Repo + alias PlausibleWeb.Live.Components.ComboBox + + def update(%{site: site}, socket) do + first_owner = + hd(Repo.preload(site, :owners).owners) + + {:ok, assign(socket, site: site, first_owner: first_owner)} + end + + def render(assigns) do + ~H""" +
+

Transfer Site

+ + <.label for="inviter_email"> + Initiate transfer as + + <.live_component + id="inviter_email" + submit_name="inviter_email" + class={[ + "mb-4" + ]} + module={ComboBox} + suggest_fun={fn input, _ -> search_email(input) end} + selected={{@first_owner.email, "#{@first_owner.name} <#{@first_owner.email}>"}} + /> + + <.label for="invitee_email"> + Send transfer invitation to + + <.live_component + id="invitee_email" + submit_name="invitee_email" + module={ComboBox} + suggest_fun={fn input, _ -> search_email(input) end} + creatable + /> + <.button phx-target={@myself} type="submit"> + Initiate Site Transfer + + +
+ """ + end + + def handle_event("init-transfer", params, socket) do + inviter = Plausible.Repo.get_by!(Plausible.Auth.User, email: params["inviter_email"]) + + case Plausible.Teams.Invitations.InviteToSite.invite( + socket.assigns.site, + inviter, + params["invitee_email"], + :owner + ) do + {:ok, _transfer} -> + success("Transfer e-mail sent!") + {:noreply, socket} + + error -> + failure("Transfer failed: #{inspect(error)}") + {:noreply, socket} + end + end + + defp search_email(input) do + Repo.all( + from u in Plausible.Auth.User, + where: ilike(u.name, ^"%#{input}%") or ilike(u.email, ^"%#{input}%"), + limit: 20, + order_by: [ + desc: fragment("?.name = ?", u, ^input), + desc: fragment("?.email = ?", u, ^input), + asc: u.name + ], + select: {u.email, u.name} + ) + |> Enum.map(fn {email, name} -> {email, "#{name} <#{email}>"} end) + end +end diff --git a/extra/lib/plausible_web/live/customer_support/team.ex b/extra/lib/plausible_web/live/customer_support/team.ex new file mode 100644 index 000000000000..bdcb8d505cb1 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team.ex @@ -0,0 +1,381 @@ +defmodule PlausibleWeb.Live.CustomerSupport.Team do + @moduledoc """ + Team coordinator LiveView for Customer Support interface. + + Manages tab-based navigation and delegates rendering to specialized + components: Overview, Members, Sites, Billing, SSO, and Audit. + """ + use PlausibleWeb.CustomerSupport.Live + + alias PlausibleWeb.CustomerSupport.Team.Components.{ + Overview, + Members, + Sites, + ConsolidatedViews, + Billing, + SSO, + Audit + } + + require Plausible.Billing.Subscription.Status + + def handle_params(%{"id" => id} = params, _uri, socket) do + tab = params["tab"] || "overview" + team_id = String.to_integer(id) + team = Resource.Team.get(team_id) + + if team do + tab_params = Map.drop(params, ["id", "tab"]) + + socket = + socket + |> assign(:team, team) + |> assign(:tab, tab) + |> assign(:tab_params, tab_params) + + {:noreply, go_to_tab(socket, tab, params, :team, tab_component(tab))} + else + {:noreply, redirect(socket, to: Routes.customer_support_path(socket, :index))} + end + end + + def handle_event("unlock", _, socket) do + {:noreply, unlock_team(socket)} + end + + def handle_event("lock", _, socket) do + {:noreply, lock_team(socket)} + end + + def handle_event("refund-lock", _, socket) do + team = socket.assigns.team + + {:ok, team} = + Plausible.Repo.transaction(fn -> + yesterday = Date.shift(Date.utc_today(), day: -1) + Plausible.Billing.SiteLocker.set_lock_status_for(team, true) + + Plausible.Repo.update!( + Plausible.Billing.Subscription.changeset(team.subscription, %{next_bill_date: yesterday}) + ) + + Resource.Team.get(team.id) + end) + + {:noreply, assign(socket, team: team)} + end + + def render(assigns) do + team = assigns.team + + usage = Plausible.Teams.Billing.quota_usage(team, with_features: true) + + limits = %{ + monthly_pageviews: Plausible.Teams.Billing.monthly_pageview_limit(team), + sites: Plausible.Teams.Billing.site_limit(team), + team_members: Plausible.Teams.Billing.team_member_limit(team) + } + + assigns = assign(assigns, usage: usage, limits: limits) + + ~H""" + + <.team_header team={@team} /> + <.team_tab_navigation team={@team} tab={@tab} limits={@limits} usage={@usage} /> + <.subscription_bar team={@team} /> + + <.live_component + module={tab_component(@tab)} + team={@team} + tab={@tab} + tab_params={@tab_params} + id={"team-#{@team.id}-#{@tab}"} + /> + + """ + end + + defp team_header(assigns) do + ~H""" +
+
+
+
+
+ +
+
+
+

+ {@team.name} +

+

+ Set up at {@team.setup_at} + Not set up yet +

+
+
+ +
+ <.input_with_clipboard + id="team-identifier" + name="team-identifier" + label="Team Identifier" + value={@team.identifier} + onfocus="this.value = this.value;" + /> +
+
+
+ """ + end + + defp team_tab_navigation(assigns) do + ~H""" + <.tab_navigation tab={@tab}> + <:tabs> + <.tab to="overview" tab={@tab}>Overview + <.tab to="members" tab={@tab}> + Members ({number_format(@usage.team_members)}/{number_format(@limits.team_members)}) + + <.tab to="sites" tab={@tab}> + Sites ({number_format(@usage.sites)}/{number_format(@limits.sites)}) + + <.tab :if={Plausible.Teams.setup?(@team)} to="consolidated_views" tab={@tab}> + Consolidated Views + + <.tab :if={has_sso_integration?(@team)} to="sso" tab={@tab}>SSO + <.tab to="billing" tab={@tab}>Billing + <.tab to="audit" tab={@tab}>Audit + + + """ + end + + defp subscription_bar(assigns) do + ~H""" +
+
+ + Subscription status
{subscription_status(@team)} +
+ +
+ + <.styled_link + phx-click="refund-lock" + data-confirm="Are you sure you want to lock? The only way to unlock, is for the user to resubscribe." + > + Refund Lock + +
+ +
+ + Locked +
+
+
+
+
+
+ + Subscription plan
{subscription_plan(@team)} +
+
+
+ + Grace Period
{grace_period_status(@team)} + +
+ +
+ + <.styled_link phx-click="unlock">Unlock +
+ +
+ + <.styled_link phx-click="lock">Lock +
+
+
+
+
+
+ """ + end + + defp tab_component("overview"), do: Overview + defp tab_component("members"), do: Members + defp tab_component("sites"), do: Sites + defp tab_component("consolidated_views"), do: ConsolidatedViews + defp tab_component("billing"), do: Billing + defp tab_component("sso"), do: SSO + defp tab_component("audit"), do: Audit + defp tab_component(_), do: Overview + + defp has_sso_integration?(team) do + case Plausible.Auth.SSO.get_integration_for(team) do + {:ok, _} -> true + {:error, :not_found} -> false + end + end + + defp team_bg(term) do + list = [ + "bg-blue-500", + "bg-blue-600", + "bg-blue-700", + "bg-blue-800", + "bg-cyan-500", + "bg-cyan-600", + "bg-cyan-700", + "bg-cyan-800", + "bg-red-500", + "bg-red-600", + "bg-red-700", + "bg-red-800", + "bg-green-500", + "bg-green-600", + "bg-green-700", + "bg-green-800", + "bg-yellow-500", + "bg-yellow-600", + "bg-yellow-700", + "bg-yellow-800", + "bg-orange-500", + "bg-orange-600", + "bg-orange-700", + "bg-orange-800", + "bg-purple-500", + "bg-purple-600", + "bg-purple-700", + "bg-purple-800", + "bg-gray-500", + "bg-gray-600", + "bg-gray-700", + "bg-gray-800", + "bg-emerald-500", + "bg-emerald-600", + "bg-emerald-700", + "bg-emerald-800" + ] + + idx = :erlang.phash2(term, length(list)) + Enum.at(list, idx) + end + + defp subscription_status(team) do + cond do + team && team.subscription -> + status_str = + PlausibleWeb.Components.Billing.Helpers.present_subscription_status( + team.subscription.status + ) + + if team.subscription.paddle_subscription_id do + assigns = %{status_str: status_str, subscription: team.subscription} + + ~H""" + <.styled_link new_tab={true} href={manage_url(@subscription)}>{@status_str} + """ + else + status_str + end + + Plausible.Teams.on_trial?(team) -> + "On trial" + + true -> + "Trial expired" + end + end + + defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do + Plausible.Billing.PaddleApi.vendors_domain() <> + "/subscriptions/customers/manage/" <> paddle_id + end + + defp subscription_plan(team) do + subscription = team.subscription + + if Plausible.Billing.Subscription.Status.active?(subscription) && + subscription.paddle_subscription_id do + quota = PlausibleWeb.AuthView.subscription_quota(subscription) + interval = PlausibleWeb.AuthView.subscription_interval(subscription) + + assigns = %{quota: quota, interval: interval, subscription: subscription} + + ~H""" + <.styled_link new_tab={true} href={manage_url(@subscription)}> + {@quota} ({@interval}) + + """ + else + "--" + end + end + + defp grace_period_status(team) do + grace_period = team.grace_period + + case grace_period do + nil -> + "--" + + %{manual_lock: true, is_over: true} -> + "Manually locked" + + %{manual_lock: true, is_over: false} -> + "Waiting for manual lock" + + %{is_over: true} -> + "ended" + + %{end_date: %Date{} = end_date} -> + days_left = Date.diff(end_date, Date.utc_today()) + "#{days_left} days left" + end + end + + defp lock_team(socket) do + if socket.assigns.team.grace_period do + team = Plausible.Teams.end_grace_period(socket.assigns.team) + Plausible.Billing.SiteLocker.set_lock_status_for(team, true) + + put_live_flash(socket, :success, "Team locked. Grace period ended.") + assign(socket, team: team) + else + put_live_flash(socket, :error, "No grace period") + socket + end + end + + defp unlock_team(socket) do + if socket.assigns.team.grace_period do + team = Plausible.Teams.remove_grace_period(socket.assigns.team) + Plausible.Billing.SiteLocker.set_lock_status_for(team, false) + + put_live_flash(socket, :success, "Team unlocked. Grace period removed.") + assign(socket, team: team) + else + socket + end + end + + defp number_format(unlimited) when unlimited in [-1, "unlimited", :unlimited] do + "unlimited" + end + + defp number_format(input) do + PlausibleWeb.TextHelpers.number_format(input) + end +end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/audit.ex b/extra/lib/plausible_web/live/customer_support/team/components/audit.ex new file mode 100644 index 000000000000..937aee3a2724 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team/components/audit.ex @@ -0,0 +1,245 @@ +defmodule PlausibleWeb.CustomerSupport.Team.Components.Audit do + @moduledoc """ + Team audit component - handles audit log viewing + """ + use PlausibleWeb, :live_component + + def update(%{team: team, tab_params: tab_params}, socket) do + pagination_params = get_pagination_params(tab_params) + audit_page = Plausible.Audit.list_entries_paginated([team_id: team.id], pagination_params) + + entries = process_audit_entries(audit_page.entries) + audit_page = %{audit_page | entries: entries} + current_limit = pagination_params["limit"] + + {:ok, + assign(socket, + team: team, + audit_page: audit_page, + revealed_audit_entry_id: nil, + current_limit: current_limit + )} + end + + def update(%{team: team}, socket) do + update(%{team: team, tab_params: %{}}, socket) + end + + def render(assigns) do + ~H""" +
+
+ No audit logs yet +
+ +
+ <.input_with_clipboard + id="audit-entry-identifier" + name="audit-entry-identifier" + label="Audit Entry Identifier" + value={@revealed_audit_entry_id} + /> +
+ <.input + rows="16" + type="textarea" + id="audit-entry-change" + name="audit-entry-change" + value={ + Jason.encode!( + Enum.find(@audit_page.entries, &(&1.id == @revealed_audit_entry_id)).change, + pretty: true + ) + } + > + + <.styled_link + class="text-sm float-right" + onclick="var textarea = document.getElementById('audit-entry-change'); textarea.focus(); textarea.select(); document.execCommand('copy');" + href="#" + > +
+ COPY +
+ + + <.styled_link + phx-click="reveal-audit-entry" + phx-target={@myself} + class="float-right pt-4 text-sm" + > + ← Return + + ESC + + +
+
+ + <.table :if={is_nil(@revealed_audit_entry_id)} rows={@audit_page.entries}> + <:thead> + <.th invisible> + <.th invisible> + <.th>Name + <.th>Entity + <.th>Actor + <.th invisible>Actions + + <:tbody :let={entry}> + <.td>{Calendar.strftime(entry.datetime, "%Y-%m-%d")} + <.td>{Calendar.strftime(entry.datetime, "%H:%M:%S")} + <.td class="font-mono">{entry.name} + <.td truncate> + <.audit_entity entry={entry} /> + + <.td :if={entry.actor_type == :system}> +
+ SYSTEM +
+ + <.td :if={entry.actor_type == :user} truncate> + <.audit_user user={entry.meta.user} /> + + + <.td actions> + <.edit_button + phx-click="reveal-audit-entry" + phx-value-id={entry.id} + phx-target={@myself} + /> + + + + +
+ <.button + :if={@audit_page.metadata.before} + id="prev-page" + phx-click="paginate-audit" + phx-value-before={@audit_page.metadata.before} + phx-value-limit={@current_limit} + phx-target={@myself} + theme="secondary" + > + ← Prev + +
+ <.button + :if={@audit_page.metadata.after} + id="next-page" + phx-click="paginate-audit" + phx-value-after={@audit_page.metadata.after} + phx-value-limit={@current_limit} + phx-target={@myself} + theme="secondary" + > + Next → + +
+
+ """ + end + + def handle_event("reveal-audit-entry", %{"id" => id}, socket) do + {:noreply, assign(socket, revealed_audit_entry_id: id)} + end + + def handle_event("reveal-audit-entry", _, socket) do + {:noreply, assign(socket, revealed_audit_entry_id: nil)} + end + + def handle_event("paginate-audit", params, socket) do + pagination_params = get_pagination_params(params) + team = socket.assigns.team + + query_params = %{"tab" => "audit"} |> Map.merge(pagination_params) + + {:noreply, + push_patch(socket, + to: Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, team.id, query_params) + )} + end + + defp process_audit_entries(entries) do + Enum.map(entries, fn entry -> + meta = entry.meta + + meta = + if entry.user_id && entry.user_id > 0 do + user = Plausible.Repo.get(Plausible.Auth.User, entry.user_id) + Map.put(meta, :user, user) + else + meta + end + + meta = + if entry.entity == "Plausible.Auth.User" do + user = Plausible.Repo.get(Plausible.Auth.User, String.to_integer(entry.entity_id)) + Map.put(meta, :entity, user) + else + meta + end + + Map.put(entry, :meta, meta) + end) + end + + attr :entry, Plausible.Audit.Entry + + defp audit_entity(assigns) do + ~H""" + <%= if @entry.entity == "Plausible.Auth.User" do %> + <.audit_user user={@entry.meta.entity} /> + <% else %> + {@entry.entity |> String.split(".") |> List.last()} #{String.slice(@entry.entity_id, 0, 8)} + <% end %> + """ + end + + attr :user, Plausible.Auth.User + + defp audit_user(%{user: nil} = assigns) do + ~H""" + (N/A) + """ + end + + defp audit_user(assigns) do + ~H""" +
+ + + <.styled_link + patch={Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, @user.id)} + class="cursor-pointer flex block items-center" + > + {@user.name} + +
+ """ + end + + defp get_pagination_params(params) do + params + |> Map.take(["after", "before", "limit"]) + |> Map.put_new("limit", 15) + end +end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/billing.ex b/extra/lib/plausible_web/live/customer_support/team/components/billing.ex new file mode 100644 index 000000000000..e4e7a4c88c7d --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team/components/billing.ex @@ -0,0 +1,463 @@ +defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do + @moduledoc """ + Team billing component - handles subscription and custom plans + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + + alias Plausible.Billing.EnterprisePlan + alias Plausible.Billing.Plans + alias Plausible.Teams + import Ecto.Query, only: [from: 2] + import PlausibleWeb.Components.Generic + + require Plausible.Billing.Subscription.Status + + def update(%{team: team}, socket) do + usage = Teams.Billing.quota_usage(team, with_features: true) + + limits = %{ + monthly_pageviews: Teams.Billing.monthly_pageview_limit(team), + sites: Teams.Billing.site_limit(team), + team_members: Teams.Billing.team_member_limit(team) + } + + plans = get_plans(team.id) + plan = Plans.get_subscription_plan(team.subscription) + + attrs = get_plan_attrs(plan) + plan_form = to_form(EnterprisePlan.changeset(%EnterprisePlan{}, attrs)) + + {:ok, + assign(socket, + team: team, + usage: usage, + limits: limits, + plan: plan, + plans: plans, + plan_form: plan_form, + show_plan_form?: false, + editing_plan: nil, + cost_estimate: 0 + )} + end + + def render(assigns) do + ~H""" +
+ + +
+ <.button + :if={!@show_plan_form?} + id="new-custom-plan" + phx-click="show-plan-form" + phx-target={@myself} + class="ml-auto" + > + New Custom Plan + +
+ +
+

Usage

+ <.table rows={monthly_pageviews_usage(@usage.monthly_pageviews, @limits.monthly_pageviews)}> + <:thead> + <.th invisible>Cycle + <.th invisible>Dates + <.th>Total + <.th>Limit + + <:tbody :let={{cycle, date, total, limit}}> + <.td>{cycle} + <.td>{date} + <.td> + limit, do: "text-red-600"}> + {number_format(total)} + + + <.td>{number_format(limit)} + + + +

+

Features Used

+ + {@usage.features |> Enum.map(& &1.display_name()) |> Enum.join(", ")} + +

+ +

+ Custom Plans +

+ <.table :if={!@show_plan_form?} rows={@plans}> + <:thead> + <.th invisible>Interval + <.th>Paddle Plan ID + <.th>Limits + <.th>Features + <.th invisible>Actions + + <:tbody :let={plan}> + <.td class="align-top"> + {plan.billing_interval} + + <.td class="align-top" data-test-id={"plan-entry-#{plan.paddle_plan_id}"}> + {plan.paddle_plan_id} + + + CURRENT + + + <.td max_width="max-w-40"> + <.table rows={[ + {"Pageviews", number_format(plan.monthly_pageview_limit)}, + {"Sites", number_format(plan.site_limit)}, + {"Members", number_format(plan.team_member_limit)}, + {"API Requests", number_format(plan.hourly_api_request_limit)} + ]}> + <:tbody :let={{label, value}}> + <.td>{label} + <.td>{value} + + + + <.td class="align-top"> + {feat.display_name()}
+ + <.td class="align-top"> + <.edit_button phx-click="edit-plan" phx-value-id={plan.id} phx-target={@myself} /> + <.delete_button + :if={not current_plan?(@team, plan.paddle_plan_id)} + data-test-id={"delete-plan-#{plan.paddle_plan_id}"} + data-confirm="Are you sure you want to delete this plan?" + phx-click="delete-plan" + phx-value-id={plan.id} + phx-target={@myself} + /> + + + + + <.form + :let={f} + :if={@show_plan_form?} + for={@plan_form} + id="save-plan" + phx-submit={if @editing_plan, do: "update-plan", else: "save-plan"} + phx-target={@myself} + phx-change="estimate-cost" + > + <.input field={f[:paddle_plan_id]} label="Paddle Plan ID" autocomplete="off" /> + <.input + type="select" + options={["monthly", "yearly"]} + field={f[:billing_interval]} + label="Billing Interval" + autocomplete="off" + /> + +
+ <.input + field={f[:monthly_pageview_limit]} + label="Monthly Pageview Limit" + autocomplete="off" + width="w-[500]" + /> + <.preview for={f[:monthly_pageview_limit]} /> +
+ +
+ <.input width="w-[500]" field={f[:site_limit]} label="Site Limit" autocomplete="off" /> + <.preview for={f[:site_limit]} /> +
+ +
+ <.input + field={f[:team_member_limit]} + label="Team Member Limit" + autocomplete="off" + width="w-[500]" + /> + <.preview for={f[:team_member_limit]} /> +
+ +
+ <.input + field={f[:hourly_api_request_limit]} + label="Hourly API Request Limit" + autocomplete="off" + width="w-[500]" + /> + <.preview for={f[:hourly_api_request_limit]} /> +
+ + <.input + :for={ + mod <- + Plausible.Billing.Feature.list() + |> Enum.sort_by(fn item -> if item.name() == :stats_api, do: 0, else: 1 end) + } + :if={mod not in Teams.Billing.free_features()} + x-on:change="featureChangeCallback(event)" + type="checkbox" + value={mod in (f.source.changes[:features] || f.source.data.features || [])} + name={"#{f.name}[features[]][#{mod.name()}]"} + label={mod.display_name()} + /> + +
+ <.input + type="checkbox" + field={f[:managed_proxy_price_modifier]} + label="Managed proxy" + /> +
+ +
+ <.input_with_clipboard + id="cost-estimate" + name="cost-estimate" + label={"#{(f[:billing_interval].value || "monthly")} cost estimate"} + value={@cost_estimate} + /> + + <.button theme="secondary" phx-click="hide-plan-form" phx-target={@myself}> + Cancel + + + <.button type="submit"> + {if @editing_plan, do: "Update Plan", else: "Save Custom Plan"} + +
+ +
+
+ """ + end + + def handle_event("show-plan-form", _, socket) do + {:noreply, assign(socket, show_plan_form?: true, editing_plan: nil)} + end + + def handle_event("edit-plan", %{"id" => plan_id}, socket) do + {plan_id, _} = Integer.parse(plan_id) + plan = Enum.find(socket.assigns.plans, &(&1.id == plan_id)) + + if plan do + plan_form = to_form(EnterprisePlan.changeset(plan, %{})) + {:noreply, assign(socket, show_plan_form?: true, editing_plan: plan, plan_form: plan_form)} + else + {:noreply, socket} + end + end + + def handle_event("delete-plan", %{"id" => plan_id}, socket) do + plan = Plausible.Repo.get(EnterprisePlan, plan_id) + + if not current_plan?(socket.assigns.team, plan.paddle_plan_id), + do: Plausible.Repo.delete(plan) + + plans = get_plans(socket.assigns.team.id) + {:noreply, assign(socket, plans: plans)} + end + + def handle_event("hide-plan-form", _, socket) do + {:noreply, assign(socket, show_plan_form?: false, editing_plan: nil)} + end + + def handle_event("estimate-cost", %{"enterprise_plan" => params}, socket) do + params = update_features_to_list(params) + + form = to_form(EnterprisePlan.changeset(%EnterprisePlan{}, params)) + + params = sanitize_params(params) + + cost_estimate = + Plausible.CustomerSupport.EnterprisePlan.estimate( + billing_interval: params["billing_interval"], + pageviews_per_month: get_int_param(params, "monthly_pageview_limit"), + sites_limit: get_int_param(params, "site_limit"), + team_members_limit: get_int_param(params, "team_member_limit"), + api_calls_limit: get_int_param(params, "hourly_api_request_limit"), + features: params["features"], + managed_proxy_price_modifier: params["managed_proxy_price_modifier"] == "true" + ) + + {:noreply, assign(socket, cost_estimate: cost_estimate, plan_form: form)} + end + + def handle_event("save-plan", %{"enterprise_plan" => params}, socket) do + params = params |> update_features_to_list() |> sanitize_params() + changeset = EnterprisePlan.changeset(%EnterprisePlan{team_id: socket.assigns.team.id}, params) + + case Plausible.Repo.insert(changeset) do + {:ok, _plan} -> + success("Plan saved") + plans = get_plans(socket.assigns.team.id) + + {:noreply, + assign(socket, + plans: plans, + plan_form: to_form(changeset), + show_plan_form?: false, + editing_plan: nil + )} + + {:error, changeset} -> + failure("Error saving plan: #{inspect(changeset.errors)}") + {:noreply, assign(socket, plan_form: to_form(changeset))} + end + end + + def handle_event("update-plan", %{"enterprise_plan" => params}, socket) do + params = params |> update_features_to_list() |> sanitize_params() + changeset = EnterprisePlan.changeset(socket.assigns.editing_plan, params) + + case Plausible.Repo.update(changeset) do + {:ok, _plan} -> + success("Plan updated") + plans = get_plans(socket.assigns.team.id) + + {:noreply, + assign(socket, + plans: plans, + plan_form: to_form(changeset), + show_plan_form?: false, + editing_plan: nil + )} + + {:error, changeset} -> + failure("Error updating plan: #{inspect(changeset.errors)}") + {:noreply, assign(socket, plan_form: to_form(changeset))} + end + end + + # Helper functions + defp get_plan_attrs(plan) when is_map(plan) do + Map.take(plan, [ + :billing_interval, + :monthly_pageview_limit, + :site_limit, + :team_member_limit, + :hourly_api_request_limit, + :features + ]) + |> Map.update(:features, [], fn features -> + Enum.map(features, &to_string(&1.name())) + end) + end + + defp get_plan_attrs(_) do + %{ + monthly_pageview_limit: 10_000, + hourly_api_request_limit: 600, + site_limit: 10, + team_member_limit: 10, + features: Plausible.Billing.Feature.list() -- [Plausible.Billing.Feature.SSO] + } + end + + defp monthly_pageviews_usage(usage, limit) do + usage + |> Enum.sort_by(fn {_cycle, usage} -> usage.date_range.first end, :desc) + |> Enum.map(fn {cycle, usage} -> + {cycle, PlausibleWeb.TextHelpers.format_date_range(usage.date_range), usage.total, limit} + end) + end + + defp get_plans(team_id) do + Plausible.Repo.all( + from ep in EnterprisePlan, + where: ep.team_id == ^team_id, + order_by: [desc: :id] + ) + end + + defp number_format(unlimited) when unlimited in [-1, "unlimited", :unlimited] do + "unlimited" + end + + defp number_format(input) do + PlausibleWeb.TextHelpers.number_format(input) + end + + defp sanitize_params(params) do + params + |> Enum.map(&clear_param/1) + |> Enum.reject(&(&1 == "")) + |> Map.new() + end + + defp clear_param({key, value}) when is_binary(value) do + {key, String.trim(value)} + end + + defp clear_param(other) do + other + end + + defp get_int_param(params, key) do + param = Map.get(params, key) + param = if param in ["", nil], do: "0", else: param + + case Integer.parse(param) do + {integer, ""} -> integer + _ -> 0 + end + end + + defp update_features_to_list(params) do + features = + params["features[]"] + |> Enum.reject(fn {_key, value} -> value == "false" or value == "" end) + |> Enum.map(fn {key, _value} -> key end) + + Map.put(params, "features", features) + end + + defp preview_number(n) do + case Integer.parse("#{n}") do + {n, ""} -> + number_format(n) <> + " (#{PlausibleWeb.StatsView.large_number_format(n)})" + + _ -> + "0" + end + end + + attr :for, :any, required: true + + defp preview(assigns) do + ~H""" + <.input + name={"#{@for.name}-preview"} + label="Preview" + autocomplete="off" + width="w-[500]" + readonly + value={preview_number(@for.value)} + class="bg-transparent border-0 p-0 m-0 text-sm w-full" + /> + """ + end + + defp current_plan?(%{subscription: %{paddle_plan_id: id}}, id), do: true + defp current_plan?(_, _), do: false +end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/consolidated_views.ex b/extra/lib/plausible_web/live/customer_support/team/components/consolidated_views.ex new file mode 100644 index 000000000000..3331436befb2 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team/components/consolidated_views.ex @@ -0,0 +1,112 @@ +defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do + @moduledoc """ + Lists ConsolidatedViews of a team and allows creating one if none exist. Current + limitation is one consolidated view per team, which always includes all sites of + this team. + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + alias Plausible.ConsolidatedView + alias Plausible.Stats + + def update(%{team: team}, socket) do + consolidated_view = ConsolidatedView.get(team) + + sparkline_intervals = + with true <- connected?(socket), + {:ok, sparkline} <- Stats.Sparkline.safe_overview_24h(consolidated_view) do + sparkline.intervals + else + _ -> + Stats.Sparkline.empty_24h_intervals() + end + + {:ok, + assign(socket, + team: team, + consolidated_views: List.wrap(consolidated_view), + sparkline_intervals: sparkline_intervals + )} + end + + def render(assigns) do + ~H""" +
+ <%= if Enum.empty?(@consolidated_views) do %> +
+

This team does not have a consolidated view yet.

+ <.button class="mx-auto" phx-click="create-consolidated-view" phx-target={@myself}> + Create one + +
+ <% else %> + <.table rows={@consolidated_views}> + <:thead> + <.th>Domain + <.th>Timezone + <.th>Available? + <.th invisible>Dashboard + <.th invisible>24H + <.th invisible>Delete + + + <:tbody :let={consolidated_view}> + <.td>{consolidated_view.domain} + <.td>{consolidated_view.timezone} + <.td>{availability(@team)} + <.td> + <.styled_link + new_tab={true} + href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, consolidated_view.domain, [])} + > + Dashboard + + + + <.td> + + + + + <.td> + <.delete_button + phx-click="delete-consolidated-view" + phx-target={@myself} + data-confirm="Are you sure you want to delete this consolidated view? All existing consolidated view configuration will be lost. The view itself will be recreated whenever eligible subscription/trial accesses /sites for that team." + /> + + + + <% end %> +
+ """ + end + + def handle_event("create-consolidated-view", _, socket) do + case ConsolidatedView.enable(socket.assigns.team) do + {:ok, consolidated_view} -> + success("Consolidated view created") + {:noreply, assign(socket, consolidated_views: [consolidated_view])} + + {:error, reason} -> + failure("Could not create consolidated view. Reason: #{inspect(reason)}") + {:noreply, socket} + end + end + + def handle_event("delete-consolidated-view", _, socket) do + ConsolidatedView.disable(socket.assigns.team) + success("Deleted consolidated view") + {:noreply, assign(socket, consolidated_views: [])} + end + + defp availability(team) do + case Plausible.Billing.Feature.ConsolidatedView.check_availability(team) do + :ok -> "Yes" + {:error, :upgrade_required} -> "No - upgrade required" + end + end +end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/members.ex b/extra/lib/plausible_web/live/customer_support/team/components/members.ex new file mode 100644 index 000000000000..45971568020a --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team/components/members.ex @@ -0,0 +1,94 @@ +defmodule PlausibleWeb.CustomerSupport.Team.Components.Members do + @moduledoc """ + Team members component - handles team member management + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + alias Plausible.Teams + + def update(%{team: team}, socket) do + {:ok, refresh_members(socket, team)} + end + + def render(assigns) do + ~H""" +
+ <.table rows={Teams.Management.Layout.sorted_for_display(@team_layout)}> + <:thead> + <.th>User + <.th>Sessions + <.th>Type + <.th>Role + + <:tbody :let={{_, member}}> + <.td truncate> +
+ <.styled_link + patch={Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, member.id)} + class="cursor-pointer flex block items-center" + > + + {member.name} <{member.email}> + +
+
+ + {member.name} <{member.email}> +
+ + <.td> + {if member.type == :membership, do: @session_counts[member.meta.user.id] || 0, else: 0} + + <.td> +
+ SSO {member.type} + + <.delete_button + :if={member.type == :membership && member.meta.user.type == :sso} + id={"deprovision-sso-user-#{member.id}"} + phx-click="deprovision-sso-user" + phx-value-identifier={member.id} + phx-target={@myself} + class="text-sm" + icon={:user_minus} + data-confirm="Are you sure you want to deprovision SSO user and convert them to a standard user? This will sign them out and force to use regular e-mail/password combination to log in again." + /> +
+ + <.td> + {member.role} + + + +
+ """ + end + + def handle_event("deprovision-sso-user", %{"identifier" => user_id}, socket) do + [id: String.to_integer(user_id)] + |> Plausible.Auth.find_user_by() + |> Plausible.Auth.SSO.deprovision_user!() + + success("SSO user deprovisioned") + {:noreply, refresh_members(socket, socket.assigns.team)} + end + + defp refresh_members(socket, team) do + team_layout = Teams.Management.Layout.init(team) + + session_counts = + team_layout + |> Enum.map(fn {_, entry} -> if entry.type == :membership, do: entry.meta.user end) + |> Enum.reject(&is_nil/1) + |> Plausible.Auth.UserSessions.count_for_users() + |> Enum.into(%{}) + + assign(socket, team: team, team_layout: team_layout, session_counts: session_counts) + end +end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex new file mode 100644 index 000000000000..3fd1866cec52 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex @@ -0,0 +1,74 @@ +defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do + @moduledoc """ + Team overview component - handles team basic info, trial dates, notes + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + + def update(%{team: team}, socket) do + changeset = Plausible.Teams.Team.crm_changeset(team, %{}) + form = to_form(changeset) + + {:ok, assign(socket, team: team, form: form)} + end + + def render(assigns) do + ~H""" +
+ <.form :let={f} for={@form} phx-submit="save-team" phx-target={@myself}> + <.input field={f[:trial_expiry_date]} type="date" label="Trial Expiry Date" /> + <.input field={f[:accept_traffic_until]} type="date" label="Accept traffic Until" /> + <.input + type="checkbox" + field={f[:allow_next_upgrade_override]} + label="Allow Next Upgrade Override" + /> + + <.input type="textarea" field={f[:notes]} label="Notes" /> + +
+ <.button type="submit"> + Save + + + <.button + phx-target={@myself} + phx-click="delete-team" + data-confirm="Are you sure you want to delete this team?" + theme="danger" + > + Delete Team + +
+ +
+ """ + end + + def handle_event("save-team", %{"team" => params}, socket) do + changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params) + + case Plausible.Repo.update(changeset) do + {:ok, team} -> + success("Team saved") + {:noreply, assign(socket, team: team, form: to_form(changeset))} + + {:error, changeset} -> + failure("Error saving team: #{inspect(changeset.errors)}") + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def handle_event("delete-team", _params, socket) do + case Plausible.Teams.delete(socket.assigns.team) do + {:ok, :deleted} -> + navigate_with_success(Routes.customer_support_path(socket, :index), "Team deleted") + {:noreply, socket} + + {:error, :active_subscription} -> + failure("The team has an active subscription which must be canceled first.") + + {:noreply, socket} + end + end +end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/sites.ex b/extra/lib/plausible_web/live/customer_support/team/components/sites.ex new file mode 100644 index 000000000000..920457cff4c6 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team/components/sites.ex @@ -0,0 +1,245 @@ +defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do + @moduledoc """ + Team sites component - handles team sites listing + """ + use PlausibleWeb, :live_component + + import Ecto.Query, except: [update: 2, update: 3] + import PlausibleWeb.Live.Components.Pagination + + alias Plausible.Repo + alias Plausible.Site + alias Plausible.Sites.Index + + @page_size 24 + + def update(%{team: team, tab_params: tab_params}, socket) do + team = Repo.preload(team, :owners) + owner = List.first(team.owners) + + socket = + assign_new(socket, :index_state, fn -> + Index.build(owner, team: team, sort_by: :traffic, sort_direction: :desc) + end) + + page = + Index.paginate(socket.assigns.index_state, page: tab_params["page"], page_size: @page_size) + + sites = fetch_sites(page.entries) + + hourly_stats = build_hourly_stats(sites, socket) + + uri = + Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, team.id, tab: "sites") + |> URI.parse() + + {:ok, + assign(socket, + team: team, + sites: sites, + hourly_stats: hourly_stats, + page_number: page.page_number, + total_pages: page.total_pages, + total_entries: page.total_entries, + uri: uri + )} + end + + def update(%{team: team}, socket) do + update(%{team: team, tab_params: %{}}, socket) + end + + def handle_event("sort", %{"by" => by}, socket) do + sort_by = parse_sort_by(by) + current_state = socket.assigns.index_state + current_sort_by = current_state.sort_by + + sort_direction = + case sort_by do + ^current_sort_by -> + flip_direction(current_state.sort_direction) + + :traffic -> + :desc + + :alnum -> + :asc + end + + new_state = Index.sort(current_state, sort_by: sort_by, sort_direction: sort_direction) + page = Index.paginate(new_state, page: 1, page_size: @page_size) + sites = fetch_sites(page.entries) + + hourly_stats = build_hourly_stats(sites, socket) + + {:noreply, + assign(socket, + index_state: new_state, + sites: sites, + page_number: page.page_number, + total_pages: page.total_pages, + total_entries: page.total_entries, + hourly_stats: hourly_stats + )} + end + + def render(assigns) do + assigns = + assign(assigns, + sort_by: assigns.index_state.sort_by, + sort_direction: assigns.index_state.sort_direction + ) + + ~H""" +
+ <.table rows={@sites}> + <:thead> + + Domain <.sort_arrow active={@sort_by == :alnum} direction={@sort_direction} /> + + <.th>Previous Domain + <.th>Timezone + <.th invisible>Settings + <.th invisible>Dashboard + + Traffic <.sort_arrow active={@sort_by == :traffic} direction={@sort_direction} /> + + + <:tbody :let={site}> + <.td> +
+ + <.styled_link + patch={Routes.customer_support_site_path(PlausibleWeb.Endpoint, :show, site.id)} + class="cursor-pointer flex block items-center" + > + {site.domain} + + + + + +
+ + <.td>{site.domain_changed_from || "--"} + <.td>{site.timezone} + <.td> + <.styled_link + new_tab={true} + href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain, [])} + > + Dashboard + + + <.td> + <.styled_link + new_tab={true} + href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, site.domain, [])} + > + Settings + + + <.td max_width="max-w-40"> + + + + + Unique visitors: {if is_map(@hourly_stats), + do: @hourly_stats[site.domain][:visitors] || 0, + else: 0} + + + + + <.pagination + :if={@total_pages > 1} + id="sites-pagination" + uri={@uri} + page_number={@page_number} + total_pages={@total_pages} + > + Total of {@total_entries} + sites. Page {@page_number} of {@total_pages} + +
+ """ + end + + defp fetch_sites([]), do: [] + + defp fetch_sites(site_ids) do + by_id = + from(s in Site.regular(), + where: s.id in ^site_ids, + select: {s.id, s} + ) + |> Repo.all() + |> Map.new() + + Enum.map(site_ids, fn id -> Map.get(by_id, id) end) + end + + defp build_hourly_stats(sites, socket) do + if connected?(socket) do + Plausible.Stats.Sparkline.parallel_overview(sites) + else + sites + |> Enum.map(fn site -> + {site.domain, + %{ + intervals: Plausible.Stats.Sparkline.empty_24h_intervals(), + visitors: 0, + visitors_change: 0 + }} + end) + |> Map.new() + end + end + + defp parse_sort_by("alnum"), do: :alnum + defp parse_sort_by(_), do: :traffic + + defp flip_direction(:asc), do: :desc + defp flip_direction(:desc), do: :asc + + attr :active, :boolean, required: true + attr :direction, :atom, required: true + + defp sort_arrow(%{active: false} = assigns) do + ~H""" + ↕ + """ + end + + defp sort_arrow(%{direction: :asc} = assigns) do + ~H""" + ↑ + """ + end + + defp sort_arrow(assigns) do + ~H""" + ↓ + """ + end +end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/sso.ex b/extra/lib/plausible_web/live/customer_support/team/components/sso.ex new file mode 100644 index 000000000000..ed4d816dbb34 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/team/components/sso.ex @@ -0,0 +1,114 @@ +defmodule PlausibleWeb.CustomerSupport.Team.Components.SSO do + @moduledoc """ + Team SSO component - handles SSO integration management + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + alias Plausible.Auth.SSO + + def update(%{team: team}, socket) do + sso_integration = get_sso_integration(team) + {:ok, assign(socket, team: team, sso_integration: sso_integration)} + end + + def render(assigns) do + ~H""" +
+
+
+ <.table rows={ + [ + {"configured?", SSO.Integration.configured?(@sso_integration)}, + {"IDP Sign-in URL", @sso_integration.config.idp_signin_url}, + {"IDP Entity ID", @sso_integration.config.idp_entity_id} + ] ++ Enum.into(Map.from_struct(@team.policy), []) + }> + <:tbody :let={{k, v}}> + <.td>{k} + <.td>{v} + + +
+ + <.table rows={@sso_integration.sso_domains}> + <:thead> + <.th>Domain + <.th>Status + <.th> + + <:tbody :let={sso_domain}> + <.td> + {sso_domain.domain} + + <.td> + {sso_domain.status} + + (via {sso_domain.verified_via} at {Calendar.strftime( + sso_domain.last_verified_at, + "%b %-d, %Y" + )}) + + + <.td actions> + <.delete_button + id={"remove-sso-domain-#{sso_domain.identifier}"} + phx-click="remove-sso-domain" + phx-value-identifier={sso_domain.identifier} + phx-target={@myself} + class="text-sm text-red-600" + data-confirm={"Are you sure you want to remove domain '#{sso_domain.domain}'? All SSO users will be deprovisioned and logged out."} + /> + + + + +
+ <.button + data-confirm="Are you sure you want to remove this SSO team integration, including all its domains and users?" + id="remove-sso-integration" + phx-click="remove-sso-integration" + phx-target={@myself} + theme="danger" + > + Remove Integration + +
+
+
+

No SSO integration configured for this team.

+
+
+ """ + end + + def handle_event("remove-sso-integration", _, socket) do + :ok = SSO.remove_integration(socket.assigns.sso_integration, force_deprovision?: true) + + socket = + socket + |> assign(sso_integration: nil) + |> push_navigate( + to: Routes.customer_support_team_path(socket, :show, socket.assigns.team.id) + ) + + success("SSO integration removed") + + {:noreply, socket} + end + + def handle_event("remove-sso-domain", %{"identifier" => i}, socket) do + domain = Enum.find(socket.assigns.sso_integration.sso_domains, &(&1.identifier == i)) + :ok = SSO.Domains.remove(domain, force_deprovision?: true) + + success("SSO domain removed") + + {:noreply, assign(socket, sso_integration: get_sso_integration(socket.assigns.team))} + end + + defp get_sso_integration(team) do + case SSO.get_integration_for(team) do + {:error, :not_found} -> nil + {:ok, integration} -> integration + end + end +end diff --git a/extra/lib/plausible_web/live/customer_support/user.ex b/extra/lib/plausible_web/live/customer_support/user.ex new file mode 100644 index 000000000000..c1ef1547951c --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/user.ex @@ -0,0 +1,115 @@ +defmodule PlausibleWeb.Live.CustomerSupport.User do + @moduledoc """ + User coordinator LiveView for Customer Support interface. + + Manages tab-based navigation and delegates rendering to specialized + components: Overview and API Keys. + """ + use PlausibleWeb.CustomerSupport.Live + + import Ecto.Query + alias Plausible.Repo + + alias PlausibleWeb.CustomerSupport.User.Components.{ + Overview, + Keys + } + + def handle_params(%{"id" => user_id} = params, _uri, socket) do + tab = params["tab"] || "overview" + user = Resource.User.get(user_id) + + if user do + keys_count = count_keys(user) + + socket = + socket + |> assign(:user, user) + |> assign(:tab, tab) + |> assign(:keys_count, keys_count) + + {:noreply, go_to_tab(socket, tab, params, :user, tab_component(tab))} + else + {:noreply, redirect(socket, to: Routes.customer_support_path(socket, :index))} + end + end + + def render(assigns) do + ~H""" + +
+ <.user_header user={@user} /> + <.user_tab_navigation user={@user} tab={@tab} keys_count={@keys_count} /> + + <.live_component + module={tab_component(@tab)} + user={@user} + tab={@tab} + id={"user-#{@user.id}-#{@tab}"} + /> +
+
+ """ + end + + defp user_header(assigns) do + ~H""" +
+
+
+
+ +
+
+
+

+

+ {@user.name} + + SSO + +
+

+

{@user.email}

+
+
+ +
+ <.input_with_clipboard + id="user-identifier" + name="user-identifier" + label="User Identifier" + value={@user.id} + /> +
+
+ """ + end + + defp user_tab_navigation(assigns) do + ~H""" + <.tab_navigation tab={@tab}> + <:tabs> + <.tab to="overview" tab={@tab}>Overview + <.tab to="keys" tab={@tab}> + API Keys ({@keys_count}) + + + + """ + end + + defp tab_component("overview"), do: Overview + defp tab_component("keys"), do: Keys + defp tab_component(_), do: Overview + + defp count_keys(user) do + from(api_key in Plausible.Auth.ApiKey, + where: api_key.user_id == ^user.id + ) + |> Repo.aggregate(:count) + end +end diff --git a/extra/lib/plausible_web/live/customer_support/user/components/keys.ex b/extra/lib/plausible_web/live/customer_support/user/components/keys.ex new file mode 100644 index 000000000000..311c9f73f5f8 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/user/components/keys.ex @@ -0,0 +1,52 @@ +defmodule PlausibleWeb.CustomerSupport.User.Components.Keys do + @moduledoc """ + User API keys component - handles displaying user's API keys + """ + use PlausibleWeb, :live_component + import Ecto.Query + alias Plausible.Repo + + def update(%{user: user}, socket) do + keys = keys(user) + {:ok, assign(socket, user: user, keys: keys)} + end + + def render(assigns) do + ~H""" +
+ <.table rows={@keys}> + <:thead> + <.th>Team + <.th>Name + <.th>Scopes + <.th>Prefix + + <:tbody :let={api_key}> + <.td :if={is_nil(api_key.team)}>N/A + <.td :if={api_key.team}> + <.styled_link patch={ + Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, api_key.team.id) + }> + {api_key.team.name} + + + <.td>{api_key.name} + <.td> + {api_key.scopes} + + <.td>{api_key.key_prefix} + + +
+ """ + end + + defp keys(user) do + from( + key in Plausible.Auth.ApiKey, + where: key.user_id == ^user.id, + preload: :team + ) + |> Repo.all() + end +end diff --git a/extra/lib/plausible_web/live/customer_support/user/components/overview.ex b/extra/lib/plausible_web/live/customer_support/user/components/overview.ex new file mode 100644 index 000000000000..77ee4a47be79 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/user/components/overview.ex @@ -0,0 +1,125 @@ +defmodule PlausibleWeb.CustomerSupport.User.Components.Overview do + @moduledoc """ + User overview component - handles user settings, team memberships, and user management + """ + use PlausibleWeb, :live_component + import PlausibleWeb.CustomerSupport.Live + + alias Plausible.Auth.TOTP + + def update(%{user: user}, socket) do + form = user |> Plausible.Auth.User.changeset() |> to_form() + {:ok, assign(socket, user: user, form: form)} + end + + def render(assigns) do + ~H""" +
+
+
+
+ + Two-Factor Authentication: + + {if TOTP.enabled?(@user), do: "Enabled", else: "Disabled"} + + +
+
+
+ + <.table rows={@user.team_memberships}> + <:thead> + <.th>Team + <.th>Role + + <:tbody :let={membership}> + <.td> + <.styled_link patch={ + Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, membership.team.id) + }> + {membership.team.name} + + + <.td>{membership.role} + + + + <.form :let={f} for={@form} phx-target={@myself} phx-submit="save-user" class="mt-8"> + <.input type="textarea" field={f[:notes]} label="Notes" /> +
+
+ <.button phx-target={@myself} type="submit"> + Save + +
+
+ <.button + :if={TOTP.enabled?(@user)} + phx-target={@myself} + phx-click="force-disable-2fa" + data-confirm="Are you sure you want to force disable 2FA for this user? This action cannot be undone." + theme="danger" + > + Force Disable 2FA + + <.button + phx-target={@myself} + phx-click="delete-user" + data-confirm="Are you sure you want to delete this user?" + theme="danger" + > + Delete User + +
+
+ +
+ """ + end + + def handle_event("delete-user", _params, socket) do + user = socket.assigns.user + + case Plausible.Auth.delete_user(user) do + {:ok, _} -> + navigate_with_success(Routes.customer_support_path(socket, :index), "User deleted") + {:noreply, socket} + + {:error, :active_subscription} -> + failure("Cannot delete user with active subscription") + {:noreply, socket} + + {:error, reason} -> + failure("Failed to delete user: #{inspect(reason)}") + {:noreply, socket} + end + end + + def handle_event("force-disable-2fa", _params, socket) do + user = socket.assigns.user + + {:ok, updated_user} = TOTP.force_disable(user) + send(self(), {:success, "2FA has been force disabled for this user"}) + {:noreply, assign(socket, user: updated_user)} + end + + def handle_event("save-user", %{"user" => params}, socket) do + user = socket.assigns.user + + case Plausible.Auth.User.changeset(user, params) |> Plausible.Repo.update() do + {:ok, updated_user} -> + form = updated_user |> Plausible.Auth.User.changeset() |> to_form() + success("User updated successfully") + {:noreply, assign(socket, user: updated_user, form: form)} + + {:error, changeset} -> + form = changeset |> to_form() + failure("Failed to update user") + {:noreply, assign(socket, form: form)} + end + end +end diff --git a/extra/lib/plausible_web/live/funnel_settings.ex b/extra/lib/plausible_web/live/funnel_settings.ex index ae0f852e4a62..4b79dd945a07 100644 --- a/extra/lib/plausible_web/live/funnel_settings.ex +++ b/extra/lib/plausible_web/live/funnel_settings.ex @@ -6,7 +6,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do use Plausible.Funnel - alias Plausible.{Goals, Funnels} + alias Plausible.{Goals, Funnels, Teams} def mount( _params, @@ -16,12 +16,14 @@ defmodule PlausibleWeb.Live.FunnelSettings do socket = socket |> assign_new(:site, fn %{current_user: current_user} -> - Plausible.Sites.get_for_user!(current_user, domain, [ - :owner, - :admin, - :editor, - :super_admin - ]) + Plausible.Sites.get_for_user!(current_user, domain, + roles: [ + :owner, + :admin, + :editor, + :super_admin + ] + ) end) |> assign_new(:all_funnels, fn %{site: %{id: ^site_id} = site} -> Funnels.list(site) @@ -32,7 +34,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do {:ok, assign(socket, - site_team: socket.assigns.site.team, + site_team: Teams.with_subscription(socket.assigns.site.team), domain: domain, displayed_funnels: socket.assigns.all_funnels, setup_funnel?: false, @@ -48,37 +50,61 @@ defmodule PlausibleWeb.Live.FunnelSettings do
<.flash_messages flash={@flash} /> - <%= if @setup_funnel? do %> - {live_render( - @socket, - PlausibleWeb.Live.FunnelSettings.Form, - id: "funnels-form", - session: %{ - "domain" => @domain, - "funnel_id" => @funnel_id - } - )} - <% end %> -
= Funnel.min_steps()}> - <.live_component - module={PlausibleWeb.Live.FunnelSettings.List} - id="funnels-list" - funnels={@displayed_funnels} - filter_text={@filter_text} - /> -
- -
- <.notice class="mt-4" title="Not enough goals"> - You need to define at least two goals to create a funnel. Go ahead and - <.styled_link href={ - PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain) - }> - add goals - - to proceed. - -
+ <.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={@site_team} + > + <:title> + Funnels + + <:subtitle :if={Enum.count(@all_funnels) > 0}> + Compose goals into funnels to track user flows and conversion rates. + + <%= if @setup_funnel? do %> + {live_render( + @socket, + PlausibleWeb.Live.FunnelSettings.Form, + id: "funnels-form", + session: %{ + "domain" => @domain, + "funnel_id" => @funnel_id + } + )} + <% end %> +
= Funnel.min_steps()}> + <.live_component + module={PlausibleWeb.Live.FunnelSettings.List} + id="funnels-list" + funnels={@displayed_funnels} + filter_text={@filter_text} + /> +
+ +
+

+ Ready to dig into user flows? +

+

+ Set up a few goals like <.highlighted>Signup, <.highlighted>Visit /, or + <.highlighted>Scroll 50% on /blog/* + first, then return here to build your first funnel. +

+ <.button_link + class="mt-4" + href={PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain)} + > + Set up goals → + +
+
""" end @@ -111,7 +137,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do Plausible.Sites.get_for_user!( socket.assigns.current_user, socket.assigns.domain, - [:owner, :admin, :editor] + roles: [:owner, :admin, :editor] ) id = String.to_integer(id) @@ -142,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 diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index a9b0dabdec8f..e273bd14007c 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -10,15 +10,18 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do import PlausibleWeb.Live.Components.Form alias Plausible.{Goals, Funnels} + alias Plausible.Stats.QueryBuilder def mount(_params, %{"domain" => domain} = session, socket) do site = - Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [ - :owner, - :admin, - :editor, - :super_admin - ]) + Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, + roles: [ + :owner, + :admin, + :editor, + :super_admin + ] + ) # We'll have the options trimmed to only the data we care about, to keep # it minimal at the socket assigns, yet, we want to retain specific %Goal{} @@ -46,7 +49,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do def render(assigns) do ~H"""
@@ -62,10 +65,10 @@ 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-800 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 + {if @funnel, do: "Edit", else: "Add"} funnel <.input @@ -74,15 +77,15 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do autocomplete="off" placeholder="e.g. From Blog to Purchase" autofocus - label="Funnel Name" + label="Funnel name" />
<.label> - Funnel Steps + Funnel steps -
+
<.live_component selected={find_preselected(@funnel, @funnel_modified?, step_idx)} @@ -115,13 +118,12 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
- <.add_step_button :if={ - length(@step_ids) < Funnel.max_steps() and - map_size(@selections_made) < length(@goals) - } /> - -
-

+

+ <.add_step_button :if={ + length(@step_ids) < Funnel.max_steps() and + map_size(@selections_made) < length(@goals) + } /> +

<%= if @evaluation_result do %> Last month conversion rate: <%= List.last(@evaluation_result.steps).conversion_rate %>% <% end %> @@ -137,7 +139,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do length(@step_ids) > map_size(@selections_made) } > - {if @funnel, do: "Update", else: "Add"} Funnel + {if @funnel, do: "Update", else: "Add"} funnel

@@ -177,7 +179,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do def add_step_button(assigns) do ~H""" - + + Add another step """ @@ -347,7 +349,12 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do steps ) - query = Plausible.Stats.Query.from(site, %{"period" => "month"}) + query = + QueryBuilder.build!(site, + metrics: [:pageviews], + input_date_range: :month + ) + {:ok, {definition, query}} end diff --git a/extra/lib/plausible_web/live/funnel_settings/list.ex b/extra/lib/plausible_web/live/funnel_settings/list.ex index 18958dc16f03..0e52c5ce313e 100644 --- a/extra/lib/plausible_web/live/funnel_settings/list.ex +++ b/extra/lib/plausible_web/live/funnel_settings/list.ex @@ -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""" -
- <.filter_bar filter_text={@filter_text} placeholder="Search Funnels"> - <.button id="add-funnel-button" phx-click="add-funnel" mt?={false}> - Add Funnel - - +
+ <%= 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 + + + <% end %> <%= if Enum.count(@funnels) > 0 do %> <.table rows={@funnels}> @@ -42,19 +46,44 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do <% else %> -

- - No funnels found for this site. Please refine or - <.styled_link phx-click="reset-filter-text" id="reset-filter-hint"> - reset your search. - - - - No funnels configured for this site. - -

+ <.no_search_results :if={@searching?} /> + <.empty_state :if={not @searching?} /> <% end %>
""" end + + defp no_search_results(assigns) do + ~H""" +

+ No funnels found for this site. Please refine or + <.styled_link phx-click="reset-filter-text" id="reset-filter-hint"> + reset your search. + +

+ """ + end + + defp empty_state(assigns) do + ~H""" +
+

+ Create your first funnel +

+

+ Compose goals into funnels to track user flows and conversion rates. + <.styled_link href="https://plausible.io/docs/funnel-analysis" target="_blank"> + Learn more + +

+ <.button + id="add-funnel-button" + phx-click="add-funnel" + class="mt-4" + > + Add funnel + +
+ """ + end end diff --git a/extra/lib/plausible_web/live/sso_management.ex b/extra/lib/plausible_web/live/sso_management.ex new file mode 100644 index 000000000000..2f57dde32c24 --- /dev/null +++ b/extra/lib/plausible_web/live/sso_management.ex @@ -0,0 +1,740 @@ +defmodule PlausibleWeb.Live.SSOManagement do + @moduledoc """ + Live view for SSO setup and management. + """ + use PlausibleWeb, :live_view + + alias Plausible.Auth.SSO + alias Plausible.Teams + + alias PlausibleWeb.Router.Helpers, as: Routes + use Plausible.Auth.SSO.Domain.Status + + @refresh_integration_interval :timer.seconds(5) + + def mount(_params, _session, socket) do + socket = load_integration(socket, socket.assigns.current_team) + + if connected?(socket) do + Process.send_after(self(), :refresh_integration, @refresh_integration_interval) + end + + {:ok, route_mode(socket)} + end + + def render(assigns) do + ~H""" + <.flash_messages flash={@flash} /> + + <.tile + :if={@mode != :manage} + docs={if @mode in [:domain_setup, :domain_verify], do: "sso#sso-domains", else: "sso"} + > + <:title> +
Single Sign-On + + <:subtitle> + Configure and manage Single Sign-On for your team. + + + <.init_view :if={@mode == :init} current_team={@current_team} /> + + <.init_setup_view + :if={@mode == :init_setup} + integration={@integration} + current_team={@current_team} + /> + + <.idp_form_view + :if={@mode == :idp_form} + integration={@integration} + config_changeset={@config_changeset} + /> + + <.domain_setup_view + :if={@mode == :domain_setup} + integration={@integration} + domain_changeset={@domain_changeset} + /> + + <.domain_verify_view :if={@mode == :domain_verify} domain={@domain} /> + + + <.manage_view + :if={@mode == :manage} + integration={@integration} + current_team={@current_team} + can_toggle_force_sso?={@can_toggle_force_sso?} + force_sso_warning={@force_sso_warning} + policy_changeset={@policy_changeset} + role_options={@role_options} + domain_delete_checks={@domain_delete_checks} + /> + """ + end + + def init_view(assigns) do + ~H""" +
+

+ Single Sign-On (SSO) enables team members to sign in without having to register an account. For more details, <.styled_link href="https://plausible.io/docs/sso">see our documentation. +

+ + <.button type="submit">Start Configuring SSO +
+ """ + end + + def init_setup_view(assigns) do + ~H""" +
+

+ Use the following parameters when configuring your Identity Provider of choice: +

+ + <.input_with_clipboard + id="sp-acs-url" + name="sp-acs-url" + label="ACS URL / Single Sign-On URL / Reply URL" + value={saml_acs_url(@integration)} + /> + + <.form id="sso-sp-config" for={} class="flex-col space-y-4"> + <.input_with_clipboard + id="sp-entity-id" + name="sp-entity-id" + label="Entity ID / Audience URI / Identifier" + value={SSO.SAMLConfig.entity_id(@integration)} + /> + + +
+

Following attribute mappings must be set up at Identity Provider:

+ +
    +
  • + {param} +
  • +
+
+ +
+

Click below to start setting up Single Sign-On for your team.

+ + <.button type="submit">Start Configuring +
+
+ """ + end + + def idp_form_view(assigns) do + ~H""" +
+

+ Enter configuration details of Identity Provider after configuring it: +

+ + <.form + :let={f} + id="sso-idp-config" + for={@config_changeset} + class="flex-col space-y-4" + phx-submit="update-integration" + > + <.input + field={f[:idp_signin_url]} + label="SSO URL / Sign-on URL / Login URL" + placeholder="" + /> + + <.input + field={f[:idp_entity_id]} + label="Entity ID / Issuer / Identifier" + placeholder="" + /> + + <.input field={f[:idp_cert_pem]} type="textarea" label="Signing Certificate in PEM format" /> + + <.button type="submit">Save + +
+ """ + end + + def domain_setup_view(assigns) do + ~H""" +
+

+ In order for Single Sign-On to work, you have to allow at least one email address domain: +

+ + <.form + :let={f} + id="sso-add-domain" + for={@domain_changeset} + class="flex-col space-y-4" + phx-submit="add-domain" + > + <.input field={f[:domain]} label="Domain" placeholder="example.com" /> + + <.button type="submit">Add Domain + +
+ """ + end + + def domain_verify_view(assigns) do + ~H""" +
+

Verifying domain {@domain.domain}

+ +

You can verify ownership of the domain using one of 3 methods:

+ +
    +
  • + <.input_with_clipboard + name="verification-dns-txt" + label={"Add a TXT record to #{@domain.domain} domain with the following value"} + id="verification-dns-txt" + value={"plausible-sso-verification=#{@domain.identifier}"} + /> +
  • +
  • + <.input_with_clipboard + name="verification-url" + label={"Publish a file or route at https://#{@domain.domain}/plausible-sso-verification rendering the following contents"} + id="verification-url" + value={@domain.identifier} + /> +
  • +
  • + <.input_with_clipboard + name="verification-meta-tag" + label={"Add a following META tag to the web page at https://#{@domain.domain}"} + id="verification-meta-tag" + value={~s||} + /> +
  • +
+ + <.notice> + We'll keep checking your domain ownership. Once any of the above verification methods succeeds, we'll send you an e-mail. Thank you for your patience. + + +
+ <.input type="hidden" name="identifier" value={@domain.identifier} /> + <.button + :if={@domain.status in [Status.in_progress(), Status.unverified(), Status.verified()]} + type="submit" + > + Run Verification Now + + + <.button :if={@domain.status == Status.pending()} type="submit">Continue +
+
+ """ + end + + def manage_view(assigns) do + ~H""" + <.tile docs="sso"> + <:title> + Single Sign-On + + <:subtitle> + Configure and manage Single Sign-On for your team. + + +
+

+ Use the following parameters when configuring your Identity Provider of choice: +

+ +
+ <.input_with_clipboard + id="sp-acs-url" + name="sp-acs-url" + label="ACS URL / Single Sign-On URL / Reply URL" + value={saml_acs_url(@integration)} + /> + + <.input_with_clipboard + id="sp-entity-id" + name="sp-entity-id" + label="Entity ID / Audience URI / Identifier" + value={SSO.SAMLConfig.entity_id(@integration)} + /> +
+ +
+

Following attribute mappings must be setup at Identity Provider:

+ +
    +
  • + {param} +
  • +
+
+ +
+

+ Current Identity Provider configuration: +

+ + <.form :let={f} id="sso-idp-config" for={} class="flex-col space-y-4"> + <.input + field={f[:idp_signin_url]} + value={@integration.config.idp_signin_url} + label="SSO URL / Sign-on URL / Login URL" + readonly={true} + /> + + <.input + field={f[:idp_entity_id]} + value={@integration.config.idp_entity_id} + label="Entity ID / Issuer / Identifier" + readonly={true} + /> + + <.input + field={f[:idp_cert_pem]} + type="textarea" + label="Signing Certificate in PEM format" + value={@integration.config.idp_cert_pem} + readonly={true} + /> + + +
+ <.button type="submit">Edit +
+
+
+ + + <.tile docs="sso#sso-domains"> + <:title> + SSO Domains + + <:subtitle> + Email domains accepted from Identity Provider + +
+ <.table rows={@integration.sso_domains}> + <:thead> + <.th>Domain + <.th hide_on_mobile>Added at + <.th>Status + <.th invisible>Actions + + <:tbody :let={domain}> + <.td>{domain.domain} + <.td hide_on_mobile> + {Calendar.strftime(domain.inserted_at, "%b %-d, %Y at %H:%m UTC")} + + <.td :if={domain.status != Status.in_progress()}>{domain.status} + <.td :if={domain.status == Status.in_progress()}> +
+ <.spinner class="w-4 h-4" /> + <.styled_link + id={"cancel-verify-domain-#{domain.identifier}"} + phx-click="cancel-verify-domain" + phx-value-identifier={domain.identifier} + > + Cancel + +
+ + <.td actions> + <.styled_link + :if={domain.status not in [Status.in_progress(), Status.verified()]} + id={"verify-domain-#{domain.identifier}"} + phx-click="verify-domain" + phx-value-identifier={domain.identifier} + > + Verify + + + <.delete_button + :if={is_nil(@domain_delete_checks[domain.identifier])} + id={"remove-domain-#{domain.identifier}"} + phx-click="remove-domain" + phx-value-identifier={domain.identifier} + class="text-sm text-red-600" + data-confirm={"Are you sure you want to remove domain '#{domain.domain}'?"} + /> + + <.delete_button + :if={@domain_delete_checks[domain.identifier]} + id={"disabled-remove-domain-#{domain.identifier}"} + class="text-sm text-red-600" + data-confirm={"You cannot delete this domain. #{@domain_delete_checks[domain.identifier]}"} + /> + + + + +
+ <.button type="submit">Add Domain +
+
+ + + <.tile docs="sso#sso-policy"> + <:title> + SSO Policy + + <:subtitle> + Adjust your SSO policy configuration + +
+

Enforce Single Sign-On for the whole team, except Owners:

+ + <.tooltip enabled?={not @can_toggle_force_sso?}> + <:tooltip_content> +
+ To get access to this feature, {@force_sso_warning}. +
+ +
+ + + Force Single Sign-On + +
+ +
+ +
+ <.form + :let={f} + id="sso-policy" + for={@policy_changeset} + class="flex-col space-y-4" + phx-submit="update-policy" + > + <.input + field={f[:sso_default_role]} + label="Default role" + type="select" + options={@role_options} + /> + + <.input + field={f[:sso_session_timeout_minutes]} + label="Session timeout (minutes)" + type="number" + /> + + <.button type="submit">Update + +
+ + """ + end + + def handle_event("init-sso", _params, socket) do + team = socket.assigns.current_team + integration = SSO.initiate_saml_integration(team) + + socket = + socket + |> assign(:integration, integration) + |> load_integration(team) + |> route_mode() + + {:noreply, socket} + end + + def handle_event("show-idp-form", _params, socket) do + {:noreply, route_mode(socket, :idp_form)} + end + + def handle_event("update-integration", params, socket) do + socket = + case SSO.update_integration(socket.assigns.integration, params["saml_config"] || %{}) do + {:ok, integration} -> + socket + |> assign(:integration, integration) + |> load_integration(socket.assigns.current_team) + |> route_mode() + + {:error, changeset} -> + assign(socket, :config_changeset, changeset) + end + + {:noreply, socket} + end + + def handle_event("add-domain", params, socket) do + integration = socket.assigns.integration + + socket = + case SSO.Domains.add(integration, params["domain"]["domain"] || "") do + {:ok, sso_domain} -> + socket + |> load_integration(socket.assigns.current_team) + |> assign(:domain, sso_domain) + |> route_mode(:domain_verify) + + {:error, changeset} -> + socket + |> assign(:domain_changeset, changeset) + end + + {:noreply, socket} + end + + def handle_event("verify-domain-submit", params, socket) do + integration = socket.assigns.integration + sso_domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"])) + + if sso_domain do + SSO.Domains.start_verification(sso_domain.domain) + {:noreply, route_mode(load_integration(socket, socket.assigns.current_team), :manage)} + else + {:noreply, socket} + end + end + + def handle_event("show-domain-setup", _params, socket) do + {:noreply, route_mode(socket, :domain_setup)} + end + + def handle_event("verify-domain", params, socket) do + integration = socket.assigns.integration + domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"])) + + if domain do + socket = + socket + |> load_integration(socket.assigns.current_team) + |> assign(:domain, domain) + |> route_mode(:domain_verify) + + {:noreply, socket} + else + {:noreply, route_mode(socket, :manage)} + end + end + + def handle_event("cancel-verify-domain", params, socket) do + integration = socket.assigns.integration + domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"])) + + socket = + if domain do + :ok = SSO.Domains.cancel_verification(domain.domain) + load_integration(socket, socket.assigns.current_team) + else + socket + end + + {:noreply, socket} + end + + def handle_event("remove-domain", params, socket) do + integration = socket.assigns.integration + domain = Enum.find(integration.sso_domains, &(&1.identifier == params["identifier"])) + + if domain do + socket = + case SSO.Domains.remove(domain) do + :ok -> + socket + |> load_integration(socket.assigns.current_team) + |> route_mode() + + {:error, :force_sso_enabled} -> + socket + + {:error, :sso_users_present} -> + socket + end + + {:noreply, socket} + else + {:noreply, route_mode(socket, :manage)} + end + end + + def handle_event("toggle-force-sso", _params, socket) do + team = socket.assigns.current_team + new_toggle = if team.policy.force_sso == :none, do: :all_but_owners, else: :none + + case SSO.set_force_sso(socket.assigns.current_team, new_toggle) do + {:ok, team} -> + socket = + socket + |> assign(:current_team, team) + + {:noreply, socket} + + {:error, _} -> + {:noreply, route_mode(socket, :manage)} + end + end + + def handle_event("update-policy", params, socket) do + team = socket.assigns.current_team + params = params["policy"] + + attrs = [ + sso_default_role: params["sso_default_role"], + sso_session_timeout_minutes: params["sso_session_timeout_minutes"] + ] + + socket = + case SSO.update_policy(team, attrs) do + {:ok, team} -> + policy_changeset = Teams.Policy.update_changeset(team.policy, %{}) + + socket + |> assign(:current_team, team) + |> assign(:policy_changeset, policy_changeset) + + {:error, changeset} -> + socket + |> assign(:policy_changeset, changeset) + end + + {:noreply, socket} + end + + def handle_info(:refresh_integration, socket) do + Process.send_after(self(), :refresh_integration, @refresh_integration_interval) + {:noreply, load_integration(socket, socket.assigns.current_team)} + end + + defp load_integration(socket, team) do + integration = + case SSO.get_integration_for(team) do + {:ok, integration} -> integration + {:error, :not_found} -> nil + end + + assign(socket, :integration, integration) + end + + defp route_mode(socket, force_mode \\ nil) do + integration = socket.assigns.integration + + mode = + cond do + force_mode -> + force_mode + + is_nil(integration) -> + :init + + not SSO.Integration.configured?(integration) -> + :init_setup + + integration.sso_domains == [] -> + :domain_setup + + true -> + :manage + end + + socket + |> assign(:mode, mode) + |> load(mode) + end + + defp load(socket, :idp_form) do + assign( + socket, + :config_changeset, + SSO.SAMLConfig.changeset(socket.assigns.integration.config, %{}) + ) + end + + defp load(socket, :domain_setup) do + assign(socket, :domain_changeset, SSO.Domain.create_changeset(socket.assigns.integration, "")) + end + + defp load(socket, :manage) do + team = socket.assigns.current_team + toggle_mode = if team.policy.force_sso == :none, do: :all_but_owners, else: :none + + {can_toggle_force_sso?, toggle_disabled_reason} = + case {SSO.check_force_sso(team, toggle_mode), socket.assigns.current_user.type} do + {:ok, :sso} -> + {true, nil} + + {:ok, :standard} -> + {false, "you must be logged in via SSO"} + + {{:error, :no_integration}, _} -> + {false, "you must first setup Single Sign-On"} + + {{:error, :no_domain}, _} -> + {false, "you must add a domain"} + + {{:error, :no_verified_domain}, _} -> + {false, "you must verify a domain"} + + {{:error, :owner_2fa_disabled}, _} -> + {false, "all Owners must have 2FA enabled"} + + {{:error, :no_sso_user}, _} -> + {false, "at least one SSO user must log in successfully"} + end + + policy_changeset = Teams.Policy.update_changeset(team.policy, %{}) + + role_options = + Enum.map(Teams.Policy.sso_member_roles(), fn role -> + {String.capitalize(to_string(role)), role} + end) + |> Enum.sort() + + domain_delete_checks = + Enum.into(socket.assigns.integration.sso_domains, %{}, fn domain -> + prevent_delete_reason = + case Plausible.Auth.SSO.Domains.check_can_remove(domain) do + :ok -> nil + {:error, :force_sso_enabled} -> "You must disable 'Force SSO' first." + {:error, :sso_users_present} -> "There are existing SSO accounts on this domain." + end + + {domain.identifier, prevent_delete_reason} + end) + + socket + |> assign(:domain_delete_checks, domain_delete_checks) + |> assign(:can_toggle_force_sso?, can_toggle_force_sso?) + |> assign(:force_sso_warning, toggle_disabled_reason) + |> assign(:policy_changeset, policy_changeset) + |> assign(:role_options, role_options) + end + + defp load(socket, _) do + socket + end + + defp saml_acs_url(integration) do + Routes.sso_url( + PlausibleWeb.Endpoint, + :saml_consume, + integration.identifier + ) + end +end diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex new file mode 100644 index 000000000000..97e03fc3c47d --- /dev/null +++ b/extra/lib/plausible_web/live/verification.ex @@ -0,0 +1,288 @@ +defmodule PlausibleWeb.Live.Verification do + @moduledoc """ + LiveView coordinating the site verification process. + Onboarding new sites, renders a standalone component. + Embedded modal variant is available for general site settings. + """ + use PlausibleWeb, :live_view + + import PlausibleWeb.Components.Generic + + alias Plausible.InstallationSupport.{State, Verification} + + @component PlausibleWeb.Live.Components.Verification + @slowdown_for_frequent_checking :timer.seconds(5) + + def mount( + %{"domain" => domain} = params, + _session, + socket + ) do + current_user = socket.assigns.current_user + + site = + Plausible.Sites.get_for_user!(current_user, domain, + roles: [ + :owner, + :admin, + :editor, + :super_admin, + :viewer + ] + ) + + true = Plausible.Sites.regular?(site) + + private = Map.get(socket.private.connect_info, :private, %{}) + + super_admin? = Plausible.Auth.is_super_admin?(current_user) + has_pageviews? = has_pageviews?(site) + + custom_url_input? = params["custom_url"] == "true" + + socket = + assign(socket, + url_to_verify: nil, + site: site, + super_admin?: super_admin?, + domain: domain, + has_pageviews?: has_pageviews?, + component: @component, + installation_type: get_installation_type(params, site), + report_to: self(), + delay: private[:delay] || 500, + slowdown: private[:slowdown] || 500, + flow: params["flow"] || "", + checks_pid: nil, + attempts: 0, + polling_pageviews?: false, + custom_url_input?: custom_url_input? + ) + + if connected?(socket) and not custom_url_input? do + launch_delayed(socket) + end + + {:ok, socket} + end + + def render(assigns) do + ~H""" + + <.custom_url_form :if={@custom_url_input?} domain={@domain} /> + <.live_component + :if={not @custom_url_input?} + module={@component} + installation_type={@installation_type} + domain={@domain} + id="verification-standalone" + attempts={@attempts} + flow={@flow} + awaiting_first_pageview?={not @has_pageviews?} + super_admin?={@super_admin?} + /> + """ + end + + def handle_event("launch-verification", _, socket) do + launch_delayed(socket) + {:noreply, reset_component(socket)} + end + + def handle_event("retry", _, socket) do + launch_delayed(socket) + {:noreply, reset_component(socket)} + end + + def handle_event("verify-custom-url", %{"custom_url" => custom_url}, socket) do + socket = + socket + |> assign(url_to_verify: custom_url) + |> assign(custom_url_input?: false) + + launch_delayed(socket) + {:noreply, reset_component(socket)} + end + + def handle_info({:start, report_to}, socket) do + domain = socket.assigns.domain + checks_pid = socket.assigns.checks_pid + + if is_pid(checks_pid) and Process.alive?(checks_pid) do + {:noreply, socket} + else + case Plausible.RateLimit.check_rate( + "site_verification:#{domain}", + :timer.minutes(60), + 3 + ) do + {:allow, _} -> :ok + {:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking) + end + + {:ok, pid} = + Verification.Checks.run( + socket.assigns.url_to_verify, + domain, + socket.assigns.installation_type, + report_to: report_to, + slowdown: socket.assigns.slowdown + ) + + {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)} + end + end + + def handle_info({:check_start, {check, state}}, socket) do + to_update = [message: check.report_progress_as()] + + to_update = + if is_binary(state.url) do + Keyword.put(to_update, :url_to_verify, state.url) + else + to_update + end + + update_component(socket, to_update) + + {:noreply, socket} + end + + def handle_info({:all_checks_done, %State{} = state}, socket) do + interpretation = Verification.Checks.interpret_diagnostics(state) + + if not socket.assigns.has_pageviews? do + schedule_pageviews_check(socket) + end + + update_component(socket, + finished?: true, + success?: interpretation.ok?, + interpretation: interpretation, + verification_state: state + ) + + {:noreply, assign(socket, checks_pid: nil)} + end + + def handle_info(:check_pageviews, socket) do + socket = + if has_pageviews?(socket.assigns.site) do + redirect_to_stats(socket) + else + socket + |> assign(polling_pageviews?: false) + |> schedule_pageviews_check() + end + + {:noreply, socket} + end + + @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types() + |> Enum.map(&String.to_atom/1) + defp get_installation_type(params, site) do + cond do + params["installation_type"] in PlausibleWeb.Tracker.supported_installation_types() -> + params["installation_type"] + + (saved_installation_type = get_saved_installation_type(site)) in @supported_installation_types_atoms -> + Atom.to_string(saved_installation_type) + + true -> + PlausibleWeb.Tracker.fallback_installation_type() + end + end + + defp get_saved_installation_type(site) do + case PlausibleWeb.Tracker.get_tracker_script_configuration(site) do + %{installation_type: installation_type} -> + installation_type + + _ -> + nil + end + end + + defp schedule_pageviews_check(socket) do + if socket.assigns.polling_pageviews? do + socket + else + Process.send_after(self(), :check_pageviews, socket.assigns.delay * 2) + assign(socket, polling_pageviews?: true) + end + end + + defp redirect_to_stats(socket) do + stats_url = Routes.stats_path(PlausibleWeb.Endpoint, :stats, socket.assigns.domain, []) + redirect(socket, to: stats_url) + end + + defp reset_component(socket) do + update_component(socket, + message: "We're visiting your site to ensure that everything is working", + finished?: false, + success?: false, + diagnostics: nil + ) + + socket + end + + defp update_component(_socket, updates) do + send_update( + @component, + Keyword.merge(updates, id: "verification-standalone") + ) + end + + defp launch_delayed(socket) do + Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay) + end + + defp has_pageviews?(site) do + Plausible.Stats.Clickhouse.has_pageviews?(site) + end + + defp custom_url_form(assigns) do + ~H""" + <.focus_box> +
+ +
+
+

+ Enter Your Custom URL +

+

+ Please enter the URL where your website with the Plausible script is located. +

+
+
+ + +
+ +
+
+ + """ + end +end diff --git a/extra/lib/plausible_web/plugins/api/controllers/funnels.ex b/extra/lib/plausible_web/plugins/api/controllers/funnels.ex index f76cdc16d36d..93fb3da15181 100644 --- a/extra/lib/plausible_web/plugins/api/controllers/funnels.ex +++ b/extra/lib/plausible_web/plugins/api/controllers/funnels.ex @@ -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: %{ diff --git a/extra/lib/plausible_web/plugs/handle_expired_session.ex b/extra/lib/plausible_web/plugs/handle_expired_session.ex new file mode 100644 index 000000000000..230c5a9f1fc2 --- /dev/null +++ b/extra/lib/plausible_web/plugs/handle_expired_session.ex @@ -0,0 +1,64 @@ +defmodule Plausible.Plugs.HandleExpiredSession do + @moduledoc """ + Plug for handling expired session. Must be added after `AuthPlug`. + """ + + use Plausible + + import Plug.Conn + + alias PlausibleWeb.Router.Helpers, as: Routes + + def init(_) do + [] + end + + def call(conn, []) do + maybe_trigger_login(conn, conn.assigns[:expired_session]) + end + + defp maybe_trigger_login(conn, nil), do: conn + + defp maybe_trigger_login(conn, user_session) do + if Plausible.Users.type(user_session.user) == :sso do + Plausible.Auth.UserSessions.revoke_by_id(user_session.user, user_session.id) + trigger_sso_login(conn, user_session.user.email) + else + conn + end + end + + defp trigger_sso_login(%{method: "GET"} = conn, email) do + return_to = + if conn.query_string && String.length(conn.query_string) > 0 do + conn.request_path <> "?" <> conn.query_string + else + conn.request_path + end + + conn + |> Phoenix.Controller.redirect( + to: + Routes.sso_path(conn, :login_form, + prefer: "manual", + email: email, + autosubmit: true, + return_to: return_to + ) + ) + |> halt() + end + + defp trigger_sso_login(conn, email) do + conn + |> Phoenix.Controller.redirect( + to: + Routes.sso_path(conn, :login_form, + prefer: "manual", + email: email, + autosubmit: true + ) + ) + |> halt() + end +end diff --git a/extra/lib/plausible_web/plugs/secure_sso.ex b/extra/lib/plausible_web/plugs/secure_sso.ex new file mode 100644 index 000000000000..39d30b20dd43 --- /dev/null +++ b/extra/lib/plausible_web/plugs/secure_sso.ex @@ -0,0 +1,39 @@ +defmodule PlausibleWeb.Plugs.SecureSSO do + @moduledoc """ + Plug for securing SSO routes by setting proper policies in headers. + """ + + alias PlausibleWeb.Router.Helpers, as: Routes + + @csp """ + default-src 'none'; + script-src 'self' 'nonce-<%= nonce %>' 'report-sample'; + img-src 'self' 'report-sample'; + report-uri <%= report_path %>; + report-to csp-report-endpoint + """ + |> String.replace("\n", " ") + + @behaviour Plug + import Plug.Conn + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _) do + nonce = :crypto.strong_rand_bytes(18) |> Base.encode64() + csp_report_path = Routes.sso_path(conn, :csp_report) + + conn + |> put_private(:sso_nonce, nonce) + |> Phoenix.Controller.put_secure_browser_headers(%{ + "cache-control" => "no-cache, no-store, must-revalidate", + "pragma" => "no-cache", + "reporting-endpoints" => "csp-report-endpoint=\"#{csp_report_path}\"", + "content-security-policy" => + EEx.eval_string(@csp, nonce: nonce, report_path: csp_report_path), + "x-xss-protection" => "1; mode=block" + }) + end +end diff --git a/extra/lib/plausible_web/sso/fake_saml_adapter.ex b/extra/lib/plausible_web/sso/fake_saml_adapter.ex new file mode 100644 index 000000000000..6a3569b15ed3 --- /dev/null +++ b/extra/lib/plausible_web/sso/fake_saml_adapter.ex @@ -0,0 +1,74 @@ +defmodule PlausibleWeb.SSO.FakeSAMLAdapter do + @moduledoc """ + Fake implementation of SAML authentication interface. + """ + + alias Plausible.Auth + alias Plausible.Auth.SSO + alias Plausible.Repo + + alias PlausibleWeb.Router.Helpers, as: Routes + + def signin(conn, params) do + conn + |> Phoenix.Controller.put_layout(false) + |> Phoenix.Controller.render("saml_signin.html", + integration_id: params["integration_id"], + email: params["email"], + return_to: params["return_to"], + nonce: conn.private[:sso_nonce] + ) + end + + def consume(conn, params) do + case SSO.get_integration(params["integration_id"]) do + {:ok, integration} -> + session_timeout_minutes = integration.team.policy.sso_session_timeout_minutes + + expires_at = + NaiveDateTime.add(NaiveDateTime.utc_now(:second), session_timeout_minutes, :minute) + + identity = + if user = Repo.get_by(Auth.User, email: params["email"]) do + %SSO.Identity{ + id: user.sso_identity_id || Ecto.UUID.generate(), + integration_id: integration.identifier, + name: user.name, + email: user.email, + expires_at: expires_at + } + else + %SSO.Identity{ + id: Ecto.UUID.generate(), + integration_id: integration.identifier, + name: name_from_email(params["email"]), + email: params["email"], + expires_at: expires_at + } + end + + "sso_login_success" + |> Plausible.Audit.Entry.new(identity, %{team_id: integration.team.id}) + |> Plausible.Audit.Entry.include_change(identity) + |> Plausible.Audit.Entry.persist!() + + PlausibleWeb.UserAuth.log_in_user(conn, identity, params["return_to"]) + + {:error, :not_found} -> + conn + |> Phoenix.Controller.put_flash(:login_error, "Wrong email.") + |> Phoenix.Controller.redirect( + to: Routes.sso_path(conn, :login_form, return_to: params["return_to"]) + ) + end + end + + defp name_from_email(email) do + email + |> String.split("@", parts: 2) + |> List.first() + |> String.split(".") + |> Enum.take(2) + |> Enum.map_join(" ", &String.capitalize/1) + end +end diff --git a/extra/lib/plausible_web/sso/real_saml_adapter.ex b/extra/lib/plausible_web/sso/real_saml_adapter.ex new file mode 100644 index 000000000000..e81fa2d67672 --- /dev/null +++ b/extra/lib/plausible_web/sso/real_saml_adapter.ex @@ -0,0 +1,237 @@ +defmodule PlausibleWeb.SSO.RealSAMLAdapter do + @moduledoc """ + Real implementation of SAML authentication interface. + """ + alias Plausible.Auth.SSO + + alias PlausibleWeb.Router.Helpers, as: Routes + + @deflate "urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE" + + @cookie_name "session_saml" + @cookie_seconds 10 * 60 + + def signin(conn, %{"integration_id" => integration_id} = params) do + email = params["email"] + return_to = params["return_to"] + + case SSO.get_integration(integration_id) do + {:ok, integration} -> + sp_entity_id = SSO.SAMLConfig.entity_id(integration) + relay_state = gen_id() + id = "saml_flow_#{gen_id()}" + + auth_xml = generate_auth_request(sp_entity_id, id, DateTime.utc_now()) + + params = %{ + "SAMLEncoding" => @deflate, + "SAMLRequest" => Base.encode64(:zlib.zip(auth_xml)), + "RelayState" => relay_state, + "login_hint" => email + } + + url = %URI{} = URI.parse(integration.config.idp_signin_url) + + query_string = + (url.query || "") + |> URI.decode_query() + |> Map.merge(params) + |> URI.encode_query() + + url = URI.to_string(%{url | query: query_string}) + + conn + |> Plug.Conn.configure_session(renew: true) + |> set_cookie( + relay_state: relay_state, + return_to: return_to + ) + |> Phoenix.Controller.redirect(external: url) + + {:error, :not_found} -> + conn + |> Phoenix.Controller.put_flash(:login_error, "Wrong email.") + |> Phoenix.Controller.redirect( + to: Routes.sso_path(conn, :login_form, return_to: return_to) + ) + end + end + + def consume(conn, _params) do + integration_id = conn.path_params["integration_id"] + saml_response = conn.body_params["SAMLResponse"] + relay_state = conn.body_params["RelayState"] |> safe_decode_www_form() + + case get_cookie(conn) do + {:ok, cookie} -> + conn + |> clear_cookie() + |> consume(integration_id, cookie, saml_response, relay_state) + + {:error, :session_expired} -> + conn + |> Phoenix.Controller.put_flash(:login_error, "Session expired.") + |> Phoenix.Controller.redirect(to: Routes.sso_path(conn, :login_form)) + end + end + + @verify_opts if Mix.env() == :test, do: [skip_time_conditions?: true], else: [] + + defp consume(conn, integration_id, cookie, saml_response, relay_state) do + with {:ok, integration} <- SSO.get_integration(integration_id), + :ok <- validate_authresp(cookie, relay_state), + {:ok, {root, assertion}} <- SimpleSaml.parse_response(saml_response), + {:ok, cert} <- convert_pem_cert(integration.config.idp_cert_pem), + public_key = X509.Certificate.public_key(cert), + :ok <- + SimpleSaml.verify_and_validate_response(root, assertion, public_key, @verify_opts), + {:ok, attributes} <- extract_attributes(assertion) do + session_timeout_minutes = integration.team.policy.sso_session_timeout_minutes + + expires_at = + NaiveDateTime.add(NaiveDateTime.utc_now(:second), session_timeout_minutes, :minute) + + identity = + %SSO.Identity{ + id: assertion.name_id, + integration_id: integration.identifier, + name: name_from_attributes(attributes), + email: attributes.email, + expires_at: expires_at + } + + "sso_login_success" + |> Plausible.Audit.Entry.new(identity, %{team_id: integration.team.id}) + |> Plausible.Audit.Entry.include_change(identity) + |> Plausible.Audit.Entry.persist!() + + PlausibleWeb.UserAuth.log_in_user(conn, identity, cookie.return_to) + else + {:error, :not_found} -> + login_error(conn, cookie, "Wrong email") + + {:error, reason} -> + with {:ok, integration} <- SSO.get_integration(integration_id) do + "sso_login_failure" + |> Plausible.Audit.Entry.new(integration, %{team_id: integration.team.id}) + |> Plausible.Audit.Entry.include_change(%{ + error: inspect(reason) + }) + |> Plausible.Audit.Entry.persist!() + end + + login_error(conn, cookie, "Authentication failed (reason: #{inspect(reason)})") + end + end + + defp convert_pem_cert(cert) do + case X509.Certificate.from_pem(cert) do + {:ok, cert} -> {:ok, cert} + {:error, _} -> {:error, :malformed_certificate} + end + end + + defp name_from_attributes(attributes) do + [attributes.first_name, attributes.last_name] + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + |> String.trim() + end + + defp extract_attributes(assertion) do + attributes = + Enum.reduce([:email, :first_name, :last_name], %{}, fn field, attrs -> + value = + assertion.attributes + |> Map.get(to_string(field), []) + |> List.first() + + Map.put(attrs, field, String.trim(value || "")) + end) + + cond do + attributes.email == "" -> + {:error, :missing_email_attribute} + + # very rudimentary way to check if the attribute is at least email-like + not String.contains?(attributes.email, "@") or String.length(attributes.email) < 3 -> + {:error, :invalid_email_attribute} + + attributes.first_name == "" and attributes.last_name == "" -> + {:error, :missing_name_attributes} + + true -> + {:ok, attributes} + end + end + + defp safe_decode_www_form(nil), do: "" + defp safe_decode_www_form(data), do: URI.decode_www_form(data) + + defp generate_auth_request(issuer_id, id, timestamp) do + XmlBuilder.generate( + {:"samlp:AuthnRequest", + [ + "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + ID: id, + Version: "2.0", + IssueInstant: DateTime.to_iso8601(timestamp) + ], [{:"saml:Issuer", ["xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion"], issuer_id}]} + ) + end + + defp validate_authresp(%{relay_state: relay_state}, relay_state) + when byte_size(relay_state) == 32 do + :ok + end + + defp validate_authresp(_, _), do: {:error, :invalid_relay_state} + + defp gen_id() do + 24 |> :crypto.strong_rand_bytes() |> Base.url_encode64() + end + + @doc false + def set_cookie(conn, attrs) do + attrs = %{ + relay_state: Keyword.fetch!(attrs, :relay_state), + return_to: Keyword.fetch!(attrs, :return_to) + } + + Plug.Conn.put_resp_cookie(conn, @cookie_name, attrs, + domain: conn.private.phoenix_endpoint.host(), + secure: true, + encrypt: true, + max_age: @cookie_seconds, + same_site: "None" + ) + end + + defp get_cookie(conn) do + conn = Plug.Conn.fetch_cookies(conn, encrypted: [@cookie_name]) + + if cookie = conn.cookies[@cookie_name] do + {:ok, cookie} + else + {:error, :session_expired} + end + end + + defp clear_cookie(conn) do + Plug.Conn.delete_resp_cookie(conn, @cookie_name, + domain: conn.private.phoenix_endpoint.host(), + secure: true, + encrypt: true, + max_age: @cookie_seconds, + same_site: "None" + ) + end + + defp login_error(conn, cookie, login_error) do + conn + |> Phoenix.Controller.put_flash(:login_error, login_error) + |> Phoenix.Controller.redirect( + to: Routes.sso_path(conn, :login_form, return_to: cookie.return_to) + ) + end +end diff --git a/extra/lib/plausible_web/templates/help_scout/layout.html.heex b/extra/lib/plausible_web/templates/help_scout/layout.html.heex new file mode 100644 index 000000000000..15f124da0e18 --- /dev/null +++ b/extra/lib/plausible_web/templates/help_scout/layout.html.heex @@ -0,0 +1,13 @@ + + + + + + + Plausible · HelpScout Integration + + + + {@inner_content} + + diff --git a/extra/lib/plausible_web/templates/sso/cta.html.heex b/extra/lib/plausible_web/templates/sso/cta.html.heex new file mode 100644 index 000000000000..b1b0fd345540 --- /dev/null +++ b/extra/lib/plausible_web/templates/sso/cta.html.heex @@ -0,0 +1,18 @@ +<.settings_tiles> + <.tile docs="sso"> + <:title> + Single Sign-On + + + <.upgrade title="Available on our Enterprise plan"> + <:icon> + + + Restrict access so only team members signing in via SSO can access your account.
Email + <.styled_link href="mailto:hello@plausible.io" class="font-medium"> + hello@plausible.io + + to upgrade. + + + diff --git a/extra/lib/plausible_web/templates/sso/login_form.html.heex b/extra/lib/plausible_web/templates/sso/login_form.html.heex new file mode 100644 index 000000000000..505d5ef94e1b --- /dev/null +++ b/extra/lib/plausible_web/templates/sso/login_form.html.heex @@ -0,0 +1,52 @@ +<.focus_box> + <:title> + {Phoenix.Flash.get(@flash, :login_title) || "Enter your Single Sign-On email"} + + <:subtitle> + <%= if Phoenix.Flash.get(@flash, :login_instructions) do %> +

+ {Phoenix.Flash.get(@flash, :login_instructions)} +

+ <% end %> + + + <.form :let={f} id="sso-login-form" for={@conn} action={Routes.sso_path(@conn, :login)}> +
+ <.input + type="email" + autocomplete="username" + placeholder="user@example.com" + field={f[:email]} + /> +
+ + <%= if login_error = Phoenix.Flash.get(@flash, :login_error) do %> +
{login_error}
+ <% end %> + + <.input type="hidden" field={f[:return_to]} /> + + <.button class="w-full" type="submit">Sign In + + + <:footer> + <.focus_list> + <:item> + Have a standard account? + <.styled_link href={ + Routes.auth_path(@conn, :login_form, + return_to: @conn.params["return_to"], + prefer: "manual" + ) + }> + Log in here + + instead. + + + + diff --git a/extra/lib/plausible_web/templates/sso/provision_issue.html.heex b/extra/lib/plausible_web/templates/sso/provision_issue.html.heex new file mode 100644 index 000000000000..57abf7d4085a --- /dev/null +++ b/extra/lib/plausible_web/templates/sso/provision_issue.html.heex @@ -0,0 +1,51 @@ +<.focus_box> + <:title> + Single Sign-On enforcement + + <:subtitle> + The owner of the team + "{@conn.assigns[:current_team].name}" + has turned off regular email and password logins. + To keep things secure and simple, you can only sign in using your organization's + Single Sign-On (SSO) system. + + +

+ To access this team, you must first leave all other teams. +

+ +

+ To log in as an SSO user, you must first leave all other teams. +

+ +
+

+ To access this team, you must either remove or transfer all sites you own under "My Personal Sites". +

+ +

+ You also have to cancel subscription for "My Personal Sites" if there is an active one. +

+
+ +
+

+ To log in as an SSO user, you must either remove or transfer all sites you own under "My Personal Sites". +

+ +

+ You also have to cancel subscription on "My Personal Sites" if there is an active one. +

+
+ +

+ To access this team, you must join as a team member first. +

+ +

+ <.styled_link href={Routes.auth_path(@conn, :login_form, prefer: "manual")}> + Log in + + with your email and password to resolve the issue. +

+ diff --git a/extra/lib/plausible_web/templates/sso/provision_notice.html.heex b/extra/lib/plausible_web/templates/sso/provision_notice.html.heex new file mode 100644 index 000000000000..12d944f9fea4 --- /dev/null +++ b/extra/lib/plausible_web/templates/sso/provision_notice.html.heex @@ -0,0 +1,17 @@ +<.focus_box> + <:title> + Single Sign-On enforcement + + <:subtitle> + The owner of the team + "{@conn.assigns[:current_team].name}" + has turned off regular email and password logins. + To keep things secure and simple, you can only sign in using your organization's + Single Sign-On (SSO) system. + + +

+ To access this team, you must first <.styled_link href="/logout">log out + and log in as SSO user. +

+ diff --git a/extra/lib/plausible_web/templates/sso/saml_signin.html.heex b/extra/lib/plausible_web/templates/sso/saml_signin.html.heex new file mode 100644 index 000000000000..e70be63538b2 --- /dev/null +++ b/extra/lib/plausible_web/templates/sso/saml_signin.html.heex @@ -0,0 +1,36 @@ + + + + + + + +
+

+ Processing Single Sign-On request... +

+ + <.form + :let={f} + id="sso-req-form" + for={@conn} + action={Routes.sso_path(@conn, :saml_consume, @integration_id)} + > + <.input type="hidden" field={f[:email]} /> + <.input type="hidden" field={f[:return_to]} /> + + <.button class="w-full" type="submit">sign in + +
+ + diff --git a/extra/lib/plausible_web/templates/sso/sso_settings.html.heex b/extra/lib/plausible_web/templates/sso/sso_settings.html.heex new file mode 100644 index 000000000000..f650b95f7429 --- /dev/null +++ b/extra/lib/plausible_web/templates/sso/sso_settings.html.heex @@ -0,0 +1,3 @@ +<.settings_tiles> + {live_render(@conn, PlausibleWeb.Live.SSOManagement, id: "sso-management")} + diff --git a/extra/lib/plausible_web/templates/sso/team_sessions.html.heex b/extra/lib/plausible_web/templates/sso/team_sessions.html.heex new file mode 100644 index 000000000000..0b9ac3a19464 --- /dev/null +++ b/extra/lib/plausible_web/templates/sso/team_sessions.html.heex @@ -0,0 +1,36 @@ +<.settings_tiles> + <.tile docs="sso#team-management"> + <:title> + SSO Login Management + + <:subtitle> + Review and log out Single Sign-On user sessions + + +
+ There are currently no active SSO sessions +
+ + <.table id="sso-sessions-list" rows={@sso_sessions}> + <:thead> + <.th>User + <.th hide_on_mobile>Device + <.th hide_on_mobile>Last seen + <.th invisible>Actions + + <:tbody :let={session}> + <.td truncate max_width="max-w-40">{session.user.name} + <.td hide_on_mobile>{session.device} + <.td hide_on_mobile>{Plausible.Auth.UserSessions.last_used_humanize(session)} + <.td :if={@current_user_session.id == session.id} actions>Current session + <.td :if={@current_user_session.id != session.id} actions> + <.delete_button + href={Routes.sso_path(@conn, :delete_session, session.id)} + method="delete" + data-confirm="Are you sure you want to log out this session?" + /> + + + + + diff --git a/extra/lib/plausible_web/views/help_scout_view.ex b/extra/lib/plausible_web/views/help_scout_view.ex index 3215c76402dc..ea7787926ed8 100644 --- a/extra/lib/plausible_web/views/help_scout_view.ex +++ b/extra/lib/plausible_web/views/help_scout_view.ex @@ -1,13 +1,18 @@ defmodule PlausibleWeb.HelpScoutView do - use PlausibleWeb, :view + use PlausibleWeb, :extra_view def render("callback.html", assigns) do ~H""" <.layout xhr?={assigns[:xhr?]}>

- Owner of {@sites_count} sites + Owner of {@sites_count} sites

@@ -115,7 +146,12 @@ defmodule PlausibleWeb.HelpScoutView do """ end attr(:usage, :map, required: true) attr(:limit, :any, required: true) attr(:period, :atom, required: true) + attr(:expanded, :boolean, required: true) + attr(:total_pageview_usage_domain, :string, default: nil) - defp monthly_pageview_usage_table(assigns) do - ~H""" - <.usage_and_limits_table> - <.usage_and_limits_row - id={"total_pageviews_#{@period}"} - title={"Total billable pageviews#{if @period == :last_30_days, do: " (last 30 days)"}"} - usage={@usage.total} - limit={@limit} - /> - <.usage_and_limits_row - id={"pageviews_#{@period}"} - pad - title="Pageviews" - usage={@usage.pageviews} - /> - <.usage_and_limits_row - id={"custom_events_#{@period}"} - pad - title="Custom events" - usage={@usage.custom_events} - /> - - """ - end - - attr(:name, :string, required: true) - attr(:date_range, :any, required: true) - attr(:tab, :atom, required: true) - attr(:disabled, :boolean, default: false) - attr(:with_separator, :boolean, default: false) + defp monthly_pageview_usage_breakdown(assigns) do + assigns = + assign( + assigns, + :total_link, + dashboard_url( + assigns.total_pageview_usage_domain, + assigns.usage.date_range + ) + ) - defp billing_cycle_tab(assigns) do ~H""" -
  • +
    +
    +

    + {PlausibleWeb.TextHelpers.format_date_range(@usage.date_range)} + {cycle_label(@period)} +

    + <.usage_progress_bar + :if={@limit != :unlimited} + id={"total_pageviews_#{@period}"} + usage={@usage.total} + limit={@limit} + /> +
    - -
  • +
    """ end - slot(:inner_block, required: true) - attr(:rest, :global) + attr :id, :string, default: nil + attr :label, :string, required: true + attr :value, :integer, required: true - def usage_and_limits_table(assigns) do + defp pageview_usage_row(assigns) do ~H""" - - - {render_slot(@inner_block)} - -
    +
    + + ‱ + {@label} + + {PlausibleWeb.TextHelpers.number_format(@value)} +
    """ end - attr(:title, :string, required: true) + defp dashboard_url(nil, _date_range), do: nil + + defp dashboard_url(domain, date_range) do + base = Routes.stats_path(PlausibleWeb.Endpoint, :stats, domain, []) + + base <> + "?period=custom&from=#{Date.to_iso8601(date_range.first)}&to=#{Date.to_iso8601(date_range.last)}" + end + + defp cycle_label(:current_cycle), do: "(current cycle)" + defp cycle_label(:last_30_days), do: "(last 30 days)" + + @doc """ + Renders a color-coded progress bar based on usage percentage. + + Color scheme: + - 0-90%: Green (healthy usage) + - 91-99%: Gradient from green through yellow to orange (approaching limit) + - 100%: Gradient from green through orange to red (at limit) + """ attr(:usage, :integer, required: true) - attr(:limit, :integer, default: nil) - attr(:pad, :boolean, default: false) + attr(:limit, :any, required: true) attr(:rest, :global) - def usage_and_limits_row(assigns) do - ~H""" - - - {@title} - - - {Cldr.Number.to_string!(@usage)} - {if is_number(@limit), do: "/ #{Cldr.Number.to_string!(@limit)}"} - - - """ - end + def usage_progress_bar(assigns) do + percentage = calculate_percentage(assigns.usage, assigns.limit) + + assigns = + assigns + |> assign(:percentage, percentage) + |> assign(:color_class, progress_bar_color_from_percentage(percentage, assigns.limit)) - def monthly_quota_box(assigns) do ~H""" -
    -

    Monthly quota

    -
    - {PlausibleWeb.AuthView.subscription_quota(@subscription, format: :long)} -
    - <.styled_link - :if={ - not (Plausible.Teams.Billing.enterprise_configured?(@team) && - Subscriptions.halted?(@subscription)) - } - id="#upgrade-or-change-plan-link" - href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} +
    +
    - {change_plan_or_upgrade_text(@subscription)} - +
    """ end + defp calculate_percentage(_usage, :unlimited), do: 0 + defp calculate_percentage(_usage, 0), do: 0 + + defp calculate_percentage(usage, limit) when is_number(limit) do + percentage = usage / limit * 100 + min(percentage, 100.0) |> Float.round(1) + end + + defp progress_bar_color_from_percentage(_percentage, :unlimited), + do: "bg-green-500 dark:bg-green-600" + + defp progress_bar_color_from_percentage(_percentage, 0), do: "bg-gray-200 dark:bg-gray-700" + + defp progress_bar_color_from_percentage(percentage, _limit) when is_number(percentage) do + cond do + percentage >= 100.0 -> + "bg-gradient-to-r from-green-500 via-orange-500 via-[80%] to-red-500 dark:from-green-600 dark:via-orange-600 dark:to-red-600" + + percentage >= 91 -> + "bg-gradient-to-r from-green-500 via-yellow-500 via-[80%] to-orange-500 dark:from-green-600 dark:via-yellow-600 dark:to-orange-600" + + true -> + "bg-green-500 dark:bg-green-600" + end + end + def present_enterprise_plan(assigns) do ~H"""
      @@ -233,27 +320,23 @@ defmodule PlausibleWeb.Components.Billing do slot :inner_block, required: true def paddle_button(assigns) do + js_action_expr = + start_paddle_checkout_expr(assigns.paddle_product_id, assigns.team, assigns.user) + confirmed = if assigns.confirm_message, do: "confirm(\"#{assigns.confirm_message}\")", else: "true" - passthrough = - if assigns.team do - "ee:#{ee?()};user:#{assigns.user.id};team:#{assigns.team.id}" - else - "ee:#{ee?()};user:#{assigns.user.id}" - end - assigns = assigns |> assign(:confirmed, confirmed) - |> assign(:passthrough, passthrough) + |> assign(:js_action_expr, js_action_expr) ~H"""
    """ end - defp pill(assigns) do + defp highlight_pill(assigns) do ~H"""

    - +

    + Custom

    @@ -117,56 +121,103 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do defp render_price_info(assigns) do ~H""" -

    - <.price_tag - kind={@kind} - selected_interval={@selected_interval} - plan_to_render={@plan_to_render} - /> -

    -

    + VAT if applicable

    - """ - end - - defp price_tag(%{plan_to_render: %Plan{monthly_cost: nil}} = assigns) do - ~H""" - - N/A - + <.price_tag kind={@kind} selected_interval={@selected_interval} plan_to_render={@plan_to_render} /> +
    + + VAT + +
    """ end defp price_tag(%{selected_interval: :monthly} = assigns) do + monthly_cost = + case assigns.plan_to_render do + %{monthly_cost: nil} -> "N/A" + %{monthly_cost: monthly_cost} -> Plausible.Billing.format_price(monthly_cost) + end + + assigns = assign(assigns, :monthly_cost, monthly_cost) + ~H""" - - {@plan_to_render.monthly_cost |> Plausible.Billing.format_price()} - - - /month - +

    + + {@monthly_cost} + + + /month + +

    """ end defp price_tag(%{selected_interval: :yearly} = assigns) do + monthly_cost = + case assigns.plan_to_render do + %{monthly_cost: nil} -> "N/A" + %{monthly_cost: monthly_cost} -> Plausible.Billing.format_price(monthly_cost) + end + + {yearly_cost, monthly_cost_with_discount} = + case assigns.plan_to_render do + %{yearly_cost: nil} -> + {"N/A", "N/A"} + + %{yearly_cost: yearly_cost} -> + { + Plausible.Billing.format_price(yearly_cost), + Plausible.Billing.format_price(Money.div!(yearly_cost, 12)) + } + end + + assigns = + assigns + |> assign(:monthly_cost, monthly_cost) + |> assign(:yearly_cost, yearly_cost) + |> assign(:monthly_cost_with_discount, monthly_cost_with_discount) + ~H""" - - {@plan_to_render.monthly_cost |> Money.mult!(12) |> Plausible.Billing.format_price()} - - - {@plan_to_render.yearly_cost |> Plausible.Billing.format_price()} - - - /year - +
    + + {@yearly_cost} + + + + /year + + +
    + + {@monthly_cost} + + + {@monthly_cost_with_discount} + +
    + + + /month + +
    """ end @@ -236,18 +287,22 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do Upgrade <% end %> - <.tooltip :if={@exceeded_plan_limits != [] && @disabled_message}> -
    - {@disabled_message} -
    - <:tooltip_content> - Your usage exceeds the following limit(s):

    -

    - {Phoenix.Naming.humanize(limit)}
    -

    - - +
    + <.tooltip testid="plan-tooltip"> + + <:tooltip_content> + Your usage exceeds the following limit(s):

    +

    + {Phoenix.Naming.humanize(limit)}
    +

    + + +
    + "Downgrade to Starter" + from_kind == :business && to_kind == :growth -> "Downgrade to Growth" - from_kind == :growth && to_kind == :business -> + from_kind == :starter && to_kind == :growth -> + "Upgrade to Growth" + + from_kind in [:starter, :growth] && to_kind == :business -> "Upgrade to Business" from_volume == to_volume && from_interval == to_interval -> diff --git a/lib/plausible_web/components/first_dashboard_launch_banner.ex b/lib/plausible_web/components/first_dashboard_launch_banner.ex index 1f624745e539..7646368a6fdd 100644 --- a/lib/plausible_web/components/first_dashboard_launch_banner.ex +++ b/lib/plausible_web/components/first_dashboard_launch_banner.ex @@ -22,14 +22,17 @@ defmodule PlausibleWeb.Components.FirstDashboardLaunchBanner do """ diff --git a/lib/plausible_web/components/flow_progress.ex b/lib/plausible_web/components/flow_progress.ex index b3528511b9ee..53288065fedd 100644 --- a/lib/plausible_web/components/flow_progress.ex +++ b/lib/plausible_web/components/flow_progress.ex @@ -26,19 +26,19 @@ defmodule PlausibleWeb.Components.FlowProgress do
    - +
    {idx + 1}
    @current_step_idx} - class="w-5 h-5 bg-gray-300 text-white dark:bg-gray-800 rounded-full flex items-center justify-center" + class="size-6 bg-gray-300 text-xs text-white font-bold dark:bg-gray-800 rounded-full flex items-center justify-center" > {idx + 1}
    diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 815578f14677..1406d2e94d48 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -4,39 +4,59 @@ defmodule PlausibleWeb.Components.Generic do """ use Phoenix.Component, global_prefixes: ~w(x-) + import PlausibleWeb.Components.Icons + @notice_themes %{ gray: %{ - bg: "bg-white dark:bg-gray-800", - icon: "text-gray-400", - title_text: "text-gray-800 dark:text-gray-400", - body_text: "text-gray-700 dark:text-gray-500 leading-5" + bg: "bg-gray-100 dark:bg-gray-800", + icon: "text-gray-600 dark:text-gray-300", + title_text: "text-sm text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-800 dark:text-gray-200 leading-5" }, yellow: %{ - bg: "bg-yellow-50 dark:bg-yellow-100", - icon: "text-yellow-400", - title_text: "text-sm text-yellow-800 dark:text-yellow-900", - body_text: "text-sm text-yellow-700 dark:text-yellow-800 leading-5" + bg: "bg-yellow-100/60 dark:bg-yellow-900/40", + icon: "text-yellow-500", + title_text: "text-sm text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" }, red: %{ - bg: "bg-red-100", - icon: "text-red-700", - title_text: "text-sm text-red-800 dark:text-red-900", - body_text: "text-sm text-red-700 dark:text-red-800" + bg: "bg-red-100 dark:bg-red-900/30", + icon: "text-red-600 dark:text-red-500", + title_text: "text-sm text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" + }, + white: %{ + bg: "bg-white dark:bg-gray-900 shadow-sm dark:shadow-none", + icon: "text-gray-600 dark:text-gray-400", + title_text: "text-sm text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-600 dark:text-gray-300 leading-5" } } @button_themes %{ - "primary" => "bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600", - "bright" => - "border border-gray-200 bg-gray-100 dark:bg-gray-300 text-gray-800 hover:bg-gray-200 focus-visible:outline-gray-100", + "primary" => + "border border-indigo-600 bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600 disabled:bg-indigo-400/60 disabled:dark:bg-indigo-600/30 disabled:dark:text-white/35", + "secondary" => + "border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 hover:bg-gray-50 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:border-gray-600 dark:hover:text-white disabled:text-gray-700/40 dark:disabled:text-gray-500 dark:disabled:bg-gray-800 dark:disabled:border-gray-800", + "yellow" => + "bg-yellow-600/90 text-white hover:bg-yellow-600 focus-visible:outline-yellow-600 disabled:bg-yellow-400/60 disabled:dark:bg-yellow-600/30 disabled:dark:text-white/35", "danger" => - "border border-gray-300 dark:border-gray-500 text-red-700 bg-white dark:bg-gray-900 hover:text-red-500 dark:hover:text-red-400 focus:border-blue-300 dark:text-red-500 active:text-red-800" + "border border-gray-300 dark:border-gray-800 text-red-600 bg-white dark:bg-gray-800 hover:text-red-700 dark:hover:text-red-400 dark:text-red-500 active:text-red-800 disabled:text-red-700/40 disabled:hover:shadow-none dark:disabled:text-red-500/35 dark:disabled:bg-gray-800", + "ghost" => + "text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:text-gray-500 disabled:dark:text-gray-600 disabled:hover:bg-transparent", + "icon" => "text-gray-400 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100" } - @button_base_class "whitespace-nowrap truncate inline-flex items-center justify-center gap-x-2 font-medium rounded-md px-3.5 py-2.5 text-sm shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700" + @button_base_class "whitespace-nowrap truncate inline-flex items-center justify-center gap-x-2 text-sm font-medium rounded-md cursor-pointer disabled:cursor-not-allowed" + + @button_sizes %{ + "sm" => "px-3 py-2", + "md" => "px-3.5 py-2.5" + } attr(:type, :string, default: "button") attr(:theme, :string, default: "primary") + attr(:size, :string, default: "md") attr(:class, :string, default: "") attr(:disabled, :boolean, default: false) attr(:mt?, :boolean, default: true) @@ -48,7 +68,8 @@ defmodule PlausibleWeb.Components.Generic do assigns = assign(assigns, button_base_class: @button_base_class, - theme_class: @button_themes[assigns.theme] + theme_class: @button_themes[assigns.theme], + size_class: @button_sizes[assigns.size] ) ~H""" @@ -58,6 +79,7 @@ defmodule PlausibleWeb.Components.Generic do class={[ @mt? && "mt-6", @button_base_class, + @size_class, @theme_class, @class ]} @@ -71,6 +93,7 @@ defmodule PlausibleWeb.Components.Generic do attr(:href, :string, required: true) attr(:class, :string, default: "") attr(:theme, :string, default: "primary") + attr(:size, :string, default: "md") attr(:disabled, :boolean, default: false) attr(:method, :string, default: "get") attr(:mt?, :boolean, default: true) @@ -94,7 +117,7 @@ defmodule PlausibleWeb.Components.Generic do theme_class = if assigns.disabled do - "bg-gray-400 text-white dark:text-white dark:text-gray-400 dark:bg-gray-700 cursor-not-allowed" + "bg-gray-400 text-white transition-all duration-150 dark:text-white dark:text-gray-400 dark:bg-gray-700 cursor-not-allowed" else @button_themes[assigns.theme] end @@ -110,7 +133,8 @@ defmodule PlausibleWeb.Components.Generic do assign(assigns, onclick: onclick, button_base_class: @button_base_class, - theme_class: theme_class + theme_class: theme_class, + size_class: @button_sizes[assigns.size] ) ~H""" @@ -120,6 +144,7 @@ defmodule PlausibleWeb.Components.Generic do class={[ @mt? && "mt-6", @button_base_class, + @size_class, @theme_class, @class ]} @@ -136,28 +161,66 @@ defmodule PlausibleWeb.Components.Generic do def docs_info(assigns) do ~H""" - - - +
    + <.tooltip enabled?={true} centered?={true}> + <:tooltip_content> + Learn more + + + + + +
    + """ + end + + attr(:title, :any, default: "") + attr(:class, :string, default: "") + attr(:rest, :global) + slot(:icon, required: true) + slot(:inner_block) + + def upgrade(assigns) do + ~H""" +
    +
    +
    + {render_slot(@icon)} +
    +
    +

    + {@title} +

    +

    + {render_slot(@inner_block)} +

    +
    +
    +
    """ end attr(:title, :any, default: nil) attr(:theme, :atom, default: :yellow) attr(:dismissable_id, :any, default: nil) + attr(:show_icon, :boolean, default: true) attr(:class, :string, default: "") attr(:rest, :global) slot(:inner_block) + slot(:actions) + slot(:icon) def notice(assigns) do assigns = assign(assigns, :theme, Map.fetch!(@notice_themes, assigns.theme)) ~H"""
    -
    +
    -
    -
    - -
    -
    -

    - {@title} -

    -
    -

    - {render_slot(@inner_block)} -

    +
    +
    +
    + <%= if @icon != [] do %> + {render_slot(@icon)} + <% else %> + <.exclamation_triangle_icon class={"size-4.5 #{@theme.icon}"} /> + <% end %> +
    +
    +

    + {@title} +

    +
    +

    + {render_slot(@inner_block)} +

    +
    +
    + {render_slot(@actions)} +
    @@ -207,7 +271,7 @@ defmodule PlausibleWeb.Components.Generic do attr(:href, :string, default: "#") attr(:new_tab, :boolean, default: false) attr(:class, :string, default: "") - attr(:rest, :global) + attr(:rest, :global, include: ~w(patch)) attr(:method, :string, default: "get") slot(:inner_block) @@ -217,7 +281,7 @@ defmodule PlausibleWeb.Components.Generic do new_tab={@new_tab} href={@href} method={@method} - class={"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600 " <> @class} + class={"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-400 transition-colors duration-150 " <> @class} {@rest} > {render_slot(@inner_block)} @@ -266,7 +330,7 @@ defmodule PlausibleWeb.Components.Generic do x-on:click.outside="close($refs.button)" style="display: none;" class={[ - "origin-top-right absolute z-50 right-0 mt-2 p-1.5 w-max rounded-md shadow-lg overflow-hidden bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none", + "origin-top-right absolute z-50 right-0 mt-2 p-1.5 w-max rounded-md shadow-lg overflow-hidden bg-white dark:bg-gray-800 ring-1 ring-black/5 focus:outline-none", @menu_class ]} > @@ -284,8 +348,8 @@ defmodule PlausibleWeb.Components.Generic do attr(:rest, :global, include: ~w(method)) slot(:inner_block, required: true) - @base_class "block rounded-lg text-sm/6 text-gray-900 ui-disabled:text-gray-500 dark:text-gray-100 dark:ui-disabled:text-gray-400 px-3.5 py-1.5" - @clickable_class "hover:bg-gray-100 dark:hover:bg-gray-700" + @base_class "block rounded-md text-sm/6 text-gray-900 ui-disabled:text-gray-500 dark:text-gray-100 dark:ui-disabled:text-gray-400 px-3 py-1.5" + @clickable_class "hover:bg-gray-100 dark:hover:bg-gray-700/80" def dropdown_item(assigns) do assigns = if assigns[:disabled] do @@ -330,7 +394,7 @@ defmodule PlausibleWeb.Components.Generic do attr(:href, :string, required: true) attr(:new_tab, :boolean, default: false) - attr(:class, :string, default: nil) + attr(:class, :string, default: "") attr(:rest, :global) attr(:method, :string, default: "get") slot(:inner_block) @@ -355,7 +419,7 @@ defmodule PlausibleWeb.Components.Generic do ~H""" <.link class={[ - "inline-flex items-center gap-x-0.5", + "inline-flex items-center gap-x-1", @class ]} href={@href} @@ -365,7 +429,7 @@ defmodule PlausibleWeb.Components.Generic do {@rest} > {render_slot(@inner_block)} - + <.external_link_icon class={[@icon_class]} /> """ else @@ -387,10 +451,10 @@ defmodule PlausibleWeb.Components.Generic do viewBox="0 0 24 24" {@rest} > - + @@ -399,6 +463,135 @@ defmodule PlausibleWeb.Components.Generic do """ end + attr :id, :string, required: true + attr :js_active_var, :string, default: nil + attr :checked, :boolean, default: nil + attr :id_suffix, :string, default: "" + attr :disabled, :boolean, default: false + + attr(:rest, :global) + + @doc """ + Renders toggle input. + + Can be used in two modes: + + 1. Alpine JS mode: Pass `:js_active_var` to control toggle state via Alpine JS. + Set this outside this component with `x-data="{ : }"`. + + 2. Server-side mode: Pass `:checked` boolean and `phx-click` event handler. + + ### Examples - Alpine JS mode + ``` +
    +
    + ``` + + ### Examples - Server-side mode + ``` + <.toggle_switch id="my_toggle" checked={@my_toggle} phx-click="toggle-my-setting" phx-target={@myself} /> + ``` + """ + def toggle_switch(assigns) do + server_mode? = not is_nil(assigns.checked) + assigns = assign(assigns, :server_mode?, server_mode?) + + ~H""" + + """ + end + + attr :id, :string, required: true + attr :js_active_var, :string, required: true + attr :id_suffix, :string, default: "" + attr :disabled, :boolean, default: false + attr :label, :string, required: true + attr :help_text, :string, default: nil + attr :show_help_text_only_when_active?, :boolean, default: false + attr :mt?, :boolean, default: true + + attr(:rest, :global) + + @doc """ + Renders toggle input with a label. Clicking the label also toggles the toggle. + Needs `:js_active_var` that controls toggle state. + Set this outside this component with `x-data="{ : }"` + Can be configured to always show a description of the field / help text `:help_text`, + or only show the help text when the toggle is activated `:show_help_text_only_when_active?`. + """ + def toggle_field(assigns) do + ~H""" +
    +
    + + {@label} + +

    + {@help_text} +

    +
    + +
    + """ + end + def settings_tiles(assigns) do ~H"""
    @@ -410,41 +603,64 @@ defmodule PlausibleWeb.Components.Generic do attr :docs, :string, default: nil slot :inner_block, required: true slot :title, required: true - slot :subtitle, required: true + slot :subtitle, required: false attr :feature_mod, :atom, default: nil - attr :site, :any - attr :conn, :any + attr :feature_toggle?, :boolean, default: false + attr :current_team, :any, default: nil + attr :current_user, :any, default: nil + attr :site, :any, default: nil + attr :conn, :any, default: nil + attr :show_content?, :boolean, default: true def tile(assigns) do ~H""" -
    +
    <.title> {render_slot(@title)} - <.docs_info :if={@docs} slug={@docs} class="absolute top-4 right-4" /> + <.docs_info :if={@docs} slug={@docs} class="absolute top-4 right-4 z-1" /> -
    +
    {render_slot(@subtitle)}
    + + <.live_component + :if={@feature_toggle?} + module={PlausibleWeb.Components.Site.Feature.ToggleLive} + id={"feature-toggle-#{@site.id}-#{@feature_mod}"} + site={@site} + feature_mod={@feature_mod} + current_user={@current_user} + /> +
    +
    +
    <%= if @feature_mod do %> - + > +
    + {render_slot(@inner_block)} +
    + + <% else %> +
    + {render_slot(@inner_block)} +
    <% end %> -
    - - -
    - {render_slot(@inner_block)}
    """ end attr(:sticky?, :boolean, default: true) + attr(:enabled?, :boolean, default: true) + attr(:centered?, :boolean, default: false) + attr(:testid, :string, default: nil) slot(:inner_block, required: true) slot(:tooltip_content, required: true) @@ -454,43 +670,141 @@ defmodule PlausibleWeb.Components.Generic do show_inner = if assigns[:sticky?], do: "hovered || sticky", else: "hovered" - assigns = assign(assigns, wrapper_data: wrapper_data, show_inner: show_inner) + base_classes = [ + "absolute", + "pb-2", + "top-0", + "-translate-y-full", + "z-[1000]", + "sm:max-w-64", + "w-max" + ] + + tooltip_position_classes = + if assigns.centered? do + base_classes ++ ["left-1/2", "-translate-x-1/2"] + else + base_classes + end - ~H""" -
    + assigns = + assign(assigns, + wrapper_data: wrapper_data, + show_inner: show_inner, + tooltip_position_classes: tooltip_position_classes + ) + + if assigns.enabled? do + ~H"""
    - {render_slot(List.first(@tooltip_content))} +
    +
    + {render_slot(@tooltip_content)} +
    +
    +
    + {render_slot(@inner_block)} +
    -
    + """ + else + ~H"{render_slot(@inner_block)}" + end + end + + slot :inner_block, required: true + + def accordion_menu(assigns) do + ~H""" +
    + {render_slot(@inner_block)} +
    + """ + end + + attr :id, :string, required: true + attr :title, :string, required: true + attr :open_by_default, :boolean, default: false + attr :title_class, :string, default: "" + slot :inner_block, required: true + + def accordion_item(assigns) do + ~H""" +
    +
    + +
    +
    {render_slot(@inner_block)} -
    +
    """ end - attr(:rest, :global, include: ~w(fill stroke stroke-width)) + attr(:rest, :global, include: ~w(fill stroke stroke-width class)) attr(:name, :atom, required: true) attr(:outline, :boolean, default: true) attr(:solid, :boolean, default: false) attr(:mini, :boolean, default: false) def dynamic_icon(assigns) do - apply(Heroicons, assigns.name, [assigns]) + case assigns.name do + :tag -> + PlausibleWeb.Components.Icons.tag_icon(%{class: assigns.rest[:class]}) + + :subscription -> + PlausibleWeb.Components.Icons.subscription_icon(%{class: assigns.rest[:class]}) + + :api_keys -> + PlausibleWeb.Components.Icons.key_icon(%{class: assigns.rest[:class]}) + + icon_name -> + apply(Heroicons, icon_name, [assigns]) + end end attr(:width, :integer, default: 100) @@ -502,9 +816,9 @@ defmodule PlausibleWeb.Components.Generic do if String.contains?(classes, "text-sm") or String.contains?(classes, "text-xs") do - ["w-3 h-3"] + ["size-3"] else - ["w-4 h-4"] + ["size-4"] end end @@ -524,15 +838,16 @@ defmodule PlausibleWeb.Components.Generic do slot :subtitle slot :inner_block, required: true slot :footer + attr :padding?, :boolean, default: true attr :rest, :global def focus_box(assigns) do ~H"""
    -
    +
    <.title :if={@title != []}> {render_slot(@title)} @@ -554,7 +869,7 @@ defmodule PlausibleWeb.Components.Generic do :if={@footer != []} class="flex flex-col dark:text-gray-200 border-t border-gray-300 dark:border-gray-700" > -
    +
    {render_slot(@footer)}
    @@ -572,7 +887,14 @@ defmodule PlausibleWeb.Components.Generic do def table(assigns) do ~H""" - +
    tbody>tr:first-child>td]:pt-0 [&>tbody>tr:last-child>td]:pb-0", + @width + ]} + {@rest} + > {render_slot(@thead)} @@ -592,6 +914,7 @@ defmodule PlausibleWeb.Components.Generic do attr :truncate, :boolean, default: false attr :max_width, :string, default: "" attr :height, :string, default: "" + attr :class, :string, default: "" attr :actions, :boolean, default: nil attr :hide_on_mobile, :boolean, default: nil attr :rest, :global @@ -608,17 +931,22 @@ defmodule PlausibleWeb.Components.Generic do ~H""" 3}> + + + + + <% end %> + + + + <%= if Plausible.Billing.Subscriptions.resumable?(@subscription) && @subscription.cancel_url do %> +
    + <.button_link theme="danger" href={@subscription.cancel_url} mt?={false}> + Cancel plan + + <%= if Application.get_env(:plausible, :environment) == "dev" do %> + <.button_link + href={@subscription.update_url} + theme="secondary" + class="text-yellow-600 dark:text-yellow-400" + mt?={false} + > + [DEV ONLY] Change status + + <% end %> +
    + <% end %> + """ + end + + on_ee do + defp consolidated_view_domain(team) do + view = Plausible.ConsolidatedView.get(team) + + if not is_nil(view) and Plausible.ConsolidatedView.ok_to_display?(team) do + view.domain + end + end + end +end diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index ab10b38fce87..77b74561968b 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -42,11 +42,11 @@ defmodule PlausibleWeb.Live.TeamManagement do <.flash_messages flash={@flash} />
    @@ -65,7 +65,7 @@ defmodule PlausibleWeb.Live.TeamManagement do
    <.dropdown id="input-role-picker"> - <:button class="role border rounded border-indigo-700 bg-transparent text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> + <:button class="role inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> {@input_role |> Atom.to_string() |> String.capitalize()} @@ -132,11 +132,11 @@ defmodule PlausibleWeb.Live.TeamManagement do
    -
    +
    Guests -
    +
    @@ -148,6 +148,7 @@ defmodule PlausibleWeb.Live.TeamManagement do label={entry_label(entry, @current_user)} my_role={@my_role} remove_disabled={not Layout.removable?(@layout, email)} + disabled={@my_role not in [:owner, :admin]} />
    @@ -314,6 +315,13 @@ defmodule PlausibleWeb.Live.TeamManagement do "The team has to have at least one owner" ) + {{:error, :disabled_2fa}, _} -> + socket + |> put_live_flash( + :error, + "User must have 2FA enabled to become an owner" + ) + {{:error, {:over_limit, limit}}, _} -> socket |> put_live_flash( @@ -324,10 +332,15 @@ defmodule PlausibleWeb.Live.TeamManagement do end defp entry_label(%Layout.Entry{role: :guest, type: :membership}, _), do: nil - defp entry_label(%Layout.Entry{type: :invitation_pending}, _), do: "Invitation Pending" - defp entry_label(%Layout.Entry{type: :invitation_sent}, _), do: "Invitation Sent" + defp entry_label(%Layout.Entry{type: :invitation_pending}, _), do: "Invitation pending" + defp entry_label(%Layout.Entry{type: :invitation_sent}, _), do: "Invitation sent" + + defp entry_label(%Layout.Entry{meta: %{user: %{id: id, type: :sso}}}, %{id: id}), + do: "You (SSO)" + defp entry_label(%Layout.Entry{meta: %{user: %{id: id}}}, %{id: id}), do: "You" - defp entry_label(_, _), do: "Team Member" + defp entry_label(%Layout.Entry{meta: %{user: %{type: :sso}}}, _), do: "SSO" + defp entry_label(_, _), do: nil def at_limit?(layout, limit) do not Plausible.Billing.Quota.below_limit?( diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index b73cec83ad3b..46826be6ca66 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -24,7 +24,7 @@ defmodule PlausibleWeb.Live.TeamSetup do %Teams.Team{} -> team_name_form = current_team - |> Teams.Team.name_changeset(%{name: "#{current_user.name}'s Team"}) + |> Teams.Team.name_changeset(%{name: "#{current_user.name}'s team"}) |> Repo.update!() |> Teams.Team.name_changeset(%{}) |> to_form() @@ -55,10 +55,12 @@ defmodule PlausibleWeb.Live.TeamSetup do end def render(assigns) do + assigns = assign(assigns, :locked?, Plausible.Teams.Billing.solo?(assigns.current_team)) + ~H""" - <.focus_box> + <.focus_box padding?={false}> <:title> -
    +
    Create a new team
    <.docs_info slug="users-roles" /> @@ -66,39 +68,50 @@ defmodule PlausibleWeb.Live.TeamSetup do
    <:subtitle> - Add your team members and assign their roles +

    + Name your team, add team members and assign roles. When ready, click "Create Team" to send invitations +

    - <.form - :let={f} - for={@team_name_form} - method="post" - phx-change="update-team" - phx-blur="update-team" - id="update-team-form" - class="mt-4 mb-8" - > - <.input - type="text" - placeholder={"#{@current_user.name}'s Team"} - autofocus - field={f[:name]} - label="Name" - width="w-full" - phx-debounce="500" - /> - - - <.label class="mb-2"> - Team Members - - {live_render(@socket, PlausibleWeb.Live.TeamManagement, - id: "team-management-setup", - container: {:div, id: "team-setup"}, - session: %{ - "mode" => "team-setup" - } - )} +
    + + <.form + :let={f} + for={@team_name_form} + method="post" + phx-change="update-team" + phx-submit="update-team" + phx-blur="update-team" + id="update-team-form" + class="mt-4 mb-8" + > + <.input + type="text" + placeholder={"#{@current_user.name}'s team"} + autofocus={not @locked?} + field={f[:name]} + label="Name" + width="w-full" + phx-debounce="500" + /> + + + <.label class="mb-2"> + Team members + + {live_render(@socket, PlausibleWeb.Live.TeamManagement, + id: "team-management-setup", + container: {:div, id: "team-setup"}, + session: %{ + "mode" => "team-setup" + } + )} + +
    """ end diff --git a/lib/plausible_web/live/verification.ex b/lib/plausible_web/live/verification.ex deleted file mode 100644 index 731c61b7fdc6..000000000000 --- a/lib/plausible_web/live/verification.ex +++ /dev/null @@ -1,217 +0,0 @@ -defmodule PlausibleWeb.Live.Verification do - @moduledoc """ - LiveView coordinating the site verification process. - Onboarding new sites, renders a standalone component. - Embedded modal variant is available for general site settings. - """ - use Plausible - use PlausibleWeb, :live_view - - alias Plausible.Verification.{Checks, State} - - @component PlausibleWeb.Live.Components.Verification - @slowdown_for_frequent_checking :timer.seconds(5) - - def mount( - %{"domain" => domain} = params, - _session, - socket - ) do - site = - Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [ - :owner, - :admin, - :editor, - :super_admin, - :viewer - ]) - - private = Map.get(socket.private.connect_info, :private, %{}) - - super_admin? = Plausible.Auth.is_super_admin?(socket.assigns.current_user) - has_pageviews? = has_pageviews?(site) - - socket = - assign(socket, - site: site, - super_admin?: super_admin?, - domain: domain, - has_pageviews?: has_pageviews?, - component: @component, - installation_type: params["installation_type"], - report_to: self(), - delay: private[:delay] || 500, - slowdown: private[:slowdown] || 500, - flow: params["flow"] || "", - checks_pid: nil, - attempts: 0 - ) - - on_ee do - if connected?(socket) do - launch_delayed(socket) - end - end - - on_ee do - {:ok, socket} - else - # on CE we skip the verification process and instead, - # we just wait for the first pageview to be recorded - socket = - if has_pageviews? do - redirect_to_stats(socket) - else - schedule_pageviews_check(socket) - end - - {:ok, socket} - end - end - - on_ee do - def render(assigns) do - ~H""" - - - <.live_component - module={@component} - installation_type={@installation_type} - domain={@domain} - id="verification-standalone" - attempts={@attempts} - flow={@flow} - awaiting_first_pageview?={not @has_pageviews?} - super_admin?={@super_admin?} - /> - """ - end - else - def render(assigns) do - ~H""" - - <.awaiting_pageviews /> - """ - end - end - - on_ce do - defp awaiting_pageviews(assigns) do - ~H""" - <.focus_box> -
    -
    -

    Awaiting your first pageview 


    -
    - - """ - end - end - - def handle_event("launch-verification", _, socket) do - launch_delayed(socket) - {:noreply, reset_component(socket)} - end - - def handle_event("retry", _, socket) do - launch_delayed(socket) - {:noreply, reset_component(socket)} - end - - def handle_info({:start, report_to}, socket) do - if is_pid(socket.assigns.checks_pid) and Process.alive?(socket.assigns.checks_pid) do - {:noreply, socket} - else - case Plausible.RateLimit.check_rate( - "site_verification_#{socket.assigns.domain}", - :timer.minutes(60), - 3 - ) do - {:allow, _} -> :ok - {:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking) - end - - {:ok, pid} = - Checks.run( - "https://#{socket.assigns.domain}", - socket.assigns.domain, - report_to: report_to, - slowdown: socket.assigns.slowdown - ) - - {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)} - end - end - - def handle_info({:verification_check_start, {check, _state}}, socket) do - update_component(socket, - message: check.report_progress_as() - ) - - {:noreply, socket} - end - - def handle_info({:verification_end, %State{} = state}, socket) do - interpretation = Checks.interpret_diagnostics(state) - - if not socket.assigns.has_pageviews? do - schedule_pageviews_check(socket) - end - - update_component(socket, - finished?: true, - success?: interpretation.ok?, - interpretation: interpretation, - verification_state: state - ) - - {:noreply, assign(socket, checks_pid: nil)} - end - - def handle_info(:check_pageviews, socket) do - socket = - if has_pageviews?(socket.assigns.site) do - redirect_to_stats(socket) - else - schedule_pageviews_check(socket) - end - - {:noreply, socket} - end - - defp schedule_pageviews_check(socket) do - Process.send_after(self(), :check_pageviews, socket.assigns.delay * 2) - socket - end - - defp redirect_to_stats(socket) do - stats_url = Routes.stats_url(PlausibleWeb.Endpoint, :stats, socket.assigns.domain, []) - redirect(socket, external: stats_url) - end - - defp reset_component(socket) do - update_component(socket, - message: "We're visiting your site to ensure that everything is working", - finished?: false, - success?: false, - diagnostics: nil - ) - - socket - end - - defp update_component(_socket, updates) do - send_update( - @component, - Keyword.merge(updates, id: "verification-standalone") - ) - end - - defp launch_delayed(socket) do - Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay) - end - - defp has_pageviews?(site) do - Plausible.Stats.Clickhouse.has_pageviews?(site) - end -end diff --git a/lib/plausible_web/login_preference.ex b/lib/plausible_web/login_preference.ex new file mode 100644 index 000000000000..3b88cfd5f28f --- /dev/null +++ b/lib/plausible_web/login_preference.ex @@ -0,0 +1,40 @@ +defmodule PlausibleWeb.LoginPreference do + @moduledoc """ + Functions for managing user login preference cookies. + + This module handles storing and retrieving the user's preferred login method + (standard or SSO) to provide a better user experience by showing their + preferred option first. + """ + + @cookie_name "login_preference" + @cookie_max_age 60 * 60 * 24 * 365 + + @spec set_sso(Plug.Conn.t()) :: Plug.Conn.t() + def set_sso(conn) do + secure_cookie = PlausibleWeb.Endpoint.secure_cookie?() + + Plug.Conn.put_resp_cookie(conn, @cookie_name, "sso", + http_only: true, + secure: secure_cookie, + max_age: @cookie_max_age, + same_site: "Lax" + ) + end + + @spec clear(Plug.Conn.t()) :: Plug.Conn.t() + def clear(conn) do + Plug.Conn.delete_resp_cookie(conn, @cookie_name) + end + + @spec get(Plug.Conn.t()) :: String.t() | nil + def get(conn) do + case Plug.Conn.fetch_cookies(conn) do + %{cookies: %{@cookie_name => "sso"}} -> + "sso" + + _ -> + nil + end + end +end diff --git a/lib/plausible_web/mjml/templates/stats_report.mjml.eex b/lib/plausible_web/mjml/templates/stats_report.mjml.eex index fef7fae37359..33f4f96e1071 100644 --- a/lib/plausible_web/mjml/templates/stats_report.mjml.eex +++ b/lib/plausible_web/mjml/templates/stats_report.mjml.eex @@ -41,10 +41,10 @@ <% end %> - <%= @site.domain %> + <%= Plausible.Sites.display_name(@site, capitalize_consolidated: true) %> - <%= @name %> Report (<%= @date %>) + <%= @report_name %> Report (<%= @date_label %>) @@ -179,7 +179,38 @@ - <%= if @login_link do %> + <%= if @stats.goals && length(@stats.goals) > 0 do %> + + + + + + + + + + Goal + + <%= for goal <- @stats.goals do %> + + <%= goal[:goal] %> + + <% end %> + + + Conversions + + <%= for goal <- @stats.goals do %> + + <%= PlausibleWeb.StatsView.large_number_format(goal[:visitors]) %> + + <% end %> + + + + <% end %> + + <%= if @site_member? do %> diff --git a/lib/plausible_web/plugins/api/controllers/custom_props.ex b/lib/plausible_web/plugins/api/controllers/custom_props.ex index cb5a65535303..e7860fb3a023 100644 --- a/lib/plausible_web/plugins/api/controllers/custom_props.ex +++ b/lib/plausible_web/plugins/api/controllers/custom_props.ex @@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.CustomProps do use PlausibleWeb, :plugins_api_controller operation(:enable, - id: "CustomProp.GetOrEnable", + operation_id: "CustomProp.GetOrEnable", summary: "Get or enable CustomProp(s)", request_body: {"CustomProp enable params", "application/json", Schemas.CustomProp.EnableRequest}, @@ -50,7 +50,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.CustomProps do end operation(:disable, - id: "CustomProp.DisableBulk", + operation_id: "CustomProp.DisableBulk", summary: "Disable CustomProp(s)", request_body: {"CustomProp disable params", "application/json", Schemas.CustomProp.DisableRequest}, diff --git a/lib/plausible_web/plugins/api/controllers/goals.ex b/lib/plausible_web/plugins/api/controllers/goals.ex index 2d2c3ce4b8fd..066a9998861d 100644 --- a/lib/plausible_web/plugins/api/controllers/goals.ex +++ b/lib/plausible_web/plugins/api/controllers/goals.ex @@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.Goals do use PlausibleWeb, :plugins_api_controller operation(:create, - id: "Goal.GetOrCreate", + operation_id: "Goal.GetOrCreate", summary: "Get or create Goal", request_body: {"Goal params", "application/json", Schemas.Goal.CreateRequest}, responses: %{ @@ -141,7 +141,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.Goals do end operation(:delete_bulk, - id: "Goal.DeleteBulk", + operation_id: "Goal.DeleteBulk", summary: "Delete Goals in bulk", request_body: {"Goal params", "application/json", Schemas.Goal.DeleteBulkRequest}, responses: %{ diff --git a/lib/plausible_web/plugins/api/controllers/tracker_script_configuration.ex b/lib/plausible_web/plugins/api/controllers/tracker_script_configuration.ex new file mode 100644 index 000000000000..b37da2ef5237 --- /dev/null +++ b/lib/plausible_web/plugins/api/controllers/tracker_script_configuration.ex @@ -0,0 +1,53 @@ +defmodule PlausibleWeb.Plugins.API.Controllers.TrackerScriptConfiguration do + @moduledoc """ + Controller for the Tracker Script Configuration resource under Plugins API + """ + use PlausibleWeb, :plugins_api_controller + + operation(:get, + summary: "Retrieve Tracker Script Configuration", + parameters: [], + responses: %{ + ok: + {"Tracker Script Configuration response", "application/json", + Schemas.TrackerScriptConfiguration}, + unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized} + } + ) + + @spec get(Plug.Conn.t(), %{}) :: Plug.Conn.t() + def get(conn, _params) do + site = conn.assigns.authorized_site + configuration = PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site) + + conn + |> put_view(Views.TrackerScriptConfiguration) + |> render("tracker_script_configuration.json", tracker_script_configuration: configuration) + end + + operation(:update, + summary: "Update Tracker Script Configuration", + request_body: + {"Tracker Script Configuration params", "application/json", + Schemas.TrackerScriptConfiguration.UpdateRequest}, + responses: %{ + ok: + {"Tracker Script Configuration", "application/json", Schemas.TrackerScriptConfiguration}, + unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized} + } + ) + + @spec update(Plug.Conn.t(), map()) :: Plug.Conn.t() + def update(conn, %{"tracker_script_configuration" => update_params}) do + site = conn.assigns.authorized_site + + update_params = Map.put(update_params, "site_id", site.id) + + updated_config = + PlausibleWeb.Tracker.update_script_configuration!(site, update_params, :plugins_api) + + conn + |> put_view(Views.TrackerScriptConfiguration) + |> render("tracker_script_configuration.json", tracker_script_configuration: updated_config) + end +end diff --git a/lib/plausible_web/plugins/api/schemas/capabilities.ex b/lib/plausible_web/plugins/api/schemas/capabilities.ex index e66ed83366be..f98daee979ce 100644 --- a/lib/plausible_web/plugins/api/schemas/capabilities.ex +++ b/lib/plausible_web/plugins/api/schemas/capabilities.ex @@ -33,7 +33,12 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Capabilities do Props: false, RevenueGoals: false, StatsAPI: false, - SiteSegments: false + SitesAPI: false, + SiteSegments: false, + Teams: false, + SharedLinks: false, + SSO: false, + ConsolidatedView: false } } }) diff --git a/lib/plausible_web/plugins/api/schemas/goal/create_request/custom_event.ex b/lib/plausible_web/plugins/api/schemas/goal/create_request/custom_event.ex index 758a428cdb96..5c9491c88e3b 100644 --- a/lib/plausible_web/plugins/api/schemas/goal/create_request/custom_event.ex +++ b/lib/plausible_web/plugins/api/schemas/goal/create_request/custom_event.ex @@ -5,6 +5,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CreateRequest.CustomEvent do use PlausibleWeb, :open_api_schema + alias Schemas.Goal.CustomProps + OpenApiSpex.schema(%{ title: "Goal.CreateRequest.CustomEvent", description: "Custom Event Goal creation params", @@ -20,7 +22,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CreateRequest.CustomEvent do type: :object, required: [:event_name], properties: %{ - event_name: %Schema{type: :string} + event_name: %Schema{type: :string}, + custom_props: CustomProps.request_schema() } } }, diff --git a/lib/plausible_web/plugins/api/schemas/goal/create_request/pageview.ex b/lib/plausible_web/plugins/api/schemas/goal/create_request/pageview.ex index 2b224dcc9f8c..e1ff2f33593a 100644 --- a/lib/plausible_web/plugins/api/schemas/goal/create_request/pageview.ex +++ b/lib/plausible_web/plugins/api/schemas/goal/create_request/pageview.ex @@ -5,6 +5,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CreateRequest.Pageview do use PlausibleWeb, :open_api_schema + alias Schemas.Goal.CustomProps + OpenApiSpex.schema(%{ title: "Goal.CreateRequest.Pageview", description: "Pageview Goal creation params", @@ -20,7 +22,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CreateRequest.Pageview do type: :object, required: [:path], properties: %{ - path: %Schema{type: :string} + path: %Schema{type: :string}, + custom_props: CustomProps.request_schema() } } }, diff --git a/lib/plausible_web/plugins/api/schemas/goal/create_request/revenue.ex b/lib/plausible_web/plugins/api/schemas/goal/create_request/revenue.ex index 4b8f413769a2..b464309b9f96 100644 --- a/lib/plausible_web/plugins/api/schemas/goal/create_request/revenue.ex +++ b/lib/plausible_web/plugins/api/schemas/goal/create_request/revenue.ex @@ -5,6 +5,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CreateRequest.Revenue do use PlausibleWeb, :open_api_schema + alias Schemas.Goal.CustomProps + OpenApiSpex.schema(%{ title: "Goal.CreateRequest.Revenue", description: "Revenue Goal creation params", @@ -21,7 +23,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CreateRequest.Revenue do required: [:event_name, :currency], properties: %{ event_name: %Schema{type: :string}, - currency: %Schema{type: :string} + currency: %Schema{type: :string}, + custom_props: CustomProps.request_schema() } } }, diff --git a/lib/plausible_web/plugins/api/schemas/goal/custom_event.ex b/lib/plausible_web/plugins/api/schemas/goal/custom_event.ex index fac4d810cd12..3e98cef14a37 100644 --- a/lib/plausible_web/plugins/api/schemas/goal/custom_event.ex +++ b/lib/plausible_web/plugins/api/schemas/goal/custom_event.ex @@ -4,6 +4,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CustomEvent do """ use PlausibleWeb, :open_api_schema + alias Schemas.Goal.CustomProps + OpenApiSpex.schema(%{ description: "Custom Event Goal object", title: "Goal.CustomEvent", @@ -20,7 +22,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CustomEvent do properties: %{ id: %Schema{type: :integer, description: "Goal ID", readOnly: true}, display_name: %Schema{type: :string, description: "Display name", readOnly: true}, - event_name: %Schema{type: :string, description: "Event Name"} + event_name: %Schema{type: :string, description: "Event Name"}, + custom_props: CustomProps.response_schema() } } } diff --git a/lib/plausible_web/plugins/api/schemas/goal/custom_props.ex b/lib/plausible_web/plugins/api/schemas/goal/custom_props.ex new file mode 100644 index 000000000000..d1524c2f6740 --- /dev/null +++ b/lib/plausible_web/plugins/api/schemas/goal/custom_props.ex @@ -0,0 +1,25 @@ +defmodule PlausibleWeb.Plugins.API.Schemas.Goal.CustomProps do + @moduledoc """ + Reusable OpenAPI schema definitions for Custom Properties in Goals + """ + + alias OpenApiSpex.Schema + + def response_schema do + %Schema{ + type: :object, + description: "Custom properties (string keys and values)", + additionalProperties: %Schema{type: :string}, + readOnly: true + } + end + + def request_schema do + %Schema{ + type: :object, + description: "Custom properties (max 3, string keys and values)", + additionalProperties: %Schema{type: :string}, + maxProperties: 3 + } + end +end diff --git a/lib/plausible_web/plugins/api/schemas/goal/pageview.ex b/lib/plausible_web/plugins/api/schemas/goal/pageview.ex index d68a0a47efdd..d7d5f1e3d73c 100644 --- a/lib/plausible_web/plugins/api/schemas/goal/pageview.ex +++ b/lib/plausible_web/plugins/api/schemas/goal/pageview.ex @@ -4,6 +4,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.Pageview do """ use PlausibleWeb, :open_api_schema + alias Schemas.Goal.CustomProps + OpenApiSpex.schema(%{ description: "Pageview Goal object", title: "Goal.Pageview", @@ -20,7 +22,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.Pageview do properties: %{ id: %Schema{type: :integer, description: "Goal ID", readOnly: true}, display_name: %Schema{type: :string, description: "Display name", readOnly: true}, - path: %Schema{type: :string, description: "Page Path"} + path: %Schema{type: :string, description: "Page Path"}, + custom_props: CustomProps.response_schema() } } } diff --git a/lib/plausible_web/plugins/api/schemas/goal/revenue.ex b/lib/plausible_web/plugins/api/schemas/goal/revenue.ex index c25406489369..580c9bb7d80c 100644 --- a/lib/plausible_web/plugins/api/schemas/goal/revenue.ex +++ b/lib/plausible_web/plugins/api/schemas/goal/revenue.ex @@ -4,6 +4,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.Revenue do """ use PlausibleWeb, :open_api_schema + alias Schemas.Goal.CustomProps + OpenApiSpex.schema(%{ description: "Revenue Goal object", title: "Goal.Revenue", @@ -21,7 +23,8 @@ defmodule PlausibleWeb.Plugins.API.Schemas.Goal.Revenue do id: %Schema{type: :integer, description: "Goal ID", readOnly: true}, display_name: %Schema{type: :string, description: "Display name", readOnly: true}, event_name: %Schema{type: :string, description: "Event Name"}, - currency: %Schema{type: :string, description: "Currency"} + currency: %Schema{type: :string, description: "Currency"}, + custom_props: CustomProps.response_schema() } } } diff --git a/lib/plausible_web/plugins/api/schemas/tracker_script_configuration.ex b/lib/plausible_web/plugins/api/schemas/tracker_script_configuration.ex new file mode 100644 index 000000000000..db10798e06b2 --- /dev/null +++ b/lib/plausible_web/plugins/api/schemas/tracker_script_configuration.ex @@ -0,0 +1,47 @@ +defmodule PlausibleWeb.Plugins.API.Schemas.TrackerScriptConfiguration do + @moduledoc """ + OpenAPI schema for TrackerScriptConfiguration object + """ + use PlausibleWeb, :open_api_schema + + OpenApiSpex.schema(%{ + description: "Tracker Script Configuration object", + type: :object, + required: [:tracker_script_configuration], + properties: %{ + tracker_script_configuration: %Schema{ + type: :object, + required: [ + :id, + :installation_type, + :hash_based_routing, + :outbound_links, + :file_downloads, + :form_submissions + ], + properties: %{ + id: %Schema{type: :string, description: "Tracker Script Configuration ID"}, + installation_type: %Schema{ + type: :string, + description: "Tracker Script Installation Type", + enum: ["manual", "wordpress", "gtm", "npm"] + }, + hash_based_routing: %Schema{type: :boolean, description: "Hash Based Routing"}, + outbound_links: %Schema{type: :boolean, description: "Track Outbound Links"}, + file_downloads: %Schema{type: :boolean, description: "Track File Downloads"}, + form_submissions: %Schema{type: :boolean, description: "Track Form Submissions"} + } + } + }, + example: %{ + tracker_script_configuration: %{ + id: "qyhkWtOWaTN0YPkhrcJgy", + installation_type: "wordpress", + hash_based_routing: true, + outbound_links: false, + file_downloads: true, + form_submissions: false + } + } + }) +end diff --git a/lib/plausible_web/plugins/api/schemas/tracker_script_configuration/update_request.ex b/lib/plausible_web/plugins/api/schemas/tracker_script_configuration/update_request.ex new file mode 100644 index 000000000000..cc5b464587e7 --- /dev/null +++ b/lib/plausible_web/plugins/api/schemas/tracker_script_configuration/update_request.ex @@ -0,0 +1,33 @@ +defmodule PlausibleWeb.Plugins.API.Schemas.TrackerScriptConfiguration.UpdateRequest do + @moduledoc """ + OpenAPI schema for TrackerScriptConfiguration update request + """ + use PlausibleWeb, :open_api_schema + + OpenApiSpex.schema(%{ + title: "TrackerScriptConfiguration.UpdateRequest", + description: "Tracker Script Configuration update params", + type: :object, + required: [:tracker_script_configuration], + properties: %{ + tracker_script_configuration: %Schema{ + type: :object, + required: [:installation_type], + properties: %{ + installation_type: %Schema{ + type: :string, + description: "Tracker Script Installation Type", + enum: ["manual", "wordpress", "gtm", "npm"] + }, + hash_based_routing: %Schema{type: :boolean, description: "Hash Based Routing"}, + outbound_links: %Schema{type: :boolean, description: "Track Outbound Links"}, + file_downloads: %Schema{type: :boolean, description: "Track File Downloads"}, + form_submissions: %Schema{type: :boolean, description: "Track Form Submissions"} + } + } + }, + example: %{ + tracker_script_configuration: %{installation_type: "wordpress", hash_based_routing: true} + } + }) +end diff --git a/lib/plausible_web/plugins/api/views/goal.ex b/lib/plausible_web/plugins/api/views/goal.ex index cc5d31c6e4cb..07e5a6f0107b 100644 --- a/lib/plausible_web/plugins/api/views/goal.ex +++ b/lib/plausible_web/plugins/api/views/goal.ex @@ -35,7 +35,8 @@ defmodule PlausibleWeb.Plugins.API.Views.Goal do goal: %{ id: pageview.id, display_name: pageview.display_name, - path: pageview.page_path + path: pageview.page_path, + custom_props: pageview.custom_props || %{} } } end @@ -48,7 +49,8 @@ defmodule PlausibleWeb.Plugins.API.Views.Goal do goal: %{ id: custom_event.id, display_name: custom_event.display_name, - event_name: custom_event.event_name + event_name: custom_event.event_name, + custom_props: custom_event.custom_props || %{} } } end @@ -63,7 +65,8 @@ defmodule PlausibleWeb.Plugins.API.Views.Goal do id: revenue_goal.id, display_name: revenue_goal.display_name, event_name: revenue_goal.event_name, - currency: revenue_goal.currency + currency: revenue_goal.currency, + custom_props: revenue_goal.custom_props || %{} } } end diff --git a/lib/plausible_web/plugins/api/views/shared_link.ex b/lib/plausible_web/plugins/api/views/shared_link.ex index 808fb26839b9..f007055faea6 100644 --- a/lib/plausible_web/plugins/api/views/shared_link.ex +++ b/lib/plausible_web/plugins/api/views/shared_link.ex @@ -29,7 +29,7 @@ defmodule PlausibleWeb.Plugins.API.Views.SharedLink do shared_link: %{ id: shared_link.id, name: shared_link.name, - password_protected: is_binary(shared_link.password_hash), + password_protected: Plausible.Site.SharedLink.password_protected?(shared_link), href: Plausible.Sites.shared_link_url(site, shared_link) } } diff --git a/lib/plausible_web/plugins/api/views/tracker_script_configuration.ex b/lib/plausible_web/plugins/api/views/tracker_script_configuration.ex new file mode 100644 index 000000000000..b69677f1b99c --- /dev/null +++ b/lib/plausible_web/plugins/api/views/tracker_script_configuration.ex @@ -0,0 +1,22 @@ +defmodule PlausibleWeb.Plugins.API.Views.TrackerScriptConfiguration do + @moduledoc """ + View for rendering Tracker Script Configuration in the Plugins API + """ + + use PlausibleWeb, :plugins_api_view + + def render("tracker_script_configuration.json", %{ + tracker_script_configuration: tracker_script_configuration + }) do + %{ + tracker_script_configuration: %{ + id: tracker_script_configuration.id, + installation_type: tracker_script_configuration.installation_type || :manual, + hash_based_routing: tracker_script_configuration.hash_based_routing, + outbound_links: tracker_script_configuration.outbound_links, + file_downloads: tracker_script_configuration.file_downloads, + form_submissions: tracker_script_configuration.form_submissions + } + } + end +end diff --git a/lib/plausible_web/plugs/auth_plug.ex b/lib/plausible_web/plugs/auth_plug.ex index 27f6d5455a9b..1f62c9c381f2 100644 --- a/lib/plausible_web/plugs/auth_plug.ex +++ b/lib/plausible_web/plugs/auth_plug.ex @@ -6,6 +6,7 @@ defmodule PlausibleWeb.AuthPlug do Must be kept in sync with `PlausibleWeb.Live.AuthContext`. """ + use Plausible import Plug.Conn alias PlausibleWeb.UserAuth @@ -20,7 +21,9 @@ defmodule PlausibleWeb.AuthPlug do user = user_session.user current_team_id_from_session = Plug.Conn.get_session(conn, "current_team_id") - current_team_id = conn.params["__team"] || current_team_id_from_session + + current_team_id = + conn.params["__team"] || current_team_id_from_session || user.last_team_identifier {current_team, current_team_role} = if current_team_id do @@ -35,9 +38,11 @@ defmodule PlausibleWeb.AuthPlug do conn = cond do current_team && current_team_id != current_team_id_from_session -> + Plausible.Users.remember_last_team(user, current_team_id) Plug.Conn.put_session(conn, "current_team_id", current_team_id) is_nil(current_team) && not is_nil(current_team_id_from_session) -> + Plausible.Users.remember_last_team(user, nil) Plug.Conn.delete_session(conn, "current_team_id") true -> @@ -59,8 +64,16 @@ defmodule PlausibleWeb.AuthPlug do |> Enum.take(3) Plausible.OpenTelemetry.add_user_attributes(user) + Sentry.Context.set_user_context(%{id: user.id, name: user.name, email: user.email}) + on_ee do + Plausible.Audit.set_context(%{ + current_user: user, + current_team: current_team + }) + end + conn |> assign(:current_user, user) |> assign(:current_user_session, user_session) @@ -71,6 +84,9 @@ defmodule PlausibleWeb.AuthPlug do |> assign(:teams, teams) |> assign(:more_teams?, teams_count > 3) + {:error, :session_expired, user_session} -> + assign(conn, :expired_session, user_session) + _ -> conn end diff --git a/lib/plausible_web/plugs/authorize_public_api.ex b/lib/plausible_web/plugs/authorize_public_api.ex index 1d5b4f9e1550..e0efb9c0e1f6 100644 --- a/lib/plausible_web/plugs/authorize_public_api.ex +++ b/lib/plausible_web/plugs/authorize_public_api.ex @@ -31,7 +31,7 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do alias Plausible.Auth alias Plausible.RateLimit - alias Plausible.Sites + alias Plausible.Teams alias PlausibleWeb.Api.Helpers, as: H require Logger @@ -54,6 +54,7 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do with {:ok, token} <- get_bearer_token(conn), {:ok, api_key, limit_key, hourly_limit} <- find_api_key(conn, token, context), :ok <- check_api_key_rate_limit(limit_key, hourly_limit), + :ok <- check_api_key_burst_limit(limit_key), {:ok, conn} <- verify_by_scope(conn, api_key, requested_scope) do conn |> assign(:current_user, api_key.user) @@ -66,9 +67,10 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do ### Verification dispatched by scope defp find_api_key(conn, token, :site) do - case Auth.find_api_key(token, team_by: {:site, conn.params["site_id"]}) do + case Auth.find_api_key_for_team_of_site(token, conn.params["site_id"]) do {:ok, %{api_key: api_key, team: nil}} -> - {:ok, api_key, limit_key(api_key, nil), Auth.ApiKey.hourly_request_limit()} + {:ok, api_key, Auth.ApiKey.legacy_limit_key(api_key.user), + Auth.ApiKey.legacy_hourly_request_limit()} {:ok, %{api_key: api_key, team: team}} -> team_role_result = Plausible.Teams.Memberships.team_role(team, api_key.user) @@ -91,7 +93,7 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do :pass end - {:ok, api_key, limit_key(api_key, team.identifier), team.hourly_api_request_limit} + {:ok, api_key, Auth.ApiKey.limit_key(team), team.hourly_api_request_limit} {:error, _} = error -> error @@ -101,34 +103,50 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do defp find_api_key(_conn, token, _) do case Auth.find_api_key(token) do {:ok, %{api_key: api_key, team: nil}} -> - {:ok, api_key, limit_key(api_key, nil), Auth.ApiKey.hourly_request_limit()} + {:ok, api_key, Auth.ApiKey.legacy_limit_key(api_key.user), + Auth.ApiKey.legacy_hourly_request_limit()} {:ok, %{api_key: api_key, team: team}} -> - {:ok, api_key, limit_key(api_key, team.identifier), team.hourly_api_request_limit} + {:ok, api_key, Auth.ApiKey.limit_key(team), team.hourly_api_request_limit} {:error, _} = error -> error end end - defp limit_key(api_key, nil) do - "api_request:#{api_key.id}" - end - - defp limit_key(api_key, team_id) do - "api_request:#{api_key.id}:#{team_id}" - end - defp verify_by_scope(conn, api_key, "stats:read:" <> _ = scope) do with :ok <- check_scope(api_key, scope), {:ok, site} <- find_site(conn.params["site_id"]), - :ok <- verify_site_access(api_key, site) do + :ok <- + verify_site_access( + site: site, + api_key: api_key, + feature: Plausible.Billing.Feature.StatsAPI, + allow_consolidated_views: conn.private[:allow_consolidated_views] + ) do Plausible.OpenTelemetry.add_site_attributes(site) site = Plausible.Repo.preload(site, :completed_imports) {:ok, assign(conn, :site, site)} end end + defp verify_by_scope(conn, api_key, "sites:" <> scope_suffix = scope) do + feature = + case scope_suffix do + "read:" <> _ -> + Plausible.Billing.Feature.StatsAPI + + "provision:" <> _ -> + Plausible.Billing.Feature.SitesAPI + end + + with :ok <- check_scope(api_key, scope), + :ok <- maybe_verify_site_access(conn, api_key, feature), + :ok <- maybe_verify_team_access(conn, api_key, feature) do + {:ok, conn} + end + end + defp verify_by_scope(conn, api_key, scope) do with :ok <- check_scope(api_key, scope) do {:ok, conn} @@ -168,8 +186,55 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do defp check_api_key_rate_limit(limit_key, hourly_limit) do case RateLimit.check_rate(limit_key, to_timeout(hour: 1), hourly_limit) do - {:allow, _} -> :ok - {:deny, _} -> {:error, :rate_limit, hourly_limit} + {:allow, _} -> + :ok + + {:deny, _} -> + {:error, :rate_limit, + "Too many API requests. The limit is #{hourly_limit} per hour. Please contact us to request more capacity."} + end + end + + defp check_api_key_burst_limit(limit_key) do + burst_period_seconds = Auth.ApiKey.burst_period_seconds() + burst_request_limit = Auth.ApiKey.burst_request_limit() + + case RateLimit.check_rate( + limit_key, + to_timeout(second: burst_period_seconds), + burst_request_limit + ) do + {:allow, _} -> + :ok + + {:deny, _} -> + {:error, :rate_limit, + "Too many API requests in a short period of time. The limit is #{burst_request_limit} per #{burst_period_seconds} seconds. Please throttle your requests."} + end + end + + defp maybe_verify_site_access(conn, api_key, feature) do + case find_site(conn.params["site_id"]) do + {:ok, site} -> + verify_site_access( + site: site, + api_key: api_key, + feature: feature, + allow_consolidated_views: conn.private[:allow_consolidated_views] + ) + + _ -> + :ok + end + end + + defp maybe_verify_team_access(conn, api_key, feature) do + team = api_key.team || Teams.get(conn.params["team_id"]) + + if team do + verify_team_access(api_key, team, feature) + else + :ok end end @@ -177,7 +242,8 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do defp find_site(site_id) do domain_based_search = - from s in Plausible.Site, where: s.domain == ^site_id or s.domain_changed_from == ^site_id + from s in Plausible.Site, + where: s.domain == ^site_id or s.domain_changed_from == ^site_id case Repo.one(domain_based_search) do %Plausible.Site{} = site -> @@ -188,23 +254,31 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do end end - defp verify_site_access(api_key, site) do + defp verify_site_access(opts) do + site = Keyword.fetch!(opts, :site) + api_key = Keyword.fetch!(opts, :api_key) + feature = Keyword.fetch!(opts, :feature) + allow_consolidated_views = Keyword.fetch!(opts, :allow_consolidated_views) + team = Repo.preload(site, :team).team is_member? = Plausible.Teams.Memberships.site_member?(site, api_key.user) is_super_admin? = Auth.is_super_admin?(api_key.user_id) cond do + Plausible.Sites.consolidated?(site) && !allow_consolidated_views -> + {:error, :unavailable_for_consolidated_view} + is_super_admin? -> :ok api_key.team_id && api_key.team_id != site.team_id -> {:error, :invalid_api_key} - Sites.locked?(site) -> + Teams.locked?(team) -> {:error, :site_locked} - Plausible.Billing.Feature.StatsAPI.check_availability(team) !== :ok -> + feature.check_availability(team) !== :ok -> {:error, :upgrade_required} is_member? -> @@ -215,6 +289,38 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do end end + defp verify_team_access(api_key, team, feature) do + is_member? = Plausible.Teams.Memberships.team_member?(team, api_key.user) + is_super_admin? = Auth.is_super_admin?(api_key.user_id) + + cond do + is_super_admin? -> + :ok + + api_key.team_id && api_key.team_id != team.id -> + {:error, :invalid_api_key} + + Teams.locked?(team) -> + {:error, :site_locked} + + feature.check_availability(team) !== :ok -> + {:error, :upgrade_required} + + is_member? -> + :ok + + true -> + {:error, :invalid_api_key} + end + end + + defp send_error(conn, _, {:error, :unavailable_for_consolidated_view}) do + H.bad_request( + conn, + "This operation is unavailable for a consolidated view" + ) + end + defp send_error(conn, _, {:error, :missing_api_key}) do H.unauthorized( conn, @@ -236,10 +342,10 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do ) end - defp send_error(conn, _, {:error, :rate_limit, limit}) do + defp send_error(conn, _, {:error, :rate_limit, message}) do H.too_many_requests( conn, - "Too many API requests. Your API key is limited to #{limit} requests per hour. Please contact us to request more capacity." + message ) end @@ -250,17 +356,32 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do ) end - defp send_error(conn, _, {:error, :upgrade_required}) do + defp send_error(conn, scope, {:error, :upgrade_required}) do + feature = + case scope do + "sites:provision:" <> _ -> + Plausible.Billing.Feature.SitesAPI + + _ -> + Plausible.Billing.Feature.StatsAPI + end + + feature_payment_error(conn, feature) + end + + defp send_error(conn, _, {:error, :site_locked}) do H.payment_required( conn, - "The account that owns this API key does not have access to Stats API. Please make sure you're using the API key of a subscriber account and that the subscription plan includes Stats API" + "This Plausible site is locked due to missing active subscription. In order to access it, the site owner should subscribe to a suitable plan" ) end - defp send_error(conn, _, {:error, :site_locked}) do + defp feature_payment_error(conn, feature) do + feature_name = feature.display_name() + H.payment_required( conn, - "This Plausible site is locked due to missing active subscription. In order to access it, the site owner should subscribe to a suitable plan" + "The account that owns this API key does not have access to #{feature_name}. Please make sure you're using the API key of a subscriber account and that the subscription plan includes #{feature_name}" ) end end diff --git a/lib/plausible_web/plugs/authorize_site_access.ex b/lib/plausible_web/plugs/authorize_site_access.ex index 589cac5ff7d9..970e2883b53d 100644 --- a/lib/plausible_web/plugs/authorize_site_access.ex +++ b/lib/plausible_web/plugs/authorize_site_access.ex @@ -81,6 +81,7 @@ defmodule PlausibleWeb.Plugs.AuthorizeSiteAccess do with {:ok, domain} <- get_domain(conn, site_param), {:ok, %{site: site, role: membership_role, member_type: member_type}} <- get_site_with_role(conn, current_user, domain), + :ok <- ensure_consolidated_view_access(conn, site), {:ok, shared_link} <- maybe_get_shared_link(conn, site) do role = cond do @@ -117,7 +118,7 @@ defmodule PlausibleWeb.Plugs.AuthorizeSiteAccess do team: [:owners, subscription: Teams.last_subscription_query()] ]) - conn = merge_assigns(conn, site: site, site_role: role) + conn = merge_assigns(conn, site: site, site_role: role, shared_link: shared_link) # Switch current team if user is a team member in it conn = @@ -188,14 +189,30 @@ defmodule PlausibleWeb.Plugs.AuthorizeSiteAccess do end end + defp ensure_consolidated_view_access(conn, site) do + if Plausible.Sites.consolidated?(site) && !conn.private[:allow_consolidated_views] do + error_not_found(conn) + else + :ok + end + end + defp maybe_get_shared_link(conn, site) do slug = conn.path_params["slug"] || conn.params["auth"] if valid_path_fragment?(slug) do - if shared_link = Repo.get_by(Plausible.Site.SharedLink, slug: slug, site_id: site.id) do + with %Plausible.Site.SharedLink{} = shared_link <- + Repo.get_by(Plausible.Site.SharedLink, slug: slug, site_id: site.id), + {%{password_protected?: true}, shared_link} <- + {%{password_protected?: Plausible.Site.SharedLink.password_protected?(shared_link)}, + shared_link}, + {:ok, shared_link} <- + PlausibleWeb.StatsController.validate_shared_link_password(conn, shared_link) do {:ok, shared_link} else - error_not_found(conn) + {%{password_protected?: false}, shared_link} -> {:ok, shared_link} + {:error, :unauthorized} -> error_not_found(conn) + nil -> error_not_found(conn) end else {:ok, nil} diff --git a/lib/plausible_web/plugs/current_path.ex b/lib/plausible_web/plugs/current_path.ex new file mode 100644 index 000000000000..fecc537e6caf --- /dev/null +++ b/lib/plausible_web/plugs/current_path.ex @@ -0,0 +1,6 @@ +defmodule PlausibleWeb.Plugs.CurrentPath do + @moduledoc false + + def init(_), do: [] + def call(conn, _), do: Plug.Conn.assign(conn, :current_path, conn.request_path) +end diff --git a/lib/plausible_web/plugs/favicon.ex b/lib/plausible_web/plugs/favicon.ex index b5c0f49e5f96..89a25fda57c8 100644 --- a/lib/plausible_web/plugs/favicon.ex +++ b/lib/plausible_web/plugs/favicon.ex @@ -29,8 +29,9 @@ defmodule PlausibleWeb.Favicon do import Plug.Conn alias Plausible.HTTPClient - @placeholder_icon_location "priv/placeholder_favicon.ico" + @placeholder_icon_location "priv/link_favicon.svg" @placeholder_icon File.read!(@placeholder_icon_location) + @external_resource @placeholder_icon_location @custom_icons %{ "Brave" => "search.brave.com", "Sogou" => "sogou.com", @@ -65,8 +66,7 @@ defmodule PlausibleWeb.Favicon do I'm not sure why DDG sometimes returns a broken PNG image in their response but we filter that out. When the icon request fails, we show a placeholder - favicon instead. The placeholder is an emoji from - [https://favicon.io/emoji-favicons/](https://favicon.io/emoji-favicons/) + favicon instead. The placeholder is an svg from [https://heroicons.com/](https://heroicons.com/). DuckDuckGo favicon service has some issues with [SVG favicons](https://css-tricks.com/svg-favicons-and-all-the-fun-things-we-can-do-with-them/). For some reason, they return them with `content-type=image/x-icon` whereas SVG @@ -123,14 +123,14 @@ defmodule PlausibleWeb.Favicon do defp send_placeholder(conn) do conn - |> put_resp_content_type("image/x-icon") + |> put_resp_content_type("image/svg+xml") |> put_resp_header("cache-control", "public, max-age=2592000") |> send_resp(200, @placeholder_icon) |> halt end @forwarded_headers ["content-type", "cache-control", "expires"] - defp forward_headers(conn, headers) do + defp forward_headers(%Plug.Conn{} = conn, headers) do headers_to_forward = Enum.filter(headers, fn {k, _} -> k in @forwarded_headers end) %Plug.Conn{conn | resp_headers: headers_to_forward} end diff --git a/lib/plausible_web/plugs/require_account.ex b/lib/plausible_web/plugs/require_account.ex index e69eb2904b49..53b7a88cbadc 100644 --- a/lib/plausible_web/plugs/require_account.ex +++ b/lib/plausible_web/plugs/require_account.ex @@ -9,11 +9,24 @@ defmodule PlausibleWeb.RequireAccountPlug do ["me"] ] + @force_2fa_exceptions [ + ["2fa", "setup", "force-initiate"], + ["2fa", "setup", "initiate"], + ["2fa", "setup", "verify"], + ["team", "select"] + ] + def init(options) do options end def call(conn, _opts) do + conn + |> require_verified_user() + |> maybe_force_2fa() + end + + defp require_verified_user(conn) do user = conn.assigns[:current_user] cond do @@ -33,6 +46,21 @@ defmodule PlausibleWeb.RequireAccountPlug do end end + defp maybe_force_2fa(%{halted: true} = conn), do: conn + + defp maybe_force_2fa(conn) do + user = conn.assigns[:current_user] + team = conn.assigns[:current_team] + + if conn.path_info not in @force_2fa_exceptions and must_enable_2fa?(user, team) do + conn + |> Phoenix.Controller.redirect(to: Routes.auth_path(conn, :force_initiate_2fa_setup)) + |> halt() + else + conn + end + end + defp redirect_to(%Plug.Conn{method: "GET"} = conn) do return_to = if conn.query_string && String.length(conn.query_string) > 0 do @@ -45,4 +73,10 @@ defmodule PlausibleWeb.RequireAccountPlug do end defp redirect_to(conn), do: Routes.auth_path(conn, :login_form) + + defp must_enable_2fa?(user, team) when is_nil(user) or is_nil(team), do: false + + defp must_enable_2fa?(user, team) do + not Plausible.Auth.TOTP.enabled?(user) and Plausible.Teams.force_2fa_enabled?(team) + end end diff --git a/lib/plausible_web/plugs/restrict_user_type.ex b/lib/plausible_web/plugs/restrict_user_type.ex new file mode 100644 index 000000000000..8b1c2a055d49 --- /dev/null +++ b/lib/plausible_web/plugs/restrict_user_type.ex @@ -0,0 +1,25 @@ +defmodule Plausible.Plugs.RestrictUserType do + @moduledoc """ + Plug for restricting user access by type. + """ + + import Plug.Conn + + alias PlausibleWeb.Router.Helpers, as: Routes + + def init(opts) do + Keyword.fetch!(opts, :deny) + end + + def call(conn, deny_type) do + user = conn.assigns[:current_user] + + if user && Plausible.Users.type(user) == deny_type do + conn + |> Phoenix.Controller.redirect(to: Routes.site_path(conn, :index)) + |> halt() + else + conn + end + end +end diff --git a/lib/plausible_web/plugs/secure_embed_headers.ex b/lib/plausible_web/plugs/secure_embed_headers.ex new file mode 100644 index 000000000000..17f1745b4ad1 --- /dev/null +++ b/lib/plausible_web/plugs/secure_embed_headers.ex @@ -0,0 +1,22 @@ +defmodule PlausibleWeb.Plugs.SecureEmbedHeaders do + @moduledoc """ + Sets secure headers tailored for embedded content. + """ + + import Plug.Conn + + def init(_opts) do + [] + end + + def call(conn, _opts) do + merge_resp_headers( + conn, + [ + {"referrer-policy", "strict-origin-when-cross-origin"}, + {"x-content-type-options", "nosniff"}, + {"x-permitted-cross-domain-policies", "none"} + ] + ) + end +end diff --git a/lib/plausible_web/plugs/sso_team_access.ex b/lib/plausible_web/plugs/sso_team_access.ex new file mode 100644 index 000000000000..1d917f39fdad --- /dev/null +++ b/lib/plausible_web/plugs/sso_team_access.ex @@ -0,0 +1,53 @@ +defmodule Plausible.Plugs.SSOTeamAccess do + @moduledoc """ + Plug ensuring user is permitted to access the team + if it has SSO setup with Force SSO policy. + """ + + use Plausible + + def init(_) do + [] + end + + on_ee do + import Phoenix.Controller, only: [redirect: 2] + import Plug.Conn + + alias PlausibleWeb.Router.Helpers, as: Routes + + def call(conn, _opts) do + current_user = conn.assigns[:current_user] + current_team = conn.assigns[:current_team] + + eligible_for_check? = + not is_nil(current_user) and + not is_nil(current_team) and + current_team.policy.force_sso == :all_but_owners and + Plausible.Users.type(current_user) == :standard + + if eligible_for_check? do + check_user(conn, current_user, current_team) + else + conn + end + end + + defp check_user(conn, user, team) do + conn = + case Plausible.Auth.SSO.check_ready_to_provision(user, team) do + :ok -> + redirect(conn, to: Routes.sso_path(conn, :provision_notice)) + + {:error, issue} -> + redirect(conn, to: Routes.sso_path(conn, :provision_issue, issue: issue)) + end + + halt(conn) + end + else + def call(conn, _opts) do + conn + end + end +end diff --git a/lib/plausible_web/plugs/tracker.ex b/lib/plausible_web/plugs/tracker.ex deleted file mode 100644 index 5b7387e65a34..000000000000 --- a/lib/plausible_web/plugs/tracker.ex +++ /dev/null @@ -1,78 +0,0 @@ -defmodule PlausibleWeb.Tracker do - import Plug.Conn - use Agent - - base_variants = [ - "hash", - "outbound-links", - "exclusions", - "compat", - "local", - "manual", - "file-downloads", - "pageview-props", - "tagged-events", - "revenue", - "pageleave" - ] - - # Generates Power Set of all variants - variants = - 1..Enum.count(base_variants) - |> Enum.map(fn x -> - Combination.combine(base_variants, x) - |> Enum.map(fn y -> Enum.sort(y) |> Enum.join(".") end) - end) - |> List.flatten() - - @base_filenames ["plausible", "script", "analytics"] - @files_available ["plausible.js", "p.js"] ++ Enum.map(variants, fn v -> "plausible.#{v}.js" end) - - def init(opts) do - Keyword.merge(opts, files_available: MapSet.new(@files_available)) - end - - def call(conn, files_available: files_available) do - filename = - case conn.request_path do - "/js/p.js" -> - "p.js" - - "/js/" <> requested_filename -> - sorted_script_variant(requested_filename) - - _ -> - nil - end - - if filename && MapSet.member?(files_available, filename) do - location = Application.app_dir(:plausible, "priv/tracker/js/" <> filename) - - conn - |> put_resp_header("content-type", "application/javascript") - |> put_resp_header("x-content-type-options", "nosniff") - |> put_resp_header("cross-origin-resource-policy", "cross-origin") - |> put_resp_header("access-control-allow-origin", "*") - |> put_resp_header("cache-control", "public, max-age=86400, must-revalidate") - |> send_file(200, location) - |> halt() - else - conn - end - end - - defp sorted_script_variant(requested_filename) do - case String.split(requested_filename, ".") do - [base_filename | rest] when base_filename in @base_filenames -> - sorted_variants = - rest - |> List.delete("js") - |> Enum.sort() - - Enum.join(["plausible"] ++ sorted_variants ++ ["js"], ".") - - _ -> - nil - end - end -end diff --git a/lib/plausible_web/plugs/tracker_plug.ex b/lib/plausible_web/plugs/tracker_plug.ex new file mode 100644 index 000000000000..2f067ffcd0ac --- /dev/null +++ b/lib/plausible_web/plugs/tracker_plug.ex @@ -0,0 +1,138 @@ +defmodule PlausibleWeb.TrackerPlug do + @moduledoc """ + Plug to serve the Plausible tracker script. + """ + + import Plug.Conn + use Agent + use Plausible + + base_variants = [ + "hash", + "outbound-links", + "exclusions", + "compat", + "local", + "manual", + "file-downloads", + "pageview-props", + "tagged-events", + "revenue", + "pageleave" + ] + + # Generates Power Set of all variants + legacy_variants = + 1..Enum.count(base_variants) + |> Enum.map(fn x -> + Combination.combine(base_variants, x) + |> Enum.map(fn y -> Enum.sort(y) |> Enum.join(".") end) + end) + |> List.flatten() + + @base_legacy_filenames ["plausible", "script", "analytics"] + @files_available ["plausible.js", "p.js"] ++ + Enum.map(legacy_variants, fn v -> "plausible.#{v}.js" end) + + def init(opts) do + Keyword.merge(opts, files_available: MapSet.new(@files_available)) + end + + def call(conn, files_available: files_available) do + case conn.request_path do + "/js/pa-" <> path -> + if String.ends_with?(path, ".js") do + id = String.replace_trailing(path, ".js", "") + request_tracker_script("pa-" <> id, conn) + else + conn + end + + "/js/p.js" -> + legacy_request_file("p.js", files_available, conn) + + "/js/" <> requested_filename -> + sorted_script_variant(requested_filename) |> legacy_request_file(files_available, conn) + + _ -> + conn + end + end + + def telemetry_event(name), do: [:plausible, :tracker_script, :request, name] + + defp request_tracker_script(id, conn) do + case PlausibleWeb.Tracker.get_plausible_main_script(id) do + script_tag when is_binary(script_tag) -> + :telemetry.execute( + telemetry_event(:v2), + %{}, + %{status: 200} + ) + + conn + |> put_resp_header("content-type", "application/javascript") + |> put_resp_header("x-content-type-options", "nosniff") + |> put_resp_header("cross-origin-resource-policy", "cross-origin") + |> put_resp_header("access-control-allow-origin", "*") + |> put_resp_header("cache-control", "public, max-age=60, no-transform") + # CDN-Tag is used by BunnyCDN to tag cached resources. This allows us to purge + # specific tracker scripts from the CDN cache. + |> put_resp_header("cdn-tag", "tracker_script::#{id}") + |> send_resp(200, script_tag) + |> halt() + + nil -> + :telemetry.execute( + telemetry_event(:v2), + %{}, + %{status: 404} + ) + + conn + |> send_resp(404, "Not found") + |> halt() + end + end + + defp legacy_request_file(filename, files_available, conn) do + if filename && MapSet.member?(files_available, filename) do + location = Application.app_dir(:plausible, "priv/tracker/js/" <> filename) + + :telemetry.execute( + telemetry_event(:legacy), + %{}, + %{status: 200} + ) + + conn + |> put_resp_header("content-type", "application/javascript") + |> put_resp_header("x-content-type-options", "nosniff") + |> put_resp_header("cross-origin-resource-policy", "cross-origin") + |> put_resp_header("access-control-allow-origin", "*") + |> put_resp_header("cache-control", "public, max-age=86400, must-revalidate") + |> send_file(200, location) + |> halt() + else + conn + end + end + + # Variants which do not factor into output + @ignore_variants ["js", "pageleave"] + + defp sorted_script_variant(requested_filename) do + case String.split(requested_filename, ".") do + [base_filename | rest] when base_filename in @base_legacy_filenames -> + sorted_variants = + rest + |> Enum.reject(&(&1 in @ignore_variants)) + |> Enum.sort() + + Enum.join(["plausible"] ++ sorted_variants ++ ["js"], ".") + + _ -> + nil + end + end +end diff --git a/lib/plausible_web/plugs/user_session_touch.ex b/lib/plausible_web/plugs/user_session_touch.ex index 5802a0c231ae..5af39c4d3c84 100644 --- a/lib/plausible_web/plugs/user_session_touch.ex +++ b/lib/plausible_web/plugs/user_session_touch.ex @@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugs.UserSessionTouch do import Plug.Conn - alias PlausibleWeb.UserAuth + alias Plausible.Auth def init(opts \\ []) do opts @@ -16,7 +16,7 @@ defmodule PlausibleWeb.Plugs.UserSessionTouch do assign( conn, :current_user_session, - UserAuth.touch_user_session(user_session) + Auth.UserSessions.touch(user_session) ) else conn diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index ca5beedbd869..9f5265bd0163 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -12,13 +12,43 @@ defmodule PlausibleWeb.Router do plug PlausibleWeb.Plugs.NoRobots on_ee(do: nil, else: plug(PlausibleWeb.FirstLaunchPlug, redirect_to: "/register")) plug PlausibleWeb.AuthPlug + on_ee(do: plug(Plausible.Plugs.HandleExpiredSession)) + on_ee(do: plug(Plausible.Plugs.SSOTeamAccess)) plug PlausibleWeb.Plugs.UserSessionTouch + plug :put_root_layout, html: {PlausibleWeb.LayoutView, :app} + end + + on_ee do + pipeline :browser_sso_notice do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_secure_browser_headers + plug PlausibleWeb.Plugs.NoRobots + on_ee(do: nil, else: plug(PlausibleWeb.FirstLaunchPlug, redirect_to: "/register")) + plug PlausibleWeb.AuthPlug + on_ee(do: plug(Plausible.Plugs.HandleExpiredSession)) + plug PlausibleWeb.Plugs.UserSessionTouch + plug :put_root_layout, html: {PlausibleWeb.LayoutView, :app} + end end pipeline :shared_link do plug :accepts, ["html"] - plug :put_secure_browser_headers + plug PlausibleWeb.Plugs.SecureEmbedHeaders plug PlausibleWeb.Plugs.NoRobots + plug :put_root_layout, html: {PlausibleWeb.LayoutView, :app} + end + + on_ee do + pipeline :helpscout do + plug :accepts, ["html"] + plug :fetch_session + plug PlausibleWeb.Plugs.SecureEmbedHeaders + plug PlausibleWeb.Plugs.NoRobots + plug PlausibleWeb.AuthPlug + plug :put_root_layout, html: {PlausibleWeb.LayoutView, :app} + end end pipeline :csrf do @@ -74,8 +104,9 @@ defmodule PlausibleWeb.Router do end end - if Mix.env() in [:dev, :ce_dev] do + if Mix.env() in [:dev, :ce_dev, :e2e_test] do forward "/sent-emails", Bamboo.SentEmailViewerPlug + forward "/sent-emails-api", Bamboo.SentEmailApiPlug end scope "/" do @@ -88,35 +119,42 @@ defmodule PlausibleWeb.Router do end on_ee do - use Kaffy.Routes, - scope: "/crm", - pipe_through: [ - PlausibleWeb.Plugs.NoRobots, - PlausibleWeb.AuthPlug, - PlausibleWeb.SuperAdminOnlyPlug - ] + scope alias: PlausibleWeb.Live, + assigns: %{connect_live_socket: true, skip_plausible_tracking: true} do + pipe_through [:browser, :csrf, :app_layout, :flags] + + live "/cs", CustomerSupport, :index, as: :customer_support + live "/cs/teams/team/:id", CustomerSupport.Team, :show, as: :customer_support_team + live "/cs/users/user/:id", CustomerSupport.User, :show, as: :customer_support_user + live "/cs/sites/site/:id", CustomerSupport.Site, :show, as: :customer_support_site + end end on_ee do - scope "/crm", PlausibleWeb do + scope path: "/flags" do pipe_through :flags - get "/teams/team/:team_id/usage", AdminController, :usage - get "/auth/user/:user_id/info", AdminController, :user_info - get "/billing/team/:team_id/current_plan", AdminController, :current_plan - get "/billing/search/team-by-id/:team_id", AdminController, :team_by_id - post "/billing/search/team", AdminController, :team_search + forward "/", FunWithFlags.UI.Router, namespace: "flags" end end on_ee do - scope path: "/flags" do - pipe_through :flags - forward "/", FunWithFlags.UI.Router, namespace: "flags" + if Mix.env() in [:dev, :test, :e2e_test] do + scope "/dev", PlausibleWeb do + pipe_through :browser + + get "/billing/create-subscription-form/:plan_id", DevSubscriptionController, :create_form + get "/billing/update-subscription-form", DevSubscriptionController, :update_form + get "/billing/cancel-subscription-form", DevSubscriptionController, :cancel_form + + post "/billing/create-subscription/:plan_id", DevSubscriptionController, :create + post "/billing/update-subscription", DevSubscriptionController, :update + post "/billing/cancel-subscription", DevSubscriptionController, :cancel + end end end # Routes for plug integration testing - if Mix.env() in [:test, :ce_test] do + if Mix.env() in [:test, :ce_test, :e2e_test] do scope "/plug-tests", PlausibleWeb do scope [] do pipe_through :browser @@ -135,6 +173,56 @@ defmodule PlausibleWeb.Router do end end + # Routes for E2E testing + on_ee do + if Mix.env() == :e2e_test do + scope "/e2e-tests", PlausibleWeb do + pipe_through :api + + post "/stats", E2EController, :populate_stats + post "/funnel", E2EController, :create_funnel + end + end + end + + # SSO routes + on_ee do + pipeline :sso_saml do + plug :accepts, ["html"] + + plug PlausibleWeb.Plugs.SecureSSO + + plug PlausibleWeb.Plugs.NoRobots + + plug :fetch_session + plug :fetch_live_flash + end + + pipeline :sso_saml_auth do + plug :protect_from_forgery, with: :clear_session + end + + scope "/sso", PlausibleWeb do + pipe_through [:browser, :csrf] + + get "/login", SSOController, :login_form + post "/login", SSOController, :login + end + + scope "/sso/saml", PlausibleWeb do + pipe_through [:sso_saml] + + scope [] do + pipe_through :sso_saml_auth + + get "/signin/:integration_id", SSOController, :saml_signin + end + + post "/consume/:integration_id", SSOController, :saml_consume + post "/csp-report", SSOController, :csp_report + end + end + scope path: "/api/plugins", as: :plugins_api do pipeline :plugins_api_auth do plug(PlausibleWeb.Plugs.AuthorizePluginsAPI) @@ -179,6 +267,9 @@ defmodule PlausibleWeb.Router do put("/custom_props", CustomProps, :enable) delete("/custom_props", CustomProps, :disable) + + get("/tracker_script_configuration", TrackerScriptConfiguration, :get) + put("/tracker_script_configuration", TrackerScriptConfiguration, :update) end end @@ -190,41 +281,46 @@ defmodule PlausibleWeb.Router do get "/:domain/funnels/:id", StatsController, :funnel end - get "/:domain/current-visitors", StatsController, :current_visitors - get "/:domain/main-graph", StatsController, :main_graph - get "/:domain/top-stats", StatsController, :top_stats - get "/:domain/sources", StatsController, :sources - get "/:domain/channels", StatsController, :channels - get "/:domain/utm_mediums", StatsController, :utm_mediums - get "/:domain/utm_sources", StatsController, :utm_sources - get "/:domain/utm_campaigns", StatsController, :utm_campaigns - get "/:domain/utm_contents", StatsController, :utm_contents - get "/:domain/utm_terms", StatsController, :utm_terms - get "/:domain/referrers/:referrer", StatsController, :referrer_drilldown - get "/:domain/pages", StatsController, :pages - get "/:domain/entry-pages", StatsController, :entry_pages - get "/:domain/exit-pages", StatsController, :exit_pages - get "/:domain/countries", StatsController, :countries - get "/:domain/regions", StatsController, :regions - get "/:domain/cities", StatsController, :cities - get "/:domain/browsers", StatsController, :browsers - get "/:domain/browser-versions", StatsController, :browser_versions - get "/:domain/operating-systems", StatsController, :operating_systems - get "/:domain/operating-system-versions", StatsController, :operating_system_versions - get "/:domain/screen-sizes", StatsController, :screen_sizes - get "/:domain/conversions", StatsController, :conversions - get "/:domain/custom-prop-values/:prop_key", StatsController, :custom_prop_values - get "/:domain/suggestions/:filter_name", StatsController, :filter_suggestions - - get "/:domain/suggestions/custom-prop-values/:prop_key", - StatsController, - :custom_prop_value_filter_suggestions - end - - scope "/:domain/segments", PlausibleWeb.Api.Internal do + scope private: %{allow_consolidated_views: true} do + post "/:domain/query", StatsController, :query + get "/:domain/current-visitors", StatsController, :current_visitors + get "/:domain/main-graph", StatsController, :main_graph + get "/:domain/top-stats", StatsController, :top_stats + get "/:domain/sources", StatsController, :sources + get "/:domain/channels", StatsController, :channels + get "/:domain/utm_mediums", StatsController, :utm_mediums + get "/:domain/utm_sources", StatsController, :utm_sources + get "/:domain/utm_campaigns", StatsController, :utm_campaigns + get "/:domain/utm_contents", StatsController, :utm_contents + get "/:domain/utm_terms", StatsController, :utm_terms + get "/:domain/referrers/:referrer", StatsController, :referrer_drilldown + get "/:domain/pages", StatsController, :pages + get "/:domain/entry-pages", StatsController, :entry_pages + get "/:domain/exit-pages", StatsController, :exit_pages + get "/:domain/countries", StatsController, :countries + get "/:domain/regions", StatsController, :regions + get "/:domain/cities", StatsController, :cities + get "/:domain/browsers", StatsController, :browsers + get "/:domain/browser-versions", StatsController, :browser_versions + get "/:domain/operating-systems", StatsController, :operating_systems + get "/:domain/operating-system-versions", StatsController, :operating_system_versions + get "/:domain/screen-sizes", StatsController, :screen_sizes + get "/:domain/conversions", StatsController, :conversions + get "/:domain/custom-prop-values/:prop_key", StatsController, :custom_prop_values + get "/:domain/suggestions/:filter_name", StatsController, :filter_suggestions + + get "/:domain/suggestions/custom-prop-values/:prop_key", + StatsController, + :custom_prop_value_filter_suggestions + end + end + + scope "/:domain/segments", PlausibleWeb.Api.Internal, + private: %{allow_consolidated_views: true} do post "/", SegmentsController, :create patch "/:segment_id", SegmentsController, :update delete "/:segment_id", SegmentsController, :delete + get "/:segment_id/shared-links", SegmentsController, :get_related_shared_links end end @@ -239,22 +335,22 @@ defmodule PlausibleWeb.Router do end scope "/api/v2", PlausibleWeb.Api, - assigns: %{api_scope: "stats:read:*", api_context: :site, schema_type: :public} do + private: %{ + allow_consolidated_views: true + }, + assigns: %{ + api_scope: "stats:read:*", + api_context: :site + } do pipe_through [:public_api, PlausibleWeb.Plugs.AuthorizePublicAPI] post "/query", ExternalQueryApiController, :query - - if Mix.env() in [:test, :ce_test] do - scope assigns: %{schema_type: :internal} do - post "/query-internal-test", ExternalQueryApiController, :query - end - end end scope "/api/docs", PlausibleWeb.Api do get "/query/schema.json", ExternalQueryApiController, :schema - scope assigns: %{schema_type: :public} do + scope [] do pipe_through :docs_stats_api post "/query", ExternalQueryApiController, :query @@ -273,6 +369,7 @@ defmodule PlausibleWeb.Router do scope assigns: %{api_context: :site} do get "/goals", ExternalSitesController, :goals_index + get "/custom-props", ExternalSitesController, :custom_props_index get "/guests", ExternalSitesController, :guests_index get "/:site_id", ExternalSitesController, :get_site end @@ -289,6 +386,10 @@ defmodule PlausibleWeb.Router do put "/goals", ExternalSitesController, :find_or_create_goal delete "/goals/:goal_id", ExternalSitesController, :delete_goal + put "/custom-props", ExternalSitesController, :add_custom_prop + # Property name can contain forward slashes, hence we match on wildcard here + delete "/custom-props/*property", ExternalSitesController, :delete_custom_prop + put "/guests", ExternalSitesController, :find_or_create_guest delete "/guests/:email", ExternalSitesController, :delete_guest @@ -356,6 +457,7 @@ defmodule PlausibleWeb.Router do post "/login", AuthController, :login get "/password/request-reset", AuthController, :password_reset_request_form post "/password/request-reset", AuthController, :password_reset_request + get "/2fa/setup/force-initiate", AuthController, :force_initiate_2fa_setup post "/2fa/setup/initiate", AuthController, :initiate_2fa_setup get "/2fa/setup/verify", AuthController, :verify_2fa_setup_form post "/2fa/setup/verify", AuthController, :verify_2fa_setup @@ -374,12 +476,17 @@ defmodule PlausibleWeb.Router do scope "/", PlausibleWeb do pipe_through [:shared_link] - get "/share/:domain", StatsController, :shared_link + get "/share/:domain/*path", StatsController, :shared_link post "/share/:slug/authenticate", StatsController, :authenticate_shared_link end scope "/settings", PlausibleWeb do - pipe_through [:browser, :csrf, PlausibleWeb.RequireAccountPlug] + pipe_through [ + :browser, + :csrf, + PlausibleWeb.RequireAccountPlug, + PlausibleWeb.Plugs.CurrentPath + ] get "/", SettingsController, :index get "/preferences", SettingsController, :preferences @@ -394,8 +501,13 @@ defmodule PlausibleWeb.Router do post "/security/email", SettingsController, :update_email post "/security/password", SettingsController, :update_password - get "/billing/subscription", SettingsController, :subscription - get "/billing/invoices", SettingsController, :invoices + live_session :settings, on_mount: PlausibleWeb.Live.SettingsContext do + scope alias: Live, assigns: %{connect_live_socket: true} do + live "/billing/subscription", SubscriptionSettings, :subscription, as: :settings + end + end + + get "/billing/invoices", SettingsController, :redirect_invoices get "/api-keys", SettingsController, :api_keys get "/api-keys/new", SettingsController, :new_api_key @@ -406,6 +518,17 @@ defmodule PlausibleWeb.Router do get "/team/general", SettingsController, :team_general post "/team/general/name", SettingsController, :update_team_name + post "/team/leave", SettingsController, :leave_team + post "/team/force_2fa/enable", SettingsController, :enable_team_force_2fa + post "/team/force_2fa/disable", SettingsController, :disable_team_force_2fa + + on_ee do + get "/sso/info", SSOController, :cta + get "/sso/general", SSOController, :sso_settings + get "/sso/sessions", SSOController, :team_sessions + delete "/sso/sessions/:session_id", SSOController, :delete_session + end + post "/team/invitations/:invitation_id/accept", InvitationController, :accept_invitation post "/team/invitations/:invitation_id/reject", InvitationController, :reject_invitation delete "/team/invitations/:invitation_id", InvitationController, :remove_team_invitation @@ -413,21 +536,36 @@ defmodule PlausibleWeb.Router do delete "/team/delete", SettingsController, :delete_team end - scope "/", PlausibleWeb do - pipe_through [:browser, :csrf] - - get "/logout", AuthController, :logout - delete "/me", AuthController, :delete_me + on_ee do + scope "/", PlausibleWeb do + pipe_through [:browser_sso_notice, :csrf] - get "/team/select", AuthController, :select_team + get "/sso/notice", SSOController, :provision_notice + get "/sso/issue", SSOController, :provision_issue + get "/logout", AuthController, :logout + get "/team/select", AuthController, :select_team + end - get "/auth/google/callback", AuthController, :google_auth_callback + scope "/", PlausibleWeb do + pipe_through [:helpscout, :csrf] - on_ee do get "/helpscout/callback", HelpScoutController, :callback get "/helpscout/show", HelpScoutController, :show get "/helpscout/search", HelpScoutController, :search end + end + + scope "/", PlausibleWeb do + pipe_through [:browser, :csrf] + + on_ce do + get "/logout", AuthController, :logout + get "/team/select", AuthController, :select_team + end + + delete "/me", AuthController, :delete_me + + get "/auth/google/callback", AuthController, :google_auth_callback get "/", PageController, :index @@ -447,54 +585,8 @@ defmodule PlausibleWeb.Router do get "/sites/new", SiteController, :new post "/sites", SiteController, :create_site - get "/sites/:domain/change-domain", SiteController, :change_domain - put "/sites/:domain/change-domain", SiteController, :change_domain_submit post "/sites/:domain/make-public", SiteController, :make_public post "/sites/:domain/make-private", SiteController, :make_private - post "/sites/:domain/weekly-report/enable", SiteController, :enable_weekly_report - post "/sites/:domain/weekly-report/disable", SiteController, :disable_weekly_report - post "/sites/:domain/weekly-report/recipients", SiteController, :add_weekly_report_recipient - - delete "/sites/:domain/weekly-report/recipients/:recipient", - SiteController, - :remove_weekly_report_recipient - - post "/sites/:domain/monthly-report/enable", SiteController, :enable_monthly_report - post "/sites/:domain/monthly-report/disable", SiteController, :disable_monthly_report - - post "/sites/:domain/monthly-report/recipients", - SiteController, - :add_monthly_report_recipient - - delete "/sites/:domain/monthly-report/recipients/:recipient", - SiteController, - :remove_monthly_report_recipient - - post "/sites/:domain/traffic-change-notification/:type/enable", - SiteController, - :enable_traffic_change_notification - - post "/sites/:domain/traffic-change-notification/:type/disable", - SiteController, - :disable_traffic_change_notification - - put "/sites/:domain/traffic-change-notification/:type", - SiteController, - :update_traffic_change_notification - - post "/sites/:domain/traffic-change-notification/:type/recipients", - SiteController, - :add_traffic_change_notification_recipient - - delete "/sites/:domain/traffic-change-notification/:type/recipients/:recipient", - SiteController, - :remove_traffic_change_notification_recipient - - get "/sites/:domain/shared-links/new", SiteController, :new_shared_link - post "/sites/:domain/shared-links", SiteController, :create_shared_link - get "/sites/:domain/shared-links/:slug/edit", SiteController, :edit_shared_link - put "/sites/:domain/shared-links/:slug", SiteController, :update_shared_link - delete "/sites/:domain/shared-links/:slug", SiteController, :delete_shared_link get "/sites/:domain/memberships/invite", Site.MembershipController, :invite_member_form post "/sites/:domain/memberships/invite", Site.MembershipController, :invite_member @@ -517,9 +609,6 @@ defmodule PlausibleWeb.Router do delete "/sites/:domain/memberships/u/:id", Site.MembershipController, :remove_member_by_user - get "/sites/:domain/weekly-report/unsubscribe", UnsubscribeController, :weekly_report - get "/sites/:domain/monthly-report/unsubscribe", UnsubscribeController, :monthly_report - scope alias: Live, assigns: %{connect_live_socket: true} do pipe_through [:app_layout, PlausibleWeb.RequireAccountPlug] @@ -532,32 +621,32 @@ defmodule PlausibleWeb.Router do scope assigns: %{ dogfood_page_path: "/:website/verification" } do - live "/:domain/verification", Verification, :verification, as: :site + live "/:domain/verification", + on_ee(do: Verification, else: AwaitingPageviews), + :verification, + as: :site + end + + scope assigns: %{ + dogfood_page_path: "/:website/change-domain" + } do + live "/:domain/change-domain", ChangeDomain, :change_domain, as: :site + live "/:domain/change-domain/success", ChangeDomain, :success, as: :site end end - get "/:domain/settings", SiteController, :settings - get "/:domain/settings/general", SiteController, :settings_general get "/:domain/settings/people", SiteController, :settings_people get "/:domain/settings/visibility", SiteController, :settings_visibility - get "/:domain/settings/goals", SiteController, :settings_goals - get "/:domain/settings/properties", SiteController, :settings_props on_ee do get "/:domain/settings/funnels", SiteController, :settings_funnels end - get "/:domain/settings/email-reports", SiteController, :settings_email_reports get "/:domain/settings/danger-zone", SiteController, :settings_danger_zone get "/:domain/settings/integrations", SiteController, :settings_integrations get "/:domain/settings/shields/:shield", SiteController, :settings_shields get "/:domain/settings/imports-exports", SiteController, :settings_imports_exports - put "/:domain/settings/features/visibility/:setting", - SiteController, - :update_feature_visibility - - put "/:domain/settings", SiteController, :update_settings put "/:domain/settings/google", SiteController, :update_google_auth delete "/:domain/settings/google-search", SiteController, :delete_google_auth delete "/:domain/settings/google-import", SiteController, :delete_google_auth @@ -583,7 +672,59 @@ defmodule PlausibleWeb.Router do get "/debug/clickhouse", DebugController, :clickhouse - get "/:domain/export", StatsController, :csv_export - get "/:domain/*path", StatsController, :stats + scope private: %{allow_consolidated_views: true} do + post "/sites/:domain/weekly-report/enable", SiteController, :enable_weekly_report + post "/sites/:domain/weekly-report/disable", SiteController, :disable_weekly_report + post "/sites/:domain/weekly-report/recipients", SiteController, :add_weekly_report_recipient + + delete "/sites/:domain/weekly-report/recipients/:recipient", + SiteController, + :remove_weekly_report_recipient + + post "/sites/:domain/monthly-report/enable", SiteController, :enable_monthly_report + post "/sites/:domain/monthly-report/disable", SiteController, :disable_monthly_report + + post "/sites/:domain/monthly-report/recipients", + SiteController, + :add_monthly_report_recipient + + delete "/sites/:domain/monthly-report/recipients/:recipient", + SiteController, + :remove_monthly_report_recipient + + post "/sites/:domain/traffic-change-notification/:type/enable", + SiteController, + :enable_traffic_change_notification + + post "/sites/:domain/traffic-change-notification/:type/disable", + SiteController, + :disable_traffic_change_notification + + put "/sites/:domain/traffic-change-notification/:type", + SiteController, + :update_traffic_change_notification + + post "/sites/:domain/traffic-change-notification/:type/recipients", + SiteController, + :add_traffic_change_notification_recipient + + delete "/sites/:domain/traffic-change-notification/:type/recipients/:recipient", + SiteController, + :remove_traffic_change_notification_recipient + + get "/sites/:domain/weekly-report/unsubscribe", UnsubscribeController, :weekly_report + get "/sites/:domain/monthly-report/unsubscribe", UnsubscribeController, :monthly_report + + get "/:domain/settings", SiteController, :settings + get "/:domain/settings/general", SiteController, :settings_general + get "/:domain/settings/goals", SiteController, :settings_goals + get "/:domain/settings/properties", SiteController, :settings_props + get "/:domain/settings/email-reports", SiteController, :settings_email_reports + + put "/:domain/settings", SiteController, :update_settings + + get "/:domain/export", StatsController, :csv_export + get "/:domain/*path", StatsController, :stats + end end end diff --git a/lib/plausible_web/templates/auth/activate.html.heex b/lib/plausible_web/templates/auth/activate.html.heex index 2cf24bca0bba..dd8ed11962f1 100644 --- a/lib/plausible_web/templates/auth/activate.html.heex +++ b/lib/plausible_web/templates/auth/activate.html.heex @@ -17,22 +17,23 @@ <:subtitle :if={@has_email_code?}> -

    +

    Please enter the 4-digit code we sent to {@conn.assigns[:current_user].email}

    <:subtitle :if={!@has_email_code?}> -

    +

    A 4-digit activation code will be sent to {@conn.assigns[:current_user].email}

    <.form :let={f} for={@conn} action={@form_submit_url}> + <.input type="hidden" field={f[:team_identifier]} /> <.input field={f[:code]} - class="tracking-widest font-medium shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-36 px-8 border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-200 dark:bg-gray-900 text-center" + class="tracking-widest font-medium shadow-xs focus:ring-indigo-500 focus:border-indigo-500 block w-36 px-8 border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-200 dark:bg-gray-900 text-center" oninput="this.value=this.value.replace(/[^0-9]/g, ''); if (this.value.length >= 4) document.getElementById('submit').focus()" onclick="this.select();" maxlength="4" diff --git a/lib/plausible_web/templates/auth/force_initiate_2fa_setup.html.heex b/lib/plausible_web/templates/auth/force_initiate_2fa_setup.html.heex new file mode 100644 index 000000000000..562f84b97cbe --- /dev/null +++ b/lib/plausible_web/templates/auth/force_initiate_2fa_setup.html.heex @@ -0,0 +1,30 @@ + + +<.focus_box> + <:title> + Setup Two-Factor Authentication + + + <:subtitle> + Redirecting to 2FA setup. Click below if it doesn't happen automatically. + + +
    + <.form + id="initiate-2fa-form" + action={Routes.auth_path(@conn, :initiate_2fa_setup, force: "true")} + for={@conn.params} + method="post" + > + <.button type="submit" class="w-full" mt?={false}> + Proceed + + +
    + diff --git a/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex b/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex index 753102c3f9fd..1b67b37db29b 100644 --- a/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex +++ b/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex @@ -4,10 +4,13 @@ <:subtitle> + <.notice :if={@forced?} class="mb-2" theme={:gray} title="Team requires 2FA setup"> + You've been redirected here because your team enforces 2FA. Please complete the setup or switch to another team. + Link your Plausible account to the authenticator app you have installed either on your phone or computer. - <:footer> + <:footer :if={not @forced?}> <.focus_list> <:item> Changed your mind? diff --git a/lib/plausible_web/templates/auth/login_form.html.heex b/lib/plausible_web/templates/auth/login_form.html.heex index a9960e5f0aa8..a22fff47dd68 100644 --- a/lib/plausible_web/templates/auth/login_form.html.heex +++ b/lib/plausible_web/templates/auth/login_form.html.heex @@ -29,8 +29,8 @@ />
    - <%= if @conn.assigns[:error] do %> -
    {@conn.assigns[:error]}
    + <%= if login_error = Phoenix.Flash.get(@flash, :login_error) do %> +
    {login_error}
    <% end %> <.input type="hidden" field={f[:return_to]} /> @@ -49,6 +49,20 @@ instead. + <:item :if={ee?()}> + <%= on_ee do %> + Have a Single Sign-on account? + <.styled_link href={ + Routes.sso_path(@conn, :login_form, + return_to: @conn.params["return_to"], + prefer: "manual" + ) + }> + Sign in here + + instead. + <% end %> + <:item> Forgot password? <.styled_link href="/password/request-reset"> diff --git a/lib/plausible_web/templates/auth/password_reset_request_success.html.heex b/lib/plausible_web/templates/auth/password_reset_request_success.html.heex index bb6d81f3a8d2..6a2a75506a1e 100644 --- a/lib/plausible_web/templates/auth/password_reset_request_success.html.heex +++ b/lib/plausible_web/templates/auth/password_reset_request_success.html.heex @@ -1,4 +1,4 @@ -
    +

    Success!

    We've sent an email containing password reset instructions to {@email} diff --git a/lib/plausible_web/templates/auth/verify_2fa.html.heex b/lib/plausible_web/templates/auth/verify_2fa.html.heex index 0b9252186999..1f049d673f1b 100644 --- a/lib/plausible_web/templates/auth/verify_2fa.html.heex +++ b/lib/plausible_web/templates/auth/verify_2fa.html.heex @@ -39,7 +39,7 @@ field={f[:remember_2fa]} value="true" label={"Trust this device for #{@remember_2fa_days} days"} - class="block h-5 w-5 rounded dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600" + class="block h-5 w-5 rounded-sm dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600" />
    diff --git a/lib/plausible_web/templates/billing/change_enterprise_plan_contact_us.html.eex b/lib/plausible_web/templates/billing/change_enterprise_plan_contact_us.html.eex index 122d9f6e43a6..772812832a3f 100644 --- a/lib/plausible_web/templates/billing/change_enterprise_plan_contact_us.html.eex +++ b/lib/plausible_web/templates/billing/change_enterprise_plan_contact_us.html.eex @@ -3,7 +3,7 @@
    -
    +
    Looking to adjust your plan?
    diff --git a/lib/plausible_web/templates/billing/change_plan_preview.html.heex b/lib/plausible_web/templates/billing/change_plan_preview.html.heex index da955cef6e92..51b002478708 100644 --- a/lib/plausible_web/templates/billing/change_plan_preview.html.heex +++ b/lib/plausible_web/templates/billing/change_plan_preview.html.heex @@ -10,7 +10,7 @@
    -
    +
    -
    +
    {render_slot(@inner_block)}
    @@ -637,7 +965,7 @@ defmodule PlausibleWeb.Components.Generic do if assigns[:invisible] do "invisible" else - "px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-medium" + "px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-semibold" end assigns = assign(assigns, class: class) @@ -655,12 +983,12 @@ defmodule PlausibleWeb.Components.Generic do def toggle_submit(assigns) do ~H""" -
    +
    """ end end attr :href, :string, default: nil + attr :class, :string, default: "" + attr :rest, :global, include: ~w(method disabled) + + def edit_button(assigns) do + ~H""" + <.icon_button :let={icon_class} href={@href} class={@class} {@rest}> + + + + + """ + end + + attr :href, :string, default: nil + attr :class, :string, default: "" + attr :icon, :atom, default: :trash attr :rest, :global, include: ~w(method disabled) def delete_button(assigns) do - if assigns[:href] do - ~H""" - <.unstyled_link href={@href} {@rest}> - - - """ - else - ~H""" - - """ - end + ~H""" + <.icon_button + icon_name={@icon} + theme="danger" + class={@class} + href={@href} + {@rest} + /> + """ end attr :filter_text, :string, default: "" @@ -732,10 +1172,15 @@ defmodule PlausibleWeb.Components.Generic do def filter_bar(assigns) do ~H""" -
    -
    -
    - +
    +
    + +
    @@ -743,9 +1188,16 @@ defmodule PlausibleWeb.Components.Generic do type="text" name="filter-text" id="filter-text" - class="w-36 sm:w-full pl-8 text-sm shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800" - placeholder={@placeholder} + class="w-full max-w-80 pl-8 pr-3.5 py-2.5 text-sm dark:bg-gray-750 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-750 rounded-md 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" + placeholder="Press / to search" + x-ref="filter_text" + phx-debounce={200} + autocomoplete="off" + x-on:keydown.slash.window="if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) return; $event.preventDefault(); $refs.filter_text.focus(); $refs.filter_text.select();" + x-on:keydown.escape="$refs.filter_text.blur(); $refs.reset_filter?.dispatchEvent(new Event('click', {bubbles: true, cancelable: true}));" value={@filter_text} + x-on:focus={"$refs.filter_text.placeholder = '#{@placeholder}';"} + x-on:blur="$refs.filter_text.placeholder = 'Press / to search';" /> - -
    +
    +
    {render_slot(@inner_block)}
    @@ -764,10 +1217,11 @@ defmodule PlausibleWeb.Components.Generic do slot :inner_block, required: true attr :class, :any, default: nil + attr :rest, :global def h2(assigns) do ~H""" -

    +

    {render_slot(@inner_block)}

    """ @@ -783,4 +1237,98 @@ defmodule PlausibleWeb.Components.Generic do """ end + + alias Phoenix.LiveView.JS + + slot :inner_block, required: true + + def disclosure(assigns) do + ~H""" +
    + {render_slot(@inner_block)} +
    + """ + end + + slot :inner_block, required: true + attr :class, :any, default: nil + + def disclosure_button(assigns) do + ~H""" + + """ + end + + slot :inner_block, required: true + + def disclosure_panel(assigns) do + ~H""" + + """ + end + + slot :inner_block, required: true + + def highlighted(assigns) do + ~H""" + + {render_slot(@inner_block)} + + """ + end + + attr(:class, :string, default: "") + attr(:color, :atom, default: :gray, values: [:gray, :indigo, :yellow, :green, :red]) + attr(:rest, :global) + slot(:inner_block, required: true) + + def pill(assigns) do + assigns = assign(assigns, :color_classes, get_pill_color_classes(assigns.color)) + + ~H""" + + {render_slot(@inner_block)} + + """ + end + + defp get_pill_color_classes(:gray) do + "bg-gray-100 text-gray-800 dark:bg-gray-750 dark:text-gray-200" + end + + defp get_pill_color_classes(:indigo) do + "bg-indigo-100/60 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-300" + end + + defp get_pill_color_classes(:yellow) do + "bg-yellow-100/80 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" + end + + defp get_pill_color_classes(:green) do + "bg-green-100/70 text-green-800 dark:bg-green-900/40 dark:text-green-300" + end + + defp get_pill_color_classes(:red) do + "bg-red-100/60 text-red-700 dark:bg-red-800/40 dark:text-red-300" + end end diff --git a/lib/plausible_web/components/google.ex b/lib/plausible_web/components/google.ex index ee750ffef4ef..bd8767f954c3 100644 --- a/lib/plausible_web/components/google.ex +++ b/lib/plausible_web/components/google.ex @@ -12,7 +12,7 @@ defmodule PlausibleWeb.Components.Google do <.unstyled_link id={@id} href={@to} - class="inline-flex pr-4 items-center border border-gray-100 shadow rounded-md focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-200 mt-8 hover:bg-gray-50 dark:hover:bg-gray-700" + class="inline-flex pr-4 items-center border border-gray-100 shadow-sm rounded-md focus:outline-hidden focus:ring-1 focus:ring-offset-1 focus:ring-gray-200 mt-8 hover:bg-gray-50 dark:hover:bg-gray-700" > <.logo /> + + + """ + end + + attr :class, :any, default: [] + + def external_link_icon(assigns) do + ~H""" + + + + """ + end + + attr :class, :any, default: [] + + def tag_icon(assigns) do + ~H""" + + + + + """ + end + + attr :class, :any, default: [] + + def subscription_icon(assigns) do + ~H""" + + + + + """ + end + + attr :class, :any, default: [] + + def key_icon(assigns) do + ~H""" + + + + + + + """ + end + + attr :class, :any, default: [] + + def exclamation_triangle_icon(assigns) do + ~H""" + + + + + + + + """ + end + + attr :class, :any, default: [] + + def envelope_icon(assigns) do + ~H""" + + + + + """ + end +end diff --git a/lib/plausible_web/components/layout.ex b/lib/plausible_web/components/layout.ex index 4ac2d23b5bc9..89075d6d1b89 100644 --- a/lib/plausible_web/components/layout.ex +++ b/lib/plausible_web/components/layout.ex @@ -53,6 +53,104 @@ defmodule PlausibleWeb.Components.Layout do """ end + attr(:selected_fn, :any, required: true) + attr(:prefix, :string, default: "") + attr(:options, :list, required: true) + + def settings_sidebar(assigns) do + ~H""" +
    + <.settings_top_tab + :for={%{key: key, value: value, icon: icon} = opts <- @options} + selected_fn={@selected_fn} + prefix={@prefix} + icon={icon} + text={key} + badge={opts[:badge]} + value={value} + /> +
    + """ + end + + attr(:selected_fn, :any) + attr(:prefix, :string, default: "") + attr(:icon, :any, default: nil) + attr(:text, :string, required: true) + attr(:badge, :any, default: nil) + attr(:value, :any, default: nil) + + defp settings_top_tab(assigns) do + ~H""" + <%= if is_binary(@value) do %> + <.settings_tab + selected_fn={@selected_fn} + prefix={@prefix} + icon={@icon} + text={@text} + badge={@badge} + value={@value} + /> + <% else %> + <.settings_tab icon={@icon} text={@text} /> + +
    + <.settings_tab + :for={%{key: key, value: value} = opts <- @value} + selected_fn={@selected_fn} + prefix={@prefix} + icon={nil} + text={key} + badge={opts[:badge]} + value={value} + submenu?={true} + /> +
    + <% end %> + """ + end + + attr(:selected_fn, :any, default: nil) + attr(:prefix, :string, default: "") + attr(:value, :any, default: nil) + attr(:icon, :any, default: nil) + attr(:submenu?, :boolean, default: false) + attr(:text, :string, required: true) + attr(:badge, :any, default: nil) + + defp settings_tab(assigns) do + current_tab? = assigns[:selected_fn] != nil and assigns.selected_fn.(assigns[:value]) + assigns = assign(assigns, :current_tab?, current_tab?) + + ~H""" + "/settings/" <> @value} + class={[ + "text-sm flex items-center px-2 py-2 leading-5 font-medium rounded-md outline-none focus:outline-none transition ease-in-out duration-150", + @current_tab? && + "text-gray-900 dark:text-gray-100 bg-gray-150 font-semibold dark:bg-gray-850 hover:text-gray-900 dark:hover:text-gray-100 focus:bg-gray-200 dark:focus:bg-gray-800", + @value && not @current_tab? && + "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-850 focus:text-gray-900 focus:bg-gray-50 dark:focus:text-gray-100 dark:focus:bg-gray-800", + !@value && "text-gray-600 dark:text-gray-300" + ]} + > + + {@text} + + NEW đŸ”„ + + + + """ + end + defp theme_preference(%{theme: theme}) when not is_nil(theme), do: theme defp theme_preference(%{current_user: %Plausible.Auth.User{theme: theme}}) diff --git a/lib/plausible_web/components/prima_dropdown.ex b/lib/plausible_web/components/prima_dropdown.ex new file mode 100644 index 000000000000..659a6f6d2caf --- /dev/null +++ b/lib/plausible_web/components/prima_dropdown.ex @@ -0,0 +1,92 @@ +defmodule PlausibleWeb.Components.PrimaDropdown do + @moduledoc false + alias Prima.Dropdown + use Phoenix.Component + + @dropdown_item_icon_base_class "text-gray-600 dark:text-gray-400 group-hover/item:text-gray-900 group-data-focus/item:text-gray-900 dark:group-hover/item:text-gray-100 dark:group-data-focus/item:text-gray-100" + + @trigger_button_base_class "whitespace-nowrap truncate inline-flex items-center justify-between gap-x-2 text-sm font-medium rounded-md cursor-pointer disabled:cursor-not-allowed" + + @trigger_button_themes %{ + "primary" => + "border border-indigo-600 bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600 disabled:bg-indigo-400/60 disabled:dark:bg-indigo-600/30 disabled:dark:text-white/35", + "secondary" => + "border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 hover:border-gray-400/60 hover:text-gray-900 dark:hover:border-gray-500 dark:hover:text-white disabled:text-gray-700/40 dark:disabled:text-gray-500 dark:disabled:bg-gray-800 dark:disabled:border-gray-800", + "ghost" => + "text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:text-gray-500 disabled:dark:text-gray-600" + } + + @trigger_button_sizes %{ + "sm" => "px-3 py-2", + "md" => "px-3.5 py-2.5" + } + + defdelegate dropdown(assigns), to: Prima.Dropdown + + attr(:id, :string, required: true) + attr(:theme, :string, default: "secondary") + attr(:size, :string, default: "md") + attr(:class, :string, default: "") + attr(:rest, :global) + slot(:inner_block, required: true) + + def dropdown_trigger(assigns) do + assigns = + assign(assigns, + computed_class: [ + @trigger_button_base_class, + @trigger_button_sizes[assigns.size], + @trigger_button_themes[assigns.theme], + assigns.class + ] + ) + + ~H""" + + {render_slot(@inner_block)} + + """ + end + + attr(:id, :string, required: true) + slot(:inner_block, required: true) + + # placement: bottom-end should probably be default in prima. Feels more natural + # for dropdown menus than bottom-start which is the current default + def dropdown_menu(assigns) do + ~H""" + + {render_slot(@inner_block)} + + """ + end + + attr(:as, :any, default: nil) + attr(:id, :string, required: true) + attr(:disabled, :boolean, default: false) + attr(:rest, :global, include: ~w(navigate patch href)) + slot(:inner_block, required: true) + + def dropdown_item(assigns) do + ~H""" + + {render_slot(@inner_block)} + + """ + end + + def dropdown_item_icon_class(size \\ "size-4") do + "#{size} #{@dropdown_item_icon_base_class}" + end +end diff --git a/lib/plausible_web/components/site/feature.ex b/lib/plausible_web/components/site/feature.ex deleted file mode 100644 index 9d147941ca9a..000000000000 --- a/lib/plausible_web/components/site/feature.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule PlausibleWeb.Components.Site.Feature do - @moduledoc """ - Phoenix Component for rendering a user-facing feature toggle - capable of flipping booleans in `Plausible.Site` via the `toggle_feature` controller action. - """ - use PlausibleWeb, :view - - attr(:site, Plausible.Site, required: true) - attr(:feature_mod, :atom, required: true, values: Plausible.Billing.Feature.list()) - attr(:conn, Plug.Conn, required: true) - attr(:class, :any, default: nil) - slot(:inner_block) - - def toggle(assigns) do - assigns = - assigns - |> assign(:current_setting, assigns.feature_mod.enabled?(assigns.site)) - |> assign(:disabled?, assigns.feature_mod.check_availability(assigns.site.team) !== :ok) - - ~H""" -
    - <.form - action={target(@site, @feature_mod.toggle_field(), @conn, !@current_setting)} - method="put" - for={nil} - class={@class} - > - <.toggle_submit set_to={@current_setting} disabled?={@disabled?}> - Show {@feature_mod.display_name()} in the Dashboard - - - -
    - {render_slot(@inner_block)} -
    -
    - """ - end - - def target(site, setting, conn, set_to) when is_boolean(set_to) do - r = conn.request_path - Routes.site_path(conn, :update_feature_visibility, site.domain, setting, r: r, set: set_to) - end -end diff --git a/lib/plausible_web/components/site/toggle_live.ex b/lib/plausible_web/components/site/toggle_live.ex new file mode 100644 index 000000000000..c374fd84bc60 --- /dev/null +++ b/lib/plausible_web/components/site/toggle_live.ex @@ -0,0 +1,92 @@ +defmodule PlausibleWeb.Components.Site.Feature.ToggleLive do + @moduledoc """ + LiveComponent for rendering a user-facing feature toggle in LiveView contexts. + Instead of using form submission, this component messages itself to handle toggles. + """ + use PlausibleWeb, :live_component + + def update(assigns, socket) do + site = Plausible.Repo.preload(assigns.site, :team) + team = Plausible.Teams.with_subscription(site.team) + site = %{site | team: team} + current_setting = assigns.feature_mod.enabled?(site) + disabled? = assigns.feature_mod.check_availability(team) !== :ok + + {:ok, + socket + |> assign(assigns) + |> assign(:site, site) + |> assign(:current_setting, current_setting) + |> assign(:disabled?, disabled?)} + end + + attr :site, Plausible.Site, required: true + attr :feature_mod, :atom, required: true + attr :current_user, Plausible.Auth.User, required: true + + def render(assigns) do + ~H""" +
    +
    + + + + Show in dashboard + +
    +
    + """ + end + + def handle_event("toggle", _params, socket) do + site = socket.assigns.site + feature_mod = socket.assigns.feature_mod + current_user = socket.assigns.current_user + + case feature_mod.toggle(site, current_user) do + {:ok, updated_site} -> + new_setting = Map.fetch!(updated_site, feature_mod.toggle_field()) + + message = + if new_setting do + "#{feature_mod.display_name()} are now visible again on your dashboard" + else + "#{feature_mod.display_name()} are now hidden from your dashboard" + end + + send(self(), {:feature_toggled, message, updated_site}) + + socket = + assign(socket, site: updated_site, current_setting: feature_mod.enabled?(updated_site)) + + {:noreply, socket} + + {:error, _} -> + {:noreply, socket} + end + end +end diff --git a/lib/plausible_web/components/team/notice.ex b/lib/plausible_web/components/team/notice.ex index 63b4703f011e..4faa263d4446 100644 --- a/lib/plausible_web/components/team/notice.ex +++ b/lib/plausible_web/components/team/notice.ex @@ -3,6 +3,9 @@ defmodule PlausibleWeb.Team.Notice do Components with teams related notices. """ use PlausibleWeb, :component + import PlausibleWeb.Components.Icons + + alias Plausible.Teams def owner_cta_banner(assigns) do ~H""" @@ -12,9 +15,10 @@ defmodule PlausibleWeb.Team.Notice do class="shadow-md dark:shadow-none mt-4" >

    - You can now create a team and assign different roles to team members, such as admin, - editor, viewer or billing. Team members will gain access to all your sites. - <.styled_link href={Routes.team_setup_path(PlausibleWeb.Endpoint, :setup)}> + You can also create a team and assign different roles to team members, such as admin, + editor, viewer or billing. Team members will gain access to all your sites. <.styled_link href={ + Routes.team_setup_path(PlausibleWeb.Endpoint, :setup) + }> Create your team here .

    @@ -31,7 +35,7 @@ defmodule PlausibleWeb.Team.Notice do class="shadow-md dark:shadow-none mt-4" >

    - It is now possible to create a team and assign different roles to team members, such as + It is also possible to create a team and assign different roles to team members, such as admin, editor, viewer or billing. Team members can gain access to all the sites. Please contact the site owner to create your team.

    @@ -43,7 +47,7 @@ defmodule PlausibleWeb.Team.Notice do def team_members_notice(assigns) do ~H"""
    + <.button + theme="secondary" + x-on:click="showAll = true" + x-show="!showAll" + > + Show more + +
    @@ -45,7 +45,7 @@
    -
    +
    @@ -75,16 +75,16 @@
    - + Back - + <.button_link href={Routes.billing_path(@conn, :change_plan, @preview_info["plan_id"])} method="post" @@ -95,7 +95,6 @@
    - Questions? - <.styled_link href="https://plausible.io/contact">Contact us + Questions? <.styled_link href="https://plausible.io/contact">Contact us
    diff --git a/lib/plausible_web/templates/billing/choose_plan.html.heex b/lib/plausible_web/templates/billing/choose_plan.html.heex index bac613786706..17b2b567360a 100644 --- a/lib/plausible_web/templates/billing/choose_plan.html.heex +++ b/lib/plausible_web/templates/billing/choose_plan.html.heex @@ -1,4 +1,4 @@ -{live_render(@conn, PlausibleWeb.Live.ChoosePlan, +{live_render(@conn, @live_module, id: "choose-plan", session: %{"remote_ip" => PlausibleWeb.RemoteIP.get(@conn)} )} diff --git a/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex b/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex index fd1e2e852ecd..e3cc10448edc 100644 --- a/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex +++ b/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex @@ -6,7 +6,7 @@
    -
    +
    {if @subscription_resumable, @@ -38,7 +38,7 @@
    <%= if @subscription_resumable do %> - + <.link id="preview-changes" href={ @@ -48,7 +48,7 @@ @latest_enterprise_plan.paddle_plan_id ) } - class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent leading-5 rounded-md hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150" + class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent leading-5 rounded-md hover:bg-indigo-500 focus:outline-hidden focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150" > diff --git a/lib/plausible_web/templates/debug/clickhouse.html.heex b/lib/plausible_web/templates/debug/clickhouse.html.heex index fd64ded6d749..0a9d51323542 100644 --- a/lib/plausible_web/templates/debug/clickhouse.html.heex +++ b/lib/plausible_web/templates/debug/clickhouse.html.heex @@ -1,4 +1,4 @@ -
    +
    <%= for log <- @queries do %>
    diff --git a/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex b/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex index ec2731eced83..15414a98b03a 100644 --- a/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex +++ b/lib/plausible_web/templates/email/approaching_accept_traffic_until.html.heex @@ -2,6 +2,6 @@ You used to have an active account with {Plausible.product_name()}, a simple, li

    We've noticed that you're still sending us stats so we're writing to inform you that we'll stop accepting stats from your sites {@time}. We're an independent, bootstrapped service and we don't sell your data, so this will reduce our server costs and help keep us sustainable.

    If you'd like to continue counting your site stats in a privacy-friendly way, please - "?__team=#{@team.identifier}"}>login to your Plausible account and start a subscription. -

    + "?__team=#{@team.identifier}"}>login to your Plausible account +and start a subscription.

    Do you have any questions or need help with anything? Just reply to this email and we'll gladly help. diff --git a/lib/plausible_web/templates/email/check_stats_email.html.heex b/lib/plausible_web/templates/email/check_stats_email.html.heex index b0222541efcf..9bc1b5f30d18 100644 --- a/lib/plausible_web/templates/email/check_stats_email.html.heex +++ b/lib/plausible_web/templates/email/check_stats_email.html.heex @@ -1,20 +1,5 @@ -Plausible is tracking your website stats without compromising the user experience and the privacy of your visitors. -

    Here's how to get even more out of your Plausible experience:

    * -Set up custom events and -pageview goals to count actions you want your visitors to take
    -* Running an ecommerce? Assign monetary values to custom events to track -revenue attribution -
    * Follow the journey from a landing page to conversion with -funnel analysis -
    * - - Tag your social media, email and paid links - to see which campaigns are responsible for most conversions
    -* Send custom properties -to collect data that we don't track automatically
    * Explore our -stats API to retrieve your stats and our -sites API to create and manage sites programmatically
    -

    -View your Plausible dashboard now -for the most valuable traffic insights at a glance.

    -Do reply back to this email if you have any questions or need some guidance. +It’s been a week since you started using Plausible.

    +We built Plausible because web analytics became too complex and too invasive.

    +We believe analytics should be simple to understand, fast to load and respectful of your visitors.

    +No cookies. No personal data. No surveillance. Just clear insights about how your website is performing.

    +Open your dashboard diff --git a/lib/plausible_web/templates/email/create_site_email.html.heex b/lib/plausible_web/templates/email/create_site_email.html.heex index 078087a2d34e..b87b760f6962 100644 --- a/lib/plausible_web/templates/email/create_site_email.html.heex +++ b/lib/plausible_web/templates/email/create_site_email.html.heex @@ -1,10 +1,6 @@ -You've activated -<%= if ee?() do %> - your free 30-day trial of -<% end %> -{Plausible.product_name()}, a simple and privacy-friendly website analytics tool.

    -Click here -to add your website URL, your timezone and install our one-line JavaScript snippet to start collecting visitor statistics. -<%= if ee?() do %> -

    Do reply back to this email if you have any questions or need some guidance. -<% end %> +You’ve created your Plausible account but haven’t added a site yet.

    +Adding your first site only takes a minute and gets you from signup to seeing real traffic.

    +Add your first site +

    +If you need help getting started, our documentation walks you through the setup step by step:
    +https://plausible.io/docs diff --git a/lib/plausible_web/templates/email/dashboard_locked.html.heex b/lib/plausible_web/templates/email/dashboard_locked.html.heex index 373cbe74cffc..0b268f69e2f3 100644 --- a/lib/plausible_web/templates/email/dashboard_locked.html.heex +++ b/lib/plausible_web/templates/email/dashboard_locked.html.heex @@ -9,10 +9,10 @@ During the last billing cycle ({PlausibleWeb.TextHelpers.format_date_range( )}), the usage was {PlausibleWeb.AuthView.delimit_integer(@usage.penultimate_cycle.total)} billable pageviews. Note that billable pageviews include both standard pageviews and custom events. In your "?__team=#{@team.identifier}"}>account settings, you'll find an overview of your usage and limits.

    -<%= if @suggested_plan == :enterprise do %> +<%= if @suggested_volume == :enterprise do %> Your usage exceeds our standard plans, so please reply back to this email for a tailored quote. <% else %> - "?__team=#{@team.identifier}"}>Click here to upgrade your subscription. We recommend you upgrade to the {@suggested_plan.volume}/mo plan. The new charge will be prorated to reflect the amount you have already paid and the time until your current subscription is supposed to expire. + "?__team=#{@team.identifier}"}>Click here to upgrade your subscription. We recommend you upgrade to the {@suggested_volume} pageviews/month plan. The new charge will be prorated to reflect the amount you have already paid and the time until your current subscription is supposed to expire.

    If your usage decreases in the future, you can switch to a lower plan at any time. Any credit balance will automatically apply to future payments. <% end %> diff --git a/lib/plausible_web/templates/email/drop_notification.html.heex b/lib/plausible_web/templates/email/drop_notification.html.heex index cc6b9c6834c6..4b3d41a63b53 100644 --- a/lib/plausible_web/templates/email/drop_notification.html.heex +++ b/lib/plausible_web/templates/email/drop_notification.html.heex @@ -1,7 +1,14 @@ -We've recorded {@current_visitors} visitors on - @site.domain}><%= @site.domain %> in the last 12 hours. +We've recorded {@current_visitors} visitors +<%= if Plausible.Sites.consolidated?(@site) do %> + across all your sites +<% else %> + on @site.domain}>{@site.domain} +<% end %> +in the last 12 hours. <%= if @dashboard_link do %> -

    View dashboard: {@dashboard_link} +

    View dashboard here. +<% end %> +<%= if @installation_link do %>

    Something looks off? Please review your installation to verify that Plausible has been integrated correctly. diff --git a/lib/plausible_web/templates/email/existing_user_invitation.html.heex b/lib/plausible_web/templates/email/existing_user_invitation.html.heex index c721e6aaeb8a..982529808274 100644 --- a/lib/plausible_web/templates/email/existing_user_invitation.html.heex +++ b/lib/plausible_web/templates/email/existing_user_invitation.html.heex @@ -1,3 +1,4 @@ {@inviter.email} has invited you to the {@site.domain} site on {Plausible.product_name()}. - "?__team=none"}>Click here to view and respond to the invitation. The invitation -will expire 48 hours after this email is sent. + "?__team=none"}>Click here +to view and respond to the invitation. The invitation +will expire 48 hours after this email is sent. If this invitation has expired, please contact the person who invited you to ask them to resend it. diff --git a/lib/plausible_web/templates/email/existing_user_team_invitation.html.heex b/lib/plausible_web/templates/email/existing_user_team_invitation.html.heex index 361d528adec0..e0e27977b7ff 100644 --- a/lib/plausible_web/templates/email/existing_user_team_invitation.html.heex +++ b/lib/plausible_web/templates/email/existing_user_team_invitation.html.heex @@ -1,3 +1,4 @@ {@inviter.email} has invited you to the "{@team.name}" team on {Plausible.product_name()}. -Click here to view and respond to the invitation. The invitation +Click here +to view and respond to the invitation. The invitation will expire 48 hours after this email is sent. diff --git a/lib/plausible_web/templates/email/force_2fa_enabled.html.heex b/lib/plausible_web/templates/email/force_2fa_enabled.html.heex new file mode 100644 index 000000000000..87da4c6c6c68 --- /dev/null +++ b/lib/plausible_web/templates/email/force_2fa_enabled.html.heex @@ -0,0 +1,10 @@ +{@enabling_user.email} has enabled a 2FA requirement for the "{@team.name}" team.

    +Please + + set up 2FA now + +to keep access to your team on Plausible. The setup takes about a minute with any authenticator app.

    +Need help? Read our 2FA guide +or reply to this email. diff --git a/lib/plausible_web/templates/email/guest_invitation_accepted.html.heex b/lib/plausible_web/templates/email/guest_invitation_accepted.html.heex index ffd3f09a0824..36223c80c22a 100644 --- a/lib/plausible_web/templates/email/guest_invitation_accepted.html.heex +++ b/lib/plausible_web/templates/email/guest_invitation_accepted.html.heex @@ -1,2 +1,5 @@ {@invitee_email} has accepted your invitation to {@site.domain}. - "?__team=#{@team.identifier}"}>Click here to view site settings. + "?__team=#{@team.identifier}"}> + Click here + +to view site settings. diff --git a/lib/plausible_web/templates/email/guest_invitation_rejected.html.heex b/lib/plausible_web/templates/email/guest_invitation_rejected.html.heex index c9b8e2116b91..f81c0f27373e 100644 --- a/lib/plausible_web/templates/email/guest_invitation_rejected.html.heex +++ b/lib/plausible_web/templates/email/guest_invitation_rejected.html.heex @@ -1,2 +1,5 @@ {@guest_invitation.team_invitation.email} has rejected your invitation to {@guest_invitation.site.domain}. - "?__team=#{@team.identifier}"}>Click here to view site settings. + "?__team=#{@team.identifier}"}> + Click here + +to view site settings. diff --git a/lib/plausible_web/templates/email/guest_to_team_member_promotion.html.heex b/lib/plausible_web/templates/email/guest_to_team_member_promotion.html.heex index 807079510b9e..fda3d6c9bbc4 100644 --- a/lib/plausible_web/templates/email/guest_to_team_member_promotion.html.heex +++ b/lib/plausible_web/templates/email/guest_to_team_member_promotion.html.heex @@ -1,2 +1,5 @@ {@inviter.email} has promoted you to a team member in the "{@team.name}" team on {Plausible.product_name()}. - "?__team=#{@team.identifier}"}>Click here to view sites managed by the team. + "?__team=#{@team.identifier}"}> + Click here + +to view sites managed by the team. diff --git a/lib/plausible_web/templates/email/new_user_invitation.html.heex b/lib/plausible_web/templates/email/new_user_invitation.html.heex index 32e3984d4b52..6c58b52d5fd4 100644 --- a/lib/plausible_web/templates/email/new_user_invitation.html.heex +++ b/lib/plausible_web/templates/email/new_user_invitation.html.heex @@ -5,6 +5,10 @@ :register_from_invitation_form, @invitation_id ) -}>Click here to create your account. The link is valid for 48 hours after this email is sent. +}> + Click here + +to create your account. The link is valid for 48 hours after this email is sent.

    +If this invitation has expired, please contact the person who invited you to ask them to resend it.

    Plausible is a lightweight and open-source website analytics tool. We hope you like our simple and ethical approach to tracking website visitors. diff --git a/lib/plausible_web/templates/email/new_user_team_invitation.html.heex b/lib/plausible_web/templates/email/new_user_team_invitation.html.heex index bbf0cc9d495a..f7487af1e7db 100644 --- a/lib/plausible_web/templates/email/new_user_team_invitation.html.heex +++ b/lib/plausible_web/templates/email/new_user_team_invitation.html.heex @@ -5,6 +5,10 @@ :register_from_invitation_form, @invitation_id ) -}>Click here to create your account. The link is valid for 48 hours after this email is sent. +}> + Click here + +to create your account. The link is valid for 48 hours after this email is sent.

    +If the invitation has expired, please contact the person who invited you and ask them to send a new one.

    Plausible is a lightweight and open-source website analytics tool. We hope you like our simple and ethical approach to tracking website visitors. diff --git a/lib/plausible_web/templates/email/over_limit.html.heex b/lib/plausible_web/templates/email/over_limit.html.heex index f14fa65fed95..ff020d6dffe1 100644 --- a/lib/plausible_web/templates/email/over_limit.html.heex +++ b/lib/plausible_web/templates/email/over_limit.html.heex @@ -10,10 +10,10 @@ During the last billing cycle ({PlausibleWeb.TextHelpers.format_date_range( )}), your account used {PlausibleWeb.AuthView.delimit_integer(@usage.penultimate_cycle.total)} billable pageviews. Note that billable pageviews include both standard pageviews and custom events. In your PlausibleWeb.Router.Helpers.settings_path(PlausibleWeb.Endpoint, :subscription) <> "?__team=#{@team.identifier}"}>account settings, you'll find an overview of your usage and limits.

    -<%= if @suggested_plan == :enterprise do %> +<%= if @suggested_volume == :enterprise do %> Your usage exceeds our standard plans, so please reply back to this email for a tailored quote. <% else %> - "?__team=#{@team.identifier}"}>Click here to upgrade your subscription. We recommend you upgrade to the {@suggested_plan.volume}/mo plan. The new charge will be prorated to reflect the amount you have already paid and the time until your current subscription is supposed to expire. + "?__team=#{@team.identifier}"}>Click here to upgrade your subscription. We recommend you upgrade to the {@suggested_volume} pageviews/month plan. The new charge will be prorated to reflect the amount you have already paid and the time until your current subscription is supposed to expire.

    If your usage decreases in the future, you can switch to a lower plan at any time. Any credit balance will automatically apply to future payments. <% end %> diff --git a/lib/plausible_web/templates/email/ownership_transfer_rejected.html.heex b/lib/plausible_web/templates/email/ownership_transfer_rejected.html.heex index 085a2b62ef67..9950537900ef 100644 --- a/lib/plausible_web/templates/email/ownership_transfer_rejected.html.heex +++ b/lib/plausible_web/templates/email/ownership_transfer_rejected.html.heex @@ -1,2 +1,5 @@ {@site_transfer.email} has rejected the ownership transfer of {@site_transfer.site.domain}. - "?__team=#{@team.identifier}"}>Click here to view site settings. + "?__team=#{@team.identifier}"}> + Click here + +to view site settings. diff --git a/lib/plausible_web/templates/email/site_setup_help_email.html.heex b/lib/plausible_web/templates/email/site_setup_help_email.html.heex index 2d77b06f64ee..0d752bc52597 100644 --- a/lib/plausible_web/templates/email/site_setup_help_email.html.heex +++ b/lib/plausible_web/templates/email/site_setup_help_email.html.heex @@ -1,14 +1,10 @@ -<%= if ee?() and Plausible.Teams.on_trial?(@site_team) do %> - You signed up for a free 30-day trial of Plausible, a simple and privacy-friendly website analytics tool. -

    -<% end %> -To finish your setup for {@site.domain}, review -your installation and start collecting visitor statistics. -

    -This Plausible script is 45 times smaller than Google Analytics script so you’ll have a fast loading site while getting all the important traffic insights on one single page. -

    On WordPress? We have a -WordPress plugin that makes the process simpler. We also have -integration guides for different site builders to help you start counting stats in no time. -<%= if ee?() do %> -

    Do reply back to this email if you have any questions or need some guidance. -<% end %> +We haven't recorded any traffic for {@site.domain} yet.

    +This usually means Plausible has not been connected to your website or the tracking code has not been added correctly.

    +To finish your setup, check + + your installation +.

    +If something still doesn't look right, see our troubleshooting guide:
    + + https://plausible.io/docs/troubleshoot-integration + diff --git a/lib/plausible_web/templates/email/site_setup_success_email.html.heex b/lib/plausible_web/templates/email/site_setup_success_email.html.heex index 72e539aceeda..a9420a7aaa01 100644 --- a/lib/plausible_web/templates/email/site_setup_success_email.html.heex +++ b/lib/plausible_web/templates/email/site_setup_success_email.html.heex @@ -1,18 +1,4 @@ -Congrats! We've recorded the first visitor on -<%= @site.domain %>. Your traffic is now being counted without compromising the user experience and privacy of your visitors. -

    -Do check out your easy to use, fast-loading and privacy-friendly dashboard. -

    -Something looks off? Take a look at our installation troubleshooting guide. -

    -<%= if (ee?() and Plausible.Teams.on_trial?(@site_team)) do %> - You're on a 30-day free trial with no obligations so do take your time to explore Plausible. - Here's how to get the most out of your Plausible experience. -

    -<% end %> -PS: You can import your historical Google Analytics stats into your Plausible dashboard. -Learn how our GA importer works. -

    -<%= unless Plausible.ce?() do %> - Do reply back to this email if you have any questions. We're here to help. -<% end %> +Your first visitor is now visible in Plausible.

    +See where visitors come from, which pages they visit and which goals they complete, all in one simple dashboard.

    +Everything is designed to be understood at a glance. No custom reports. No digging through menus.

    +View your Plausible dashboard diff --git a/lib/plausible_web/templates/email/spike_notification.html.heex b/lib/plausible_web/templates/email/spike_notification.html.heex index 4db4ca3a3530..32ba841876c5 100644 --- a/lib/plausible_web/templates/email/spike_notification.html.heex +++ b/lib/plausible_web/templates/email/spike_notification.html.heex @@ -1,5 +1,11 @@ There are currently {@current_visitors} -visitors on @site.domain}><%= @site.domain %>.
    +visitors +<%= if Plausible.Sites.consolidated?(@site) do %> + across all your sites. +<% else %> + on @site.domain}>{@site.domain}. +<% end %> +
    <%= if Enum.count(@sources) > 0 do %>
    The top sources for current visitors:
    <%= if @link do %> -
    View dashboard: {@link} +
    View dashboard here <% end %>

    Congrats on the spike in traffic! <%= if ce?() do %> diff --git a/lib/plausible_web/templates/email/sso_domain_verification_failure.html.heex b/lib/plausible_web/templates/email/sso_domain_verification_failure.html.heex new file mode 100644 index 000000000000..09fdfce5d9ca --- /dev/null +++ b/lib/plausible_web/templates/email/sso_domain_verification_failure.html.heex @@ -0,0 +1,3 @@ +We were unable to verify the SSO domain '{@domain}' you attempted to set up. Despite multiple attempts, the validation process could not be completed and we have exhausted all automatic retries.
    +Please review your domain configuration and follow our configuration guide to resolve common issues: +SSO Documentation. diff --git a/lib/plausible_web/templates/email/sso_domain_verification_success.html.heex b/lib/plausible_web/templates/email/sso_domain_verification_success.html.heex new file mode 100644 index 000000000000..1fca7f869938 --- /dev/null +++ b/lib/plausible_web/templates/email/sso_domain_verification_success.html.heex @@ -0,0 +1,2 @@ +We are pleased to inform you that your Single Sign-On (SSO) domain '{@domain}' has been successfully verified and is now ready for use.
    +You can now enable SSO for your organization and allow users to sign in using their corporate credentials. diff --git a/lib/plausible_web/templates/email/team_changed.html.heex b/lib/plausible_web/templates/email/team_changed.html.heex index 4ce1b43e73cd..64a49751ba5b 100644 --- a/lib/plausible_web/templates/email/team_changed.html.heex +++ b/lib/plausible_web/templates/email/team_changed.html.heex @@ -1,2 +1,5 @@ {@user.email} has transferred {@site.domain} to the "{@team.name}" team on Plausible Analytics. - "?__team=#{@team.identifier}"}>Click here to view the stats. + "?__team=#{@team.identifier}"}> + Click here + +to view the stats. diff --git a/lib/plausible_web/templates/email/team_invitation_accepted.html.heex b/lib/plausible_web/templates/email/team_invitation_accepted.html.heex index 1504cf2ed952..47a4e5008ed4 100644 --- a/lib/plausible_web/templates/email/team_invitation_accepted.html.heex +++ b/lib/plausible_web/templates/email/team_invitation_accepted.html.heex @@ -1,2 +1,5 @@ {@invitee_email} has accepted your invitation to "{@team.name}" team. - "?__team=#{@team.identifier}"}>Click here to view team settings. + "?__team=#{@team.identifier}"}> + Click here + +to view team settings. diff --git a/lib/plausible_web/templates/email/team_invitation_rejected.html.heex b/lib/plausible_web/templates/email/team_invitation_rejected.html.heex index 892bd692f2e7..4290c2cbc770 100644 --- a/lib/plausible_web/templates/email/team_invitation_rejected.html.heex +++ b/lib/plausible_web/templates/email/team_invitation_rejected.html.heex @@ -1,2 +1,5 @@ {@team_invitation.email} has rejected your invitation to \"{@team.name}\" team. - "?__team=#{@team.identifier}"}>Click here to view team settings. + "?__team=#{@team.identifier}"}> + Click here + +to view team settings. diff --git a/lib/plausible_web/templates/email/team_member_left.html.heex b/lib/plausible_web/templates/email/team_member_left.html.heex new file mode 100644 index 000000000000..e1a2a5cada65 --- /dev/null +++ b/lib/plausible_web/templates/email/team_member_left.html.heex @@ -0,0 +1,3 @@ +You are no longer a member of "{@team_membership.team.name}" team.

    + "?__team=none"}>Click here +to view your sites. diff --git a/lib/plausible_web/templates/email/trial_ending_today.html.heex b/lib/plausible_web/templates/email/trial_ending_today.html.heex new file mode 100644 index 000000000000..a6245a43ee30 --- /dev/null +++ b/lib/plausible_web/templates/email/trial_ending_today.html.heex @@ -0,0 +1,15 @@ +Your Plausible trial ends today.

    +So far, your account has used {PlausibleWeb.AuthView.delimit_integer(@usage)} billable pageviews{if @custom_events > + 0, + do: + " and custom events in total", + else: + ""}.

    +<%= if @suggested_volume == :enterprise do %> + This exceeds our standard plans. Once the trial ends, your dashboard will be locked. Please reply to this email and we'll prepare a custom plan for your volume. +<% else %> + To keep your dashboard unlocked and your stats collecting without interruption, select the {@suggested_volume} pageviews/month plan.

    + "?__team=#{@team.identifier}"}> + Upgrade your account + +<% end %> diff --git a/lib/plausible_web/templates/email/trial_ending_tomorrow.html.heex b/lib/plausible_web/templates/email/trial_ending_tomorrow.html.heex new file mode 100644 index 000000000000..2e97db970332 --- /dev/null +++ b/lib/plausible_web/templates/email/trial_ending_tomorrow.html.heex @@ -0,0 +1,17 @@ +Your Plausible trial ends tomorrow.

    +Over the last 30 days, your account has used {PlausibleWeb.AuthView.delimit_integer(@usage)} billable pageviews{if @custom_events > + 0, + do: + " and custom events in total", + else: + ""}.

    +<%= if @suggested_volume == :enterprise do %> + This exceeds our standard plans. Please reply to this email and we’ll prepare a custom plan for your volume. +<% else %> + Based on your usage, the {@suggested_volume} pageviews/month plan would be the right fit.

    + You can + "?__team=#{@team.identifier}"}> + upgrade your account + + at any time to continue without interruption. +<% end %> diff --git a/lib/plausible_web/templates/email/trial_one_week_reminder.html.heex b/lib/plausible_web/templates/email/trial_one_week_reminder.html.heex index 03d056a4f75e..0e509a6357fa 100644 --- a/lib/plausible_web/templates/email/trial_one_week_reminder.html.heex +++ b/lib/plausible_web/templates/email/trial_one_week_reminder.html.heex @@ -1,6 +1,8 @@ -Time flies! Your 30-day free trial of Plausible will end next week.

    -Over the last three weeks, We hope you got to experience the potential benefits of having website stats in an easy to use dashboard while respecting the privacy of your visitors, not annoying them with the cookie and privacy notices and still having a fast loading site. -

    -In order to continue receiving valuable website traffic insights at a glance, you’ll need to - "?__team=#{@team.identifier}"}>upgrade your account. -

    If you have any questions or feedback for us, feel free to reply to this email. +Your Plausible trial ends in a week.

    +If Plausible has become part of how you check traffic and measure results, you can continue without interruption by choosing a subscription plan.

    +Everything you've set up will remain exactly as it is. No migration. No changes needed.

    +You can + "?__team=#{@team.identifier}"}> + upgrade your account + +at any time. diff --git a/lib/plausible_web/templates/email/trial_over_email.html.heex b/lib/plausible_web/templates/email/trial_over_email.html.heex index de730c4dabdf..48dc08538d4b 100644 --- a/lib/plausible_web/templates/email/trial_over_email.html.heex +++ b/lib/plausible_web/templates/email/trial_over_email.html.heex @@ -1,7 +1,8 @@ -Your free Plausible trial has now expired. Upgrade your account to continue receiving valuable website traffic insights at a glance while respecting the privacy of your visitors and still having a fast loading site. -

    - +Your Plausible trial has expired and your dashboard is now locked.

    +We’re still recording visitor stats in the background, so nothing is lost while you decide.

    +When you’re ready, upgrade your account to unlock your dashboard and continue without interruption.

    "?__team=#{@team.identifier}"}> - Upgrade now + Upgrade your account -

    We will keep recording stats for {@extra_offset} days to give you time to upgrade. +

    +We’ll keep recording stats for {@extra_offset} more days. If you upgrade during this time, your dashboard will simply unlock and continue as normal. diff --git a/lib/plausible_web/templates/email/trial_upgrade_email.html.heex b/lib/plausible_web/templates/email/trial_upgrade_email.html.heex index d7536a6a94f3..77843ee9ec85 100644 --- a/lib/plausible_web/templates/email/trial_upgrade_email.html.heex +++ b/lib/plausible_web/templates/email/trial_upgrade_email.html.heex @@ -6,10 +6,10 @@ In the last month, your account has used {PlausibleWeb.AuthView.delimit_integer( " and custom events in total", else: ""}. -<%= if @suggested_plan == :enterprise do %> +<%= if @suggested_volume == :enterprise do %> This is more than our standard plans, so please reply back to this email to get a quote for your volume. <% else %> - Based on that we recommend you select a {@suggested_plan.volume}/mo plan.

    + Based on that we recommend you select a {@suggested_volume} pageviews/month plan.

    "?__team=#{@team.identifier}"}> Upgrade now diff --git a/lib/plausible_web/templates/email/welcome_email.html.heex b/lib/plausible_web/templates/email/welcome_email.html.heex index 4b118e1b6e20..c9f69f6d6643 100644 --- a/lib/plausible_web/templates/email/welcome_email.html.heex +++ b/lib/plausible_web/templates/email/welcome_email.html.heex @@ -1,19 +1,11 @@ -We are building Plausible to provide a simple and ethical approach to tracking website visitors. -We're super excited to have you on board!

    -Here's how to get the most out of your Plausible experience:

    * -Enable email reports and notifications for -traffic spikes -
    * -Integrate with Search Console to get keyword phrases people find your site with
    -* Invite team members and other collaborators -
    * Set up easy goals including -404 error pages, -file downloads and -outbound link clicks -
    * Opt out from counting your own visits -
    * If you're concerned about adblockers, -set up a proxy to bypass them -
    -

    Then you're ready to start exploring your fast loading, ethical and actionable -Plausible dashboard.

    -Have a question, feedback or need some guidance? Do reply back to this email. +Welcome to Plausible.

    +You can go from signup to seeing your first visitor in just a few minutes.

    +Add your site and start tracking. That's it.

    +Once installed, your dashboard will start updating in real time. No cookies. No consent banners. No configuration required.

    +If you need help installing Plausible, see our integration guide:
    + + https://plausible.io/docs/troubleshoot-integration + +

    +Open your Plausible dashboard +

    Enjoy exploring Plausible. diff --git a/lib/plausible_web/templates/email/yearly_expiration_notification.html.heex b/lib/plausible_web/templates/email/yearly_expiration_notification.html.heex index 4417c4ed66b7..6f3d382ca140 100644 --- a/lib/plausible_web/templates/email/yearly_expiration_notification.html.heex +++ b/lib/plausible_web/templates/email/yearly_expiration_notification.html.heex @@ -1,6 +1,9 @@ Time flies! This is a reminder that your annual subscription for {Plausible.product_name()} will expire on {@next_bill_date}.

    You need to - "?__team=#{@team.identifier}"}>renew your subscription if you want to continue using Plausible to count your website stats in a privacy-friendly way. + "?__team=#{@team.identifier}"}> + renew your subscription + +if you want to continue using Plausible to count your website stats in a privacy-friendly way.

    If you don't want to continue your subscription, there's no action required. You will lose access to your dashboard on {@next_bill_date} and we'll stop accepting stats on {@accept_traffic_until}.

    diff --git a/lib/plausible_web/templates/error/404_error.html.heex b/lib/plausible_web/templates/error/404_error.html.heex index 2387750d7924..de173b95831b 100644 --- a/lib/plausible_web/templates/error/404_error.html.heex +++ b/lib/plausible_web/templates/error/404_error.html.heex @@ -13,7 +13,7 @@ > Log in - <.button_link theme="bright" href={PlausibleWeb.LayoutView.home_dest(@conn)}> + <.button_link theme="secondary" href={PlausibleWeb.LayoutView.home_dest(@conn)}> Go to homepage
    diff --git a/lib/plausible_web/templates/error/server_error.html.heex b/lib/plausible_web/templates/error/server_error.html.heex index dee3ebaa0153..dd9d66cda29e 100644 --- a/lib/plausible_web/templates/error/server_error.html.heex +++ b/lib/plausible_web/templates/error/server_error.html.heex @@ -1,10 +1,10 @@
    -
    +

    Oops, sorry about that...

    -
    +
    diff --git a/lib/plausible_web/templates/error/server_error_report_thanks.html.heex b/lib/plausible_web/templates/error/server_error_report_thanks.html.heex index e9239a3bb443..8280119cecb0 100644 --- a/lib/plausible_web/templates/error/server_error_report_thanks.html.heex +++ b/lib/plausible_web/templates/error/server_error_report_thanks.html.heex @@ -1,5 +1,5 @@
    -
    +

    Thank you!

    diff --git a/lib/plausible_web/templates/google_analytics/confirm.html.heex b/lib/plausible_web/templates/google_analytics/confirm.html.heex index 7a91216fefed..578c07f18a85 100644 --- a/lib/plausible_web/templates/google_analytics/confirm.html.heex +++ b/lib/plausible_web/templates/google_analytics/confirm.html.heex @@ -1,7 +1,7 @@ <.form :let={f} for={@conn} - class="max-w-md w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8" + class="max-w-md w-full mx-auto bg-white dark:bg-gray-900 shadow-md rounded-sm px-8 pt-6 pb-8 mb-4 mt-8" onsubmit="confirmButton.disabled = true; return true;" action={Routes.google_analytics_path(@conn, :import, @site.domain)} > diff --git a/lib/plausible_web/templates/layout/_flash.html.heex b/lib/plausible_web/templates/layout/_flash.html.heex index 37bc021e3627..bcd8719a6661 100644 --- a/lib/plausible_web/templates/layout/_flash.html.heex +++ b/lib/plausible_web/templates/layout/_flash.html.heex @@ -12,10 +12,10 @@ x-transition:leave-end="opacity-0" class="max-w-sm w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto" > -
    +
    -
    +
    -
    +
    12}> - - - - - <% end %> - - diff --git a/lib/plausible_web/templates/settings/new_api_key.html.heex b/lib/plausible_web/templates/settings/new_api_key.html.heex index 96bcac8aaaa4..5cfbee0fd81a 100644 --- a/lib/plausible_web/templates/settings/new_api_key.html.heex +++ b/lib/plausible_web/templates/settings/new_api_key.html.heex @@ -1,24 +1,80 @@ <.focus_box> <:title>Create new API key - <.form :let={f} for={@changeset} action={Routes.settings_path(@conn, :api_keys)}> - <.input type="text" field={f[:name]} label="Name" placeholder="Development" /> - -
    - <.input_with_clipboard id="key-input" name="api_key[key]" label="Key" value={f[:key].value} /> - - <.error :for={ - msg <- Enum.map(f[:key].errors, &PlausibleWeb.Live.Components.Form.translate_error/1) - }> - {msg} - - -

    - Make sure to store the key in a secure place. Once created, we will not be able to show it again. -

    -
    - <.button type="submit" class="w-full"> - Continue - - +
    + <.form :let={f} for={@changeset} action={Routes.settings_path(@conn, :api_keys)}> + <.input type="text" field={f[:name]} label="Name" placeholder="Development" /> + + + +
    +
    + <.label>Type +
    + + <.input + x-on:click="submitDisabled = false" + type="radio" + class="block h-5 w-5 dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600" + id={f[:type].id <> "_0"} + name={f[:type].name} + value="stats_api" + checked={f[:type].value == "stats_api"} + label="Stats API" + > + <:help_content> + Full access to + <.styled_link href="https://plausible.io/docs/stats-api">Stats API + + + + <.input + x-on:click={"submitDisabled = " <> if(@sites_api_enabled?, do: "false", else: "true")} + type="radio" + id={f[:type].id <> "_1"} + name={f[:type].name} + value="sites_api" + checked={f[:type].value == "sites_api"} + label="Sites API" + > + <:help_content> + Full access to + <.styled_link href="https://plausible.io/docs/stats-api">Stats API + and <.styled_link href="https://plausible.io/docs/sites-api">Sites API + + + +
    + +
    + Your current subscription plan does not include Sites API access. + Contact us + if interested. +
    +
    +
    + +
    + <.input_with_clipboard + id="key-input" + name="api_key[key]" + label="Key" + value={f[:key].value} + /> + + <.error :for={ + msg <- Enum.map(f[:key].errors, &PlausibleWeb.Live.Components.Form.translate_error/1) + }> + {msg} + + +

    + Make sure to store the key in a secure place. Once created, we will not be able to show it again. +

    +
    + <.button type="submit" class="w-full" x-bind:disabled="submitDisabled"> + Create API key + + +
    diff --git a/lib/plausible_web/templates/settings/preferences.html.heex b/lib/plausible_web/templates/settings/preferences.html.heex index 2623fff2b593..4bf2c054d468 100644 --- a/lib/plausible_web/templates/settings/preferences.html.heex +++ b/lib/plausible_web/templates/settings/preferences.html.heex @@ -1,32 +1,35 @@ <.settings_tiles> - <.tile> + <.tile :if={Plausible.Users.type(@current_user) == :standard}> <:title> - Your Name + Your name - <:subtitle> - Change the name associated with your account - <.form :let={f} action={Routes.settings_path(@conn, :update_name)} for={@name_changeset} method="post" > - <.input type="text" field={f[:name]} label="Name" width="w-1/2" /> + <.input type="text" field={f[:name]} label="Name" width="w-1/2" mt?={false} /> <.button type="submit"> - Change Name + Change name + <.tile :if={Plausible.Users.type(@current_user) == :sso}> + <:title> + Your name + + <.form :let={f} for={@name_changeset}> + <.input type="text" field={f[:name]} disabled={true} label="Name" width="w-1/2" mt?={false} /> + + + <.tile docs="dashboard-appearance"> <:title> - Dashboard Appearance + Appearance - <:subtitle> - Set your visual preferences - <.form :let={f} action={Routes.settings_path(@conn, :update_theme)} @@ -39,10 +42,11 @@ options={Plausible.Themes.options()} label="Theme" width="w-1/2" + mt?={false} /> <.button type="submit"> - Change Theme + Change theme diff --git a/lib/plausible_web/templates/settings/security.html.heex b/lib/plausible_web/templates/settings/security.html.heex index 92285a64b793..91b1a3784542 100644 --- a/lib/plausible_web/templates/settings/security.html.heex +++ b/lib/plausible_web/templates/settings/security.html.heex @@ -1,10 +1,10 @@ <.settings_tiles> - <.tile docs="change-email"> + <.tile :if={Plausible.Users.type(@current_user) == :standard} docs="change-email"> <:title> - Email Address + Email address <:subtitle> - Change the address associated with your account + Change the address associated with your account. <.form :let={f} @@ -16,27 +16,48 @@ type="text" name="user[current_email]" value={f.data.email} - label="Current Email" + label="Current email" width="w-1/2" + mt?={false} disabled /> - <.input type="text" field={f[:email]} label="New E-mail" width="w-1/2" /> + <.input type="email" field={f[:email]} label="New email" width="w-1/2" /> - <.input type="password" field={f[:password]} label="Account Password" width="w-1/2" /> + <.input type="password" field={f[:password]} label="Confirm with password" width="w-1/2" /> <.button type="submit"> - Change Email + Change email - <.tile docs="reset-password"> + <.tile :if={Plausible.Users.type(@current_user) == :sso}> + <:title> + Email address + + <:subtitle> + Address associated with your account. + + <.form :let={f} for={@email_changeset}> + <.input + type="text" + name="user[current_email]" + value={f.data.email} + label="Current email" + width="w-1/2" + mt?={false} + disabled + /> + + + + <.tile :if={Plausible.Users.type(@current_user) == :standard} docs="reset-password"> <:title> Password <:subtitle> - Change your password + Change your password. <.form :let={f} @@ -48,15 +69,16 @@ type="password" max_one_error field={f[:old_password]} - label="Old Password" + label="Old password" width="w-1/2" + mt?={false} /> <.input type="password" max_one_error field={f[:password]} - label="New Password" + label="New password" width="w-1/2" /> @@ -65,7 +87,7 @@ max_one_error autocomplete="new-password" field={f[:password_confirmation]} - label="Confirm New Password" + label="Confirm new password" width="w-1/2" /> @@ -81,7 +103,7 @@ <.button type="submit"> - Change Password + Change password @@ -91,12 +113,13 @@ Two-Factor Authentication (2FA) <:subtitle> - Two-Factor Authentication protects your account by adding an extra security step when you log in + Protect your account by adding an extra security step when you log in.
    <.button + disabled={Plausible.Users.type(@current_user) == :sso} x-on:click="disable2FAOpen = true; $refs.disable2FAPassword.value = ''" theme="danger" mt?={false} @@ -205,7 +228,7 @@ <.input type="password" id="regenerate_2fa_password" - name="regenerate_2fa_password" + name="password" value="" placeholder="Enter password" x-ref="regenerate2FAPassword" @@ -217,10 +240,10 @@ <.tile docs="login-management"> <:title> - Login Management + Login management <:subtitle> - Log out of your account on other devices. Note that logged-in sessions automatically expire after 14 days of inactivity + Log out other devices. Inactive sessions auto-expire after 14 days. <.table rows={@user_sessions}> diff --git a/lib/plausible_web/templates/settings/subscription.html.heex b/lib/plausible_web/templates/settings/subscription.html.heex deleted file mode 100644 index 3d0c41169280..000000000000 --- a/lib/plausible_web/templates/settings/subscription.html.heex +++ /dev/null @@ -1,104 +0,0 @@ -<.settings_tiles> - <.tile docs="billing"> - <:title> - Subscription - - <:subtitle> - Manage your plan - -
    - - Business - - - {present_subscription_status(@subscription.status)} - -
    - - - -
    - -
    -

    Next bill amount

    - <%= if Plausible.Billing.Subscription.Status.in?(@subscription, [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()]) do %> -
    - {PlausibleWeb.BillingView.present_currency(@subscription.currency_code)}{@subscription.next_bill_amount} -
    - <.styled_link :if={@subscription.update_url} href={@subscription.update_url}> - Update billing info - - <% else %> -
    ---
    - <% end %> -
    -
    -

    Next bill date

    - - <%= if @subscription && @subscription.next_bill_date && Plausible.Billing.Subscription.Status.in?(@subscription, [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()]) do %> -
    - {Calendar.strftime(@subscription.next_bill_date, "%b %-d, %Y")} -
    - - ({subscription_interval(@subscription)} billing) - - <% else %> -
    ---
    - <% end %> -
    -
    - - - -
    - <.title>Sites & team members usage - - - - -
    - - <%= cond do %> - <% Plausible.Billing.Subscriptions.resumable?(@subscription) && @subscription.cancel_url -> %> -
    - <.button_link theme="danger" href={@subscription.cancel_url}> - Cancel my subscription - -
    - <% true -> %> -
    - -
    - <% end %> - - diff --git a/lib/plausible_web/templates/settings/team_danger_zone.html.heex b/lib/plausible_web/templates/settings/team_danger_zone.html.heex index acc6f3b2fd6c..552c430cb6f3 100644 --- a/lib/plausible_web/templates/settings/team_danger_zone.html.heex +++ b/lib/plausible_web/templates/settings/team_danger_zone.html.heex @@ -1,11 +1,11 @@ -<.notice title="Danger Zone" theme={:red}> +<.notice title="Danger zone" theme={:red}> Destructive actions below can result in irrecoverable data loss. Be careful. <.settings_tiles> <.tile docs="delete-team"> - <:title>Delete Team - <:subtitle>Deleting the team removes all associated sites and collected stats + <:title>Delete team + <:subtitle>Remove all associated sites and collected stats. <%= if Plausible.Billing.Subscription.Status.active?(@current_team && @current_team.subscription) do %> <.notice theme={:gray} title="Cannot delete the team at this time"> @@ -17,6 +17,7 @@ href={Routes.settings_path(@conn, :delete_team)} method="delete" theme="danger" + mt?={false} > Delete "{Plausible.Teams.name(@current_team)}" diff --git a/lib/plausible_web/templates/settings/team_general.html.heex b/lib/plausible_web/templates/settings/team_general.html.heex index e8585895a09d..e4b4e069aaa6 100644 --- a/lib/plausible_web/templates/settings/team_general.html.heex +++ b/lib/plausible_web/templates/settings/team_general.html.heex @@ -1,10 +1,10 @@ <.settings_tiles> <.tile docs="users-roles"> <:title> - Team Information + Team name <:subtitle> - Change the name of your team + Change the name of your team. <.form :let={f} @@ -18,23 +18,122 @@ field={f[:name]} label="Name" width="w-1/2" + mt?={false} /> <.button type="submit" disabled={@current_team_role not in [:owner, :admin]}> - Change Name + Change name - <.tile docs="users-roles#managing-team-member-roles"> + <.tile + docs="users-roles#managing-team-member-roles" + current_user={@current_user} + current_team={@current_team} + feature_mod={Plausible.Billing.Feature.Teams} + > <:title> - Team Members + Team members <:subtitle> - Add or remove team members and adjust their roles + Add or remove team members and adjust their roles. {live_render(@conn, PlausibleWeb.Live.TeamManagement, id: "team-setup", session: %{"mode" => "team-management"} )} + <.tile :if={@current_team_role == :owner} docs="2fa#require-all-team-members-to-enable-2fa"> + <:title> + Force Two-Factor Authentication (2FA) + + <:subtitle> + Increase account security by requiring all team members to enable 2FA. + + +
    + <.form + action={Routes.settings_path(@conn, :disable_team_force_2fa)} + for={@conn.params} + method="post" + > + <.button + theme="danger" + x-on:click="disableTeamForce2FAOpen = true; $refs.disableTeamForce2FAPassword.value = ''" + mt?={false} + > + Stop Forcing 2FA + + + + <:icon> + + + <:buttons> + <.button type="submit" class="w-full sm:w-auto"> + Stop enforcing 2FA + + + +
    + This will remove the 2FA requirement for all team members. +
    + +
    + Enter your password to stop enforcing 2FA. +
    + +
    + <.input + type="password" + id="disable_team_force_2fa_password" + name="password" + value="" + placeholder="Enter password" + x-ref="disableTeamForce2FAPassword" + /> +
    +
    + +
    + +
    + <.form + action={Routes.settings_path(@conn, :enable_team_force_2fa)} + for={@conn.params} + method="post" + > + <.button + data-confirm="All team members, including you, will need to set up 2FA. Are you sure you want to enforce it?" + type="submit" + mt?={false} + > + Enforce 2FA + + +
    + + <.tile docs="users-roles#leaving-team"> + <:title>Leave team + <:subtitle>Remove yourself from this team as a member. + <.button_link + data-confirm="Are you sure you want to leave this team?" + href={Routes.settings_path(@conn, :leave_team)} + method="post" + theme="danger" + mt?={false} + > + Leave team + + diff --git a/lib/plausible_web/templates/site/change_domain.html.heex b/lib/plausible_web/templates/site/change_domain.html.heex deleted file mode 100644 index 0d05408126d9..000000000000 --- a/lib/plausible_web/templates/site/change_domain.html.heex +++ /dev/null @@ -1,50 +0,0 @@ - -<.focus_box> - <:title>Change your website domain - - <:subtitle> - Once you change your domain, you must - update Plausible Installation on your site within 72 hours to guarantee continuous tracking. -

    If you're using the API, please also make sure to update your API credentials. Visit our - <.styled_link new_tab href="https://plausible.io/docs/change-domain-name/"> - documentation - - for details. - - - <:footer> - <.focus_list> - <:item> - Changed your mind? Go back to - <.styled_link href={Routes.site_path(@conn, :settings_general, @site.domain)}> - Site Settings - - - - - - <.form - :let={f} - for={@changeset} - action={ - Routes.site_path(@conn, :change_domain_submit, @site.domain, - flow: PlausibleWeb.Flows.domain_change() - ) - } - > - <.input - help_text="Just the naked domain or subdomain without 'www', 'https' etc." - type="text" - placeholder="example.com" - field={f[:domain]} - label="Domain" - /> - - <.button type="submit" class="mt-4 w-full"> - Change Domain and add new Snippet - - - diff --git a/lib/plausible_web/templates/site/edit_shared_link.html.heex b/lib/plausible_web/templates/site/edit_shared_link.html.heex deleted file mode 100644 index b448597c5a91..000000000000 --- a/lib/plausible_web/templates/site/edit_shared_link.html.heex +++ /dev/null @@ -1,14 +0,0 @@ -<.focus_box> - <:title>Edit Shared Link - - <.form - :let={f} - for={@changeset} - action={"/sites/#{URI.encode_www_form(@site.domain)}/shared-links/#{@changeset.data.slug}"} - class="" - > - <.input type="text" field={f[:name]} label="Name" /> - - <.button class="w-full" type="submit">Update - - diff --git a/lib/plausible_web/templates/site/membership/invite_member_form.html.heex b/lib/plausible_web/templates/site/membership/invite_member_form.html.heex index 8e2a05433bb7..0b124747ae6a 100644 --- a/lib/plausible_web/templates/site/membership/invite_member_form.html.heex +++ b/lib/plausible_web/templates/site/membership/invite_member_form.html.heex @@ -4,17 +4,18 @@ <:subtitle> - Enter the email address and role of the person you want to invite. We will contact them over email to offer them access to {@site.domain} analytics.

    - The invitation will expire in 48 hours + Enter their email and role, and we'll email them an invite to join + {@site.domain} + analytics. Invitations expire after 48 hours. <.form :let={f} for={@conn} action={Routes.membership_path(@conn, :invite_member, @site.domain)}>
    @@ -31,13 +32,13 @@ <% end %>
    -
    +
    <.label for={f[:role].id}> Role -
    +
    - <.button_link href={} theme="bright" x-on:click="showAll = true" x-show="!showAll"> - Show More - -