diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 4af802f0e5612..0000000000000 --- a/.coveragerc +++ /dev/null @@ -1,10 +0,0 @@ -[run] -source = - posthog/ - ee/ - -branch = true - -omit = - */migrations/* - manage.py diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index bdcdfe942e552..0000000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,26 +0,0 @@ -version = 1 - -test_patterns = [ - "**/test_*.py", -] - -exclude_patterns = [ - "**/migrations/*.py", -] - -[[analyzers]] -name = "python" -enabled = true - - [analyzers.meta] - runtime_version = "3.x.x" - -[[analyzers]] -name = "docker" -enabled = true - - [analyzers.meta] - dockerfile_paths = [ - "preview.Dockerfile", - "production.Dockerfile", - ] diff --git a/.dockerignore b/.dockerignore index 4448c2a7d88d1..14f0bba5c33fb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,3 +33,6 @@ !plugin-server/.prettierrc !share/GeoLite2-City.mmdb !hogvm/python +!unit.json +!plugin-transpiler/src +!plugin-transpiler/*.* diff --git a/.environment b/.environment deleted file mode 100644 index c678756de8d30..0000000000000 --- a/.environment +++ /dev/null @@ -1 +0,0 @@ -export SECRET_KEY=$PLATFORM_PROJECT_ENTROPY \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 9a308618f1b66..9d6792f0fd652 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,27 +1,40 @@ /* global module */ + +const env = { + browser: true, + es6: true, + 'cypress/globals': true, +} + +const globals = { + Atomics: 'readonly', + SharedArrayBuffer: 'readonly', +} + module.exports = { ignorePatterns: ['node_modules', 'plugin-server'], - env: { - browser: true, - es6: true, - 'cypress/globals': true, - }, + env, settings: { react: { version: 'detect', }, + 'import/resolver': { + node: { + paths: ['eslint-rules'], // Add the directory containing your custom rules + extensions: ['.js', '.jsx', '.ts', '.tsx'], // Ensure ESLint resolves both JS and TS files + }, + }, }, extends: [ + 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', 'plugin:eslint-comments/recommended', 'plugin:storybook/recommended', 'prettier', + 'plugin:compat/recommended', ], - globals: { - Atomics: 'readonly', - SharedArrayBuffer: 'readonly', - }, + globals, parser: '@typescript-eslint/parser', parserOptions: { ecmaFeatures: { @@ -30,7 +43,7 @@ module.exports = { ecmaVersion: 2018, sourceType: 'module', }, - plugins: ['prettier', 'react', 'cypress', '@typescript-eslint', 'no-only-tests'], + plugins: ['prettier', 'react', 'cypress', '@typescript-eslint', 'no-only-tests', 'jest', 'compat', 'posthog'], rules: { 'no-console': ['error', { allow: ['warn', 'error'] }], 'no-debugger': 'error', @@ -73,7 +86,7 @@ module.exports = { }, ], 'react/forbid-dom-props': [ - 1, + 'warn', { forbid: [ { @@ -84,8 +97,8 @@ module.exports = { ], }, ], - 'react/forbid-elements': [ - 1, + 'posthog/warn-elements': [ + 'warn', { forbid: [ { @@ -97,18 +110,10 @@ module.exports = { element: 'Col', message: 'use flex utility classes instead - most of the time can simply be a plain
', }, - { - element: 'Space', - message: 'use flex or space utility classes instead', - }, { element: 'Divider', message: 'use instead', }, - { - element: 'Typography', - message: 'use utility classes instead', - }, { element: 'Card', message: 'use utility classes instead', @@ -117,10 +122,6 @@ module.exports = { element: 'Button', message: 'use instead', }, - { - element: 'Input.TextArea', - message: 'use instead', - }, { element: 'Input', message: 'use instead', @@ -138,14 +139,14 @@ module.exports = { message: 'use instead', }, { - element: 'a', - message: 'use instead', + element: 'LemonButtonWithDropdown', + message: 'use with a child instead', }, ], }, ], 'react/forbid-elements': [ - 2, + 'error', { forbid: [ { @@ -156,6 +157,10 @@ module.exports = { element: 'Tabs', message: 'use instead', }, + { + element: 'Space', + message: 'use flex or space utility classes instead', + }, { element: 'Spin', message: 'use Spinner instead', @@ -168,11 +173,50 @@ module.exports = { element: 'Collapse', message: 'use instead', }, + { + element: 'Checkbox', + message: 'use instead', + }, + { + element: 'MonacoEditor', + message: 'use instead', + }, + { + element: 'Typography', + message: 'use utility classes instead', + }, + { + element: 'Input.TextArea', + message: 'use instead', + }, + { + element: 'ReactMarkdown', + message: 'use instead', + }, + { + element: 'a', + message: 'use instead', + }, ], }, ], + 'no-constant-condition': 'off', + 'no-prototype-builtins': 'off', + 'no-irregular-whitespace': 'off', }, overrides: [ + { + files: ['**/test/**/*', '**/*.test.*'], + env: { + ...env, + node: true, + 'jest/globals': true, + }, + globals: { + ...globals, + given: 'readonly', + }, + }, { // disable these rules for files generated by kea-typegen files: ['*Type.ts', '*Type.tsx'], @@ -206,6 +250,16 @@ module.exports = { '@typescript-eslint/no-var-requires': 'off', }, }, + { + files: 'eslint-rules/**/*', + extends: ['eslint:recommended'], + rules: { + '@typescript-eslint/no-var-requires': 'off', + }, + env: { + node: true, + }, + }, ], reportUnusedDisableDirectives: true, } diff --git a/.github/ISSUE_TEMPLATE/sprint_planning_retro.md b/.github/ISSUE_TEMPLATE/sprint_planning_retro.md index 8ac6003de66dc..02e76d616a041 100644 --- a/.github/ISSUE_TEMPLATE/sprint_planning_retro.md +++ b/.github/ISSUE_TEMPLATE/sprint_planning_retro.md @@ -13,14 +13,6 @@ title: Sprint 1.n.0 m/2 - Jan 1 to Jan 12 2. 3. -## Retro: What can we do better next sprint? - -1. -2. -3. -4. -5. - # Team sprint planning diff --git a/.github/actions/build-n-cache-image/action.yml b/.github/actions/build-n-cache-image/action.yml index ce2bc45218322..a114e276025e3 100644 --- a/.github/actions/build-n-cache-image/action.yml +++ b/.github/actions/build-n-cache-image/action.yml @@ -4,15 +4,21 @@ inputs: actions-id-token-request-url: required: true description: "ACTIONS_ID_TOKEN_REQUEST_URL, issued by GitHub when permission 'id-token' is set to 'write'" - load: + save: required: false default: 'false' - description: Whether to load the image into local Docker after building it + description: Whether to save the image in the Depot ephemeral registry after building it outputs: tag: description: The tag of the image that was built value: ${{ steps.emit.outputs.tag }} + build-id: + description: The ID of the build + value: ${{ steps.build.outputs.build-id }} + unit-build-id: + description: The ID of the unit build + value: ${{ steps.build-unit.outputs.build-id }} runs: using: 'composite' @@ -30,8 +36,22 @@ runs: uses: depot/build-push-action@v1 with: buildx-fallback: false # buildx is so slow it's better to just fail - load: ${{ inputs.load }} tags: ${{ steps.emit.outputs.tag }} platforms: linux/amd64,linux/arm64 + build-args: COMMIT_HASH=${{ github.sha }} + save: ${{ inputs.save }} + env: + ACTIONS_ID_TOKEN_REQUEST_URL: ${{ inputs.actions-id-token-request-url }} + + - name: Build unit image + id: build-unit + uses: depot/build-push-action@v1 + with: + buildx-fallback: false # buildx is so slow it's better to just fail + file: production-unit.Dockerfile + tags: ${{ steps.emit.outputs.tag }} + platforms: linux/amd64 + build-args: COMMIT_HASH=${{ github.sha }} + save: ${{ inputs.save }} env: ACTIONS_ID_TOKEN_REQUEST_URL: ${{ inputs.actions-id-token-request-url }} diff --git a/.github/actions/run-backend-tests/action.yml b/.github/actions/run-backend-tests/action.yml index 56582581fe3ea..edd36992a6614 100644 --- a/.github/actions/run-backend-tests/action.yml +++ b/.github/actions/run-backend-tests/action.yml @@ -35,6 +35,7 @@ runs: shell: bash run: | export CLICKHOUSE_SERVER_IMAGE=${{ inputs.clickhouse-server-image }} + export DOCKER_REGISTRY_PREFIX="us-east1-docker.pkg.dev/posthog-301601/mirror/" docker compose -f docker-compose.dev.yml down docker compose -f docker-compose.dev.yml up -d @@ -48,11 +49,36 @@ runs: python-version: ${{ inputs.python-version }} token: ${{ inputs.token }} + - name: Determine if hogql-parser has changed compared to master + shell: bash + id: hogql-parser-diff + run: | + git fetch --no-tags --prune --depth=1 origin + changed=$(git diff --quiet HEAD origin/master -- hogql_parser/ && echo "false" || echo "true") + echo "changed=$changed" >> $GITHUB_OUTPUT + - name: Install SAML (python3-saml) dependencies shell: bash run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl + sudo apt-get update && sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8.x.x + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: pnpm + + - name: Install plugin-transpiler + shell: bash + run: | + cd plugin-transpiler + pnpm install + pnpm run build - uses: syphar/restore-virtualenv@v1 id: cache-backend-tests @@ -62,12 +88,37 @@ runs: - uses: syphar/restore-pip-download-cache@v1 if: steps.cache-backend-tests.outputs.cache-hit != 'true' - - name: Install python dependencies + - name: Install Python dependencies if: steps.cache-backend-tests.outputs.cache-hit != 'true' shell: bash run: | - python -m pip install -r requirements-dev.txt - python -m pip install -r requirements.txt + pip install -r requirements.txt -r requirements-dev.txt + + - name: Install the working version of hogql-parser + if: steps.hogql-parser-diff.outputs.changed == 'true' + shell: bash + # This is not cached currently, as it's important to build the current HEAD version of hogql-parser if it has + # changed (requirements.txt has the already-published version) + run: | + sudo apt-get install libboost-all-dev unzip cmake curl uuid pkg-config + curl https://www.antlr.org/download/antlr4-cpp-runtime-4.13.1-source.zip --output antlr4-source.zip + # Check that the downloaded archive is the expected runtime - a security measure + anltr_known_md5sum="c875c148991aacd043f733827644a76f" + antlr_found_ms5sum="$(md5sum antlr4-source.zip | cut -d' ' -f1)" + if [[ "$anltr_known_md5sum" != "$antlr_found_ms5sum" ]]; then + echo "Unexpected MD5 sum of antlr4-source.zip!" + echo "Known: $anltr_known_md5sum" + echo "Found: $antlr_found_ms5sum" + exit 64 + fi + unzip antlr4-source.zip -d antlr4-source && cd antlr4-source + cmake . + DESTDIR=out make install + sudo cp -r out/usr/local/include/antlr4-runtime /usr/include/ + sudo cp out/usr/local/lib/libantlr4-runtime.so* /usr/lib/ + sudo ldconfig + cd .. + pip install ./hogql_parser - name: Set up needed files shell: bash @@ -102,13 +153,29 @@ runs: PERSON_ON_EVENTS_V2_ENABLED: ${{ inputs.person-on-events }} GROUPS_ON_EVENTS_ENABLED: ${{ inputs.person-on-events }} shell: bash - run: | # async_migrations are covered in ci-async-migrations.yml - pytest hogvm posthog -m "not async_migrations" \ + run: | # async_migrations covered in ci-async-migrations.yml + pytest ${{ + inputs.person-on-events == 'true' + && './posthog/clickhouse/ ./posthog/hogql/ ./posthog/queries/ ./posthog/api/test/test_insight* ./posthog/api/test/dashboards/test_dashboard.py' + || 'hogvm posthog' + }} -m "not async_migrations" \ + --splits ${{ inputs.concurrency }} --group ${{ inputs.group }} \ + --durations=100 --durations-min=1.0 \ + $PYTEST_ARGS + + - name: Run EE tests + if: ${{ inputs.segment == 'EE' }} + env: + PERSON_ON_EVENTS_V2_ENABLED: ${{ inputs.person-on-events }} + GROUPS_ON_EVENTS_ENABLED: ${{ inputs.person-on-events }} + shell: bash + run: | # async_migrations covered in ci-async-migrations.yml + pytest ${{ inputs.person-on-events == 'true' && 'ee/clickhouse/' || 'ee/' }} -m "not async_migrations" \ --splits ${{ inputs.concurrency }} --group ${{ inputs.group }} \ - --durations=100 --durations-min=1.0 --store-durations \ + --durations=100 --durations-min=1.0 \ $PYTEST_ARGS - - name: Run Decide read replica tests + - name: Run /decide read replica tests if: ${{ inputs.segment == 'FOSS' && inputs.group == 1 && inputs.person-on-events != 'true' }} env: POSTHOG_DB_NAME: posthog @@ -121,24 +188,3 @@ runs: pytest posthog/api/test/test_decide.py::TestDecideUsesReadReplica \ --durations=100 --durations-min=1.0 \ $PYTEST_ARGS - - - name: Run EE tests - if: ${{ inputs.segment == 'EE' }} - env: - PERSON_ON_EVENTS_V2_ENABLED: ${{ inputs.person-on-events }} - GROUPS_ON_EVENTS_ENABLED: ${{ inputs.person-on-events }} - shell: bash - run: | # async_migrations are covered in ci-async-migrations.yml - pytest ee -m "not async_migrations" \ - --splits ${{ inputs.concurrency }} --group ${{ inputs.group }} \ - --durations=100 --durations-min=1.0 --store-durations \ - $PYTEST_ARGS - - # Post-tests - - - name: Upload updated timing data as artifacts - uses: actions/upload-artifact@v2 - if: ${{ inputs.segment == 'EE' && inputs.person-on-events != 'true'}} - with: - name: timing_data-${{ inputs.group }} - path: .test_durations diff --git a/.github/workflows/build-hogql-parser.yml b/.github/workflows/build-hogql-parser.yml new file mode 100644 index 0000000000000..90395eaa52180 --- /dev/null +++ b/.github/workflows/build-hogql-parser.yml @@ -0,0 +1,136 @@ +name: Release hogql-parser + +on: + push: + branches: + - master + paths: + - hogql_parser/** + - .github/workflows/build-hogql-parser.yml + pull_request: + paths: + - hogql_parser/** + - .github/workflows/build-hogql-parser.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check-version: + name: Check version legitimacy + if: github.repository == 'PostHog/posthog' + runs-on: ubuntu-22.04 + outputs: + parser-release-needed: ${{ steps.version.outputs.parser-release-needed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetching all for comparison since last push (not just last commit) + + - name: Check if hogql_parser/ has changed + id: changed-files + uses: tj-actions/changed-files@v39 + with: + since_last_remote_commit: true + files_yaml: | + parser: + - hogql_parser/** + + - name: Check if version was bumped + shell: bash + id: version + run: | + parser_release_needed='false' + if [[ ${{ steps.changed-files.outputs.parser_any_changed }} == 'true' ]]; then + published=$(curl -fSsl https://pypi.org/pypi/hogql-parser/json | jq -r '.info.version') + local=$(python hogql_parser/setup.py --version) + if [[ "$published" != "$local" ]]; then + parser_release_needed='true' + else + message_body="It looks like the code of \`hogql-parser\` has changed since last push, but its version stayed the same at $local. 👀\nMake sure to resolve this in \`hogql_parser/setup.py\` before merging!" + curl -s -u posthog-bot:${{ secrets.POSTHOG_BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} -X POST -d "{ \"body\": \"$message_body\" }" "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" + fi + fi + echo "::set-output name=parser-release-needed::$parser_release_needed" + + build-wheels: + name: Build wheels on ${{ matrix.os }} + needs: check-version + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + if: ${{ needs.check-version.outputs.parser-release-needed == 'true' }} + strategy: + matrix: + # As of October 2023, GitHub doesn't have ARM Actions runners… and ARM emulation is insanely slow + # (20x longer) on the Linux runners (while being reasonable on the macOS runners). Hence, we use + # BuildJet as a provider of ARM runners - this solution saves a lot of time and consequently some money. + os: [ubuntu-22.04, buildjet-2vcpu-ubuntu-2204-arm, macos-12] + + steps: + - uses: actions/checkout@v4 + + - if: ${{ !endsWith(matrix.os, '-arm') }} + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - if: ${{ endsWith(matrix.os, '-arm') }} + uses: deadsnakes/action@v3.0.1 # Unfortunately actions/setup-python@v4 just doesn't work on ARM! This does + with: + python-version: '3.11' + + - name: Build sdist + if: matrix.os == 'ubuntu-22.04' # Only build the sdist once + run: cd hogql_parser && python setup.py sdist + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel==2.16.* + + - name: Build wheels + run: cd hogql_parser && python -m cibuildwheel --output-dir dist + env: + MACOSX_DEPLOYMENT_TARGET: '12' # A modern target allows us to use C++20 + + - uses: actions/upload-artifact@v3 + with: + path: | + hogql_parser/dist/*.whl + hogql_parser/dist/*.tar.gz + if-no-files-found: error + + publish: + name: Publish on PyPI + needs: build-wheels + environment: pypi-hogql-parser + permissions: + id-token: write + runs-on: ubuntu-22.04 + steps: + - name: Fetch wheels + uses: actions/download-artifact@v3 + with: + name: artifact + path: dist/ + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - uses: actions/checkout@v4 + with: + token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.head.ref }} + + - name: Update hogql-parser in requirements + shell: bash + run: | + local=$(python hogql_parser/setup.py --version) + sed -i "s/hogql-parser==.*/hogql-parser==${local}/g" requirements.in + sed -i "s/hogql-parser==.*/hogql-parser==${local}/g" requirements.txt + + - uses: EndBug/add-and-commit@v9 + with: + add: '["requirements.in", "requirements.txt"]' + message: 'Use new hogql-parser version' + default_author: github_actions + github_token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 5a22422ab5aa7..f80300fca0a56 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -15,6 +15,12 @@ on: description: ClickHouse server version. Leave blank for default type: string +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + # This is so that the workflow run isn't canceled when a snapshot update is pushed within it by posthog-bot + # We do however cancel from container-images-ci.yml if a commit is pushed by someone OTHER than posthog-bot + cancel-in-progress: false + env: SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' @@ -62,6 +68,7 @@ jobs: - mypy.ini - pytest.ini - frontend/src/queries/schema.json # Used for generating schema.py + - plugin-transpiler/src # Used for transpiling plugins # Make sure we run if someone is explicitly change the workflow - .github/workflows/ci-backend.yml - .github/actions/run-backend-tests/action.yml @@ -76,7 +83,7 @@ jobs: backend-code-quality: needs: changes - timeout-minutes: 10 + timeout-minutes: 30 name: Python code quality checks runs-on: ubuntu-latest @@ -92,13 +99,6 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 1 - path: 'current/' - - - name: Stop/Start stack with Docker Compose - run: | - cd current - docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d - name: Set up Python uses: actions/setup-python@v4 @@ -117,40 +117,32 @@ jobs: - name: Install SAML (python3-saml) dependencies run: | sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl + sudo apt-get install libxml2-dev libxmlsec1 libxmlsec1-dev libxmlsec1-openssl - - name: Install python dependencies + - name: Install Python dependencies if: steps.cache-backend-tests.outputs.cache-hit != 'true' run: | - cd current python -m pip install -r requirements.txt -r requirements-dev.txt - name: Check for syntax errors, import sort, and code style violations run: | - cd current - ruff . + ruff check . - name: Check formatting run: | - cd current - black --exclude posthog/hogql/grammar --check . + ruff format --exclude posthog/hogql/grammar --check --diff . - name: Check static typing run: | - cd current mypy -p posthog --exclude bin/migrate_kafka_data.py --exclude posthog/hogql/grammar/HogQLParser.py --exclude gunicorn.config.py --enable-recursive-aliases - name: Check if "schema.py" is up to date run: | - cd current npm run schema:build:python && git diff --exit-code - - name: Check if antlr definitions are up to date + - name: Check if ANTLR definitions are up to date run: | - # Installing a version of ant compatible with what we use in development from homebrew (4.13) - # "apt-get install antlr" would install 4.7 which is incompatible with our grammar. - export ANTLR_VERSION=4.13.0 - # java version doesn't matter + cd .. sudo apt-get install default-jre mkdir antlr cd antlr @@ -162,14 +154,18 @@ jobs: export CLASSPATH=".:$PWD/antlr.jar:$CLASSPATH" export PATH="$PWD:$PATH" - cd ../current + cd ../posthog antlr | grep "Version" npm run grammar:build && git diff --exit-code + env: + # Installing a version of ANTLR compatible with what's in Homebrew as of October 2023 (version 4.13), + # as apt-get is quite out of date. The same version must be set in hogql_parser/pyproject.toml + ANTLR_VERSION: '4.13.1' check-migrations: needs: changes if: needs.changes.outputs.backend == 'true' - timeout-minutes: 5 + timeout-minutes: 10 name: Validate Django migrations runs-on: ubuntu-latest @@ -277,9 +273,9 @@ jobs: # Also skip for persons-on-events runs, as we want to ignore snapshots diverging there if: ${{ github.repository == 'PostHog/posthog' && needs.changes.outputs.backend == 'true' && !matrix.person-on-events }} with: - add: '["ee", "posthog/clickhouse/test/__snapshots__", "posthog/api/test/__snapshots__", "posthog/test/__snapshots__", "posthog/queries/", "posthog/migrations", "posthog/tasks", "posthog/hogql/"]' + add: '["ee", "./**/*.ambr", "posthog/queries/", "posthog/migrations", "posthog/tasks", "posthog/hogql/"]' message: 'Update query snapshots' - pull: --rebase --autostash # Make sure we're up to date with other segments' updates + pull: --rebase --autostash # Make sure we're up-to-date with other segments' updates default_author: github_actions github_token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} @@ -332,19 +328,19 @@ jobs: python-version: 3.10.10 token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - name: Install SAML (python3-saml) dependencies - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl - - uses: syphar/restore-virtualenv@v1 - id: cache-async-migrations-tests + id: cache-backend-tests with: - custom_cache_key_element: v1-${{ inputs.cache-id }} + custom_cache_key_element: v1- - uses: syphar/restore-pip-download-cache@v1 if: steps.cache-backend-tests.outputs.cache-hit != 'true' + - name: Install SAML (python3-saml) dependencies + run: | + sudo apt-get update + sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl + - name: Install python dependencies if: steps.cache-backend-tests.outputs.cache-hit != 'true' shell: bash diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index ac08dce5795a4..0ba4db2392761 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -49,13 +49,14 @@ jobs: # version changes - docker-compose.dev.yml - Dockerfile + - cypress/** # Job that lists and chunks spec file names and caches node modules chunks: needs: changes name: Cypress preparation runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 5 outputs: chunks: ${{ steps.chunk.outputs.chunks }} @@ -65,13 +66,38 @@ jobs: - name: Group spec files into chunks of three id: chunk - run: echo "chunks=$(ls cypress/e2e/* | jq --slurp --raw-input -c 'split("\n")[:-1] | _nwise(3) | join("\n")' | jq --slurp -c .)" >> $GITHUB_OUTPUT + run: echo "chunks=$(ls cypress/e2e/* | jq --slurp --raw-input -c 'split("\n")[:-1] | _nwise(2) | join("\n")' | jq --slurp -c .)" >> $GITHUB_OUTPUT + + container: + name: Build and cache container image + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: [changes] + permissions: + contents: read + id-token: write # allow issuing OIDC tokens for this workflow run + outputs: + tag: ${{ steps.build.outputs.tag }} + build-id: ${{ steps.build.outputs.build-id }} + unit-build-id: ${{ steps.build.outputs.unit-build-id }} + steps: + - name: Checkout + if: needs.changes.outputs.shouldTriggerCypress == 'true' + uses: actions/checkout@v3 + - name: Build the Docker image with Depot + if: needs.changes.outputs.shouldTriggerCypress == 'true' + # Build the container image in preparation for the E2E tests + uses: ./.github/actions/build-n-cache-image + id: build + with: + save: true + actions-id-token-request-url: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }} cypress: name: Cypress E2E tests (${{ strategy.job-index }}) runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [chunks, changes] + timeout-minutes: 60 + needs: [chunks, changes, container] permissions: id-token: write # allow issuing OIDC tokens for this workflow run @@ -137,17 +163,20 @@ jobs: if: needs.changes.outputs.shouldTriggerCypress == 'true' run: ./bin/check_kafka_clickhouse_up + - name: Install Depot CLI + if: needs.changes.outputs.shouldTriggerCypress == 'true' + uses: depot/setup-action@v1 + - name: Get Docker image cached in Depot if: needs.changes.outputs.shouldTriggerCypress == 'true' - # We don't actually build the image here, because we use Depot, which acts as our cross-workflow cache. - # The build is first initiated in container-images-ci.yml, so by the time this runs, some layers already - # are cached, and the in-flight builds overall are deduplicated. According to Depot folks, this applies - # even if the builds _start_ concurrently! In short, only one build per commit push is ever executed. - uses: ./.github/actions/build-n-cache-image - id: docker-build + uses: depot/pull-action@v1 with: - actions-id-token-request-url: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }} - load: true + # Use the production.Dockerfile image: + # build-id: ${{ needs.container.outputs.build-id }} + # Use the production-unit.Dockerfile image: + build-id: ${{ needs.container.outputs.unit-build-id }} + tags: | + ${{ needs.container.outputs.tag }} - name: Write .env # This step intentionally has no if, so that GH always considers the action as having run run: | @@ -179,8 +208,8 @@ jobs: run: | mkdir -p /tmp/logs - echo "Starting PostHog using the container image ${{ steps.docker-build.outputs.tag }}" - DOCKER_RUN="docker run --rm --network host --add-host kafka:127.0.0.1 --env-file .env ${{ steps.docker-build.outputs.tag }}" + echo "Starting PostHog using the container image ${{ needs.container.outputs.tag }}" + DOCKER_RUN="docker run --rm --network host --add-host kafka:127.0.0.1 --env-file .env ${{ needs.container.outputs.tag }}" $DOCKER_RUN ./bin/migrate $DOCKER_RUN python manage.py setup_dev @@ -192,7 +221,10 @@ jobs: - name: Wait for PostHog # these are required checks so, we can't skip entire sections if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: iFaxity/wait-on-action@v1 + # this action might be abandoned - but v1 doesn't point to latest of v1 (which it should) + # so pointing to v1.1.0 to remove warnings about node version with v1 + # todo check https://github.com/iFaxity/wait-on-action/releases for new releases + uses: iFaxity/wait-on-action@v1.1.0 timeout-minutes: 3 with: verbose: true @@ -202,12 +234,16 @@ jobs: - name: Cypress run # these are required checks so, we can't skip entire sections if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: cypress-io/github-action@v5 + uses: cypress-io/github-action@v6 with: config-file: cypress.e2e.config.ts config: retries=2 spec: ${{ matrix.chunk }} install: false + env: + E2E_TESTING: 1 + OPT_OUT_CAPTURE: 0 + GITHUB_ACTION_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - name: Archive test screenshots uses: actions/upload-artifact@v3 diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index 0cbd969b00b76..3411304b8795a 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -17,6 +17,7 @@ concurrency: jobs: frontend-code-quality: name: Code quality checks + # kea typegen and typescript:check need some more oomph runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -27,7 +28,7 @@ jobs: version: 8.x.x - name: Set up Node.js - uses: actions/setup-node@v3 + uses: buildjet/setup-node@v3 with: node-version: 18 @@ -76,7 +77,7 @@ jobs: version: 8.x.x - name: Set up Node.js - uses: actions/setup-node@v3 + uses: buildjet/setup-node@v3 with: node-version: 18 cache: pnpm diff --git a/.github/workflows/ci-plugin-server.yml b/.github/workflows/ci-plugin-server.yml index 49c959e3e747f..81aa5cf4cd81f 100644 --- a/.github/workflows/ci-plugin-server.yml +++ b/.github/workflows/ci-plugin-server.yml @@ -190,7 +190,6 @@ jobs: fail-fast: false matrix: POE_EMBRACE_JOIN_FOR_TEAMS: ['', '*'] - KAFKA_CONSUMPTION_USE_RDKAFKA: ['true', 'false'] env: REDIS_URL: 'redis://localhost' @@ -198,8 +197,8 @@ jobs: CLICKHOUSE_DATABASE: 'posthog_test' KAFKA_HOSTS: 'kafka:9092' DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' + RELOAD_PLUGIN_JITTER_MAX_MS: 0 POE_EMBRACE_JOIN_FOR_TEAMS: ${{matrix.POE_EMBRACE_JOIN_FOR_TEAMS}} - KAFKA_CONSUMPTION_USE_RDKAFKA: ${{matrix.KAFKA_CONSUMPTION_USE_RDKAFKA}} steps: - name: Code check out @@ -281,93 +280,3 @@ jobs: if-no-files-found: warn retention-days: 1 path: 'plugin-server/coverage' - - recording-ingestion-load-test: - name: Recording ingestion load test - needs: changes - if: needs.changes.outputs.plugin-server == 'true' - # This test runs a load test on the ingestion pipeline, using - # synthetic recordings data. It is not meant to produce directly - # comparable results, but rather to give a rough idea of how - # ingestion performance changes over time. - - runs-on: ubuntu-latest - - env: - DEBUG: 'true' - KAFKA_HOSTS: 'kafka:9092' - DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' - - steps: - - name: Code check out - uses: actions/checkout@v3 - - - name: Start Kafka, PostgreSQL and Redis - run: | - # Note: we need to start Redis as well, this is just so - # `./manage.py setup_dev` runs. - docker compose -f docker-compose.dev.yml up kafka db redis -d - - - name: Add Kafka to /etc/hosts - run: echo "127.0.0.1 kafka" | sudo tee -a /etc/hosts - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: 3.10.10 - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - - uses: syphar/restore-virtualenv@v1 - id: cache-backend-tests - with: - custom_cache_key_element: v1- - - - uses: syphar/restore-pip-download-cache@v1 - if: steps.cache-backend-tests.outputs.cache-hit != 'true' - - - name: Install SAML (python3-saml) dependencies - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl - if: steps.cache-backend-tests.outputs.cache-hit != 'true' - - - name: Install python dependencies - if: steps.cache-backend-tests.outputs.cache-hit != 'true' - run: | - python -m pip install -r requirements-dev.txt - python -m pip install -r requirements.txt - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8.x.x - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - cache: pnpm - cache-dependency-path: plugin-server/pnpm-lock.yaml - - - name: Install package.json dependencies with pnpm - run: | - cd plugin-server - pnpm install --frozen-lockfile - pnpm build - - - name: Set up postgres - run: | - ./manage.py migrate - ./manage.py setup_dev --no-data - - - name: Run load test - run: | - cd plugin-server - SESSIONS_COUNT=2500 ./bin/ci_session_recordings_load_test.sh - - - name: Upload profile as artifact - uses: actions/upload-artifact@v2 - with: - name: profile - path: | - plugin-server/profile diff --git a/.github/workflows/container-images-cd.yml b/.github/workflows/container-images-cd.yml index ff0d3276c6673..5345308504bb3 100644 --- a/.github/workflows/container-images-cd.yml +++ b/.github/workflows/container-images-cd.yml @@ -68,8 +68,20 @@ jobs: with: buildx-fallback: false # the fallback is so slow it's better to just fail push: true - tags: posthog/posthog:latest,${{ steps.aws-ecr.outputs.registry }}/posthog-cloud:master + tags: posthog/posthog:${{ github.sha }},posthog/posthog:latest,${{ steps.aws-ecr.outputs.registry }}/posthog-cloud:master platforms: linux/arm64,linux/amd64 + build-args: COMMIT_HASH=${{ github.sha }} + + - name: Build and push unit container image + id: build-unit + uses: depot/build-push-action@v1 + with: + buildx-fallback: false # the fallback is so slow it's better to just fail + push: true + file: production-unit.Dockerfile + tags: ${{ steps.aws-ecr.outputs.registry }}/posthog-cloud:unit + platforms: linux/amd64 + build-args: COMMIT_HASH=${{ github.sha }} - name: get deployer token id: deployer @@ -110,3 +122,21 @@ jobs: { "image_tag": "${{ steps.build.outputs.digest }}" } + + - name: Check for changes that affect temporal worker + id: check_changes_temporal_worker + run: | + echo "::set-output name=changed::$(git diff --name-only HEAD^ HEAD | grep -E '^posthog/temporal/|^posthog/batch_exports/|^posthog/management/commands/start_temporal_worker.py$' || true)" + + - name: Trigger Temporal Worker Cloud deployment + if: steps.check_changes_temporal_worker.outputs.changed != '' + uses: mvasigh/dispatch-action@main + with: + token: ${{ steps.deployer.outputs.token }} + repo: charts + owner: PostHog + event_type: temporal_worker_deploy + message: | + { + "image_tag": "${{ steps.build.outputs.digest }}" + } diff --git a/.github/workflows/container-images-ci.yml b/.github/workflows/container-images-ci.yml index 18f2939675d27..e37d2e730efb3 100644 --- a/.github/workflows/container-images-ci.yml +++ b/.github/workflows/container-images-ci.yml @@ -16,6 +16,21 @@ jobs: contents: read # allow at least reading the repo contents, add other permissions if necessary steps: + # If this run wasn't initiated by PostHog Bot (meaning: snapshot update), + # cancel previous runs of snapshot update-inducing workflows + + - uses: n1hility/cancel-previous-runs@v3 + if: github.actor != 'posthog-bot' + with: + token: ${{ secrets.GITHUB_TOKEN }} + workflow: .github/workflows/storybook-chromatic.yml + + - uses: n1hility/cancel-previous-runs@v3 + if: github.actor != 'posthog-bot' + with: + token: ${{ secrets.GITHUB_TOKEN }} + workflow: .github/workflows/ci-backend.yml + - name: Check out uses: actions/checkout@v3 diff --git a/.github/workflows/customer-data-pipeline.yml b/.github/workflows/customer-data-pipeline.yml index 6a179053a3030..ff60596f2193a 100644 --- a/.github/workflows/customer-data-pipeline.yml +++ b/.github/workflows/customer-data-pipeline.yml @@ -46,7 +46,7 @@ jobs: images: ghcr.io/${{ steps.lowercase.outputs.repository }}/cdp # Make the image tags used for docker cache. We use this rather than - # ${{ github.repository }} directly because the repository + # ${{ github.repository }} directly because the repository # organization name is has upper case characters, which are not # allowed in docker image names. - uses: docker/metadata-action@v4 diff --git a/.github/workflows/lint-new-pr.yml b/.github/workflows/lint-new-pr.yml index 9ce7908761e59..f4debeb5a445b 100644 --- a/.github/workflows/lint-new-pr.yml +++ b/.github/workflows/lint-new-pr.yml @@ -2,12 +2,13 @@ name: Lint new PR on: pull_request: - types: [opened] + types: [opened, ready_for_review] jobs: check-description: name: Check that PR has description runs-on: ubuntu-20.04 + if: github.event.pull_request.draft == false steps: - name: Check if PR is shame-worthy diff --git a/.github/workflows/storybook-chromatic.yml b/.github/workflows/storybook-chromatic.yml index abbe68257c1b0..ace0ae3e79349 100644 --- a/.github/workflows/storybook-chromatic.yml +++ b/.github/workflows/storybook-chromatic.yml @@ -6,6 +6,13 @@ on: - 'frontend/**' - '.storybook/**' - 'package.json' + - '.github/workflows/storybook-chromatic.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + # This is so that the workflow run isn't canceled when a snapshot update is pushed within it by posthog-bot + # We do however cancel from container-images-ci.yml if a commit is pushed by someone OTHER than posthog-bot + cancel-in-progress: false jobs: storybook-chromatic: @@ -47,7 +54,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 container: - image: mcr.microsoft.com/playwright:v1.29.2 + image: mcr.microsoft.com/playwright:v1.29.2 # Same as @storybook/test-runner@0.13's dependency strategy: fail-fast: false matrix: @@ -92,12 +99,6 @@ jobs: firefox-2-total: ${{ steps.diff.outputs.firefox-2-total }} firefox-2-commitHash: ${{ steps.commit-hash.outputs.firefox-2-commitHash }} steps: - # If this run wasn't initiated by the bot (meaning: snapshot update), cancel previous runs - - uses: n1hility/cancel-previous-runs@v3 - if: github.actor != 'posthog-bot' - with: - token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@v3 with: fetch-depth: 1 @@ -112,7 +113,7 @@ jobs: version: 8.x.x - name: Set up Node.js - uses: actions/setup-node@v3 + uses: buildjet/setup-node@v3 with: node-version: 18 cache: pnpm @@ -128,8 +129,15 @@ jobs: - name: Serve Storybook in the background run: | - pnpm exec http-server storybook-static --port 6006 --silent & - pnpm wait-on http://127.0.0.1:6006 --timeout 60 # Wait for the server to be ready + retries=3 + while [ $retries -gt 0 ]; do + pnpm exec http-server storybook-static --port 6006 --silent & + if pnpm wait-on http://127.0.0.1:6006 --timeout 60; then + break + fi + retries=$((retries-1)) + echo "Failed to serve Storybook, retrying... ($retries retries left)" + done - name: Run @storybook/test-runner env: @@ -138,7 +146,14 @@ jobs: # Update snapshots for PRs on the main repo, verify on forks, which don't have access to PostHog Bot VARIANT: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'update' || 'verify' }} run: | - pnpm test:visual-regression:stories:ci:$VARIANT --browsers ${{ matrix.browser }} --shard ${{ matrix.shard }}/$SHARD_COUNT + retries=3 + while [ $retries -gt 0 ]; do + if pnpm test:visual-regression:stories:ci:$VARIANT --browsers ${{ matrix.browser }} --shard ${{ matrix.shard }}/$SHARD_COUNT; then + break + fi + retries=$((retries-1)) + echo "Failed @storybook/test-runner, retrying... ($retries retries left)" + done - name: Run @playwright/test (legacy, Chromium-only) if: matrix.browser == 'chromium' && matrix.shard == 1 @@ -164,8 +179,24 @@ jobs: if [ $ADDED -gt 0 ] || [ $MODIFIED -gt 0 ]; then echo "Snapshots updated ($ADDED new, $MODIFIED changed), running OptiPNG" apt update && apt install -y optipng - git add frontend/__snapshots__/ playwright/ - pnpm lint-staged + optipng -clobber -o4 -strip all + + # we don't want to _always_ run OptiPNG + # so, we run it after checking for a diff + # but, the files we diffed might then be changed by OptiPNG + # and as a result they might no longer be different... + + # we check again + git diff --name-status frontend/__snapshots__/ # For debugging + ADDED=$(git diff --name-status frontend/__snapshots__/ | grep '^A' | wc -l) + MODIFIED=$(git diff --name-status frontend/__snapshots__/ | grep '^M' | wc -l) + DELETED=$(git diff --name-status frontend/__snapshots__/ | grep '^D' | wc -l) + TOTAL=$(git diff --name-status frontend/__snapshots__/ | wc -l) + + if [ $ADDED -gt 0 ] || [ $MODIFIED -gt 0 ]; then + echo "Snapshots updated ($ADDED new, $MODIFIED changed), _even after_ running OptiPNG" + git add frontend/__snapshots__/ playwright/ + fi fi echo "${{ matrix.browser }}-${{ matrix.shard }}-added=$ADDED" >> $GITHUB_OUTPUT diff --git a/.gitignore b/.gitignore index 459afb6d2e9c1..b3ee2cc921353 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ gen/ .antlr upgrade/ hogvm/typescript/dist - +.wokeignore +plugin-transpiler/dist diff --git a/.husky/pre-commit b/.husky/pre-commit index fdf550a219f53..fab6428a1a72a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,16 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -# Check if staged files contain any added or modified PNGs - skip when merging -if \ - git rev-parse -q --verify MERGE_HEAD \ - && git diff --cached --name-status | grep '^[AM]' | grep -q '.png$' -then - # Error if OptiPNG is not installed - if ! command -v optipng >/dev/null; then - echo "PNG files must be optimized before being committed, but OptiPNG is not installed! Fix this with \`brew/apt install optipng\`." - exit 1 - fi -fi - pnpm lint-staged diff --git a/.run/PostHog.run.xml b/.run/PostHog.run.xml index 7dedccabd3be0..df41d468add82 100644 --- a/.run/PostHog.run.xml +++ b/.run/PostHog.run.xml @@ -13,12 +13,13 @@ + - +
Have questions?{' '} - + Visit support - {' '} + {' '} or{' '} - + read our documentation - + .
diff --git a/frontend/src/scenes/organization/Settings/InviteModal.tsx b/frontend/src/scenes/organization/Settings/InviteModal.tsx index 3e1bf1ca9c70e..9e9743512547f 100644 --- a/frontend/src/scenes/organization/Settings/InviteModal.tsx +++ b/frontend/src/scenes/organization/Settings/InviteModal.tsx @@ -3,10 +3,10 @@ import './InviteModal.scss' import { isEmail, pluralize } from 'lib/utils' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { inviteLogic } from './inviteLogic' -import { IconDelete, IconOpenInNew, IconPlus } from 'lib/lemon-ui/icons' +import { IconDelete, IconPlus } from 'lib/lemon-ui/icons' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' -import { LemonTextArea, LemonInput } from '@posthog/lemon-ui' +import { LemonTextArea, LemonInput, Link } from '@posthog/lemon-ui' import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' import { OrganizationInviteType } from '~/types' import { userLogic } from 'scenes/userLogic' @@ -24,10 +24,9 @@ export function EmailUnavailableMessage(): JSX.Element { <> This PostHog instance isn't{' '} - + configured to send emails  - - + .
Remember to share the invite link with each team member you invite. @@ -174,7 +173,8 @@ export function InviteModal({ isOpen, onClose }: { isOpen: boolean; onClose: () {preflight?.licensed_users_available === 0 && ( You've hit the limit of team members you can invite to your PostHog instance given your license. - Please contact sales@posthog.com to upgrade your license. + Please contact sales@posthog.com to upgrade your + license. )}
@@ -194,7 +194,7 @@ export function InviteModal({ isOpen, onClose }: { isOpen: boolean; onClose: ()
{invite.is_expired ? ( - Expired! Delete and recreate + Expired – please recreate ) : ( <> {preflight?.email_service_available ? ( diff --git a/frontend/src/scenes/organization/Settings/Invites.tsx b/frontend/src/scenes/organization/Settings/Invites.tsx index f3b4e78fdccfd..ada95fcca7147 100644 --- a/frontend/src/scenes/organization/Settings/Invites.tsx +++ b/frontend/src/scenes/organization/Settings/Invites.tsx @@ -14,7 +14,7 @@ import { LemonDialog } from 'lib/lemon-ui/LemonDialog' function InviteLinkComponent(id: string, invite: OrganizationInviteType): JSX.Element { const url = new URL(`/signup/${id}`, document.baseURI).href return invite.is_expired ? ( - Expired! Delete and recreate + Expired – please recreate ) : ( {url} @@ -98,7 +98,7 @@ export function Invites(): JSX.Element { return (
-

+

Pending Invites Invite team member diff --git a/frontend/src/scenes/organization/Settings/Permissions/Permissions.tsx b/frontend/src/scenes/organization/Settings/Permissions/Permissions.tsx index 65213ed128481..7adcbed6a3621 100644 --- a/frontend/src/scenes/organization/Settings/Permissions/Permissions.tsx +++ b/frontend/src/scenes/organization/Settings/Permissions/Permissions.tsx @@ -54,7 +54,7 @@ export function Permissions({ isRestricted }: RestrictedComponentProps): JSX.Ele return ( <>
-
+

Permission Defaults

diff --git a/frontend/src/scenes/organization/Settings/Permissions/Roles/Roles.tsx b/frontend/src/scenes/organization/Settings/Permissions/Roles/Roles.tsx index 35b4524c1a9a0..915433298a2d3 100644 --- a/frontend/src/scenes/organization/Settings/Permissions/Roles/Roles.tsx +++ b/frontend/src/scenes/organization/Settings/Permissions/Roles/Roles.tsx @@ -23,7 +23,7 @@ export function Roles({ isRestricted }: RestrictedComponentProps): JSX.Element { render: function RoleNameRender(_, role) { return (
{ setRoleInFocus(role) }} @@ -58,7 +58,7 @@ export function Roles({ isRestricted }: RestrictedComponentProps): JSX.Element { return ( <>
-
+

Roles

diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/ConfigureSAMLModal.tsx b/frontend/src/scenes/organization/Settings/VerifiedDomains/ConfigureSAMLModal.tsx index 874dde567a724..2d8304b40ca7f 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/ConfigureSAMLModal.tsx +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/ConfigureSAMLModal.tsx @@ -10,7 +10,6 @@ import { Form } from 'kea-forms' import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { Link } from '@posthog/lemon-ui' -import { IconOpenInNew } from 'lib/lemon-ui/icons' export function ConfigureSAMLModal(): JSX.Element { const { configureSAMLModalId, isSamlConfigSubmitting, samlConfig } = useValues(verifiedDomainsLogic) @@ -33,8 +32,8 @@ export function ConfigureSAMLModal(): JSX.Element {

- - Read the docs + + Read the docs

diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.stories.tsx b/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.stories.tsx index 4ec9e7c0b0030..d1ea2e91c1b11 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.stories.tsx +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.stories.tsx @@ -1,16 +1,17 @@ import { useState } from 'react' -import { ComponentStory, ComponentMeta } from '@storybook/react' +import { StoryFn, Meta } from '@storybook/react' import { SSOSelect } from './SSOSelect' import { SSOProvider } from '~/types' import { useStorybookMocks } from '~/mocks/browser' import preflightJSON from '~/mocks/fixtures/_preflight.json' -export default { +const meta: Meta = { title: 'Components/SSO Select', component: SSOSelect, -} as ComponentMeta +} +export default meta -const Template: ComponentStory = (args) => { +const Template: StoryFn = (args) => { const [value, setValue] = useState('google-oauth2' as SSOProvider | '') useStorybookMocks({ get: { diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx b/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx index a35a6afd0c0b3..f8072bdc57d32 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx @@ -5,7 +5,7 @@ import { SSO_PROVIDER_NAMES } from 'lib/constants' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { SSOProvider } from '~/types' -interface SSOSelectInterface { +export interface SSOSelectInterface { value: SSOProvider | '' loading: boolean onChange: (value: SSOProvider | '') => void diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/VerifiedDomains.tsx b/frontend/src/scenes/organization/Settings/VerifiedDomains/VerifiedDomains.tsx index 885fd6d4ccfda..f911bb31128bb 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/VerifiedDomains.tsx +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/VerifiedDomains.tsx @@ -27,8 +27,7 @@ export function VerifiedDomains(): JSX.Element { return ( <>
-
-
{/** For backwards link compatibility. Remove after 2022-06-01. */} +

Authentication domains

diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap b/frontend/src/scenes/organization/Settings/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap index a6ac9ebf026d7..4f2b11ec18402 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap @@ -16,10 +16,7 @@ exports[`verifiedDomainsLogic values has proper defaults 1`] = ` "id": "ABCD", "is_member_join_email_enabled": true, "membership_level": 8, - "metadata": { - "taxonomy_set_events_count": 60, - "taxonomy_set_properties_count": 17, - }, + "metadata": {}, "name": "MockHog", "plugins_access_level": 9, "slug": "mockhog-fstn", @@ -77,7 +74,10 @@ exports[`verifiedDomainsLogic values has proper defaults 1`] = ` "recording_domains": [ "https://recordings.posthog.com/", ], + "session_recording_linked_flag": null, + "session_recording_minimum_duration_milliseconds": null, "session_recording_opt_in": true, + "session_recording_sample_rate": "1.0", "slack_incoming_webhook": "", "test_account_filters": [ { diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.test.ts b/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.test.ts index 76df4afccb7c6..ecddf961652d7 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.test.ts +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.test.ts @@ -1,4 +1,4 @@ -import { verifiedDomainsLogic } from './verifiedDomainsLogic' +import { isSecureURL, verifiedDomainsLogic } from './verifiedDomainsLogic' import { initKeaTests } from '~/test/init' import { useAvailableFeatures } from '~/mocks/features' import { AvailableFeature } from '~/types' @@ -55,6 +55,28 @@ describe('verifiedDomainsLogic', () => { logic.mount() }) + describe('isSecureURL', () => { + it('should return true for an https URL', () => { + expect(isSecureURL('https://www.example.com')).toEqual(true) + expect(isSecureURL('https://www.example.com/pathname?query=true#hash')).toEqual(true) + expect(isSecureURL('https://localhost:8080')).toEqual(true) + expect(isSecureURL('https://localhost:8080/pathname?query=true#hash')).toEqual(true) + + expect(isSecureURL('http://www.example.com')).toEqual(false) + expect(isSecureURL('http://www.example.com/pathname?query=true#hash')).toEqual(false) + expect(isSecureURL('http://localhost:8080')).toEqual(false) + expect(isSecureURL('http://localhost:8080/pathname?query=true#hash')).toEqual(false) + + expect(isSecureURL('www.example.com')).toEqual(false) + expect(isSecureURL('www.example.com/pathname?query=true#hash')).toEqual(false) + expect(isSecureURL('localhost:8080')).toEqual(false) + expect(isSecureURL('localhost:8080/pathname?query=true#hash')).toEqual(false) + + expect(isSecureURL('notadomainorurl')).toEqual(false) + expect(isSecureURL('123456')).toEqual(false) + }) + }) + describe('values', () => { it('has proper defaults', async () => { await expectLogic(logic).toFinishAllListeners() diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.ts b/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.ts index 964da2ab343c1..39746e2109b3b 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.ts +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/verifiedDomainsLogic.ts @@ -18,6 +18,15 @@ export type SAMLConfigType = Partial< Pick > +export const isSecureURL = (url: string): boolean => { + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' + } catch (_) { + return false + } +} + export const verifiedDomainsLogic = kea([ path(['scenes', 'organization', 'verifiedDomainsLogic']), connect({ values: [organizationLogic, ['currentOrganization']] }), diff --git a/frontend/src/scenes/organization/Settings/index.tsx b/frontend/src/scenes/organization/Settings/index.tsx index 80b8d81a1f5b7..f37152a173da0 100644 --- a/frontend/src/scenes/organization/Settings/index.tsx +++ b/frontend/src/scenes/organization/Settings/index.tsx @@ -1,9 +1,10 @@ +import { actionToUrl, urlToAction } from 'kea-router' import { useState } from 'react' import { PageHeader } from 'lib/components/PageHeader' import { Invites } from './Invites' import { Members } from './Members' import { organizationLogic } from '../../organizationLogic' -import { kea, useActions, useValues } from 'kea' +import { kea, useActions, useValues, path, actions, reducers } from 'kea' import { DangerZone } from './DangerZone' import { RestrictedArea, RestrictedComponentProps } from 'lib/components/RestrictedArea' import { FEATURE_FLAGS, OrganizationMembershipLevel } from 'lib/constants' @@ -82,30 +83,30 @@ function EmailPreferences({ isRestricted }: RestrictedComponentProps): JSX.Eleme ) } -const organizationSettingsTabsLogic = kea({ - path: ['scenes', 'organization', 'Settings', 'index'], - actions: { +const organizationSettingsTabsLogic = kea([ + path(['scenes', 'organization', 'Settings', 'index']), + actions({ setTab: (tab: OrganizationSettingsTabs) => ({ tab }), - }, - reducers: { + }), + reducers({ tab: [ OrganizationSettingsTabs.GENERAL as OrganizationSettingsTabs, { setTab: (_, { tab }) => tab, }, ], - }, - actionToUrl: () => ({ - setTab: ({ tab }) => `${urls.organizationSettings()}?tab=${tab}`, }), - urlToAction: ({ values, actions }) => ({ + actionToUrl(() => ({ + setTab: ({ tab }) => `${urls.organizationSettings()}?tab=${tab}`, + })), + urlToAction(({ values, actions }) => ({ [urls.organizationSettings()]: (_, searchParams) => { if (searchParams['tab'] && values.tab !== searchParams['tab']) { actions.setTab(searchParams['tab']) } }, - }), -}) + })), +]) export function OrganizationSettings(): JSX.Element { const { user } = useValues(userLogic) diff --git a/frontend/src/scenes/organization/Settings/inviteLogic.ts b/frontend/src/scenes/organization/Settings/inviteLogic.ts index cfb7811a457e6..8ebc6a1a72ef3 100644 --- a/frontend/src/scenes/organization/Settings/inviteLogic.ts +++ b/frontend/src/scenes/organization/Settings/inviteLogic.ts @@ -1,11 +1,12 @@ -import { kea } from 'kea' +import { loaders } from 'kea-loaders' +import { kea, path, connect, actions, reducers, selectors, listeners, events } from 'kea' import { OrganizationInviteType } from '~/types' import api from 'lib/api' import { organizationLogic } from 'scenes/organizationLogic' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import type { inviteLogicType } from './inviteLogicType' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' -import { router } from 'kea-router' +import { router, urlToAction } from 'kea-router' import { lemonToast } from 'lib/lemon-ui/lemonToast' /** State of a single invite row (with input data) in bulk invite creation. */ @@ -18,9 +19,13 @@ export interface InviteRowState { const EMPTY_INVITE: InviteRowState = { target_email: '', first_name: '', isValid: true } -export const inviteLogic = kea({ - path: ['scenes', 'organization', 'Settings', 'inviteLogic'], - actions: { +export const inviteLogic = kea([ + path(['scenes', 'organization', 'Settings', 'inviteLogic']), + connect({ + values: [preflightLogic, ['preflight']], + actions: [router, ['locationChanged']], + }), + actions({ showInviteModal: true, hideInviteModal: true, updateInviteAtIndex: (payload, index: number) => ({ payload, index }), @@ -28,12 +33,45 @@ export const inviteLogic = kea({ updateMessage: (message: string) => ({ message }), appendInviteRow: true, resetInviteRows: true, - }, - connect: { - values: [preflightLogic, ['preflight']], - actions: [router, ['locationChanged']], - }, - reducers: () => ({ + }), + loaders(({ values }) => ({ + invitedTeamMembersInternal: [ + [] as OrganizationInviteType[], + { + inviteTeamMembers: async () => { + if (!values.canSubmit) { + return { invites: [] } + } + + const payload: Pick[] = + values.invitesToSend.filter((invite) => invite.target_email) + eventUsageLogic.actions.reportBulkInviteAttempted( + payload.length, + payload.filter((invite) => !!invite.first_name).length + ) + if (values.message) { + payload.forEach((payload) => (payload.message = values.message)) + } + return await api.create('api/organizations/@current/invites/bulk/', payload) + }, + }, + ], + invites: [ + [] as OrganizationInviteType[], + { + loadInvites: async () => { + return (await api.get('api/organizations/@current/invites/')).results + }, + deleteInvite: async (invite: OrganizationInviteType) => { + await api.delete(`api/organizations/@current/invites/${invite.id}/`) + preflightLogic.actions.loadPreflight() // Make sure licensed_users_available is updated + lemonToast.success(`Invite for ${invite.target_email} has been canceled`) + return values.invites.filter((thisInvite) => thisInvite.id !== invite.id) + }, + }, + ], + })), + reducers(() => ({ isInviteModalShown: [ false, { @@ -66,53 +104,16 @@ export const inviteLogic = kea({ updateMessage: (_, { message }) => message, }, ], - }), - selectors: { + })), + selectors({ canSubmit: [ (selectors) => [selectors.invitesToSend], (invites: InviteRowState[]) => invites.filter(({ target_email }) => !!target_email).length > 0 && invites.filter(({ isValid }) => !isValid).length == 0, ], - }, - loaders: ({ values }) => ({ - invitedTeamMembersInternal: [ - [] as OrganizationInviteType[], - { - inviteTeamMembers: async () => { - if (!values.canSubmit) { - return { invites: [] } - } - - const payload: Pick[] = - values.invitesToSend.filter((invite) => invite.target_email) - eventUsageLogic.actions.reportBulkInviteAttempted( - payload.length, - payload.filter((invite) => !!invite.first_name).length - ) - if (values.message) { - payload.forEach((payload) => (payload.message = values.message)) - } - return await api.create('api/organizations/@current/invites/bulk/', payload) - }, - }, - ], - invites: [ - [] as OrganizationInviteType[], - { - loadInvites: async () => { - return (await api.get('api/organizations/@current/invites/')).results - }, - deleteInvite: async (invite: OrganizationInviteType) => { - await api.delete(`api/organizations/@current/invites/${invite.id}/`) - preflightLogic.actions.loadPreflight() // Make sure licensed_users_available is updated - lemonToast.success(`Invite for ${invite.target_email} has been canceled`) - return values.invites.filter((thisInvite) => thisInvite.id !== invite.id) - }, - }, - ], }), - listeners: ({ values, actions }) => ({ + listeners(({ values, actions }) => ({ inviteTeamMembersSuccess: (): void => { const inviteCount = values.invitedTeamMembersInternal.length if (values.preflight?.email_service_available) { @@ -128,15 +129,15 @@ export const inviteLogic = kea({ actions.hideInviteModal() } }, - }), - events: ({ actions }) => ({ - afterMount: [actions.loadInvites], - }), - urlToAction: ({ actions }) => ({ + })), + urlToAction(({ actions }) => ({ '*': (_, searchParams) => { if (searchParams.invite_modal) { actions.showInviteModal() } }, - }), -}) + })), + events(({ actions }) => ({ + afterMount: [actions.loadInvites], + })), +]) diff --git a/frontend/src/scenes/organization/Settings/invitesLogic.tsx b/frontend/src/scenes/organization/Settings/invitesLogic.tsx index f935bb9c68164..1b119f9a52da9 100644 --- a/frontend/src/scenes/organization/Settings/invitesLogic.tsx +++ b/frontend/src/scenes/organization/Settings/invitesLogic.tsx @@ -1,4 +1,5 @@ -import { kea } from 'kea' +import { loaders } from 'kea-loaders' +import { kea, path, listeners, events } from 'kea' import api from 'lib/api' import { OrganizationInviteType } from '~/types' import type { invitesLogicType } from './invitesLogicType' @@ -6,9 +7,9 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { lemonToast } from 'lib/lemon-ui/lemonToast' -export const invitesLogic = kea({ - path: ['scenes', 'organization', 'Settings', 'invitesLogic'], - loaders: ({ values }) => ({ +export const invitesLogic = kea([ + path(['scenes', 'organization', 'Settings', 'invitesLogic']), + loaders(({ values }) => ({ invites: { __default: [] as OrganizationInviteType[], loadInvites: async () => { @@ -41,8 +42,8 @@ export const invitesLogic = kea({ return values.invites.filter((thisInvite) => thisInvite.id !== invite.id) }, }, - }), - listeners: { + })), + listeners({ createInviteSuccess: async () => { const nameProvided = false // TODO: Change when adding support for names on invites eventUsageLogic.actions.reportInviteAttempted( @@ -50,8 +51,8 @@ export const invitesLogic = kea({ !!preflightLogic.values.preflight?.email_service_available ) }, - }, - events: ({ actions }) => ({ - afterMount: actions.loadInvites, }), -}) + events(({ actions }) => ({ + afterMount: actions.loadInvites, + })), +]) diff --git a/frontend/src/scenes/organization/Settings/membersLogic.tsx b/frontend/src/scenes/organization/Settings/membersLogic.tsx index eb82867e3d3d8..ebf874aaa5063 100644 --- a/frontend/src/scenes/organization/Settings/membersLogic.tsx +++ b/frontend/src/scenes/organization/Settings/membersLogic.tsx @@ -1,4 +1,5 @@ -import { kea } from 'kea' +import { loaders } from 'kea-loaders' +import { kea, path, connect, actions, reducers, selectors, listeners, events } from 'kea' import api from 'lib/api' import type { membersLogicType } from './membersLogicType' import { OrganizationMembershipLevel } from 'lib/constants' @@ -9,26 +10,22 @@ import { membershipLevelToName } from 'lib/utils/permissioning' import { lemonToast } from 'lib/lemon-ui/lemonToast' import Fuse from 'fuse.js' -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface MembersFuse extends Fuse {} -export const membersLogic = kea({ - path: ['scenes', 'organization', 'Settings', 'membersLogic'], - connect: { +export const membersLogic = kea([ + path(['scenes', 'organization', 'Settings', 'membersLogic']), + connect({ values: [userLogic, ['user']], - }, - actions: { + }), + actions({ setSearch: (search) => ({ search }), changeMemberAccessLevel: (member: OrganizationMemberType, level: OrganizationMembershipLevel) => ({ member, level, }), postRemoveMember: (userUuid: string) => ({ userUuid }), - }, - reducers: { - search: ['', { setSearch: (_, { search }) => search }], - }, - loaders: ({ values, actions }) => ({ + }), + loaders(({ values, actions }) => ({ members: { __default: [] as OrganizationMemberType[], loadMembers: async () => { @@ -45,8 +42,11 @@ export const membersLogic = kea({ return values.members.filter((thisMember) => thisMember.user.id !== member.user.id) }, }, + })), + reducers({ + search: ['', { setSearch: (_, { search }) => search }], }), - selectors: { + selectors({ meFirstMembers: [ (s) => [s.members, s.user], (members, user) => { @@ -73,8 +73,8 @@ export const membersLogic = kea({ (members, membersFuse, search) => search ? membersFuse.search(search).map((result) => result.item) : members, ], - }, - listeners: ({ actions }) => ({ + }), + listeners(({ actions }) => ({ changeMemberAccessLevel: async ({ member, level }) => { await api.update(`api/organizations/@current/members/${member.user.uuid}/`, { level }) lemonToast.success( @@ -93,8 +93,8 @@ export const membersLogic = kea({ location.reload() } }, - }), - events: ({ actions }) => ({ + })), + events(({ actions }) => ({ afterMount: actions.loadMembers, - }), -}) + })), +]) diff --git a/frontend/src/scenes/organizationLogic.tsx b/frontend/src/scenes/organizationLogic.tsx index d3ef2f087070f..c582391f506ef 100644 --- a/frontend/src/scenes/organizationLogic.tsx +++ b/frontend/src/scenes/organizationLogic.tsx @@ -1,4 +1,4 @@ -import { kea } from 'kea' +import { actions, afterMount, kea, listeners, path, reducers, selectors } from 'kea' import api from 'lib/api' import type { organizationLogicType } from './organizationLogicType' import { AvailableFeature, OrganizationType } from '~/types' @@ -7,19 +7,20 @@ import { getAppContext } from 'lib/utils/getAppContext' import { OrganizationMembershipLevel } from 'lib/constants' import { isUserLoggedIn } from 'lib/utils' import { lemonToast } from 'lib/lemon-ui/lemonToast' +import { loaders } from 'kea-loaders' export type OrganizationUpdatePayload = Partial< Pick > -export const organizationLogic = kea({ - path: ['scenes', 'organizationLogic'], - actions: { +export const organizationLogic = kea([ + path(['scenes', 'organizationLogic']), + actions({ deleteOrganization: (organization: OrganizationType) => ({ organization }), deleteOrganizationSuccess: true, deleteOrganizationFailure: true, - }, - reducers: { + }), + reducers({ organizationBeingDeleted: [ null as OrganizationType | null, { @@ -28,38 +29,8 @@ export const organizationLogic = kea({ deleteOrganizationFailure: () => null, }, ], - }, - selectors: { - hasDashboardCollaboration: [ - (s) => [s.currentOrganization], - (currentOrganization) => - currentOrganization?.available_features?.includes(AvailableFeature.DASHBOARD_COLLABORATION), - ], - isCurrentOrganizationUnavailable: [ - (s) => [s.currentOrganization, s.currentOrganizationLoading], - (currentOrganization, currentOrganizationLoading): boolean => - !currentOrganization?.membership_level && !currentOrganizationLoading, - ], - projectCreationForbiddenReason: [ - (s) => [s.currentOrganization], - (currentOrganization): string | null => - !currentOrganization?.membership_level || - currentOrganization.membership_level < OrganizationMembershipLevel.Admin - ? 'You need to be an organization admin or above to create new projects.' - : null, - ], - isAdminOrOwner: [ - (s) => [s.currentOrganization], - (currentOrganization): boolean | null => - !!( - currentOrganization?.membership_level && - [OrganizationMembershipLevel.Admin, OrganizationMembershipLevel.Owner].includes( - currentOrganization.membership_level - ) - ), - ], - }, - loaders: ({ values }) => ({ + }), + loaders(({ values }) => ({ currentOrganization: [ null as OrganizationType | null, { @@ -89,8 +60,38 @@ export const organizationLogic = kea({ completeOnboarding: async () => await api.create('api/organizations/@current/onboarding/', {}), }, ], + })), + selectors({ + hasDashboardCollaboration: [ + (s) => [s.currentOrganization], + (currentOrganization) => + currentOrganization?.available_features?.includes(AvailableFeature.DASHBOARD_COLLABORATION), + ], + isCurrentOrganizationUnavailable: [ + (s) => [s.currentOrganization, s.currentOrganizationLoading], + (currentOrganization, currentOrganizationLoading): boolean => + !currentOrganization?.membership_level && !currentOrganizationLoading, + ], + projectCreationForbiddenReason: [ + (s) => [s.currentOrganization], + (currentOrganization): string | null => + !currentOrganization?.membership_level || + currentOrganization.membership_level < OrganizationMembershipLevel.Admin + ? 'You need to be an organization admin or above to create new projects.' + : null, + ], + isAdminOrOwner: [ + (s) => [s.currentOrganization], + (currentOrganization): boolean | null => + !!( + currentOrganization?.membership_level && + [OrganizationMembershipLevel.Admin, OrganizationMembershipLevel.Owner].includes( + currentOrganization.membership_level + ) + ), + ], }), - listeners: ({ actions }) => ({ + listeners(({ actions }) => ({ createOrganizationSuccess: () => { window.location.href = '/organization/members' }, @@ -109,18 +110,16 @@ export const organizationLogic = kea({ deleteOrganizationSuccess: () => { lemonToast.success('Organization has been deleted') }, + })), + afterMount(({ actions }) => { + const appContext = getAppContext() + const contextualOrganization = appContext?.current_user?.organization + if (contextualOrganization) { + // If app context is available (it should be practically always) we can immediately know currentOrganization + actions.loadCurrentOrganizationSuccess(contextualOrganization) + } else { + // If app context is not available, a traditional request is needed + actions.loadCurrentOrganization() + } }), - events: ({ actions }) => ({ - afterMount: () => { - const appContext = getAppContext() - const contextualOrganization = appContext?.current_user?.organization - if (contextualOrganization) { - // If app context is available (it should be practically always) we can immediately know currentOrganization - actions.loadCurrentOrganizationSuccess(contextualOrganization) - } else { - // If app context is not available, a traditional request is needed - actions.loadCurrentOrganization() - } - }, - }), -}) +]) diff --git a/frontend/src/scenes/paths/PathNodeCardButton.tsx b/frontend/src/scenes/paths/PathNodeCardButton.tsx index d849df0694458..9923b6f2eac19 100644 --- a/frontend/src/scenes/paths/PathNodeCardButton.tsx +++ b/frontend/src/scenes/paths/PathNodeCardButton.tsx @@ -53,7 +53,7 @@ export function PathNodeCardButton({
- + {count} diff --git a/frontend/src/scenes/persons/PersonDeleteModal.tsx b/frontend/src/scenes/persons/PersonDeleteModal.tsx index 30df1eebc499c..4a026ec4c5b3b 100644 --- a/frontend/src/scenes/persons/PersonDeleteModal.tsx +++ b/frontend/src/scenes/persons/PersonDeleteModal.tsx @@ -1,5 +1,5 @@ import { useActions, useValues } from 'kea' -import { LemonButton, LemonModal } from '@posthog/lemon-ui' +import { LemonButton, LemonModal, Link } from '@posthog/lemon-ui' import { PersonType } from '~/types' import { personDeleteModalLogic } from 'scenes/persons/personDeleteModalLogic' import { asDisplay } from './person-utils' @@ -22,16 +22,10 @@ export function PersonDeleteModal(): JSX.Element | null {

If you opt to delete the person and its corresponding events, the events will not be immediately - removed. Instead these events will be deleted on a set schedule during non-peak usage times. - - {' '} + removed. Instead these events will be deleted on a set schedule during non-peak usage times.{' '} + Learn more - +

} diff --git a/frontend/src/scenes/persons/PersonDisplay.tsx b/frontend/src/scenes/persons/PersonDisplay.tsx index 85c65e8e1fcf6..06b3e4b2158ad 100644 --- a/frontend/src/scenes/persons/PersonDisplay.tsx +++ b/frontend/src/scenes/persons/PersonDisplay.tsx @@ -7,6 +7,8 @@ import { PersonPreview } from './PersonPreview' import { useMemo, useState } from 'react' import { router } from 'kea-router' import { asDisplay, asLink } from './person-utils' +import { useNotebookNode } from 'scenes/notebooks/Nodes/notebookNodeLogic' +import { NotebookNodeType } from '~/types' type PersonPropType = | { properties?: Record; distinct_ids?: string[]; distinct_id?: never } @@ -15,15 +17,18 @@ type PersonPropType = export interface PersonDisplayProps { person?: PersonPropType | null withIcon?: boolean | ProfilePictureProps['size'] + href?: string noLink?: boolean noEllipsis?: boolean noPopover?: boolean + isCentered?: boolean } -export function PersonDisplay({ person, withIcon, noEllipsis, noPopover, noLink }: PersonDisplayProps): JSX.Element { - const href = asLink(person) +export function PersonIcon({ + person, + ...props +}: Pick & Omit): JSX.Element { const display = asDisplay(person) - const [visible, setVisible] = useState(false) const email: string | undefined = useMemo(() => { // The email property could be correct but it could also be set strangely such as an array or not even a string @@ -33,11 +38,26 @@ export function PersonDisplay({ person, withIcon, noEllipsis, noPopover, noLink return typeof possibleEmail === 'string' ? possibleEmail : undefined }, [person?.properties?.email]) + return +} + +export function PersonDisplay({ + person, + withIcon, + noEllipsis, + noPopover, + noLink, + isCentered, + href = asLink(person), +}: PersonDisplayProps): JSX.Element { + const display = asDisplay(person) + const [visible, setVisible] = useState(false) + + const notebookNode = useNotebookNode() + let content = ( - - {withIcon && ( - - )} + + {withIcon && } {display} ) @@ -52,6 +72,19 @@ export function PersonDisplay({ person, withIcon, noEllipsis, noPopover, noLink router.actions.push(href) } else { setVisible(true) + + if (notebookNode && person) { + notebookNode.actions.updateAttributes({ + children: [ + { + type: NotebookNodeType.Person, + attrs: { + id: person.distinct_id || person.distinct_ids?.[0], + }, + }, + ], + }) + } } } : undefined @@ -76,20 +109,26 @@ export function PersonDisplay({ person, withIcon, noEllipsis, noPopover, noLink ) - content = noPopover ? ( - content - ) : ( - } - visible={visible} - onClickOutside={() => setVisible(false)} - placement="right" - fallbackPlacements={['bottom', 'top']} - showArrow - > - {content} - - ) + content = + noPopover || notebookNode ? ( + content + ) : ( + setVisible(false)} + /> + } + visible={visible} + onClickOutside={() => setVisible(false)} + placement="right" + fallbackPlacements={['bottom', 'top']} + showArrow + > + {content} + + ) return content } diff --git a/frontend/src/scenes/persons/PersonFeedCanvas.tsx b/frontend/src/scenes/persons/PersonFeedCanvas.tsx new file mode 100644 index 0000000000000..fd3ebd2448934 --- /dev/null +++ b/frontend/src/scenes/persons/PersonFeedCanvas.tsx @@ -0,0 +1,61 @@ +import { useValues } from 'kea' + +import { PersonType } from '~/types' +import { Notebook } from 'scenes/notebooks/Notebook/Notebook' +import { uuid } from 'lib/utils' +import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' + +type PersonFeedCanvasProps = { + person: PersonType +} + +const PersonFeedCanvas = ({ person }: PersonFeedCanvasProps): JSX.Element => { + const { isCloudOrDev } = useValues(preflightLogic) + + const id = person.id + + const personId = person.distinct_ids[0] + + return ( + + ) +} + +export default PersonFeedCanvas diff --git a/frontend/src/scenes/persons/PersonPreview.tsx b/frontend/src/scenes/persons/PersonPreview.tsx index fbf6e49993626..7b9e61fe377aa 100644 --- a/frontend/src/scenes/persons/PersonPreview.tsx +++ b/frontend/src/scenes/persons/PersonPreview.tsx @@ -5,12 +5,14 @@ import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' import { LemonButton, Link } from '@posthog/lemon-ui' import { urls } from 'scenes/urls' import { PropertiesTable } from 'lib/components/PropertiesTable' -import { PropertyDefinitionType } from '~/types' +import { NotebookNodeType, PropertyDefinitionType } from '~/types' import { IconOpenInNew } from 'lib/lemon-ui/icons' import { asDisplay } from './person-utils' +import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' export type PersonPreviewProps = { distinctId: string | undefined + onClose?: () => void } export function PersonPreview(props: PersonPreviewProps): JSX.Element | null { @@ -30,15 +32,30 @@ export function PersonPreview(props: PersonPreviewProps): JSX.Element | null { } const display = asDisplay(person) - const url = urls.person(person?.distinct_ids[0]) + const url = urls.personByDistinctId(person?.distinct_ids[0]) return (
- + {display} - } to={urls.person(person?.distinct_ids[0])} /> + + props.onClose?.()} + size="small" + /> + } + to={urls.personByDistinctId(person?.distinct_ids[0])} + />
diff --git a/frontend/src/scenes/persons/Person.tsx b/frontend/src/scenes/persons/PersonScene.tsx similarity index 90% rename from frontend/src/scenes/persons/Person.tsx rename to frontend/src/scenes/persons/PersonScene.tsx index 57aa9232c9c4a..bce2a2b72d130 100644 --- a/frontend/src/scenes/persons/Person.tsx +++ b/frontend/src/scenes/persons/PersonScene.tsx @@ -10,7 +10,7 @@ import { PersonCohorts } from './PersonCohorts' import { PropertiesTable } from 'lib/components/PropertiesTable' import { TZLabel } from 'lib/components/TZLabel' import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { PersonsTabType, PersonType, PropertyDefinitionType } from '~/types' +import { NotebookNodeType, PersonsTabType, PersonType, PropertyDefinitionType } from '~/types' import { PageHeader } from 'lib/components/PageHeader' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -23,7 +23,6 @@ import { teamLogic } from 'scenes/teamLogic' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { PersonDeleteModal } from 'scenes/persons/PersonDeleteModal' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' -import { SessionRecordingsPlaylist } from 'scenes/session-recordings/playlist/SessionRecordingsPlaylist' import { NotFound } from 'lib/components/NotFound' import { RelatedFeatureFlags } from './RelatedFeatureFlags' import { Query } from '~/queries/Query/Query' @@ -33,9 +32,12 @@ import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' import { IconInfo } from 'lib/lemon-ui/icons' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { PersonDashboard } from './PersonDashboard' +import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' +import { SessionRecordingsPlaylist } from 'scenes/session-recordings/playlist/SessionRecordingsPlaylist' +import PersonFeedCanvas from './PersonFeedCanvas' export const scene: SceneExport = { - component: Person, + component: PersonScene, logic: personsLogic, paramsToProps: ({ params: { _: rawUrlId } }): (typeof personsLogic)['props'] => ({ syncWithUrl: true, @@ -105,9 +107,10 @@ function PersonCaption({ person }: { person: PersonType }): JSX.Element { ) } -export function Person(): JSX.Element | null { +export function PersonScene(): JSX.Element | null { const { showCustomerSuccessDashboards, + feedEnabled, person, personLoading, currentTab, @@ -126,7 +129,7 @@ export function Person(): JSX.Element | null { return personLoading ? : } - const url = urls.person(urlId || person.distinct_ids[0] || String(person.id)) + const url = urls.personByDistinctId(urlId || person.distinct_ids[0] || String(person.id)) return ( <> @@ -142,6 +145,15 @@ export function Person(): JSX.Element | null { } buttons={
+ showPersonDeleteModal(person, () => loadPersons())} disabled={deletedPersonLoading} @@ -175,6 +187,13 @@ export function Person(): JSX.Element | null { }} data-attr="persons-tabs" tabs={[ + feedEnabled + ? { + key: PersonsTabType.FEED, + label: Feed, + content: , + } + : false, { key: PersonsTabType.PROPERTIES, label: Properties, @@ -225,7 +244,9 @@ export function Person(): JSX.Element | null {
) : null} - +
+ +
), }, @@ -262,7 +283,7 @@ export function Person(): JSX.Element | null {
value && setDistinctId(value)} options={person.distinct_ids.map((distinct_id) => ({ label: distinct_id, diff --git a/frontend/src/scenes/persons/Persons.tsx b/frontend/src/scenes/persons/Persons.tsx index a8d91faa203af..2393c765e76bc 100644 --- a/frontend/src/scenes/persons/Persons.tsx +++ b/frontend/src/scenes/persons/Persons.tsx @@ -13,6 +13,7 @@ import { LemonTableColumn } from 'lib/lemon-ui/LemonTable' import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' import { router } from 'kea-router' import { urls } from 'scenes/urls' +import { Link } from '@posthog/lemon-ui' interface PersonsProps { cohort?: CohortType['id'] @@ -82,8 +83,8 @@ export function PersonsScene({ <> Exporting by CSV is limited to 10,000 users.
- To export more, please use the API. Do you want - to export by CSV? + To export more, please use the API. Do you + want to export by CSV? } onConfirm={() => triggerExport(exporterProps[0])} @@ -93,7 +94,7 @@ export function PersonsScene({ icon={} > {listFilters.properties && listFilters.properties.length > 0 ? ( -
+
Export ({listFilters.properties.length} filter)
) : ( diff --git a/frontend/src/scenes/persons/RelatedFeatureFlags.tsx b/frontend/src/scenes/persons/RelatedFeatureFlags.tsx index 49d8ab3f35a41..3e1505f4b1aeb 100644 --- a/frontend/src/scenes/persons/RelatedFeatureFlags.tsx +++ b/frontend/src/scenes/persons/RelatedFeatureFlags.tsx @@ -7,6 +7,7 @@ import stringWithWBR from 'lib/utils/stringWithWBR' import { urls } from 'scenes/urls' import { FeatureFlagReleaseType } from '~/types' import { relatedFeatureFlagsLogic, RelatedFeatureFlag } from './relatedFeatureFlagsLogic' +import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' interface Props { distinctId: string @@ -55,7 +56,11 @@ export function RelatedFeatureFlags({ distinctId, groups }: Props): JSX.Element {isExperiment ? 'Experiment' : 'Feature flag'} - {featureFlag.name && {featureFlag.name}} + {featureFlag.name && ( + + {featureFlag.name} + + )} ) }, @@ -78,7 +83,7 @@ export function RelatedFeatureFlags({ distinctId, groups }: Props): JSX.Element
{featureFlag.active && featureFlag.value ? capitalizeFirstLetter(featureFlag.value.toString()) - : '--'} + : 'False'}
) }, @@ -112,6 +117,16 @@ export function RelatedFeatureFlags({ distinctId, groups }: Props): JSX.Element }, }, ] + + const options = [ + { label: 'All types', value: 'all' }, + { + label: FeatureFlagReleaseType.ReleaseToggle, + value: FeatureFlagReleaseType.ReleaseToggle, + }, + { label: FeatureFlagReleaseType.Variants, value: FeatureFlagReleaseType.Variants }, + ] + return ( <>
@@ -126,14 +141,7 @@ export function RelatedFeatureFlags({ distinctId, groups }: Props): JSX.Element Type { if (type) { if (type === 'all') { @@ -153,11 +161,13 @@ export function RelatedFeatureFlags({ distinctId, groups }: Props): JSX.Element Match evaluation { if (reason) { if (reason === 'all') { @@ -174,7 +184,7 @@ export function RelatedFeatureFlags({ distinctId, groups }: Props): JSX.Element dropdownMaxContentWidth /> - Status + Flag status { @@ -189,11 +199,13 @@ export function RelatedFeatureFlags({ distinctId, groups }: Props): JSX.Element } } }} - options={[ - { label: 'All', value: 'all' }, - { label: 'Enabled', value: 'true' }, - { label: 'Disabled', value: 'false' }, - ]} + options={ + [ + { label: 'All', value: 'all' }, + { label: 'Enabled', value: 'true' }, + { label: 'Disabled', value: 'false' }, + ] as { label: string; value: string }[] + } value="all" dropdownMaxContentWidth /> diff --git a/frontend/src/scenes/persons/activityDescriptions.tsx b/frontend/src/scenes/persons/activityDescriptions.tsx index f6e13f0d0d921..f11568827c9dd 100644 --- a/frontend/src/scenes/persons/activityDescriptions.tsx +++ b/frontend/src/scenes/persons/activityDescriptions.tsx @@ -68,7 +68,7 @@ export function personActivityDescriber(logItem: ActivityLogItem): HumanizedChan } listParts={distinctIds.map((di) => ( - {di} + {di} ))} /> diff --git a/frontend/src/scenes/persons/mergeSplitPersonLogic.ts b/frontend/src/scenes/persons/mergeSplitPersonLogic.ts index b695f4f36112c..3e5c4dae2417f 100644 --- a/frontend/src/scenes/persons/mergeSplitPersonLogic.ts +++ b/frontend/src/scenes/persons/mergeSplitPersonLogic.ts @@ -1,4 +1,5 @@ -import { kea } from 'kea' +import { loaders } from 'kea-loaders' +import { kea, props, key, path, connect, actions, reducers, listeners, events } from 'kea' import { router } from 'kea-router' import api from 'lib/api' import { lemonToast } from 'lib/lemon-ui/lemonToast' @@ -13,41 +14,22 @@ export interface SplitPersonLogicProps { export type PersonUuids = NonNullable[] -export const mergeSplitPersonLogic = kea({ - props: {} as SplitPersonLogicProps, - key: (props) => props.person.id ?? 'new', - path: (key) => ['scenes', 'persons', 'mergeSplitPersonLogic', key], - connect: () => ({ +export const mergeSplitPersonLogic = kea([ + props({} as SplitPersonLogicProps), + key((props) => props.person.id ?? 'new'), + path((key) => ['scenes', 'persons', 'mergeSplitPersonLogic', key]), + connect(() => ({ actions: [ personsLogic({ syncWithUrl: true }), ['setListFilters', 'loadPersons', 'setPerson', 'setSplitMergeModalShown'], ], values: [personsLogic({ syncWithUrl: true }), ['persons']], - }), - actions: { + })), + actions({ setSelectedPersonToAssignSplit: (id: string) => ({ id }), cancel: true, - }, - reducers: ({ props }) => ({ - person: [props.person, {}], - selectedPersonsToAssignSplit: [ - null as null | string, - { - setSelectedPersonToAssignSplit: (_, { id }) => id, - }, - ], - }), - listeners: ({ actions, values }) => ({ - setListFilters: () => { - actions.loadPersons() - }, - cancel: () => { - if (!values.executedLoading) { - actions.setSplitMergeModalShown(false) - } - }, }), - loaders: ({ values, actions }) => ({ + loaders(({ values, actions }) => ({ executed: [ false, { @@ -70,8 +52,27 @@ export const mergeSplitPersonLogic = kea({ }, }, ], - }), - events: ({ actions }) => ({ + })), + reducers(({ props }) => ({ + person: [props.person, {}], + selectedPersonsToAssignSplit: [ + null as null | string, + { + setSelectedPersonToAssignSplit: (_, { id }) => id, + }, + ], + })), + listeners(({ actions, values }) => ({ + setListFilters: () => { + actions.loadPersons() + }, + cancel: () => { + if (!values.executedLoading) { + actions.setSplitMergeModalShown(false) + } + }, + })), + events(({ actions }) => ({ afterMount: [actions.loadPersons], - }), -}) + })), +]) diff --git a/frontend/src/scenes/persons/person-utils.test.ts b/frontend/src/scenes/persons/person-utils.test.ts index c4cb7777b9572..2baca39945f4b 100644 --- a/frontend/src/scenes/persons/person-utils.test.ts +++ b/frontend/src/scenes/persons/person-utils.test.ts @@ -6,10 +6,10 @@ import { asLink, asDisplay } from './person-utils' describe('the person header', () => { describe('linking to a person', () => { const personLinksTestCases = [ - { distinctIds: ['a uuid'], expectedLink: urls.person('a uuid'), name: 'with one id' }, + { distinctIds: ['a uuid'], expectedLink: urls.personByDistinctId('a uuid'), name: 'with one id' }, { distinctIds: ['the first uuid', 'a uuid'], - expectedLink: urls.person('the first uuid'), + expectedLink: urls.personByDistinctId('the first uuid'), name: 'with more than one id', }, { @@ -19,7 +19,7 @@ describe('the person header', () => { }, { distinctIds: ['a+dicey/@!'], - expectedLink: urls.person('a+dicey/@!'), + expectedLink: urls.personByDistinctId('a+dicey/@!'), name: 'with no ids', }, ] diff --git a/frontend/src/scenes/persons/person-utils.ts b/frontend/src/scenes/persons/person-utils.ts index c1f9dc63b11d9..35928473b08be 100644 --- a/frontend/src/scenes/persons/person-utils.ts +++ b/frontend/src/scenes/persons/person-utils.ts @@ -60,7 +60,7 @@ export function asDisplay(person: PersonPropType | null | undefined, maxLength?: export const asLink = (person?: PersonPropType | null): string | undefined => person?.distinct_id - ? urls.person(person.distinct_id) + ? urls.personByDistinctId(person.distinct_id) : person?.distinct_ids?.length - ? urls.person(person.distinct_ids[0]) + ? urls.personByDistinctId(person.distinct_ids[0]) : undefined diff --git a/frontend/src/scenes/persons/personsLogic.tsx b/frontend/src/scenes/persons/personsLogic.tsx index 7db36eba90513..136d69f317baf 100644 --- a/frontend/src/scenes/persons/personsLogic.tsx +++ b/frontend/src/scenes/persons/personsLogic.tsx @@ -1,5 +1,6 @@ -import { kea } from 'kea' -import { decodeParams, router } from 'kea-router' +import { loaders } from 'kea-loaders' +import { kea, props, key, path, connect, actions, reducers, selectors, listeners, events } from 'kea' +import { decodeParams, router, actionToUrl, urlToAction } from 'kea-router' import api, { CountedPaginatedResponse } from 'lib/api' import type { personsLogicType } from './personsLogicType' import { @@ -22,6 +23,7 @@ import { TriggerExportProps } from 'lib/components/ExportButton/exporter' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { FEATURE_FLAGS } from 'lib/constants' import { asDisplay } from './person-utils' +import { hogqlQuery } from '~/queries/query' export interface PersonsLogicProps { cohort?: number | 'new' @@ -30,24 +32,25 @@ export interface PersonsLogicProps { fixedProperties?: PersonPropertyFilter[] } -export const personsLogic = kea({ - props: {} as PersonsLogicProps, - key: (props) => { +export const personsLogic = kea([ + props({} as PersonsLogicProps), + key((props) => { if (props.fixedProperties) { return JSON.stringify(props.fixedProperties) } return props.cohort ? `cohort_${props.cohort}` : 'scene' - }, - path: (key) => ['scenes', 'persons', 'personsLogic', key], - connect: { + }), + path((key) => ['scenes', 'persons', 'personsLogic', key]), + connect(() => ({ actions: [eventUsageLogic, ['reportPersonDetailViewed']], values: [teamLogic, ['currentTeam'], featureFlagLogic, ['featureFlags']], - }, - actions: { + })), + actions({ setPerson: (person: PersonType | null) => ({ person }), setPersons: (persons: PersonType[]) => ({ persons }), loadPerson: (id: string) => ({ id }), + loadPersonUUID: (uuid: string) => ({ uuid }), loadPersons: (url: string | null = '') => ({ url }), setListFilters: (payload: PersonListParams) => ({ payload }), setHiddenListProperties: (payload: AnyPropertyFilter[]) => ({ payload }), @@ -58,8 +61,106 @@ export const personsLogic = kea({ setActiveTab: (tab: PersonsTabType) => ({ tab }), setSplitMergeModalShown: (shown: boolean) => ({ shown }), setDistinctId: (distinctId: string) => ({ distinctId }), - }, - reducers: () => ({ + }), + loaders(({ values, actions, props }) => ({ + persons: [ + { next: null, previous: null, count: 0, results: [], offset: 0 } as CountedPaginatedResponse & { + offset: number + }, + { + loadPersons: async ({ url }) => { + let result: CountedPaginatedResponse & { offset: number } + if (!url) { + const newFilters: PersonListParams = { ...values.listFilters } + newFilters.properties = [ + ...(values.listFilters.properties || []), + ...values.hiddenListProperties, + ] + if (values.featureFlags[FEATURE_FLAGS.POSTHOG_3000]) { + newFilters.include_total = true // The total count is slow, but needed for infinite loading + } + if (props.cohort) { + result = { + ...(await api.get(`api/cohort/${props.cohort}/persons/?${toParams(newFilters)}`)), + offset: 0, + } + } else { + result = { ...(await api.persons.list(newFilters)), offset: 0 } + } + } else { + result = { ...(await api.get(url)), offset: parseInt(decodeParams(url).offset) } + } + return result + }, + }, + ], + person: [ + null as PersonType | null, + { + loadPerson: async ({ id }): Promise => { + if (values.featureFlags[FEATURE_FLAGS.PERSONS_HOGQL_QUERY]) { + const response = await hogqlQuery( + 'select id, groupArray(pdi.distinct_id) as distinct_ids, properties, is_identified, created_at from persons where pdi.distinct_id={distinct_id} group by id, properties, is_identified, created_at', + { distinct_id: id } + ) + const row = response?.results?.[0] + if (row) { + const person: PersonType = { + id: row[0], + uuid: row[0], + distinct_ids: row[1], + properties: JSON.parse(row[2] || '{}'), + is_identified: !!row[3], + created_at: row[4], + } + actions.reportPersonDetailViewed(person) + return person + } + } + + const response = await api.persons.list({ distinct_id: id }) + const person = response.results[0] + if (person) { + actions.reportPersonDetailViewed(person) + } + return person + }, + loadPersonUUID: async ({ uuid }): Promise => { + const response = await hogqlQuery( + 'select id, groupArray(pdi.distinct_id) as distinct_ids, properties, is_identified, created_at from persons where id={id} group by id, properties, is_identified, created_at', + { id: uuid } + ) + const row = response?.results?.[0] + if (row) { + const person: PersonType = { + id: row[0], + uuid: row[0], + distinct_ids: row[1], + properties: JSON.parse(row[2] || '{}'), + is_identified: !!row[3], + created_at: row[4], + } + actions.reportPersonDetailViewed(person) + return person + } + return null + }, + }, + ], + cohorts: [ + null as CohortType[] | null, + { + loadCohorts: async (): Promise => { + if (!values.person?.id) { + return null + } + const response = await api.get(`api/person/cohorts/?person_id=${values.person?.id}`) + return response.results + }, + }, + ], + })), + reducers(() => ({ listFilters: [ {} as PersonListParams, { @@ -124,21 +225,20 @@ export const personsLogic = kea({ setDistinctId: (_, { distinctId }) => distinctId, }, ], - }), - selectors: () => ({ + })), + selectors(() => ({ apiDocsURL: [ () => [(_, props) => props.cohort], (cohort: PersonsLogicProps['cohort']) => - !!cohort + cohort ? 'https://posthog.com/docs/api/cohorts#get-api-projects-project_id-cohorts-id-persons' : 'https://posthog.com/docs/api/persons', ], cohortId: [() => [(_, props) => props.cohort], (cohort: PersonsLogicProps['cohort']) => cohort], - currentTab: [ - (s) => [s.activeTab], - (activeTab) => { - return activeTab || PersonsTabType.PROPERTIES - }, + currentTab: [(s) => [s.activeTab, s.defaultTab], (activeTab, defaultTab) => activeTab || defaultTab], + defaultTab: [ + (s) => [s.feedEnabled], + (feedEnabled) => (feedEnabled ? PersonsTabType.FEED : PersonsTabType.PROPERTIES), ], breadcrumbs: [ (s) => [s.person, router.selectors.location], @@ -168,7 +268,6 @@ export const personsLogic = kea({ path: cohort ? api.cohorts.determineListUrl(cohort, listFilters) : api.persons.determineListUrl(listFilters), - max_limit: 10000, }, }, ], @@ -178,8 +277,9 @@ export const personsLogic = kea({ (s) => [s.featureFlags], (featureFlags) => featureFlags[FEATURE_FLAGS.CS_DASHBOARDS], ], - }), - listeners: ({ actions, values }) => ({ + feedEnabled: [(s) => [s.featureFlags], (featureFlags) => !!featureFlags[FEATURE_FLAGS.PERSON_FEED_CANVAS]], + })), + listeners(({ actions, values }) => ({ editProperty: async ({ key, newValue }) => { const person = values.person @@ -244,66 +344,8 @@ export const personsLogic = kea({ navigateToCohort: ({ cohort }) => { router.actions.push(urls.cohort(cohort.id)) }, - }), - loaders: ({ values, actions, props }) => ({ - persons: [ - { next: null, previous: null, count: 0, results: [], offset: 0 } as CountedPaginatedResponse & { - offset: number - }, - { - loadPersons: async ({ url }) => { - let result: CountedPaginatedResponse & { offset: number } - if (!url) { - const newFilters: PersonListParams = { ...values.listFilters } - newFilters.properties = [ - ...(values.listFilters.properties || []), - ...values.hiddenListProperties, - ] - if (values.featureFlags[FEATURE_FLAGS.POSTHOG_3000]) { - newFilters.include_total = true // The total count is slow, but needed for infinite loading - } - if (props.cohort) { - result = { - ...(await api.get(`api/cohort/${props.cohort}/persons/?${toParams(newFilters)}`)), - offset: 0, - } - } else { - result = { ...(await api.persons.list(newFilters)), offset: 0 } - } - } else { - result = { ...(await api.get(url)), offset: parseInt(decodeParams(url).offset) } - } - return result - }, - }, - ], - person: [ - null as PersonType | null, - { - loadPerson: async ({ id }): Promise => { - const response = await api.persons.list({ distinct_id: id }) - const person = response.results[0] - if (person) { - actions.reportPersonDetailViewed(person) - } - return person - }, - }, - ], - cohorts: [ - null as CohortType[] | null, - { - loadCohorts: async (): Promise => { - if (!values.person?.id) { - return null - } - const response = await api.get(`api/person/cohorts/?person_id=${values.person?.id}`) - return response.results - }, - }, - ], - }), - actionToUrl: ({ values, props }) => ({ + })), + actionToUrl(({ values, props }) => ({ setListFilters: () => { if (props.syncWithUrl && router.values.location.pathname.indexOf('/persons') > -1) { return ['/persons', values.listFilters, undefined, { replace: true }] @@ -323,8 +365,8 @@ export const personsLogic = kea({ ] } }, - }), - urlToAction: ({ actions, values, props }) => ({ + })), + urlToAction(({ actions, values, props }) => ({ '/person/*': ({ _: rawPersonDistinctId }, { sessionRecordingId }, { activeTab }) => { if (props.syncWithUrl) { if (sessionRecordingId && values.activeTab !== PersonsTabType.SESSION_RECORDINGS) { @@ -334,7 +376,7 @@ export const personsLogic = kea({ } if (!activeTab) { - actions.setActiveTab(PersonsTabType.PROPERTIES) + actions.setActiveTab(values.defaultTab) } if (rawPersonDistinctId) { @@ -347,8 +389,28 @@ export const personsLogic = kea({ } } }, - }), - events: ({ props, actions }) => ({ + '/persons/*': ({ _: rawPersonUUID }, { sessionRecordingId }, { activeTab }) => { + if (props.syncWithUrl) { + if (sessionRecordingId && values.activeTab !== PersonsTabType.SESSION_RECORDINGS) { + actions.navigateToTab(PersonsTabType.SESSION_RECORDINGS) + } else if (activeTab && values.activeTab !== activeTab) { + actions.navigateToTab(activeTab as PersonsTabType) + } + + if (!activeTab) { + actions.setActiveTab(values.defaultTab) + } + + if (rawPersonUUID) { + const decodedPersonUUID = decodeURIComponent(rawPersonUUID) + if (!values.person || values.person.id != decodedPersonUUID) { + actions.loadPersonUUID(decodedPersonUUID) + } + } + } + }, + })), + events(({ props, actions }) => ({ afterMount: () => { if (props.cohort && typeof props.cohort === 'number') { actions.setListFilters({ cohort: props.cohort }) @@ -360,5 +422,5 @@ export const personsLogic = kea({ actions.loadPersons() } }, - }), -}) + })), +]) diff --git a/frontend/src/scenes/persons/personsSceneLogic.ts b/frontend/src/scenes/persons/personsSceneLogic.ts index 336c562b8a89b..080f644d47a64 100644 --- a/frontend/src/scenes/persons/personsSceneLogic.ts +++ b/frontend/src/scenes/persons/personsSceneLogic.ts @@ -1,4 +1,4 @@ -import { actions, kea, path, reducers } from 'kea' +import { actions, connect, kea, path, reducers, selectors } from 'kea' import { actionToUrl, urlToAction } from 'kea-router' import equal from 'fast-deep-equal' @@ -8,25 +8,42 @@ import { objectsEqual } from 'lib/utils' import { lemonToast } from 'lib/lemon-ui/lemonToast' import type { personsSceneLogicType } from './personsSceneLogicType' +import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' -const getDefaultQuery = (): DataTableNode => ({ +const getDefaultQuery = (usePersonsQuery = false): DataTableNode => ({ kind: NodeKind.DataTableNode, - source: { kind: NodeKind.PersonsNode }, + source: usePersonsQuery + ? { kind: NodeKind.PersonsQuery, select: defaultDataTableColumns(NodeKind.PersonsQuery) } + : { kind: NodeKind.PersonsNode }, full: true, propertiesViaUrl: true, }) export const personsSceneLogic = kea([ path(['scenes', 'persons', 'personsSceneLogic']), + connect({ values: [featureFlagLogic, ['featureFlags']] }), + selectors({ + queryFlagEnabled: [ + (s) => [s.featureFlags], + (featureFlags) => !!featureFlags?.[FEATURE_FLAGS.PERSONS_HOGQL_QUERY], + ], + }), actions({ setQuery: (query: Node) => ({ query }) }), - reducers({ query: [getDefaultQuery() as Node, { setQuery: (_, { query }) => query }] }), + reducers(({ selectors }) => ({ + query: [ + ((state: Record) => getDefaultQuery(selectors.queryFlagEnabled(state))) as any as Node, + { setQuery: (_, { query }) => query }, + ], + })), actionToUrl(({ values }) => ({ setQuery: () => [ urls.persons(), {}, - objectsEqual(values.query, getDefaultQuery()) ? {} : { q: values.query }, + objectsEqual(values.query, getDefaultQuery(values.queryFlagEnabled)) ? {} : { q: values.query }, { replace: true }, ], })), @@ -36,9 +53,10 @@ export const personsSceneLogic = kea([ if (!equal(queryParam, values.query)) { // nothing in the URL if (!queryParam) { + const defaultQuery = getDefaultQuery(values.queryFlagEnabled) // set the default unless it's already there - if (!objectsEqual(values.query, getDefaultQuery())) { - actions.setQuery(getDefaultQuery()) + if (!objectsEqual(values.query, defaultQuery)) { + actions.setQuery(defaultQuery) } } else { if (typeof queryParam === 'object') { diff --git a/frontend/src/scenes/pipeline/Pipeline.stories.tsx b/frontend/src/scenes/pipeline/Pipeline.stories.tsx new file mode 100644 index 0000000000000..e386b2400c0a5 --- /dev/null +++ b/frontend/src/scenes/pipeline/Pipeline.stories.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { Meta } from '@storybook/react' +import { App } from 'scenes/App' +import { router } from 'kea-router' +import { urls } from 'scenes/urls' +import { PipelineTabs } from '~/types' +import { pipelineLogic } from './pipelineLogic' + +export default { + title: 'Scenes-App/Pipeline', + decorators: [], + parameters: { layout: 'fullscreen', options: { showPanel: false }, viewMode: 'story' }, // scene mode +} as Meta + +export function PipelineLandingPage(): JSX.Element { + // also Destinations page + useEffect(() => { + router.actions.push(urls.pipeline()) + pipelineLogic.mount() + }, []) + return +} +export function PipelineFilteringPage(): JSX.Element { + useEffect(() => { + router.actions.push(urls.pipeline(PipelineTabs.Filters)) + pipelineLogic.mount() + }, []) + return +} +export function PipelineTransformationsPage(): JSX.Element { + useEffect(() => { + router.actions.push(urls.pipeline(PipelineTabs.Transformations)) + pipelineLogic.mount() + }, []) + return +} diff --git a/frontend/src/scenes/pipeline/Pipeline.tsx b/frontend/src/scenes/pipeline/Pipeline.tsx new file mode 100644 index 0000000000000..29d417fbf614d --- /dev/null +++ b/frontend/src/scenes/pipeline/Pipeline.tsx @@ -0,0 +1,40 @@ +import { SceneExport } from 'scenes/sceneTypes' +import { PageHeader } from 'lib/components/PageHeader' +import { humanFriendlyTabName, pipelineLogic, singularName } from './pipelineLogic' +import { LemonTabs } from 'lib/lemon-ui/LemonTabs' +import { useValues } from 'kea' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { urls } from 'scenes/urls' +import { router } from 'kea-router' +import { PipelineTabs } from '~/types' + +export function Pipeline(): JSX.Element { + const { currentTab } = useValues(pipelineLogic) + + const singular = singularName(currentTab) + return ( +
+ + New {singular} + + } + /> + router.actions.push(urls.pipeline(tab as PipelineTabs))} + tabs={Object.values(PipelineTabs).map((tab) => ({ + label: humanFriendlyTabName(tab), + key: tab, + }))} + /> +
+ ) +} + +export const scene: SceneExport = { + component: Pipeline, + logic: pipelineLogic, +} diff --git a/frontend/src/scenes/pipeline/pipelineLogic.tsx b/frontend/src/scenes/pipeline/pipelineLogic.tsx new file mode 100644 index 0000000000000..017e1966745b7 --- /dev/null +++ b/frontend/src/scenes/pipeline/pipelineLogic.tsx @@ -0,0 +1,67 @@ +import { actions, kea, path, reducers, selectors } from 'kea' +import type { pipelineLogicType } from './pipelineLogicType' +import { actionToUrl, urlToAction } from 'kea-router' +import { urls } from 'scenes/urls' +import { Breadcrumb, PipelineTabs } from '~/types' + +export const singularName = (tab: PipelineTabs): string => { + switch (tab) { + case PipelineTabs.Filters: + return 'filter' + case PipelineTabs.Transformations: + return 'transformation' + case PipelineTabs.Destinations: + return 'destination' + } +} + +export const humanFriendlyTabName = (tab: PipelineTabs): string => { + switch (tab) { + case PipelineTabs.Filters: + return 'Filters' + case PipelineTabs.Transformations: + return 'Transformations' + case PipelineTabs.Destinations: + return 'Destinations' + } +} + +export const pipelineLogic = kea([ + path(['scenes', 'pipeline', 'pipelineLogic']), + actions({ + setCurrentTab: (tab: PipelineTabs = PipelineTabs.Destinations) => ({ tab }), + }), + reducers({ + currentTab: [ + PipelineTabs.Destinations as PipelineTabs, + { + setCurrentTab: (_, { tab }) => tab, + }, + ], + }), + selectors(() => ({ + breadcrumbs: [ + (s) => [s.currentTab], + (tab): Breadcrumb[] => { + const breadcrumbs: Breadcrumb[] = [{ name: 'Pipeline' }] + breadcrumbs.push({ + name: humanFriendlyTabName(tab), + }) + + return breadcrumbs + }, + ], + })), + actionToUrl(({ values }) => { + return { + setCurrentTab: () => [urls.pipeline(values.currentTab)], + } + }), + urlToAction(({ actions, values }) => ({ + '/pipeline/:tab': ({ tab }) => { + if (tab !== values.currentTab) { + actions.setCurrentTab(tab as PipelineTabs) + } + }, + })), +]) diff --git a/frontend/src/scenes/plugins/AppsScene.tsx b/frontend/src/scenes/plugins/AppsScene.tsx new file mode 100644 index 0000000000000..374681460c088 --- /dev/null +++ b/frontend/src/scenes/plugins/AppsScene.tsx @@ -0,0 +1,74 @@ +import { useEffect } from 'react' +import { useActions, useValues } from 'kea' +import { pluginsLogic } from './pluginsLogic' +import { PageHeader } from 'lib/components/PageHeader' +import { canGloballyManagePlugins, canViewPlugins } from './access' +import { userLogic } from 'scenes/userLogic' +import { SceneExport } from 'scenes/sceneTypes' +import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' +import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' +import { LemonTabs } from 'lib/lemon-ui/LemonTabs' +import { BatchExportsTab } from './tabs/batch-exports/BatchExportsTab' +import { AppsTab } from './tabs/apps/AppsTab' +import { PluginTab } from './types' +import { LemonButton } from '@posthog/lemon-ui' +import { urls } from 'scenes/urls' + +import './Plugins.scss' +import { AppsManagementTab } from './tabs/apps/AppsManagementTab' + +export const scene: SceneExport = { + component: AppsScene, + logic: pluginsLogic, +} + +export function AppsScene(): JSX.Element | null { + const { user } = useValues(userLogic) + const { pluginTab } = useValues(pluginsLogic) + const { setPluginTab } = useActions(pluginsLogic) + + useEffect(() => { + if (!canViewPlugins(user?.organization)) { + window.location.href = '/' + } + }, [user]) + + if (!user || !canViewPlugins(user?.organization)) { + return null + } + + return ( + <> + + Create export workflow + + ) : undefined + } + /> + setPluginTab(newKey)} + tabs={[ + { key: PluginTab.Apps, label: 'Apps', content: }, + { key: PluginTab.BatchExports, label: 'Batch Exports', content: }, + { + key: PluginTab.History, + label: 'History', + content: , + }, + canGloballyManagePlugins(user?.organization) && { + key: PluginTab.AppsManagement, + label: 'Apps Management', + content: , + }, + ]} + /> + + ) +} diff --git a/frontend/src/scenes/plugins/Plugins.scss b/frontend/src/scenes/plugins/Plugins.scss index e52cb443a0a55..cf6c4a5787ac0 100644 --- a/frontend/src/scenes/plugins/Plugins.scss +++ b/frontend/src/scenes/plugins/Plugins.scss @@ -1,94 +1,13 @@ -.plugins-scene { - padding-bottom: 60px; -} - -.plugins-installed-tab-section-header:hover, -.plugins-repository-tab-section-header:hover { - cursor: pointer; -} - -.plugins-repository-tab-section-header { - margin-left: 5px; -} - -.plugin-capabilities-tag { - cursor: default; -} -.plugins-scene-plugin-card { - > .ant-card-body { - padding: 15px; - } - .plugin-card-row > .ant-col { - margin-right: 14px; - &:last-child { - margin-right: 0; - } - } - a.plugin-title-link { - color: var(--color-text); - &:hover { - text-decoration: underline; - } - } - .plugin-image { - width: 60px; - height: 60px; - display: flex; - justify-content: center; - align-items: center; - margin-left: auto; - margin-right: auto; - border: none; - padding: 4px; - } - .order-handle { - .ant-tag { - margin-right: 0; - cursor: move; - } - .arrow { - color: #888; - height: 19px; - } - cursor: move; - text-align: center; - height: 100%; - margin-top: -10px; - margin-bottom: -10px; - } - .hide-over-500 { - display: none; - } - @media (max-width: 500px) { - .show-over-500 { - display: none; - } - .hide-over-500 { - display: inline-block; - } - button.ant-btn.padding-under-500 { - padding: 4px 8px; - } - .hide-plugin-image-below-500 { - display: none; - } - } -} - -.plugins-popconfirm { - z-index: var(--z-plugins-popconfirm); -} - -.plugin-job-json-editor { +.Plugin__JobJsonEditor { border: 1px solid #efefef; } -.plugin-run-job-button { +.Plugin__RunJobButton { color: var(--success); cursor: pointer; } -.plugin-run-job-button-disabled { +.Plugin__RunJobButton--disabled { color: #9a9a9a; cursor: default; } diff --git a/frontend/src/scenes/plugins/Plugins.stories.tsx b/frontend/src/scenes/plugins/Plugins.stories.tsx index 07edddd554883..2344623bb0fcc 100644 --- a/frontend/src/scenes/plugins/Plugins.stories.tsx +++ b/frontend/src/scenes/plugins/Plugins.stories.tsx @@ -3,26 +3,24 @@ import { App } from 'scenes/App' import { useEffect } from 'react' import { router } from 'kea-router' import { urls } from 'scenes/urls' -import { PluginTab } from 'scenes/plugins/types' import { useAvailableFeatures } from '~/mocks/features' import { AvailableFeature } from '~/types' -export default { +const meta: Meta = { title: 'Scenes-App/Apps', parameters: { layout: 'fullscreen', - options: { showPanel: false }, testOptions: { excludeNavigationFromSnapshot: true, }, viewMode: 'story', }, -} as Meta - +} +export default meta export const Installed: Story = () => { useAvailableFeatures([AvailableFeature.APP_METRICS]) useEffect(() => { - router.actions.push(urls.projectApps(PluginTab.Installed)) + router.actions.push(urls.projectApps()) }) return } diff --git a/frontend/src/scenes/plugins/Plugins.tsx b/frontend/src/scenes/plugins/Plugins.tsx deleted file mode 100644 index 3a28495ffc052..0000000000000 --- a/frontend/src/scenes/plugins/Plugins.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import './Plugins.scss' -import { useEffect } from 'react' -import { PluginDrawer } from 'scenes/plugins/edit/PluginDrawer' -import { RepositoryTab } from 'scenes/plugins/tabs/repository/RepositoryTab' -import { InstalledTab } from 'scenes/plugins/tabs/installed/InstalledTab' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from './pluginsLogic' -import { PageHeader } from 'lib/components/PageHeader' -import { PluginTab } from 'scenes/plugins/types' -import { AdvancedTab } from 'scenes/plugins/tabs/advanced/AdvancedTab' -import { canGloballyManagePlugins, canInstallPlugins, canViewPlugins } from './access' -import { userLogic } from 'scenes/userLogic' -import { SceneExport } from 'scenes/sceneTypes' -import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' -import { LemonTag } from '@posthog/lemon-ui' -import { LemonTabs } from 'lib/lemon-ui/LemonTabs' - -export const scene: SceneExport = { - component: Plugins, - logic: pluginsLogic, -} - -const BetaTag = (): JSX.Element => ( - - BETA - -) - -export function Plugins(): JSX.Element | null { - const { user } = useValues(userLogic) - const { pluginTab } = useValues(pluginsLogic) - const { setPluginTab } = useActions(pluginsLogic) - - useEffect(() => { - if (!canViewPlugins(user?.organization)) { - window.location.href = '/' - } - }, [user]) - - if (!user || !canViewPlugins(user?.organization)) { - return null - } - - return ( -
- - Apps enable you to extend PostHog's core data processing functionality. -
- Make use of verified apps from the{' '} - - App Library - {' '} - – or{' '} - - build your own - - . - - } - tabbedPage - /> - setPluginTab(newKey)} - tabs={[ - { key: PluginTab.Installed, label: 'Installed', content: }, - canGloballyManagePlugins(user.organization) && { - key: PluginTab.Repository, - label: 'Repository', - content: , - }, - { - key: PluginTab.History, - label: ( - <> - History - - - ), - content: , - }, - canInstallPlugins(user.organization) && { - key: PluginTab.Advanced, - label: 'Advanced', - content: , - }, - ]} - /> - -
- ) -} diff --git a/frontend/src/scenes/plugins/PluginsSearch.tsx b/frontend/src/scenes/plugins/PluginsSearch.tsx index a690a0b2ae10b..0cb80c2bf9679 100644 --- a/frontend/src/scenes/plugins/PluginsSearch.tsx +++ b/frontend/src/scenes/plugins/PluginsSearch.tsx @@ -3,17 +3,16 @@ import { pluginsLogic } from 'scenes/plugins/pluginsLogic' import { LemonInput } from '@posthog/lemon-ui' export function PluginsSearch(): JSX.Element { - const { searchTerm, rearranging } = useValues(pluginsLogic) + const { searchTerm } = useValues(pluginsLogic) const { setSearchTerm } = useActions(pluginsLogic) return ( ) } diff --git a/frontend/src/scenes/plugins/access.ts b/frontend/src/scenes/plugins/access.ts index 25ccd0a4b0c85..e5c087aa025ce 100644 --- a/frontend/src/scenes/plugins/access.ts +++ b/frontend/src/scenes/plugins/access.ts @@ -21,13 +21,6 @@ export function canInstallPlugins( return organization.plugins_access_level >= PluginsAccessLevel.Install } -export function canConfigurePlugins(organization: OrganizationType | null | undefined): boolean { - if (!organization) { - return false - } - return organization.plugins_access_level >= PluginsAccessLevel.Config -} - export function canViewPlugins(organization: OrganizationType | null | undefined): boolean { if (!organization) { return false diff --git a/frontend/src/scenes/plugins/edit/PluginDrawer.tsx b/frontend/src/scenes/plugins/edit/PluginDrawer.tsx index fdc905c45d3d7..ad135d40a8043 100644 --- a/frontend/src/scenes/plugins/edit/PluginDrawer.tsx +++ b/frontend/src/scenes/plugins/edit/PluginDrawer.tsx @@ -1,25 +1,25 @@ import React, { useEffect, useState } from 'react' import { useActions, useValues } from 'kea' import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { Button, Form, Popconfirm, Space, Switch, Tag } from 'antd' -import { DeleteOutlined, CodeOutlined, LockFilled, GlobalOutlined, RollbackOutlined } from '@ant-design/icons' +import { Form, Switch } from 'antd' +import { LockFilled } from '@ant-design/icons' import { userLogic } from 'scenes/userLogic' import { PluginImage } from 'scenes/plugins/plugin/PluginImage' import { Drawer } from 'lib/components/Drawer' -import { LocalPluginTag } from 'scenes/plugins/plugin/LocalPluginTag' import { defaultConfigForPlugin, doFieldRequirementsMatch, getConfigSchemaArray } from 'scenes/plugins/utils' -import ReactMarkdown from 'react-markdown' -import { SourcePluginTag } from 'scenes/plugins/plugin/SourcePluginTag' import { PluginSource } from '../source/PluginSource' import { PluginConfigChoice, PluginConfigSchema } from '@posthog/plugin-scaffold' import { PluginField } from 'scenes/plugins/edit/PluginField' import { endWithPunctation } from 'lib/utils' -import { canGloballyManagePlugins, canInstallPlugins } from '../access' -import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' +import { canGloballyManagePlugins } from '../access' import { capabilitiesInfo } from './CapabilitiesInfo' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { PluginJobOptions } from './interface-jobs/PluginJobOptions' import { MOCK_NODE_PROCESS } from 'lib/constants' +import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' +import { PluginTags } from '../tabs/apps/components' +import { LemonButton, LemonTag, Link } from '@posthog/lemon-ui' +import { IconCode } from '@posthog/icons' window.process = MOCK_NODE_PROCESS @@ -51,17 +51,9 @@ const SecretFieldIcon = (): JSX.Element => ( export function PluginDrawer(): JSX.Element { const { user } = useValues(userLogic) - const { preflight } = useValues(preflightLogic) const { editingPlugin, loading, editingSource, editingPluginInitialChanges } = useValues(pluginsLogic) - const { - editPlugin, - savePluginConfig, - uninstallPlugin, - setEditingSource, - generateApiKeysIfNeeded, - patchPlugin, - showPluginLogs, - } = useActions(pluginsLogic) + const { editPlugin, savePluginConfig, setEditingSource, generateApiKeysIfNeeded, showPluginLogs } = + useActions(pluginsLogic) const [form] = Form.useForm() @@ -145,84 +137,19 @@ export function PluginDrawer(): JSX.Element { title={editingPlugin?.name} data-attr="plugin-drawer" footer={ -
- - {editingPlugin && - !editingPlugin.is_global && - canInstallPlugins(user?.organization, editingPlugin.organization_id) && ( - uninstallPlugin(editingPlugin.name)} - okText="Uninstall" - cancelText="Cancel" - className="plugins-popconfirm" - > - - - )} - {preflight?.cloud && - editingPlugin && - canGloballyManagePlugins(user?.organization) && - (editingPlugin.is_global ? ( - - This app can currently be used by other organizations in this instance - of PostHog. This action will disable and hide it for all - organizations other than yours. - - } - > - - - ) : ( - - This action will mark this app as installed for all organizations{' '} - in this instance of PostHog. - - } - > - - - ))} - - - - - +
+ editPlugin(null)} data-attr="plugin-drawer-cancel"> + Cancel + + + Save +
} > @@ -230,28 +157,19 @@ export function PluginDrawer(): JSX.Element { {/* TODO: Rework as Kea form with Lemon UI components */} {editingPlugin ? (
-
- -
- {endWithPunctation(editingPlugin.description)} -
- {editingPlugin?.plugin_type === 'local' && editingPlugin.url ? ( - - ) : editingPlugin.plugin_type === 'source' ? ( - - ) : null} +
+ +
+ {endWithPunctation(editingPlugin.description)} +
+ {editingPlugin.url && ( - + ⤷ Learn more - + )}
-
+
- +
) : null} @@ -291,12 +209,12 @@ export function PluginDrawer(): JSX.Element { ) .map((capability) => ( - {capability} + {capability} ))} {(editingPlugin.capabilities?.jobs || []).map((jobName) => ( - {jobName} + {jobName} ))}
@@ -324,9 +242,7 @@ export function PluginDrawer(): JSX.Element { ) : null} {getConfigSchemaArray(editingPlugin.config_schema).map((fieldConfig, index) => ( - {fieldConfig.markdown && ( - - )} + {fieldConfig.markdown && {fieldConfig.markdown}} {fieldConfig.type && isValidField(fieldConfig) ? (
- } - > - {compareUrl ? ( - - - Update available! - - - ) : ( - - Update available! - - )} - - ) -} diff --git a/frontend/src/scenes/plugins/plugin/pluginLogsLogic.ts b/frontend/src/scenes/plugins/plugin/pluginLogsLogic.ts index 40e98a0efccf3..437393efcb4cf 100644 --- a/frontend/src/scenes/plugins/plugin/pluginLogsLogic.ts +++ b/frontend/src/scenes/plugins/plugin/pluginLogsLogic.ts @@ -1,4 +1,5 @@ -import { kea } from 'kea' +import { loaders } from 'kea-loaders' +import { kea, props, key, path, connect, actions, reducers, selectors, listeners, events } from 'kea' import api from '~/lib/api' import { PluginLogEntry, PluginLogEntryType } from '~/types' import { teamLogic } from '../../teamLogic' @@ -11,24 +12,22 @@ export interface PluginLogsProps { export const LOGS_PORTION_LIMIT = 50 -export const pluginLogsLogic = kea({ - props: {} as PluginLogsProps, - key: ({ pluginConfigId }: PluginLogsProps) => pluginConfigId, - path: (key) => ['scenes', 'plugins', 'plugin', 'pluginLogsLogic', key], - connect: { +export const pluginLogsLogic = kea([ + props({} as PluginLogsProps), + key(({ pluginConfigId }: PluginLogsProps) => pluginConfigId), + path((key) => ['scenes', 'plugins', 'plugin', 'pluginLogsLogic', key]), + connect({ values: [teamLogic, ['currentTeamId']], - }, - - actions: { + }), + actions({ clearPluginLogsBackground: true, markLogsEnd: true, setPluginLogsTypes: (typeFilters: CheckboxValueType[]) => ({ typeFilters, }), setSearchTerm: (searchTerm: string) => ({ searchTerm }), - }, - - loaders: ({ props: { pluginConfigId }, values, actions, cache }) => ({ + }), + loaders(({ props: { pluginConfigId }, values, actions, cache }) => ({ pluginLogs: { __default: [] as PluginLogEntry[], loadPluginLogs: async () => { @@ -83,16 +82,8 @@ export const pluginLogsLogic = kea({ return [...results, ...values.pluginLogsBackground] }, }, - }), - listeners: ({ actions }) => ({ - setPluginLogsTypes: () => { - actions.loadPluginLogs() - }, - setSearchTerm: () => { - actions.loadPluginLogs() - }, - }), - reducers: { + })), + reducers({ pluginLogsTypes: [ Object.values(PluginLogEntryType).filter((type) => type !== 'DEBUG'), { @@ -124,9 +115,8 @@ export const pluginLogsLogic = kea({ markLogsEnd: () => false, }, ], - }, - - selectors: ({ selectors }) => ({ + }), + selectors(({ selectors }) => ({ leadingEntry: [ () => [selectors.pluginLogs, selectors.pluginLogsBackground], (pluginLogs: PluginLogEntry[], pluginLogsBackground: PluginLogEntry[]): PluginLogEntry | null => { @@ -151,14 +141,24 @@ export const pluginLogsLogic = kea({ return null }, ], - }), - - events: ({ actions, cache }) => ({ + })), + listeners(({ actions }) => ({ + setPluginLogsTypes: () => { + actions.loadPluginLogs() + }, + setSearchTerm: async ({ searchTerm }, breakpoint) => { + if (searchTerm) { + await breakpoint(1000) + } + actions.loadPluginLogs() + }, + })), + events(({ actions, cache }) => ({ afterMount: () => { actions.loadPluginLogs() }, beforeUnmount: () => { clearInterval(cache.pollingInterval) }, - }), -}) + })), +]) diff --git a/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx b/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx index 016a4cfd86e1d..839f1f7861399 100644 --- a/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx +++ b/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx @@ -35,7 +35,7 @@ export function pluginActivityDescriber(logItem: ActivityLogItem): HumanizedChan const newValue = change.after === SECRET_FIELD_VALUE ? '' : change.after changes.push( <> - field {change.field} set to {newValue} + field {change.field} set to {newValue as string} ) } @@ -134,20 +134,20 @@ export function pluginActivityDescriber(logItem: ActivityLogItem): HumanizedChan if (change.action === 'created') { changeWording = ( <> - added new field {change.field}" with value {changeAfter} + added new field {change.field}" with value {changeAfter as string} ) } else if (change.action === 'deleted') { changeWording = ( <> - removed field {change.field}, which had value {changeBefore} + removed field {change.field}, which had value {changeBefore as string} ) } else if (change.action === 'changed') { changeWording = ( <> - updated field {change.field} from value {changeBefore} to value{' '} - {changeAfter}{' '} + updated field {change.field} from value {changeBefore as string} to + value {changeAfter as string}{' '} ) } @@ -175,27 +175,28 @@ export function pluginActivityDescriber(logItem: ActivityLogItem): HumanizedChan if (logItem.activity === 'attachment_created') { changeWording = ( <> - attached a file {change.after} + attached a file {change.after as string} ) } else if (logItem.activity == 'attachment_updated') { if (change.after === change.before) { changeWording = ( <> - updated attached file {change.after} + updated attached file {change.after as string} ) } else { changeWording = ( <> - updated attached file from {change.before} to {change.after} + updated attached file from {change.before as string} to{' '} + {change.after as string} ) } } else if (logItem.activity === 'attachment_deleted') { changeWording = ( <> - deleted attached file {change.before} + deleted attached file {change.before as string} ) } diff --git a/frontend/src/scenes/plugins/pluginsLogic.ts b/frontend/src/scenes/plugins/pluginsLogic.ts index f3f67e51b593e..dd03bed0a3f6b 100644 --- a/frontend/src/scenes/plugins/pluginsLogic.ts +++ b/frontend/src/scenes/plugins/pluginsLogic.ts @@ -3,7 +3,7 @@ import { loaders } from 'kea-loaders' import { actionToUrl, router, urlToAction } from 'kea-router' import type { pluginsLogicType } from './pluginsLogicType' import api from 'lib/api' -import { AvailableFeature, PersonalAPIKeyType, PluginConfigType, PluginType } from '~/types' +import { PersonalAPIKeyType, PluginConfigType, PluginType } from '~/types' import { PluginInstallationType, PluginRepositoryEntry, @@ -24,17 +24,9 @@ import { lemonToast } from 'lib/lemon-ui/lemonToast' export type PluginForm = FormInstance -export enum PluginSection { - Upgrade = 'upgrade', - Installed = 'installed', - Enabled = 'enabled', - Disabled = 'disabled', -} - export interface PluginSelectionType { name: string url?: string - tab: PluginTab } const PAGINATION_DEFAULT_MAX_PAGES = 10 @@ -72,13 +64,12 @@ export const pluginsLogic = kea([ editPlugin: (id: number | null, pluginConfigChanges: Record = {}) => ({ id, pluginConfigChanges }), savePluginConfig: (pluginConfigChanges: Record) => ({ pluginConfigChanges }), installPlugin: (pluginUrl: string, pluginType: PluginInstallationType) => ({ pluginUrl, pluginType }), - uninstallPlugin: (name: string) => ({ name }), + uninstallPlugin: (id: number) => ({ id }), setCustomPluginUrl: (customPluginUrl: string) => ({ customPluginUrl }), setLocalPluginUrl: (localPluginUrl: string) => ({ localPluginUrl }), setSourcePluginName: (sourcePluginName: string) => ({ sourcePluginName }), setPluginTab: (tab: PluginTab) => ({ tab }), setEditingSource: (editingSource: boolean) => ({ editingSource }), - resetPluginConfigError: (id: number) => ({ id }), checkForUpdates: (checkAll: boolean, initialUpdateStatus: Record = {}) => ({ checkAll, initialUpdateStatus, @@ -90,21 +81,20 @@ export const pluginsLogic = kea([ pluginUpdated: (id: number) => ({ id }), patchPlugin: (id: number, pluginChanges: Partial = {}) => ({ id, pluginChanges }), generateApiKeysIfNeeded: (form: PluginForm) => ({ form }), - rearrange: true, setTemporaryOrder: (temporaryOrder: Record, movedPluginId: number) => ({ temporaryOrder, movedPluginId, }), - makePluginOrderSaveable: true, savePluginOrders: (newOrders: Record) => ({ newOrders }), cancelRearranging: true, showPluginLogs: (id: number) => ({ id }), hidePluginLogs: true, - processSearchInput: (term: string) => ({ term }), setSearchTerm: (term: string | null) => ({ term }), - setPluginConfigPollTimeout: (timeout: number | null) => ({ timeout }), - toggleSectionOpen: (section: PluginSection) => ({ section }), syncFrontendAppState: (id: number) => ({ id }), + openAdvancedInstallModal: true, + closeAdvancedInstallModal: true, + openReorderModal: true, + closeReorderModal: true, }), loaders(({ actions, values }) => ({ @@ -132,16 +122,14 @@ export const pluginsLogic = kea([ actions.loadPlugins() } capturePluginEvent(`plugin installed`, response, pluginType) + + actions.closeAdvancedInstallModal() return { ...values.plugins, [response.id]: response } }, - uninstallPlugin: async () => { - const { plugins, editingPlugin } = values - if (!editingPlugin) { - return plugins - } - await api.delete(`api/organizations/@current/plugins/${editingPlugin.id}`) - capturePluginEvent(`plugin uninstalled`, editingPlugin) - const { [editingPlugin.id]: _discard, ...rest } = plugins + uninstallPlugin: async ({ id }) => { + await api.delete(`api/organizations/@current/plugins/${id}`) + capturePluginEvent(`plugin uninstalled`, values.plugins[id]) + const { [id]: _discard, ...rest } = values.plugins return rest }, updatePlugin: async ({ id }) => { @@ -225,13 +213,6 @@ export const pluginsLogic = kea([ }) return { ...pluginConfigs, [response.plugin]: response } }, - resetPluginConfigError: async ({ id }) => { - const { pluginConfigs } = values - const response = await api.update(`api/plugin_config/${id}`, { - error: null, - }) - return { ...pluginConfigs, [response.plugin]: response } - }, savePluginOrders: async ({ newOrders }) => { const { pluginConfigs } = values const response: PluginConfigType[] = await api.update(`api/plugin_config/rearrange`, { @@ -260,6 +241,16 @@ export const pluginsLogic = kea([ }, }, ], + unusedPlugins: [ + // used for know if plugin can be uninstalled + [] as number[], + { + loadUnusedPlugins: async () => { + const results = await api.get('api/organizations/@current/plugins/unused') + return results + }, + }, + ], })), reducers({ @@ -341,10 +332,9 @@ export const pluginsLogic = kea([ }, }, pluginTab: [ - PluginTab.Installed as PluginTab, + PluginTab.Apps as PluginTab, { setPluginTab: (_, { tab }) => tab, - installPluginSuccess: () => PluginTab.Installed, }, ], updateStatus: [ @@ -374,26 +364,9 @@ export const pluginsLogic = kea([ checkedForUpdates: () => false, }, ], - pluginOrderSaveable: [ - false, - { - makePluginOrderSaveable: () => true, - cancelRearranging: () => false, - savePluginOrdersSuccess: () => false, - }, - ], - rearranging: [ - false, - { - rearrange: () => true, - cancelRearranging: () => false, - savePluginOrdersSuccess: () => false, - }, - ], temporaryOrder: [ {} as Record, { - rearrange: () => ({}), setTemporaryOrder: (_, { temporaryOrder }) => temporaryOrder, cancelRearranging: () => ({}), savePluginOrdersSuccess: () => ({}), @@ -402,7 +375,6 @@ export const pluginsLogic = kea([ movedPlugins: [ {} as Record, { - rearrange: () => ({}), setTemporaryOrder: (state, { movedPluginId }) => ({ ...state, [movedPluginId]: true }), cancelRearranging: () => ({}), savePluginOrdersSuccess: () => ({}), @@ -427,15 +399,18 @@ export const pluginsLogic = kea([ setSearchTerm: (_, { term }) => term, }, ], - sectionsOpen: [ - [PluginSection.Enabled, PluginSection.Disabled] as PluginSection[], + advancedInstallModalOpen: [ + false, { - toggleSectionOpen: (currentOpenSections, { section }) => { - if (currentOpenSections.includes(section)) { - return currentOpenSections.filter((s) => section !== s) - } - return [...currentOpenSections, section] - }, + openAdvancedInstallModal: () => true, + closeAdvancedInstallModal: () => false, + }, + ], + reorderModalOpen: [ + false, + { + openReorderModal: () => true, + closeReorderModal: () => false, }, ], }), @@ -457,6 +432,7 @@ export const pluginsLogic = kea([ } const pluginValues = Object.values(plugins) + return pluginValues .map((plugin, index) => { let pluginConfig: PluginConfigType = { ...pluginConfigs[plugin.id] } @@ -467,6 +443,7 @@ export const pluginsLogic = kea([ config[key] = def } ) + pluginConfig = { id: undefined, team_id: currentTeam.id, @@ -647,38 +624,28 @@ export const pluginsLogic = kea([ (repository, plugins) => { const allPossiblePlugins: PluginSelectionType[] = [] for (const plugin of Object.values(plugins) as PluginType[]) { - allPossiblePlugins.push({ name: plugin.name, url: plugin.url, tab: PluginTab.Installed }) + allPossiblePlugins.push({ name: plugin.name, url: plugin.url }) } const installedUrls = new Set(Object.values(plugins).map((plugin) => plugin.url)) for (const plugin of Object.values(repository) as PluginRepositoryEntry[]) { if (!installedUrls.has(plugin.url)) { - allPossiblePlugins.push({ name: plugin.name, url: plugin.url, tab: PluginTab.Repository }) + allPossiblePlugins.push({ name: plugin.name, url: plugin.url }) } } return allPossiblePlugins }, ], - shouldShowAppMetrics: [ - () => [userLogic.selectors.hasAvailableFeature], - (hasAvailableFeature) => hasAvailableFeature(AvailableFeature.APP_METRICS), - ], showAppMetricsForPlugin: [ - (s) => [s.shouldShowAppMetrics], - (featureShown) => (plugin: Partial | undefined) => { - if (!featureShown) { - return false - } + () => [], + () => (plugin: Partial | undefined) => { return plugin?.capabilities?.methods?.length || plugin?.capabilities?.scheduled_tasks?.length }, ], }), listeners(({ actions, values }) => ({ - toggleEnabledSuccess: ({ payload: { id } }) => { - actions.syncFrontendAppState(id) - }, // Load or unload an app, as directed by its enabled state in pluginsLogic syncFrontendAppState: ({ id }) => { const pluginConfig = values.getPluginConfig(id) @@ -705,7 +672,6 @@ export const pluginsLogic = kea([ } actions.checkedForUpdates() - actions.toggleSectionOpen(PluginSection.Upgrade) }, loadPluginsSuccess() { const initialUpdateStatus: Record = {} @@ -716,12 +682,6 @@ export const pluginsLogic = kea([ } if (canInstallPlugins(userLogic.values.user?.organization)) { actions.checkForUpdates(false, initialUpdateStatus) - if ( - Object.keys(values.plugins).length === 0 && - canGloballyManagePlugins(userLogic.values.user?.organization) - ) { - actions.setPluginTab(PluginTab.Repository) - } } }, generateApiKeysIfNeeded: async ({ form }, breakpoint) => { @@ -754,6 +714,10 @@ export const pluginsLogic = kea([ form.setFieldsValue({ posthogHost: window.location.origin }) } }, + + savePluginOrdersSuccess: () => { + actions.closeReorderModal() + }, })), actionToUrl(({ values }) => { function getUrl(): string { @@ -775,8 +739,8 @@ export const pluginsLogic = kea([ } let replace = false // set a page in history - if (!searchParams['tab'] && values.pluginTab === PluginTab.Installed) { - // we are on the Installed page, and have clicked the Installed tab, don't set history + if (!searchParams['tab'] && values.pluginTab === PluginTab.Apps) { + // we are on the Apps page, and have clicked the Apps tab, don't set history replace = true } searchParams['tab'] = values.pluginTab @@ -810,7 +774,7 @@ export const pluginsLogic = kea([ if (tab) { actions.setPluginTab(tab as PluginTab) } - if (name && [PluginTab.Repository, PluginTab.Installed].includes(values.pluginTab)) { + if (name && values.pluginTab === PluginTab.Apps) { actions.setSearchTerm(name) } runActions(null, false, null) @@ -832,6 +796,7 @@ export const pluginsLogic = kea([ actions.loadPluginConfigs() if (canGloballyManagePlugins(userLogic.values.user?.organization)) { actions.loadRepository() + actions.loadUnusedPlugins() } }), ]) diff --git a/frontend/src/scenes/plugins/source/PluginSource.tsx b/frontend/src/scenes/plugins/source/PluginSource.tsx index 093e1c4630980..0d016a06a7ba1 100644 --- a/frontend/src/scenes/plugins/source/PluginSource.tsx +++ b/frontend/src/scenes/plugins/source/PluginSource.tsx @@ -2,7 +2,7 @@ import './PluginSource.scss' import { useEffect } from 'react' import { useActions, useValues } from 'kea' import { Button, Skeleton } from 'antd' -import MonacoEditor, { useMonaco } from '@monaco-editor/react' +import { useMonaco } from '@monaco-editor/react' import { Drawer } from 'lib/components/Drawer' import { userLogic } from 'scenes/userLogic' @@ -13,7 +13,8 @@ import { PluginSourceTabs } from 'scenes/plugins/source/PluginSourceTabs' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { createDefaultPluginSource } from 'scenes/plugins/source/createDefaultPluginSource' import { Form } from 'kea-forms' -import { Spinner } from 'lib/lemon-ui/Spinner' +import { CodeEditor } from 'lib/components/CodeEditors' +import { Link } from '@posthog/lemon-ui' interface PluginSourceProps { pluginId: number @@ -79,7 +80,7 @@ export function PluginSource({ title={pluginSourceLoading ? 'Loading...' : `Edit App: ${name}`} placement={placement ?? 'left'} footer={ -
+
@@ -94,15 +95,15 @@ export function PluginSource({ <>

Read our{' '} - + app building overview in PostHog Docs - {' '} + {' '} for a good grasp of possibilities.
Once satisfied with your app, feel free to{' '} - + submit it to the official App Store - + .

@@ -114,8 +115,7 @@ export function PluginSource({ {({ value, onChange }) => ( <> - } /> {!value && createDefaultPluginSource(name)[currentFile] ? ( -
+
diff --git a/frontend/src/scenes/plugins/tabs/advanced/AdvancedTab.tsx b/frontend/src/scenes/plugins/tabs/advanced/AdvancedTab.tsx deleted file mode 100644 index fa82f5f4ddad1..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/AdvancedTab.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Alert } from 'antd' -import { PluginTab } from 'scenes/plugins/types' -import { Subtitle } from 'lib/components/PageHeader' -import { SourcePlugin } from 'scenes/plugins/tabs/advanced/SourcePlugin' -import { CustomPlugin } from 'scenes/plugins/tabs/advanced/CustomPlugin' -import { LocalPlugin } from 'scenes/plugins/tabs/advanced/LocalPlugin' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' - -export function AdvancedTab(): JSX.Element { - const { preflight } = useValues(preflightLogic) - const { setPluginTab } = useActions(pluginsLogic) - - return ( - <> - - Create and install your own apps or apps from third-parties. If you're looking for - officially supported apps, try the{' '} - { - e.preventDefault() - setPluginTab(PluginTab.Repository) - }} - > - App Repository - - . - - } - type="warning" - showIcon - /> - - - - {preflight && !preflight.cloud && } - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/advanced/CustomPlugin.tsx b/frontend/src/scenes/plugins/tabs/advanced/CustomPlugin.tsx deleted file mode 100644 index 54a4fb08928e2..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/CustomPlugin.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Button, Card, Col, Input, Row } from 'antd' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginInstallationType } from 'scenes/plugins/types' -import Title from 'antd/lib/typography/Title' -import Paragraph from 'antd/lib/typography/Paragraph' - -export function CustomPlugin(): JSX.Element { - const { customPluginUrl, pluginError, loading } = useValues(pluginsLogic) - const { setCustomPluginUrl, installPlugin } = useActions(pluginsLogic) - - return ( -
- - Install from GitHub, GitLab or npm - - To install a third-party or custom app, paste its URL below. For{' '} - - GitHub - - {', '} - - GitLab - - {' and '} - - npm - {' '} - private repositories, append ?private_token=TOKEN to the end of the URL. -
- Warning: Only install apps from trusted sources. -
- - - - setCustomPluginUrl(e.target.value)} - placeholder="https://github.com/user/repo" - /> - - - - - - {pluginError ?

{pluginError}

: null} -
-
- ) -} diff --git a/frontend/src/scenes/plugins/tabs/advanced/LocalPlugin.tsx b/frontend/src/scenes/plugins/tabs/advanced/LocalPlugin.tsx deleted file mode 100644 index 584483c9d92d3..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/LocalPlugin.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Button, Card, Col, Input, Row } from 'antd' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginInstallationType } from 'scenes/plugins/types' -import Title from 'antd/lib/typography/Title' -import Paragraph from 'antd/lib/typography/Paragraph' - -export function LocalPlugin(): JSX.Element { - const { localPluginUrl, pluginError, loading } = useValues(pluginsLogic) - const { setLocalPluginUrl, installPlugin } = useActions(pluginsLogic) - - return ( -
- - Install Local App - To install a local app from this computer/server, give its full path below. - - - - setLocalPluginUrl(e.target.value)} - placeholder="/var/posthog/plugins/helloworldplugin" - /> - - - - - - {pluginError ?

{pluginError}

: null} -
-
- ) -} diff --git a/frontend/src/scenes/plugins/tabs/advanced/SourcePlugin.tsx b/frontend/src/scenes/plugins/tabs/advanced/SourcePlugin.tsx deleted file mode 100644 index 76f95aaca8dff..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/SourcePlugin.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Button, Card, Col, Input, Row } from 'antd' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginInstallationType } from 'scenes/plugins/types' -import Title from 'antd/lib/typography/Title' -import Paragraph from 'antd/lib/typography/Paragraph' - -export function SourcePlugin(): JSX.Element { - const { sourcePluginName, pluginError, loading } = useValues(pluginsLogic) - const { setSourcePluginName, installPlugin } = useActions(pluginsLogic) - - return ( -
- - App editor - - Write your app directly in PostHog.{' '} - - Read the documentation for more information! - - - - - setSourcePluginName(e.target.value)} - placeholder={`For example: "Hourly Weather Sync App"`} - /> - - - - - - {pluginError ?

{pluginError}

: null} -
-
- ) -} diff --git a/frontend/src/scenes/plugins/tabs/apps/AdvancedInstallModal.tsx b/frontend/src/scenes/plugins/tabs/apps/AdvancedInstallModal.tsx new file mode 100644 index 0000000000000..59f18196126e6 --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/AdvancedInstallModal.tsx @@ -0,0 +1,137 @@ +import { LemonModal } from 'lib/lemon-ui/LemonModal' +import { useValues, useActions } from 'kea' +import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { LemonButton, LemonInput, LemonLabel, Link } from '@posthog/lemon-ui' +import { PluginInstallationType } from 'scenes/plugins/types' + +export function AdvancedInstallModal(): JSX.Element { + const { preflight } = useValues(preflightLogic) + + const { advancedInstallModalOpen, pluginError, loading, sourcePluginName, customPluginUrl, localPluginUrl } = + useValues(pluginsLogic) + const { closeAdvancedInstallModal, installPlugin, setSourcePluginName, setCustomPluginUrl, setLocalPluginUrl } = + useActions(pluginsLogic) + + return ( + + + Cancel + + + } + > +
+ + <> + Advanced features ahead +
+ Create and install your own apps or apps from third-parties. + +
+ + {pluginError ? {pluginError} : null} + +
+ Code your own app +

+ Write your app directly in PostHog.{' '} + + Read the documentation for more information! + +

+
+ + installPlugin(sourcePluginName, PluginInstallationType.Source)} + > + Start coding + +
+
+ +
+ Install from GitHub, GitLab or npm +

+ To install a third-party or custom app, paste its URL below. For{' '} + + GitHub + + {', '} + + GitLab + + {' and '} + + npm + {' '} + private repositories, append ?private_token=TOKEN to the end of the URL. +
+ Warning: Only install apps from trusted sources. +

+
+ + installPlugin(customPluginUrl, PluginInstallationType.Custom)} + > + Fetch and install + +
+
+ {preflight && !preflight.cloud && ( + <> +
+ Install Local App +

To install a local app from this computer/server, give its full path below.

+
+ + installPlugin(localPluginUrl, PluginInstallationType.Local)} + > + Install + +
+
+ + )} +
+
+ ) +} diff --git a/frontend/src/scenes/plugins/tabs/apps/AppManagementView.tsx b/frontend/src/scenes/plugins/tabs/apps/AppManagementView.tsx new file mode 100644 index 0000000000000..6324af5a536b8 --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/AppManagementView.tsx @@ -0,0 +1,138 @@ +import { LemonButton, Link } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { IconCheckmark, IconCloudDownload } from 'lib/lemon-ui/icons' +import { PluginImage } from 'scenes/plugins/plugin/PluginImage' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { PluginTypeWithConfig, PluginRepositoryEntry, PluginInstallationType } from 'scenes/plugins/types' +import { PluginType } from '~/types' +import { PluginTags } from './components' +import { Tooltip } from 'lib/lemon-ui/Tooltip' +import { Popconfirm } from 'antd' +import { DeleteOutlined, GlobalOutlined, RollbackOutlined } from '@ant-design/icons' +import { canGloballyManagePlugins } from 'scenes/plugins/access' +import { userLogic } from 'scenes/userLogic' + +export function AppManagementView({ + plugin, +}: { + plugin: PluginTypeWithConfig | PluginType | PluginRepositoryEntry +}): JSX.Element { + const { user } = useValues(userLogic) + + if (!canGloballyManagePlugins(user?.organization)) { + return <> + } + const { installingPluginUrl, pluginsNeedingUpdates, pluginsUpdating, loading, unusedPlugins } = + useValues(pluginsLogic) + const { installPlugin, editPlugin, updatePlugin, uninstallPlugin, patchPlugin } = useActions(pluginsLogic) + + return ( +
+
+ +
+
+ + + {plugin.name} + + + +
+
{plugin.description}
+
+
+ +
+ {'id' in plugin ? ( + <> + {'updateStatus' in plugin && pluginsNeedingUpdates.find((x) => x.id === plugin.id) && ( + { + plugin.updateStatus?.updated ? editPlugin(plugin.id) : updatePlugin(plugin.id) + }} + loading={pluginsUpdating.includes(plugin.id)} + icon={plugin.updateStatus?.updated ? : } + > + {plugin.updateStatus?.updated ? 'Updated' : 'Update'} + + )} + uninstallPlugin(plugin.id)} + okText="Uninstall" + cancelText="Cancel" + className="Plugins__Popconfirm" + > + } + disabledReason={ + unusedPlugins.includes(plugin.id) ? undefined : 'This app is still in use.' + } + data-attr="plugin-uninstall" + > + Uninstall + + + {plugin.is_global ? ( + + This app can currently be used by other organizations in this instance of + PostHog. This action will disable and hide it for all organizations other + than yours. + + } + > + } + onClick={() => patchPlugin(plugin.id, { is_global: false })} + > + Make local + + + ) : ( + + This action will mark this app as installed for all organizations in this + instance of PostHog. + + } + > + } + onClick={() => patchPlugin(plugin.id, { is_global: true })} + > + Make global + + + )} + + ) : ( + } + size="small" + onClick={() => + plugin.url ? installPlugin(plugin.url, PluginInstallationType.Repository) : undefined + } + > + Install + + )} +
+
+ ) +} diff --git a/frontend/src/scenes/plugins/tabs/apps/AppView.tsx b/frontend/src/scenes/plugins/tabs/apps/AppView.tsx new file mode 100644 index 0000000000000..f4de0dfacaa8c --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/AppView.tsx @@ -0,0 +1,165 @@ +import { Link, LemonButton, LemonBadge } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { LemonMenuItem, LemonMenu } from 'lib/lemon-ui/LemonMenu' +import { IconLink, IconSettings, IconEllipsis, IconLegend, IconErrorOutline } from 'lib/lemon-ui/icons' +import { PluginImage } from 'scenes/plugins/plugin/PluginImage' +import { SuccessRateBadge } from 'scenes/plugins/plugin/SuccessRateBadge' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { PluginTypeWithConfig, PluginRepositoryEntry } from 'scenes/plugins/types' +import { urls } from 'scenes/urls' +import { PluginType } from '~/types' +import { PluginTags } from './components' +import { Tooltip } from 'lib/lemon-ui/Tooltip' + +export function AppView({ + plugin, +}: { + plugin: PluginTypeWithConfig | PluginType | PluginRepositoryEntry +}): JSX.Element { + const { showAppMetricsForPlugin, sortableEnabledPlugins } = useValues(pluginsLogic) + const { editPlugin, toggleEnabled, openReorderModal } = useActions(pluginsLogic) + + const pluginConfig = 'pluginConfig' in plugin ? plugin.pluginConfig : null + const isConfigured = !!pluginConfig?.id + const orderedIndex = sortableEnabledPlugins.indexOf(plugin as unknown as any) + 1 + const menuItems: LemonMenuItem[] = [] + + if (plugin.url) { + menuItems.push({ + label: 'Source', + sideIcon: , + to: plugin.url, + targetBlank: true, + }) + } + + if (isConfigured) { + menuItems.push({ + label: pluginConfig?.enabled ? 'Disable' : 'Enable', + status: pluginConfig.enabled ? 'danger' : 'primary', + onClick: () => + toggleEnabled({ + id: pluginConfig.id, + enabled: !pluginConfig.enabled, + }), + }) + } + + return ( +
+
+ {isConfigured && pluginConfig.enabled && ( + + +   + {orderedIndex ? ( + <> + Apps that react to incoming events run in order. This app runs in position{' '} + {orderedIndex}. +
+ Click to change the order of the plugins. + + ) : ( + <>As this app is not part of the processing flow, the order is unimportant + )} + + } + > + + {orderedIndex ? ( + + ) : ( + + )} + +
+
+ )} + +
+
+ {pluginConfig && showAppMetricsForPlugin(plugin) && pluginConfig.id && ( + + )} + + {isConfigured ? ( + + {plugin.name} + + ) : ( + plugin.name + )} + + +
+
{plugin.description}
+
+
+ +
+ {'id' in plugin && ( + <> + {pluginConfig && !pluginConfig.enabled && isConfigured && ( + + toggleEnabled({ + id: pluginConfig.id, + enabled: true, + }) + } + > + Enable + + )} + + {pluginConfig && + pluginConfig.id && + (pluginConfig.error ? ( + } + to={urls.appLogs(pluginConfig.id)} + > + Errors + + ) : ( + } + to={urls.appLogs(pluginConfig.id)} + > + Logs & metrics + + ))} + + } + onClick={() => editPlugin(plugin.id)} + > + Configure + + + )} + + + } /> + +
+
+ ) +} diff --git a/frontend/src/scenes/plugins/tabs/apps/AppsManagementTab.tsx b/frontend/src/scenes/plugins/tabs/apps/AppsManagementTab.tsx new file mode 100644 index 0000000000000..ea6dc22ccae73 --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/AppsManagementTab.tsx @@ -0,0 +1,115 @@ +import { LemonButton, LemonDivider } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { IconCloudDownload, IconRefresh } from 'lib/lemon-ui/icons' +import { useMemo } from 'react' +import { PluginsSearch } from 'scenes/plugins/PluginsSearch' +import { canGloballyManagePlugins } from 'scenes/plugins/access' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { userLogic } from 'scenes/userLogic' +import { AdvancedInstallModal } from './AdvancedInstallModal' +import { AppsTable } from './AppsTable' +import { AppManagementView } from './AppManagementView' +import { PluginRepositoryEntry, PluginTypeWithConfig } from 'scenes/plugins/types' +import { PluginType } from '~/types' + +export function AppsManagementTab(): JSX.Element { + const { user } = useValues(userLogic) + + if (!canGloballyManagePlugins(user?.organization)) { + return <> + } + + const { checkForUpdates, openAdvancedInstallModal } = useActions(pluginsLogic) + + const { + installedPlugins, + installedPluginUrls, + filteredPluginsNeedingUpdates, + loading, + filteredUninstalledPlugins, + repositoryLoading, + pluginsNeedingUpdates, + hasUpdatablePlugins, + checkingForUpdates, + updateStatus, + } = useValues(pluginsLogic) + + const officialPlugins = useMemo( + () => filteredUninstalledPlugins.filter((plugin) => plugin.maintainer === 'official'), + [filteredUninstalledPlugins] + ) + const communityPlugins = useMemo( + () => filteredUninstalledPlugins.filter((plugin) => plugin.maintainer === 'community'), + [filteredUninstalledPlugins] + ) + + const renderfn: (plugin: PluginTypeWithConfig | PluginType | PluginRepositoryEntry) => JSX.Element = (plugin) => ( + + ) + + return ( + <> +
+
+ + +
+ {hasUpdatablePlugins && ( + 0 ? : } + onClick={(e) => { + e.stopPropagation() + checkForUpdates(true) + }} + loading={checkingForUpdates} + > + {checkingForUpdates + ? `Checking app ${Object.keys(updateStatus).length + 1} out of ${ + Object.keys(installedPluginUrls).length + }` + : pluginsNeedingUpdates.length > 0 + ? 'Check again for updates' + : 'Check for updates'} + + )} + + Install app (advanced) + +
+
+ + {filteredPluginsNeedingUpdates.length > 0 && ( + + )} + + + + {canGloballyManagePlugins(user?.organization) && ( + <> + + + + + + )} +
+ + + ) +} diff --git a/frontend/src/scenes/plugins/tabs/apps/AppsTab.tsx b/frontend/src/scenes/plugins/tabs/apps/AppsTab.tsx new file mode 100644 index 0000000000000..a02ad02f27fef --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/AppsTab.tsx @@ -0,0 +1,46 @@ +import { useValues } from 'kea' +import { PluginsSearch } from 'scenes/plugins/PluginsSearch' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { PluginDrawer } from 'scenes/plugins/edit/PluginDrawer' +import { BatchExportsAlternativeWarning } from './components' +import { InstalledAppsReorderModal } from './InstalledAppsReorderModal' +import { AppsTable } from './AppsTable' +import { AppView } from './AppView' +import { PluginRepositoryEntry, PluginTypeWithConfig } from 'scenes/plugins/types' +import { PluginType } from '~/types' + +export function AppsTab(): JSX.Element { + const { sortableEnabledPlugins, unsortableEnabledPlugins, filteredDisabledPlugins, loading } = + useValues(pluginsLogic) + + const renderfn: (plugin: PluginTypeWithConfig | PluginType | PluginRepositoryEntry) => JSX.Element = (plugin) => ( + + ) + + return ( + <> +
+
+ +
+ + + + + +
+ + + + ) +} diff --git a/frontend/src/scenes/plugins/tabs/apps/AppsTable.tsx b/frontend/src/scenes/plugins/tabs/apps/AppsTable.tsx new file mode 100644 index 0000000000000..5fb4af0d52ac7 --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/AppsTable.tsx @@ -0,0 +1,61 @@ +import { LemonTable, LemonButton } from '@posthog/lemon-ui' +import { useValues } from 'kea' +import { IconUnfoldLess, IconUnfoldMore } from 'lib/lemon-ui/icons' +import { useState } from 'react' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { PluginRepositoryEntry, PluginTypeWithConfig } from 'scenes/plugins/types' +import { PluginType } from '~/types' + +export function AppsTable({ + title = 'Apps', + plugins, + loading, + renderfn, +}: { + title?: string + plugins: (PluginTypeWithConfig | PluginType | PluginRepositoryEntry)[] + loading: boolean + renderfn: (plugin: PluginTypeWithConfig | PluginType | PluginRepositoryEntry) => JSX.Element +}): JSX.Element { + const [expanded, setExpanded] = useState(true) + const { searchTerm } = useValues(pluginsLogic) + + return ( + + : } + onClick={() => setExpanded(!expanded)} + className="-ml-2 mr-2" + /> + {title} + + ), + key: 'app', + // Passing a function to render after loading + render: (_, plugin) => renderfn(plugin), + }, + ]} + emptyState={ + !expanded ? ( + + setExpanded(true)}> + Show apps + + + ) : searchTerm ? ( + 'No apps matching your search criteria' + ) : ( + 'No apps found' + ) + } + /> + ) +} diff --git a/frontend/src/scenes/plugins/tabs/apps/InstalledAppsReorderModal.tsx b/frontend/src/scenes/plugins/tabs/apps/InstalledAppsReorderModal.tsx new file mode 100644 index 0000000000000..382a157bbbf6f --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/InstalledAppsReorderModal.tsx @@ -0,0 +1,102 @@ +import { LemonModal } from 'lib/lemon-ui/LemonModal' +import { useValues, useActions } from 'kea' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { LemonBadge, LemonButton } from '@posthog/lemon-ui' +import { PluginTypeWithConfig } from 'scenes/plugins/types' +import { PluginImage } from 'scenes/plugins/plugin/PluginImage' +import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { DndContext, DragEndEvent } from '@dnd-kit/core' +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { CSS } from '@dnd-kit/utilities' + +const MinimalAppView = ({ plugin, order }: { plugin: PluginTypeWithConfig; order: number }): JSX.Element => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: plugin.id }) + + return ( +
+ + + {plugin.name} +
+ ) +} + +export function InstalledAppsReorderModal(): JSX.Element { + const { reorderModalOpen, sortableEnabledPlugins, temporaryOrder, pluginConfigsLoading } = useValues(pluginsLogic) + const { closeReorderModal, setTemporaryOrder, cancelRearranging, savePluginOrders } = useActions(pluginsLogic) + + const onClose = (): void => { + cancelRearranging() + closeReorderModal() + } + + const handleDragEnd = ({ active, over }: DragEndEvent): void => { + const itemIds = sortableEnabledPlugins.map((item) => item.id) + + if (over && active.id !== over.id) { + const oldIndex = itemIds.indexOf(Number(active.id)) + const newIndex = itemIds.indexOf(Number(over.id)) + const newOrder = arrayMove(sortableEnabledPlugins, oldIndex, newIndex) + + const newTemporaryOrder = newOrder.reduce((acc, plugin, index) => { + return { + ...acc, + [plugin.id]: index + 1, + } + }, {}) + + setTemporaryOrder(newTemporaryOrder, Number(active.id)) + } + } + + return ( + + The order of some apps is important as they are processed sequentially. You can{' '} + drag and drop the apps below to change their order. +

+ } + footer={ + <> + + Cancel + + savePluginOrders(temporaryOrder)} + > + Save order + + + } + > +
+ + + {sortableEnabledPlugins.map((item, index) => ( + + ))} + + +
+
+ ) +} diff --git a/frontend/src/scenes/plugins/tabs/apps/components.tsx b/frontend/src/scenes/plugins/tabs/apps/components.tsx new file mode 100644 index 0000000000000..4a0ecb9426d35 --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/apps/components.tsx @@ -0,0 +1,83 @@ +import { PluginType } from '~/types' +import { LemonTag, Link } from '@posthog/lemon-ui' +import { Tooltip } from 'lib/lemon-ui/Tooltip' +import { PluginRepositoryEntry, PluginTab } from 'scenes/plugins/types' +import { useValues } from 'kea' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { organizationLogic } from 'scenes/organizationLogic' +import { PluginsAccessLevel } from 'lib/constants' +import { copyToClipboard } from 'lib/utils' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { urls } from 'scenes/urls' + +export function RepositoryTag({ plugin }: { plugin: PluginType | PluginRepositoryEntry }): JSX.Element | null { + const { pluginUrlToMaintainer } = useValues(pluginsLogic) + + const pluginMaintainer = plugin.maintainer || pluginUrlToMaintainer[plugin.url || ''] + const isOfficial = pluginMaintainer === 'official' + + if ('plugin_type' in plugin) { + if (plugin.plugin_type === 'source') { + return Source code + } + + if (plugin.plugin_type === 'local' && plugin.url) { + return ( + await copyToClipboard(plugin.url?.substring(5) || '')}> + Installed Locally + + ) + } + } + + if (!pluginMaintainer) { + return null + } + + return ( + + {isOfficial ? 'Official' : 'Community'} + + ) +} + +export function PluginTags({ plugin }: { plugin: PluginType | PluginRepositoryEntry }): JSX.Element | null { + const { currentOrganization } = useValues(organizationLogic) + + return ( + <> + + + {'is_global' in plugin && + plugin.is_global && + !!currentOrganization && + currentOrganization.plugins_access_level >= PluginsAccessLevel.Install && ( + + Global + + )} + + ) +} + +export function BatchExportsAlternativeWarning(): JSX.Element | null { + const { searchTerm } = useValues(pluginsLogic) + + const exporterTerms = ['export', 'batch', 's3', 'snowflake', 'redshift', 'bigquery'] + + if (!searchTerm || !exporterTerms.includes(searchTerm?.toLowerCase())) { + return null + } + return ( + + It looks like you're trying to search for an exporter. There is now a dedicated{' '} + Batch Exports area for these. + + ) +} diff --git a/frontend/src/scenes/plugins/tabs/batch-exports/BatchExportsTab.tsx b/frontend/src/scenes/plugins/tabs/batch-exports/BatchExportsTab.tsx new file mode 100644 index 0000000000000..00907e6a84f4a --- /dev/null +++ b/frontend/src/scenes/plugins/tabs/batch-exports/BatchExportsTab.tsx @@ -0,0 +1,5 @@ +import { BatchExportsList } from 'scenes/batch_exports/BatchExportsListScene' + +export function BatchExportsTab(): JSX.Element { + return +} diff --git a/frontend/src/scenes/plugins/tabs/installed/InstalledPlugin.tsx b/frontend/src/scenes/plugins/tabs/installed/InstalledPlugin.tsx deleted file mode 100644 index 69ee1f4cea048..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/InstalledPlugin.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { PluginCard } from 'scenes/plugins/plugin/PluginCard' -import { PluginTypeWithConfig } from 'scenes/plugins/types' - -export function InstalledPlugin({ - plugin, - showUpdateButton, - order, - maxOrder, - rearranging, - DragColumn, - unorderedPlugin = false, -}: { - plugin: PluginTypeWithConfig - showUpdateButton?: boolean - order?: number - maxOrder?: number - rearranging?: boolean - DragColumn?: React.ComponentClass | React.FC - unorderedPlugin?: boolean -}): JSX.Element { - return ( - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/InstalledTab.tsx b/frontend/src/scenes/plugins/tabs/installed/InstalledTab.tsx deleted file mode 100644 index f3ec7c929635a..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/InstalledTab.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { LogsDrawer } from '../../plugin/LogsDrawer' -import { PluginsSearch } from '../../PluginsSearch' -import { PluginsEmptyState } from './sections/PluginsEmptyState' -import { DisabledPluginSection } from './sections/DisabledPluginsSection' -import { UpgradeSection } from './sections/UpgradeSection' -import { EnabledPluginSection } from './sections/EnabledPluginsSection' - -export function InstalledTab(): JSX.Element { - const { installedPlugins } = useValues(pluginsLogic) - - if (installedPlugins.length === 0) { - return - } - - return ( - <> -
- - - - -
- - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/DisabledPluginsSection.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/DisabledPluginsSection.tsx deleted file mode 100644 index 417edb5887b72..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/DisabledPluginsSection.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons' -import { Row } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { useActions, useValues } from 'kea' -import { PluginSection, pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { InstalledPlugin } from '../InstalledPlugin' - -export function DisabledPluginSection(): JSX.Element { - const { filteredDisabledPlugins, sectionsOpen, disabledPlugins } = useValues(pluginsLogic) - const { toggleSectionOpen } = useActions(pluginsLogic) - - if (disabledPlugins.length === 0) { - return <> - } - - return ( - <> -
toggleSectionOpen(PluginSection.Disabled)} - > - - {sectionsOpen.includes(PluginSection.Disabled) ? ( - - ) : ( - - )} - {` Installed apps (${filteredDisabledPlugins.length})`} - - } - /> -
- {sectionsOpen.includes(PluginSection.Disabled) ? ( - <> - {filteredDisabledPlugins.length > 0 ? ( - - {filteredDisabledPlugins.map((plugin) => ( - - ))} - - ) : ( -

No apps match your search.

- )} - - ) : null} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/EnabledPluginsSection.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/EnabledPluginsSection.tsx deleted file mode 100644 index c07169df0a7e7..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/EnabledPluginsSection.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { - CaretRightOutlined, - CaretDownOutlined, - CloseOutlined, - SaveOutlined, - OrderedListOutlined, -} from '@ant-design/icons' -import { Button, Col, Row, Space, Tag } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { useActions, useValues } from 'kea' -import { PluginSection, pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { InstalledPlugin } from '../InstalledPlugin' -import { canConfigurePlugins } from '../../../access' -import { userLogic } from 'scenes/userLogic' -import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc' -import { PluginTypeWithConfig } from 'scenes/plugins/types' -import { Tooltip } from 'lib/lemon-ui/Tooltip' - -type HandleProps = { children?: JSX.Element } - -const DragColumn = SortableHandle(({ children }: HandleProps) => ( - {children} -)) - -const SortablePlugin = SortableElement( - ({ - plugin, - order, - maxOrder, - rearranging, - }: { - plugin: PluginTypeWithConfig - order: number - maxOrder: number - rearranging: boolean - }) => ( - - ) -) -const SortablePlugins = SortableContainer(({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -}) - -export function EnabledPluginSection(): JSX.Element { - const { user } = useValues(userLogic) - - const { - rearrange, - setTemporaryOrder, - cancelRearranging, - savePluginOrders, - makePluginOrderSaveable, - toggleSectionOpen, - } = useActions(pluginsLogic) - - const { - enabledPlugins, - filteredEnabledPlugins, - sortableEnabledPlugins, - unsortableEnabledPlugins, - rearranging, - loading, - temporaryOrder, - pluginOrderSaveable, - searchTerm, - sectionsOpen, - } = useValues(pluginsLogic) - - const canRearrange: boolean = canConfigurePlugins(user?.organization) && sortableEnabledPlugins.length > 1 - - const rearrangingButtons = rearranging ? ( - <> - - - - ) : ( - - {!!searchTerm ? ( - 'Editing the order of apps is disabled when searching.' - ) : ( - <> - Order matters because event processing with apps works like a pipe: the event is - processed by every enabled app in sequence. - - )} - - ) - } - placement="top" - > - - - ) - - const onSortEnd = ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }): void => { - if (oldIndex === newIndex) { - return - } - - const move = (arr: PluginTypeWithConfig[], from: number, to: number): { id: number; order: number }[] => { - const clone = [...arr] - Array.prototype.splice.call(clone, to, 0, Array.prototype.splice.call(clone, from, 1)[0]) - return clone.map(({ id }, order) => ({ id, order: order + 1 })) - } - - const movedPluginId: number = enabledPlugins[oldIndex]?.id - - const newTemporaryOrder: Record = {} - for (const { id, order } of move(enabledPlugins, oldIndex, newIndex)) { - newTemporaryOrder[id] = order - } - - if (!rearranging) { - rearrange() - } - setTemporaryOrder(newTemporaryOrder, movedPluginId) - } - - const EnabledPluginsHeader = (): JSX.Element => ( -
toggleSectionOpen(PluginSection.Enabled)}> - - {sectionsOpen.includes(PluginSection.Enabled) ? : } - {` Enabled apps (${filteredEnabledPlugins.length})`} - {rearranging && sectionsOpen.includes(PluginSection.Enabled) && ( - - Reordering in progress - - )} - - } - buttons={{sectionsOpen.includes(PluginSection.Enabled) && rearrangingButtons}} - /> -
- ) - - if (enabledPlugins.length === 0) { - return ( - <> - - {sectionsOpen.includes(PluginSection.Enabled) &&

No apps enabled.

} - - ) - } - - return ( - <> - - {sectionsOpen.includes(PluginSection.Enabled) && ( - <> - {sortableEnabledPlugins.length === 0 && unsortableEnabledPlugins.length === 0 && ( -

No apps match your search.

- )} - {canRearrange || rearranging ? ( - <> - {sortableEnabledPlugins.length > 0 && ( - <> - - {sortableEnabledPlugins.map((plugin, index) => ( - - ))} - - - )} - - ) : ( - - {sortableEnabledPlugins.length > 0 && ( - <> - {sortableEnabledPlugins.map((plugin, index) => ( - - ))} - - )} - - )} - {unsortableEnabledPlugins.map((plugin) => ( - - ))} - - )} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/PluginsEmptyState.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/PluginsEmptyState.tsx deleted file mode 100644 index 18b09d2315b73..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/PluginsEmptyState.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { CaretRightOutlined } from '@ant-design/icons' -import { Button, Col, Empty, Row, Skeleton } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { PluginLoading } from 'scenes/plugins/plugin/PluginLoading' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginTab } from 'scenes/plugins/types' -import { canGloballyManagePlugins } from 'scenes/plugins/access' -import { userLogic } from 'scenes/userLogic' - -export function PluginsEmptyState(): JSX.Element { - const { setPluginTab } = useActions(pluginsLogic) - const { loading } = useValues(pluginsLogic) - const { user } = useValues(userLogic) - - return ( - <> - {loading ? ( - <> - - {' '} - {'Enabled apps'}{' '} - - } - buttons={} - /> - - - ) : ( - <> - - - - You haven't installed any apps yet}> - {canGloballyManagePlugins(user?.organization) && ( - - )} - - - - - )} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/UpgradeSection.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/UpgradeSection.tsx deleted file mode 100644 index 2bdf4ff3af7cd..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/UpgradeSection.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { CaretRightOutlined, CaretDownOutlined, SyncOutlined, CloudDownloadOutlined } from '@ant-design/icons' -import { Button, Row } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { useActions, useValues } from 'kea' -import { PluginSection, pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { InstalledPlugin } from '../InstalledPlugin' -import { canInstallPlugins } from 'scenes/plugins/access' -import { userLogic } from 'scenes/userLogic' - -export function UpgradeSection(): JSX.Element { - const { checkForUpdates, toggleSectionOpen } = useActions(pluginsLogic) - const { sectionsOpen } = useValues(pluginsLogic) - const { user } = useValues(userLogic) - - const { - filteredPluginsNeedingUpdates, - pluginsNeedingUpdates, - checkingForUpdates, - installedPluginUrls, - updateStatus, - rearranging, - hasUpdatablePlugins, - } = useValues(pluginsLogic) - - const upgradeButton = canInstallPlugins(user?.organization) && hasUpdatablePlugins && ( - - ) - - return ( - <> -
toggleSectionOpen(PluginSection.Upgrade)} - > - - {sectionsOpen.includes(PluginSection.Upgrade) ? ( - - ) : ( - - )} - {` Apps to update (${filteredPluginsNeedingUpdates.length})`} - - } - buttons={!rearranging && upgradeButton} - /> -
- {sectionsOpen.includes(PluginSection.Upgrade) ? ( - <> - {pluginsNeedingUpdates.length > 0 ? ( - - {filteredPluginsNeedingUpdates.length > 0 ? ( - <> - {filteredPluginsNeedingUpdates.map((plugin) => ( - - ))} - - ) : ( -

No apps match your search.

- )} -
- ) : ( -

All your apps are up to date. Great work!

- )} - - ) : null} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/repository/RepositoryTab.tsx b/frontend/src/scenes/plugins/tabs/repository/RepositoryTab.tsx deleted file mode 100644 index 6079cbfb2db34..0000000000000 --- a/frontend/src/scenes/plugins/tabs/repository/RepositoryTab.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState } from 'react' -import { Col, Row } from 'antd' -import { useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginCard } from 'scenes/plugins/plugin/PluginCard' -import { Subtitle } from 'lib/components/PageHeader' -import { PluginLoading } from 'scenes/plugins/plugin/PluginLoading' -import { PluginsSearch } from '../../PluginsSearch' -import { CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons' - -export enum RepositorySection { - Official = 'official', - Community = 'community', -} - -export function RepositoryTab(): JSX.Element { - const { repositoryLoading, filteredUninstalledPlugins } = useValues(pluginsLogic) - const [repositorySectionsOpen, setRepositorySectionsOpen] = useState([ - RepositorySection.Official, - RepositorySection.Community, - ]) - - const officialPlugins = filteredUninstalledPlugins.filter((plugin) => plugin.maintainer === 'official') - const communityPlugins = filteredUninstalledPlugins.filter((plugin) => plugin.maintainer === 'community') - - const toggleRepositorySectionOpen = (section: RepositorySection): void => { - if (repositorySectionsOpen.includes(section)) { - setRepositorySectionsOpen(repositorySectionsOpen.filter((s) => section !== s)) - return - } - setRepositorySectionsOpen([...repositorySectionsOpen, section]) - } - - return ( -
- - -
- {(!repositoryLoading || filteredUninstalledPlugins.length > 0) && ( - <> - -
toggleRepositorySectionOpen(RepositorySection.Official)} - > - - {repositorySectionsOpen.includes(RepositorySection.Official) ? ( - - ) : ( - - )} - {` Official apps (${officialPlugins.length})`} - - } - /> -
- {repositorySectionsOpen.includes(RepositorySection.Official) && ( - <> - - {officialPlugins.length > 0 - ? 'Official apps are built and maintained by the PostHog team.' - : 'You have already installed all official apps!'} - -
- {officialPlugins.map((plugin) => { - return ( - - ) - })} - - )} -
- -
toggleRepositorySectionOpen(RepositorySection.Community)} - > - - {repositorySectionsOpen.includes(RepositorySection.Community) ? ( - - ) : ( - - )} - {` Community apps (${communityPlugins.length})`} - - } - /> -
- {repositorySectionsOpen.includes(RepositorySection.Community) && ( - <> - - {communityPlugins.length > 0 ? ( - - Community apps are not built nor maintained by the PostHog team.{' '} - - Anyone, including you, can build an app. - - - ) : ( - 'You have already installed all community apps!' - )} - -
- {communityPlugins.map((plugin) => { - return ( - - ) - })} - - )} -
- - )} -
- {repositoryLoading && filteredUninstalledPlugins.length === 0 && ( - - - - )} -
- ) -} diff --git a/frontend/src/scenes/plugins/types.ts b/frontend/src/scenes/plugins/types.ts index 56369ca096fa4..f662a93708dd5 100644 --- a/frontend/src/scenes/plugins/types.ts +++ b/frontend/src/scenes/plugins/types.ts @@ -36,8 +36,8 @@ export enum PluginInstallationType { } export enum PluginTab { - Installed = 'installed', - Repository = 'repository', - Advanced = 'advanced', + Apps = 'apps', + AppsManagement = 'apps_management', + BatchExports = 'batch_exports', History = 'history', } diff --git a/frontend/src/scenes/products/Products.tsx b/frontend/src/scenes/products/Products.tsx new file mode 100644 index 0000000000000..e05e18ee22f24 --- /dev/null +++ b/frontend/src/scenes/products/Products.tsx @@ -0,0 +1,145 @@ +import { LemonButton } from '@posthog/lemon-ui' +import { IconBarChart } from 'lib/lemon-ui/icons' +import { SceneExport } from 'scenes/sceneTypes' +import { BillingProductV2Type, ProductKey } from '~/types' +import { useActions, useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { useEffect } from 'react' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' +import { urls } from 'scenes/urls' +import { billingLogic } from 'scenes/billing/billingLogic' +import { Spinner } from 'lib/lemon-ui/Spinner' +import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard' +import { router } from 'kea-router' +import { getProductUri } from 'scenes/onboarding/onboardingLogic' +import { productsLogic } from './productsLogic' + +export const scene: SceneExport = { + component: Products, + logic: productsLogic, +} + +function OnboardingCompletedButton({ + productUrl, + onboardingUrl, + productKey, +}: { + productUrl: string + onboardingUrl: string + productKey: ProductKey +}): JSX.Element { + const { onSelectProduct } = useActions(productsLogic) + return ( + <> + + Go to product + + { + onSelectProduct(productKey) + router.actions.push(onboardingUrl) + }} + > + Set up again + + + ) +} + +function OnboardingNotCompletedButton({ url, productKey }: { url: string; productKey: ProductKey }): JSX.Element { + const { onSelectProduct } = useActions(productsLogic) + return ( + { + onSelectProduct(productKey) + router.actions.push(url) + }} + > + Get started + + ) +} + +function ProductCard({ product }: { product: BillingProductV2Type }): JSX.Element { + const { currentTeam } = useValues(teamLogic) + const onboardingCompleted = currentTeam?.has_completed_onboarding_for?.[product.type] + return ( + +
+
+ {product.image_url ? ( + {`Logo + ) : ( + + )} +
+
+
+

{product.name}

+
+

{product.description}

+
+ {onboardingCompleted ? ( + + ) : ( + + )} +
+
+ ) +} + +export function Products(): JSX.Element { + const { featureFlags } = useValues(featureFlagLogic) + const { billing } = useValues(billingLogic) + const { currentTeam } = useValues(teamLogic) + const isFirstProduct = Object.keys(currentTeam?.has_completed_onboarding_for || {}).length === 0 + const products = billing?.products || [] + + useEffect(() => { + if (featureFlags[FEATURE_FLAGS.PRODUCT_SPECIFIC_ONBOARDING] !== 'test') { + location.href = urls.ingestion() + } + }, []) + + return ( +
+
+

Pick your {isFirstProduct ? 'first' : 'next'} product.

+

+ Pick your {isFirstProduct ? 'first' : 'next'} product to get started with. You can set up any others + you'd like later. +

+
+ {products.length > 0 ? ( + <> +
+ {products + .filter( + (product) => + !product.contact_support && + !product.inclusion_only && + product.type !== 'data_warehouse' + ) + .map((product) => ( + + ))} +
+ + ) : ( + + )} +
+ ) +} diff --git a/frontend/src/scenes/products/productsLogic.tsx b/frontend/src/scenes/products/productsLogic.tsx new file mode 100644 index 0000000000000..48a17171bdc8a --- /dev/null +++ b/frontend/src/scenes/products/productsLogic.tsx @@ -0,0 +1,34 @@ +import { kea, path, actions, listeners } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { ProductKey } from '~/types' + +import type { productsLogicType } from './productsLogicType' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' + +export const productsLogic = kea([ + path(() => ['scenes', 'products', 'productsLogic']), + actions(() => ({ + onSelectProduct: (product: ProductKey) => ({ product }), + })), + listeners(() => ({ + onSelectProduct: ({ product }) => { + eventUsageLogic.actions.reportOnboardingProductSelected(product) + + switch (product) { + case ProductKey.PRODUCT_ANALYTICS: + return + case ProductKey.SESSION_REPLAY: + teamLogic.actions.updateCurrentTeam({ + session_recording_opt_in: true, + capture_console_log_opt_in: true, + capture_performance_opt_in: true, + }) + return + case ProductKey.FEATURE_FLAGS: + return + default: + return + } + }, + })), +]) diff --git a/frontend/src/scenes/project-homepage/NewlySeenPersons.tsx b/frontend/src/scenes/project-homepage/NewlySeenPersons.tsx index 7b68fc572dc75..0aea88331c5e3 100644 --- a/frontend/src/scenes/project-homepage/NewlySeenPersons.tsx +++ b/frontend/src/scenes/project-homepage/NewlySeenPersons.tsx @@ -17,7 +17,7 @@ function PersonRow({ person }: { person: PersonType }): JSX.Element { return ( } diff --git a/frontend/src/scenes/project-homepage/ProjectHomePageCompactListItem.tsx b/frontend/src/scenes/project-homepage/ProjectHomePageCompactListItem.tsx index 393d27fd174cd..c61ac0e2bcc8c 100644 --- a/frontend/src/scenes/project-homepage/ProjectHomePageCompactListItem.tsx +++ b/frontend/src/scenes/project-homepage/ProjectHomePageCompactListItem.tsx @@ -22,7 +22,7 @@ export function ProjectHomePageCompactListItem({ {prefix ? {prefix} : null}
-
{title}
+
{title}
{subtitle}
diff --git a/frontend/src/scenes/project-homepage/ProjectHomepage.scss b/frontend/src/scenes/project-homepage/ProjectHomepage.scss index d24cdf5c09a38..b8062e75dda4b 100644 --- a/frontend/src/scenes/project-homepage/ProjectHomepage.scss +++ b/frontend/src/scenes/project-homepage/ProjectHomepage.scss @@ -25,6 +25,10 @@ .top-list { margin-bottom: 1.5rem; + .posthog-3000 & { + margin-bottom: 0px; + } + width: 33%; } diff --git a/frontend/src/scenes/project-homepage/ProjectHomepage.stories.tsx b/frontend/src/scenes/project-homepage/ProjectHomepage.stories.tsx index 8dbc6fdac6269..ee72de13b7ad1 100644 --- a/frontend/src/scenes/project-homepage/ProjectHomepage.stories.tsx +++ b/frontend/src/scenes/project-homepage/ProjectHomepage.stories.tsx @@ -5,7 +5,7 @@ import { App } from 'scenes/App' import { router } from 'kea-router' import { urls } from 'scenes/urls' -export default { +const meta: Meta = { title: 'Scenes-App/Project Homepage', decorators: [ mswDecorator({ @@ -18,15 +18,14 @@ export default { ], parameters: { layout: 'fullscreen', - options: { showPanel: false }, testOptions: { excludeNavigationFromSnapshot: true, }, viewMode: 'story', mockDate: '2023-02-01', }, -} as Meta - +} +export default meta export const ProjectHomepage = (): JSX.Element => { useEffect(() => { router.actions.push(urls.projectHomepage()) diff --git a/frontend/src/scenes/project-homepage/ProjectHomepage.tsx b/frontend/src/scenes/project-homepage/ProjectHomepage.tsx index cd367d73c5c18..69d8233fdacf2 100644 --- a/frontend/src/scenes/project-homepage/ProjectHomepage.tsx +++ b/frontend/src/scenes/project-homepage/ProjectHomepage.tsx @@ -39,6 +39,8 @@ export function ProjectHomepage(): JSX.Element { const topListContainerRef = useRef(null) const [topListContainerWidth] = useSize(topListContainerRef) + const has3000 = featureFlags[FEATURE_FLAGS.POSTHOG_3000] + const headerButtons = ( <> Invite members - + {!has3000 && } ) return (
- {!featureFlags[FEATURE_FLAGS.POSTHOG_3000] && ( + +
+
+ +
+
+
+ +
+
+
+ +
+
+ {currentTeam?.primary_dashboard ? ( <> - -
-
- -
-
-
- +
+
+ {!dashboard && } + {dashboard?.name && ( + <> + + + {dashboard?.name} + + + )}
-
-
- +
+ + Change dashboard +
- - )} - {currentTeam?.primary_dashboard ? ( - <> - {!featureFlags[FEATURE_FLAGS.POSTHOG_3000] && ( - <> -
-
- {!dashboard && } - {dashboard?.name && ( - <> - - - {dashboard?.name} - - - )} -
-
- - Change dashboard - - {featureFlags[FEATURE_FLAGS.POSTHOG_3000] && headerButtons} -
-
- - - )} +

- Correlation analysis exclusions{' '} - - Beta - + Correlation analysis exclusions

Globally exclude events or properties that do not provide relevant signals for your conversions.

diff --git a/frontend/src/scenes/project/Settings/DataAttributes.tsx b/frontend/src/scenes/project/Settings/DataAttributes.tsx index c6b5b3a12e47c..4efdb3c1ca763 100644 --- a/frontend/src/scenes/project/Settings/DataAttributes.tsx +++ b/frontend/src/scenes/project/Settings/DataAttributes.tsx @@ -1,4 +1,4 @@ -import { LemonButton } from '@posthog/lemon-ui' +import { LemonButton, Link } from '@posthog/lemon-ui' import { Skeleton } from 'antd' import { useActions, useValues } from 'kea' import { LemonSelectMultiple } from 'lib/lemon-ui/LemonSelectMultiple/LemonSelectMultiple' @@ -20,12 +20,9 @@ export function DataAttributes(): JSX.Element { <>

Specify a comma-separated list of{' '} - + data attributes - {' '} + {' '} used in your app. For example: data-attr, data-custom-id, data-myref-*. These attributes will be used when using the toolbar and defining actions to match unique elements on your pages. You can use * as a wildcard. @@ -33,8 +30,8 @@ export function DataAttributes(): JSX.Element {

For example, when creating an action on your CTA button, the best selector could be something like:{' '} div > form > button:nth-child(2). However all buttons in your app have a{' '} - data-custom-id attribute. If you whitelist it here, the selector for your button will - instead be button[data-custom-id='cta-button']. + data-custom-id attribute. If you allow it here, the selector for your button will instead + be button[data-custom-id='cta-button'].

+
)} - +
diff --git a/frontend/src/scenes/project/Settings/IngestionInfo.tsx b/frontend/src/scenes/project/Settings/IngestionInfo.tsx index 6a3e52e950873..73c62f32539ef 100644 --- a/frontend/src/scenes/project/Settings/IngestionInfo.tsx +++ b/frontend/src/scenes/project/Settings/IngestionInfo.tsx @@ -45,7 +45,7 @@ export function IngestionInfo({ loadingComponent }: { loadingComponent: JSX.Elem

For more guidance, including on identifying users,{' '} - see PostHog Docs. + see PostHog Docs.

{currentTeamLoading && !currentTeam ? loadingComponent : } @@ -62,9 +62,9 @@ export function IngestionInfo({ loadingComponent }: { loadingComponent: JSX.Elem

Send custom events

- To send custom events visit PostHog Docs and integrate - the library for the specific language or platform you're using. We support Python, Ruby, Node, Go, PHP, iOS, - Android, and more. + To send custom events visit PostHog Docs and + integrate the library for the specific language or platform you're using. We support Python, Ruby, Node, Go, + PHP, iOS, Android, and more.

Project Variables @@ -74,7 +74,7 @@ export function IngestionInfo({ loadingComponent }: { loadingComponent: JSX.Elem

You can use this write-only key in any one of{' '} - our libraries. + our libraries.

- You can use this ID to reference your project in our API. + You can use this ID to reference your project in our API.

{String(currentTeam?.id || '')} diff --git a/frontend/src/scenes/project/Settings/SlackIntegration.stories.tsx b/frontend/src/scenes/project/Settings/SlackIntegration.stories.tsx index e2a4f7ae1e7d0..aa21717e2cbf5 100644 --- a/frontend/src/scenes/project/Settings/SlackIntegration.stories.tsx +++ b/frontend/src/scenes/project/Settings/SlackIntegration.stories.tsx @@ -1,15 +1,16 @@ -import { ComponentMeta } from '@storybook/react' +import { Meta } from '@storybook/react' import { AvailableFeature } from '~/types' import { useAvailableFeatures } from '~/mocks/features' import { useStorybookMocks } from '~/mocks/browser' import { mockIntegration } from '~/test/mocks' import { SlackIntegration } from './SlackIntegration' -export default { +const meta: Meta = { title: 'Components/Integrations/Slack', component: SlackIntegration, parameters: {}, -} as ComponentMeta +} +export default meta const Template = (args: { instanceConfigured?: boolean; integrated?: boolean }): JSX.Element => { const { instanceConfigured = true, integrated = false } = args diff --git a/frontend/src/scenes/project/Settings/SlackIntegration.tsx b/frontend/src/scenes/project/Settings/SlackIntegration.tsx index 6e7efa8de718f..93732b838cbf8 100644 --- a/frontend/src/scenes/project/Settings/SlackIntegration.tsx +++ b/frontend/src/scenes/project/Settings/SlackIntegration.tsx @@ -38,10 +38,13 @@ export function SlackIntegration(): JSX.Element { return (

- Integrate with Slack directly to get more advanced options such as sending webhook events to{' '} - different channels and subscribing to an Insight or Dashboard for regular reports to Slack - channels of your choice. Guidance on integrating with Slack available{' '} - in our docs. + Integrate with Slack directly to get more advanced options such as{' '} + subscribing to an Insight or Dashboard for regular reports to Slack channels of your choice. + Guidance on integrating with Slack available{' '} + + in our docs + + .

@@ -69,7 +72,7 @@ export function SlackIntegration(): JSX.Element {

) : addToSlackButtonUrl() ? ( - + Add to Slack - + ) : user?.is_staff ? ( !showSlackInstructions ? ( <> @@ -93,9 +96,9 @@ export function SlackIntegration(): JSX.Element {
  • Copy the below Slack App Template
  • Go to{' '} - + Slack Apps - +
  • Create an App using the provided template
  • diff --git a/frontend/src/scenes/project/Settings/Survey.tsx b/frontend/src/scenes/project/Settings/Survey.tsx new file mode 100644 index 0000000000000..a33f4cdd9cb13 --- /dev/null +++ b/frontend/src/scenes/project/Settings/Survey.tsx @@ -0,0 +1,20 @@ +import { LemonDivider, Link } from '@posthog/lemon-ui' +import { SurveySettings as BasicSurveySettings } from 'scenes/surveys/SurveySettings' +import { urls } from 'scenes/urls' + +export function SurveySettings(): JSX.Element { + return ( + <> +

    + Surveys +

    +

    + Get qualitative and quantitative data on how your users are doing. Surveys are found in the{' '} + surveys page. +

    + + + + + ) +} diff --git a/frontend/src/scenes/project/Settings/TimezoneConfig.tsx b/frontend/src/scenes/project/Settings/TimezoneConfig.tsx index 409f438e6e57a..09d7c8a70c026 100644 --- a/frontend/src/scenes/project/Settings/TimezoneConfig.tsx +++ b/frontend/src/scenes/project/Settings/TimezoneConfig.tsx @@ -6,50 +6,65 @@ import { LemonSelectMultiple } from 'lib/lemon-ui/LemonSelectMultiple/LemonSelec import { LemonDialog } from 'lib/lemon-ui/LemonDialog' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' +const tzLabel = (tz: string, offset: number): string => + `${tz.replace(/\//g, ' / ').replace(/_/g, ' ')} (UTC${offset === 0 ? '±' : offset > 0 ? '+' : '-'}${Math.abs( + Math.floor(offset) + )}:${(Math.abs(offset % 1) * 60).toString().padStart(2, '0')})` + export function TimezoneConfig(): JSX.Element { const { preflight } = useValues(preflightLogic) - const { currentTeam, currentTeamLoading } = useValues(teamLogic) + const { currentTeam, timezone: currentTimezone, currentTeamLoading } = useValues(teamLogic) const { updateCurrentTeam } = useActions(teamLogic) if (!preflight?.available_timezones || !currentTeam) { return } - function onChange(val: string): void { - LemonDialog.open({ - title: `Do you want to change the timezone of this project?`, - description: - 'This will change how every graph in this project is calculated, which means your data will look different than it did before.', - primaryButton: { - children: 'Change timezone', - status: 'danger', - onClick: () => updateCurrentTeam({ timezone: val }), - }, - secondaryButton: { - children: 'Cancel', - }, - }) - } - - const options = Object.entries(preflight.available_timezones).map(([tz, offset]) => { - const label = `${tz.replace(/\//g, ' / ').replace(/_/g, ' ')} (UTC${ - offset === 0 ? '±' : offset > 0 ? '+' : '-' - }${Math.abs(offset)})` - return { - key: tz, - label: label, - } - }) + const options = Object.entries(preflight.available_timezones).map(([tz, offset]) => ({ + key: tz, + label: tzLabel(tz, offset), + })) return ( - onChange(val as any)} - options={options} - data-attr="timezone-select" - /> +
    + { + // This is a string for a single-mode select, but typing is poor + if (!preflight?.available_timezones) { + throw new Error('No timezones are available') + } + const currentOffset = preflight.available_timezones[currentTimezone] + const newOffset = preflight.available_timezones[newTimezone] + if (currentOffset === newOffset) { + updateCurrentTeam({ timezone: newTimezone }) + } else { + LemonDialog.open({ + title: `Change time zone to ${tzLabel(newTimezone, newOffset)}?`, + description: ( +

    + This time zone has an offset different from the current{' '} + {tzLabel(currentTimezone, currentOffset)}, so queries will need to + be recalculated. There will be a difference in date-based time ranges, and in + day/week/month buckets. +

    + ), + primaryButton: { + children: 'Change time zone', + onClick: () => updateCurrentTeam({ timezone: newTimezone }), + }, + secondaryButton: { + children: 'Cancel', + }, + }) + } + }} + options={options} + data-attr="timezone-select" + /> +
    ) } diff --git a/frontend/src/scenes/project/Settings/ToolbarSettings.tsx b/frontend/src/scenes/project/Settings/ToolbarSettings.tsx deleted file mode 100644 index 907384ef8e44d..0000000000000 --- a/frontend/src/scenes/project/Settings/ToolbarSettings.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useValues, useActions } from 'kea' -import { userLogic } from 'scenes/userLogic' -import { LemonSwitch } from '@posthog/lemon-ui' - -export function ToolbarSettings(): JSX.Element { - const { user, userLoading } = useValues(userLogic) - const { updateUser } = useActions(userLogic) - - return ( - { - updateUser({ - toolbar_mode: user?.toolbar_mode === 'disabled' ? 'toolbar' : 'disabled', - }) - }} - checked={user?.toolbar_mode !== 'disabled'} - disabled={userLoading} - label="Enable PostHog Toolbar" - bordered - /> - ) -} diff --git a/frontend/src/scenes/project/Settings/WebhookIntegration.tsx b/frontend/src/scenes/project/Settings/WebhookIntegration.tsx index 83ba8c9a13114..f2fae688f7340 100644 --- a/frontend/src/scenes/project/Settings/WebhookIntegration.tsx +++ b/frontend/src/scenes/project/Settings/WebhookIntegration.tsx @@ -2,13 +2,18 @@ import { useEffect, useState } from 'react' import { useActions, useValues } from 'kea' import { teamLogic } from 'scenes/teamLogic' import { webhookIntegrationLogic } from './webhookIntegrationLogic' -import { LemonButton, LemonInput } from '@posthog/lemon-ui' +import { LemonButton, LemonInput, Link } from '@posthog/lemon-ui' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' +import { supportLogic } from 'lib/components/Support/supportLogic' export function WebhookIntegration(): JSX.Element { const [webhook, setWebhook] = useState('') const { testWebhook, removeWebhook } = useActions(webhookIntegrationLogic) const { loading } = useValues(webhookIntegrationLogic) const { currentTeam } = useValues(teamLogic) + const { featureFlags } = useValues(featureFlagLogic) + const { openSupportForm } = useActions(supportLogic) useEffect(() => { if (currentTeam?.slack_incoming_webhook) { @@ -16,15 +21,27 @@ export function WebhookIntegration(): JSX.Element { } }, [currentTeam]) + const webhooks_disallowed = featureFlags[FEATURE_FLAGS.WEBHOOKS_DENYLIST] + if (webhooks_disallowed) { + return ( +
    +

    + Webhooks are currently not available for your organization.{' '} + openSupportForm('support', 'apps')}>Contact support +

    +
    + ) + } + return (

    Send notifications when selected actions are performed by users.
    Guidance on integrating with webhooks available in our docs,{' '} - for Slack and{' '} - for Microsoft Teams. Discord is also - supported. + for Slack and{' '} + for Microsoft Teams. Discord is + also supported.

    diff --git a/frontend/src/scenes/project/Settings/WeekStartConfig.tsx b/frontend/src/scenes/project/Settings/WeekStartConfig.tsx new file mode 100644 index 0000000000000..4a00d35729c23 --- /dev/null +++ b/frontend/src/scenes/project/Settings/WeekStartConfig.tsx @@ -0,0 +1,33 @@ +import { useActions, useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { LemonSelect } from '@posthog/lemon-ui' +import { LemonDialog } from 'lib/lemon-ui/LemonDialog' + +export function WeekStartConfig(): JSX.Element { + const { currentTeam, currentTeamLoading } = useValues(teamLogic) + const { updateCurrentTeam } = useActions(teamLogic) + + return ( + { + LemonDialog.open({ + title: `Change the first day of the week to ${value === 0 ? 'Sunday' : 'Monday'}?`, + description: 'Queries grouped by week will need to be recalculated.', + primaryButton: { + children: 'Change week definition', + onClick: () => updateCurrentTeam({ week_start_day: value }), + }, + secondaryButton: { + children: 'Cancel', + }, + }) + }} + loading={currentTeamLoading} + options={[ + { value: 0, label: 'Sunday' }, + { value: 1, label: 'Monday' }, + ]} + /> + ) +} diff --git a/frontend/src/scenes/project/Settings/groupAnalyticsConfigLogic.ts b/frontend/src/scenes/project/Settings/groupAnalyticsConfigLogic.ts index 22c9aed1ffafc..89a3029b4d47e 100644 --- a/frontend/src/scenes/project/Settings/groupAnalyticsConfigLogic.ts +++ b/frontend/src/scenes/project/Settings/groupAnalyticsConfigLogic.ts @@ -1,20 +1,20 @@ -import { kea } from 'kea' +import { kea, path, connect, actions, reducers, selectors, listeners } from 'kea' import { groupsModel } from '~/models/groupsModel' import type { groupAnalyticsConfigLogicType } from './groupAnalyticsConfigLogicType' -export const groupAnalyticsConfigLogic = kea({ - path: ['scenes', 'project', 'Settings', 'groupAnalyticsConfigLogic'], - connect: { +export const groupAnalyticsConfigLogic = kea([ + path(['scenes', 'project', 'Settings', 'groupAnalyticsConfigLogic']), + connect({ values: [groupsModel, ['groupTypes', 'groupTypesLoading']], actions: [groupsModel, ['updateGroupTypesMetadata']], - }, - actions: { + }), + actions({ setSingular: (groupTypeIndex: number, value: string) => ({ groupTypeIndex, value }), setPlural: (groupTypeIndex: number, value: string) => ({ groupTypeIndex, value }), reset: true, save: true, - }, - reducers: { + }), + reducers({ singularChanges: [ {} as Record, { @@ -31,18 +31,18 @@ export const groupAnalyticsConfigLogic = kea({ updateGroupTypesMetadataSuccess: () => ({}), }, ], - }, - selectors: { + }), + selectors({ hasChanges: [ (s) => [s.singularChanges, s.pluralChanges], (singularChanges, pluralChanges) => Object.keys(singularChanges).length > 0 || Object.keys(pluralChanges).length > 0, ], - }, - listeners: ({ values, actions }) => ({ + }), + listeners(({ values, actions }) => ({ save: async () => { const { groupTypes, singularChanges, pluralChanges } = values - const payload = groupTypes.map((groupType) => { + const payload = Array.from(groupTypes.values()).map((groupType) => { const result = { ...groupType } if (singularChanges[groupType.group_type_index]) { result.name_singular = singularChanges[groupType.group_type_index] @@ -55,5 +55,5 @@ export const groupAnalyticsConfigLogic = kea({ actions.updateGroupTypesMetadata(payload) }, - }), -}) + })), +]) diff --git a/frontend/src/scenes/project/Settings/index.tsx b/frontend/src/scenes/project/Settings/index.tsx index 891dfaa9adcb6..dcf5c15091d50 100644 --- a/frontend/src/scenes/project/Settings/index.tsx +++ b/frontend/src/scenes/project/Settings/index.tsx @@ -5,7 +5,6 @@ import { SessionRecording } from './SessionRecording' import { WebhookIntegration } from './WebhookIntegration' import { useAnchor } from 'lib/hooks/useAnchor' import { router } from 'kea-router' -import { ToolbarSettings } from './ToolbarSettings' import { teamLogic } from 'scenes/teamLogic' import { DangerZone } from './DangerZone' import { PageHeader } from 'lib/components/PageHeader' @@ -29,12 +28,14 @@ import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUr import { GroupAnalytics } from 'scenes/project/Settings/GroupAnalytics' import { PersonDisplayNameProperties } from './PersonDisplayNameProperties' import { SlackIntegration } from './SlackIntegration' -import { LemonButton, LemonDivider, LemonInput } from '@posthog/lemon-ui' +import { LemonButton, LemonDivider, LemonInput, LemonLabel } from '@posthog/lemon-ui' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' import { AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic' import { IngestionInfo } from './IngestionInfo' import { ExtraTeamSettings } from './ExtraTeamSettings' +import { WeekStartConfig } from './WeekStartConfig' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { SurveySettings } from './Survey' export const scene: SceneExport = { component: ProjectSettings, @@ -55,7 +56,7 @@ function DisplayName(): JSX.Element { } return ( -
    +
    } /> )} -

    - Timezone +

    + Date and time

    - Set the timezone for your project. All charts will be based on this timezone, including how PostHog - buckets data in day/week/month intervals. + These settings affect how PostHog displays, buckets, and filters time-series data. You may need to + refresh insights for new settings to apply.

    -
    +
    + Time zone + Week starts on +

    @@ -210,7 +214,7 @@ export function ProjectSettings(): JSX.Element {

    - Person Display Name + Person display name

    @@ -231,16 +235,10 @@ export function ProjectSettings(): JSX.Element {

    -

    PostHog Toolbar

    -

    - Enable PostHog Toolbar, which gives access to heatmaps, stats and allows you to create actions, - right there on your website! -

    - - + diff --git a/frontend/src/scenes/project/Settings/integrationsLogic.ts b/frontend/src/scenes/project/Settings/integrationsLogic.ts index e585b28c6d2a3..56cfa47d330f3 100644 --- a/frontend/src/scenes/project/Settings/integrationsLogic.ts +++ b/frontend/src/scenes/project/Settings/integrationsLogic.ts @@ -94,7 +94,7 @@ export const integrationsLogic = kea([ listeners(({ actions }) => ({ handleRedirect: async ({ kind, searchParams }) => { switch (kind) { - case 'slack': + case 'slack': { const { state, code, error, next } = searchParams const replaceUrl = next || urls.projectSettings() @@ -120,6 +120,7 @@ export const integrationsLogic = kea([ } return + } default: lemonToast.error(`Something went wrong.`) } diff --git a/frontend/src/scenes/project/Settings/teamMembersLogic.tsx b/frontend/src/scenes/project/Settings/teamMembersLogic.tsx index b7023710d2182..67455ac28c39f 100644 --- a/frontend/src/scenes/project/Settings/teamMembersLogic.tsx +++ b/frontend/src/scenes/project/Settings/teamMembersLogic.tsx @@ -50,7 +50,7 @@ export const teamMembersLogic = kea([ ) ) lemonToast.success( - `Added ${newMembers.length} members${newMembers.length !== 1 && 's'} to the project.` + `Added ${newMembers.length} member${newMembers.length !== 1 ? 's' : ''} to the project.` ) return [...values.explicitMembers, ...newMembers] }, diff --git a/frontend/src/scenes/project/Settings/webhookIntegrationLogic.ts b/frontend/src/scenes/project/Settings/webhookIntegrationLogic.ts index c08d74ca651bf..77cf7e1a69180 100644 --- a/frontend/src/scenes/project/Settings/webhookIntegrationLogic.ts +++ b/frontend/src/scenes/project/Settings/webhookIntegrationLogic.ts @@ -1,4 +1,5 @@ -import { kea } from 'kea' +import { loaders } from 'kea-loaders' +import { kea, path, selectors, listeners } from 'kea' import api from 'lib/api' import { lemonToast } from 'lib/lemon-ui/lemonToast' import { capitalizeFirstLetter } from 'lib/utils' @@ -10,9 +11,9 @@ function adjustDiscordWebhook(webhookUrl: string): string { return webhookUrl.replace(/\/*(?:posthog|slack)?\/?$/, '/slack') } -export const webhookIntegrationLogic = kea({ - path: ['scenes', 'project', 'Settings', 'webhookIntegrationLogic'], - loaders: ({ actions }) => ({ +export const webhookIntegrationLogic = kea([ + path(['scenes', 'project', 'Settings', 'webhookIntegrationLogic']), + loaders(({ actions }) => ({ testedWebhook: [ null as string | null, { @@ -46,8 +47,14 @@ export const webhookIntegrationLogic = kea({ }, }, ], + })), + selectors({ + loading: [ + (s) => [s.testedWebhookLoading, teamLogic.selectors.currentTeamLoading], + (testedWebhookLoading: boolean, currentTeamLoading: boolean) => testedWebhookLoading || currentTeamLoading, + ], }), - listeners: () => ({ + listeners(() => ({ testWebhookSuccess: async ({ testedWebhook }) => { if (testedWebhook) { teamLogic.actions.updateCurrentTeam({ slack_incoming_webhook: testedWebhook }) @@ -56,11 +63,5 @@ export const webhookIntegrationLogic = kea({ testWebhookFailure: ({ error }) => { lemonToast.error(capitalizeFirstLetter(error)) }, - }), - selectors: { - loading: [ - (s) => [s.testedWebhookLoading, teamLogic.selectors.currentTeamLoading], - (testedWebhookLoading: boolean, currentTeamLoading: boolean) => testedWebhookLoading || currentTeamLoading, - ], - }, -}) + })), +]) diff --git a/frontend/src/scenes/query/QueryScene.tsx b/frontend/src/scenes/query/QueryScene.tsx deleted file mode 100644 index 03d2d79871174..0000000000000 --- a/frontend/src/scenes/query/QueryScene.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { querySceneLogic } from './querySceneLogic' -import { SceneExport } from 'scenes/sceneTypes' -import { PageHeader } from 'lib/components/PageHeader' -import { Query } from '~/queries/Query/Query' -import { useActions, useValues } from 'kea' -import { stringifiedExamples } from '~/queries/examples' -import { LemonSelect } from 'lib/lemon-ui/LemonSelect' -import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel' - -export function QueryScene(): JSX.Element { - const { query } = useValues(querySceneLogic) - const { setQuery } = useActions(querySceneLogic) - - let showEditor = true - try { - const parsed = JSON.parse(query) - if ( - parsed && - parsed.kind == 'DataTableNode' && - parsed.source.kind == 'HogQLQuery' && - (parsed.full || parsed.showHogQLEditor) - ) { - showEditor = false - } - } catch (e) {} - - return ( -
    - - Example queries:{' '} - { - return { label: k, value: v } - })} - onChange={(v) => { - if (v) { - setQuery(v) - } - }} - /> - - } - /> - - setQuery(JSON.stringify(query, null, 2))} - context={{ - showQueryEditor: showEditor, - }} - /> -
    - ) -} - -export const scene: SceneExport = { - component: QueryScene, - logic: querySceneLogic, -} diff --git a/frontend/src/scenes/query/querySceneLogic.ts b/frontend/src/scenes/query/querySceneLogic.ts deleted file mode 100644 index c53fd3911ec91..0000000000000 --- a/frontend/src/scenes/query/querySceneLogic.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { actions, kea, path, reducers } from 'kea' - -import type { querySceneLogicType } from './querySceneLogicType' -import { actionToUrl, urlToAction } from 'kea-router' -import { urls } from 'scenes/urls' -import { stringifiedExamples } from '~/queries/examples' - -const DEFAULT_QUERY: string = stringifiedExamples['Events'] - -export const querySceneLogic = kea([ - path(['scenes', 'query', 'querySceneLogic']), - actions({ - setQuery: (query: string) => ({ query: query }), - }), - reducers({ - query: [DEFAULT_QUERY, { setQuery: (_, { query }) => query }], - }), - actionToUrl({ - setQuery: ({ query }) => { - return [urls.debugQuery(), {}, { q: query }, { replace: true }] - }, - }), - urlToAction(({ actions, values }) => ({ - [urls.debugQuery()]: (_, __, { q }) => { - if (q && q !== values.query) { - actions.setQuery(q) - } - }, - })), -]) diff --git a/frontend/src/scenes/retention/RetentionModal.tsx b/frontend/src/scenes/retention/RetentionModal.tsx index c53514f1444e3..a47a895d40fff 100644 --- a/frontend/src/scenes/retention/RetentionModal.tsx +++ b/frontend/src/scenes/retention/RetentionModal.tsx @@ -47,7 +47,6 @@ export function RetentionModal(): JSX.Element | null { export_format: ExporterFormat.CSV, export_context: { path: row?.people_url, - max_limit: 10000, }, }) } @@ -110,7 +109,9 @@ export function RetentionModal(): JSX.Element | null { ) : ( {asDisplay(personAppearances.person)} diff --git a/frontend/src/scenes/retention/retentionLineGraphLogic.ts b/frontend/src/scenes/retention/retentionLineGraphLogic.ts index f0624e1599954..127ff04440385 100644 --- a/frontend/src/scenes/retention/retentionLineGraphLogic.ts +++ b/frontend/src/scenes/retention/retentionLineGraphLogic.ts @@ -1,5 +1,5 @@ import { dayjs, QUnitType } from 'lib/dayjs' -import { kea } from 'kea' +import { kea, props, key, path, connect, selectors } from 'kea' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' import { RetentionTrendPayload } from 'scenes/retention/types' import { InsightLogicProps, RetentionPeriod } from '~/types' @@ -12,20 +12,19 @@ import type { retentionLineGraphLogicType } from './retentionLineGraphLogicType' const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' -export const retentionLineGraphLogic = kea({ - props: {} as InsightLogicProps, - key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), - path: (key) => ['scenes', 'retention', 'retentionLineGraphLogic', key], - connect: (props: InsightLogicProps) => ({ +export const retentionLineGraphLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY)), + path((key) => ['scenes', 'retention', 'retentionLineGraphLogic', key]), + connect((props: InsightLogicProps) => ({ values: [ insightVizDataLogic(props), ['querySource', 'dateRange', 'retentionFilter'], retentionLogic(props), ['results'], ], - }), - - selectors: { + })), + selectors({ trendSeries: [ (s) => [s.results, s.retentionFilter], (results, retentionFilter): RetentionTrendPayload[] => { @@ -71,7 +70,7 @@ export const retentionLineGraphLogic = kea({ label: cohortRetention.date ? period === 'Hour' ? dayjs(cohortRetention.date).format('MMM D, h A') - : dayjs.utc(cohortRetention.date).format('MMM D') + : dayjs(cohortRetention.date).format('MMM D') : cohortRetention.label, data: retention_reference === 'previous' @@ -121,5 +120,5 @@ export const retentionLineGraphLogic = kea({ return querySource?.aggregation_group_type_index ?? 'people' }, ], - }, -}) + }), +]) diff --git a/frontend/src/scenes/retention/retentionLogic.ts b/frontend/src/scenes/retention/retentionLogic.ts index 75fd4ff8f389c..bf97e36dfdbb6 100644 --- a/frontend/src/scenes/retention/retentionLogic.ts +++ b/frontend/src/scenes/retention/retentionLogic.ts @@ -1,4 +1,4 @@ -import { kea } from 'kea' +import { kea, props, key, path, connect, selectors } from 'kea' import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' import { RetentionTablePayload } from 'scenes/retention/types' @@ -9,19 +9,19 @@ import type { retentionLogicType } from './retentionLogicType' const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' -export const retentionLogic = kea({ - props: {} as InsightLogicProps, - key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), - path: (key) => ['scenes', 'retention', 'retentionLogic', key], - connect: (props: InsightLogicProps) => ({ +export const retentionLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY)), + path((key) => ['scenes', 'retention', 'retentionLogic', key]), + connect((props: InsightLogicProps) => ({ values: [insightVizDataLogic(props), ['insightQuery', 'insightData', 'querySource']], - }), - selectors: { + })), + selectors({ results: [ (s) => [s.insightQuery, s.insightData], (insightQuery, insightData): RetentionTablePayload[] => { return isRetentionQuery(insightQuery) ? insightData?.result ?? [] : [] }, ], - }, -}) + }), +]) diff --git a/frontend/src/scenes/retention/retentionModalLogic.ts b/frontend/src/scenes/retention/retentionModalLogic.ts index 5301b0e52e965..ebd464a94f4b0 100644 --- a/frontend/src/scenes/retention/retentionModalLogic.ts +++ b/frontend/src/scenes/retention/retentionModalLogic.ts @@ -1,4 +1,4 @@ -import { kea } from 'kea' +import { kea, props, key, path, connect, actions, reducers, selectors, listeners } from 'kea' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' import { Noun, groupsModel } from '~/models/groupsModel' import { InsightLogicProps } from '~/types' @@ -10,19 +10,19 @@ import type { retentionModalLogicType } from './retentionModalLogicType' const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' -export const retentionModalLogic = kea({ - props: {} as InsightLogicProps, - key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), - path: (key) => ['scenes', 'retention', 'retentionModalLogic', key], - connect: (props: InsightLogicProps) => ({ +export const retentionModalLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY)), + path((key) => ['scenes', 'retention', 'retentionModalLogic', key]), + connect((props: InsightLogicProps) => ({ values: [insightVizDataLogic(props), ['querySource'], groupsModel, ['aggregationLabel']], actions: [retentionPeopleLogic(props), ['loadPeople']], - }), - actions: () => ({ + })), + actions(() => ({ openModal: (rowIndex: number) => ({ rowIndex }), closeModal: true, - }), - reducers: { + })), + reducers({ selectedRow: [ null as number | null, { @@ -30,8 +30,8 @@ export const retentionModalLogic = kea({ closeModal: () => null, }, ], - }, - selectors: { + }), + selectors({ aggregationTargetLabel: [ (s) => [s.querySource, s.aggregationLabel], (querySource, aggregationLabel): Noun => { @@ -39,10 +39,10 @@ export const retentionModalLogic = kea({ return aggregationLabel(aggregation_group_type_index) }, ], - }, - listeners: ({ actions }) => ({ + }), + listeners(({ actions }) => ({ openModal: ({ rowIndex }) => { actions.loadPeople(rowIndex) }, - }), -}) + })), +]) diff --git a/frontend/src/scenes/retention/retentionPeopleLogic.ts b/frontend/src/scenes/retention/retentionPeopleLogic.ts index 886795974a813..3268a44c228fd 100644 --- a/frontend/src/scenes/retention/retentionPeopleLogic.ts +++ b/frontend/src/scenes/retention/retentionPeopleLogic.ts @@ -1,4 +1,5 @@ -import { kea } from 'kea' +import { loaders } from 'kea-loaders' +import { kea, props, key, path, connect, actions, reducers, selectors, listeners } from 'kea' import api from 'lib/api' import { toParams } from 'lib/utils' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' @@ -12,23 +13,20 @@ import type { retentionPeopleLogicType } from './retentionPeopleLogicType' const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' -export const retentionPeopleLogic = kea({ - props: {} as InsightLogicProps, - key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), - path: (key) => ['scenes', 'retention', 'retentionPeopleLogic', key], - connect: (props: InsightLogicProps) => ({ +export const retentionPeopleLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY)), + path((key) => ['scenes', 'retention', 'retentionPeopleLogic', key]), + connect((props: InsightLogicProps) => ({ values: [insightVizDataLogic(props), ['querySource']], actions: [insightVizDataLogic(props), ['loadDataSuccess']], - }), - actions: () => ({ + })), + actions(() => ({ clearPeople: true, loadMorePeople: true, loadMorePeopleSuccess: (payload: RetentionTablePeoplePayload) => ({ payload }), - }), - selectors: () => ({ - apiFilters: [(s) => [s.querySource], (querySource) => (querySource ? queryNodeToFilter(querySource) : {})], - }), - loaders: ({ values }) => ({ + })), + loaders(({ values }) => ({ people: { __default: {} as RetentionTablePeoplePayload, loadPeople: async (rowIndex: number) => { @@ -36,8 +34,8 @@ export const retentionPeopleLogic = kea({ return (await api.get(`api/person/retention/?${urlParams}`)) as RetentionTablePeoplePayload }, }, - }), - reducers: { + })), + reducers({ people: { clearPeople: () => ({}), loadPeople: () => ({}), @@ -50,8 +48,11 @@ export const retentionPeopleLogic = kea({ loadMorePeopleSuccess: () => false, }, ], - }, - listeners: ({ actions, values }) => ({ + }), + selectors(() => ({ + apiFilters: [(s) => [s.querySource], (querySource) => (querySource ? queryNodeToFilter(querySource) : {})], + })), + listeners(({ actions, values }) => ({ loadDataSuccess: () => { // clear people when changing the insight filters actions.clearPeople() @@ -67,5 +68,5 @@ export const retentionPeopleLogic = kea({ actions.loadMorePeopleSuccess(newPayload) } }, - }), -}) + })), +]) diff --git a/frontend/src/scenes/retention/retentionTableLogic.ts b/frontend/src/scenes/retention/retentionTableLogic.ts index 9dfacbc58433c..52e442b0d125e 100644 --- a/frontend/src/scenes/retention/retentionTableLogic.ts +++ b/frontend/src/scenes/retention/retentionTableLogic.ts @@ -1,5 +1,5 @@ import { dayjs } from 'lib/dayjs' -import { kea } from 'kea' +import { kea, props, key, path, connect, selectors } from 'kea' import { range } from 'lib/utils' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' import { InsightLogicProps } from '~/types' @@ -29,19 +29,19 @@ const periodIsLatest = (date_to: string | null, period: string | null): boolean } } -export const retentionTableLogic = kea({ - props: {} as InsightLogicProps, - key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), - path: (key) => ['scenes', 'retention', 'retentionTableLogic', key], - connect: (props: InsightLogicProps) => ({ +export const retentionTableLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY)), + path((key) => ['scenes', 'retention', 'retentionTableLogic', key]), + connect((props: InsightLogicProps) => ({ values: [ insightVizDataLogic(props), ['dateRange', 'retentionFilter', 'breakdown'], retentionLogic(props), ['results'], ], - }), - selectors: { + })), + selectors({ isLatestPeriod: [ (s) => [s.dateRange, s.retentionFilter], (dateRange, retentionFilter) => periodIsLatest(dateRange?.date_to || null, retentionFilter?.period || null), @@ -73,7 +73,7 @@ export const retentionTableLogic = kea({ ? results[rowIndex].label : period === 'Hour' ? dayjs(results[rowIndex].date).format('MMM D, h A') - : dayjs.utc(results[rowIndex].date).format('MMM D'), + : dayjs(results[rowIndex].date).format('MMM D'), // Second column is the first value (which is essentially the total) results[rowIndex].values[0].count, // All other columns are rendered as percentage @@ -91,5 +91,5 @@ export const retentionTableLogic = kea({ ]) }, ], - }, -}) + }), +]) diff --git a/frontend/src/scenes/saved-insights/SavedInsights.stories.tsx b/frontend/src/scenes/saved-insights/SavedInsights.stories.tsx index 9db351702825a..3b4ee1eb9a163 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.stories.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.stories.tsx @@ -7,18 +7,17 @@ import { useEffect } from 'react' import { router } from 'kea-router' import { mswDecorator, useStorybookMocks } from '~/mocks/browser' -import trendsBarBreakdown from '../insights/__mocks__/trendsBarBreakdown.json' -import trendsPieBreakdown from '../insights/__mocks__/trendsPieBreakdown.json' -import funnelTopToBottom from '../insights/__mocks__/funnelTopToBottom.json' +import trendsBarBreakdown from '../../mocks/fixtures/api/projects/team_id/insights/trendsBarBreakdown.json' +import trendsPieBreakdown from '../../mocks/fixtures/api/projects/team_id/insights/trendsPieBreakdown.json' +import funnelTopToBottom from '../../mocks/fixtures/api/projects/team_id/insights/funnelTopToBottom.json' import { EMPTY_PAGINATED_RESPONSE, toPaginatedResponse } from '~/mocks/handlers' const insights = [trendsBarBreakdown, trendsPieBreakdown, funnelTopToBottom] -export default { +const meta: Meta = { title: 'Scenes-App/Saved Insights', parameters: { layout: 'fullscreen', - options: { showPanel: false }, testOptions: { excludeNavigationFromSnapshot: true, }, @@ -39,8 +38,8 @@ export default { }, }), ], -} as Meta - +} +export default meta export const ListView: Story = () => { useEffect(() => { router.actions.push('/insights') @@ -55,7 +54,7 @@ export const CardView: Story = () => { return } CardView.parameters = { - testOptions: { waitForLoadersToDisappear: '[data-attr=trend-line-graph] > canvas' }, + testOptions: { waitForSelector: '[data-attr=trend-line-graph] > canvas' }, } export const EmptyState: Story = () => { diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index a59b440c4c2a2..229ebc9b0c613 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -57,6 +57,7 @@ import { FEATURE_FLAGS } from 'lib/constants' import { isInsightVizNode } from '~/queries/utils' import { overlayForNewInsightMenu } from 'scenes/saved-insights/newInsightsMenu' import { summarizeInsight } from 'scenes/insights/summarizeInsight' +import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' interface NewInsightButtonProps { dataAttr: string @@ -181,6 +182,18 @@ export const QUERY_TYPES_METADATA: Record = { icon: IconPerson, inMenu: true, }, + [NodeKind.PersonsQuery]: { + name: 'Persons', + description: 'List of persons matching specified conditions', + icon: IconPerson, + inMenu: false, + }, + [NodeKind.InsightPersonsQuery]: { + name: 'Persons', + description: 'List of persons matching specified conditions, derived from an insight', + icon: IconPerson, + inMenu: false, + }, [NodeKind.DataTableNode]: { name: 'Data table', description: 'Slice and dice your data in a table', @@ -223,6 +236,12 @@ export const QUERY_TYPES_METADATA: Record = { icon: IconCoffee, inMenu: true, }, + [NodeKind.SessionsTimelineQuery]: { + name: 'Sessions', + description: 'Sessions timeline query', + icon: InsightsTrendsIcon, + inMenu: true, + }, [NodeKind.HogQLQuery]: { name: 'HogQL', description: 'Direct HogQL query', @@ -241,6 +260,24 @@ export const QUERY_TYPES_METADATA: Record = { icon: InsightSQLIcon, inMenu: true, }, + [NodeKind.WebOverviewQuery]: { + name: 'Overview Stats', + description: 'View overview stats for a website', + icon: InsightsTrendsIcon, + inMenu: true, + }, + [NodeKind.WebStatsTableQuery]: { + name: 'Web Table', + description: 'A table of results from web analytics, with a breakdown', + icon: InsightsTrendsIcon, + inMenu: true, + }, + [NodeKind.WebTopClicksQuery]: { + name: 'Top Clicks', + description: 'View top clicks for a website', + icon: InsightsTrendsIcon, + inMenu: true, + }, } export const INSIGHT_TYPE_OPTIONS: LemonSelectOptions = [ @@ -396,7 +433,9 @@ export function SavedInsights(): JSX.Element { /> {hasDashboardCollaboration && insight.description && ( - {insight.description} + + {insight.description} + )} ) diff --git a/frontend/src/scenes/saved-insights/activityDescriptions.tsx b/frontend/src/scenes/saved-insights/activityDescriptions.tsx index e52c66a9c2991..ab67fb7972184 100644 --- a/frontend/src/scenes/saved-insights/activityDescriptions.tsx +++ b/frontend/src/scenes/saved-insights/activityDescriptions.tsx @@ -99,7 +99,8 @@ const insightActionsMapping: Record< return { description: [ <> - changed the short id {asNotification && ' of the insight '}to "{change?.after}" + changed the short id {asNotification && ' of the insight '}to{' '} + "{change?.after as string}" , ], } @@ -119,7 +120,8 @@ const insightActionsMapping: Record< return { description: [ <> - changed the description {asNotification && ' of the insight '}to "{change?.after}" + changed the description {asNotification && ' of the insight '}to{' '} + "{change?.after as string}" , ], } diff --git a/frontend/src/scenes/saved-insights/savedInsightsLogic.ts b/frontend/src/scenes/saved-insights/savedInsightsLogic.ts index 4f2bf792c7f9c..c1bb0bfdb91a5 100644 --- a/frontend/src/scenes/saved-insights/savedInsightsLogic.ts +++ b/frontend/src/scenes/saved-insights/savedInsightsLogic.ts @@ -1,5 +1,6 @@ -import { kea } from 'kea' -import { router } from 'kea-router' +import { loaders } from 'kea-loaders' +import { kea, path, connect, actions, reducers, selectors, listeners } from 'kea' +import { router, actionToUrl, urlToAction } from 'kea-router' import api from 'lib/api' import { objectDiffShallow, objectsEqual, toParams } from 'lib/utils' import { InsightModel, LayoutView, SavedInsightsTabs } from '~/types' @@ -58,13 +59,13 @@ function cleanFilters(values: Partial): SavedInsightFilters } } -export const savedInsightsLogic = kea({ - path: ['scenes', 'saved-insights', 'savedInsightsLogic'], - connect: { +export const savedInsightsLogic = kea([ + path(['scenes', 'saved-insights', 'savedInsightsLogic']), + connect({ values: [teamLogic, ['currentTeamId'], featureFlagLogic, ['featureFlags']], logic: [eventUsageLogic], - }, - actions: { + }), + actions({ setSavedInsightsFilters: ( filters: Partial, merge: boolean = true, @@ -79,8 +80,8 @@ export const savedInsightsLogic = kea({ loadInsights: (debounce: boolean = true) => ({ debounce }), setInsight: (insight: InsightModel) => ({ insight }), addInsight: (insight: InsightModel) => ({ insight }), - }, - loaders: ({ values }) => ({ + }), + loaders(({ values }) => ({ insights: { __default: { results: [], count: 0, filters: null, offset: 0 } as InsightsResult, loadInsights: async ({ debounce }, breakpoint) => { @@ -139,8 +140,8 @@ export const savedInsightsLogic = kea({ return { ...values.insights, results: updatedInsights } }, }, - }), - reducers: { + })), + reducers({ insights: { setInsight: (state, { insight }) => ({ ...state, @@ -164,8 +165,8 @@ export const savedInsightsLogic = kea({ }), }, ], - }, - selectors: ({ actions }) => ({ + }), + selectors(({ actions }) => ({ filters: [(s) => [s.rawFilters], (rawFilters): SavedInsightFilters => cleanFilters(rawFilters || {})], count: [(s) => [s.insights], (insights) => insights.count], usingFilters: [ @@ -236,8 +237,8 @@ export const savedInsightsLogic = kea({ } }, ], - }), - listeners: ({ actions, asyncActions, values, selectors }) => ({ + })), + listeners(({ actions, asyncActions, values, selectors }) => ({ setSavedInsightsFilters: async ({ merge, debounce }, breakpoint, __, previousState) => { const oldFilters = selectors.filters(previousState) const firstLoad = selectors.rawFilters(previousState) === null @@ -307,8 +308,8 @@ export const savedInsightsLogic = kea({ actions.loadInsights() } }, - }), - actionToUrl: ({ values }) => { + })), + actionToUrl(({ values }) => { const changeUrl = (): | [ string, @@ -336,8 +337,8 @@ export const savedInsightsLogic = kea({ loadInsights: changeUrl, setLayoutView: changeUrl, } - }, - urlToAction: ({ actions, values }) => ({ + }), + urlToAction(({ actions, values }) => ({ [urls.savedInsights()]: async (_, searchParams, hashParams) => { if (hashParams.fromItem && String(hashParams.fromItem).match(/^[0-9]+$/)) { // `fromItem` for legacy /insights url redirect support @@ -367,5 +368,5 @@ export const savedInsightsLogic = kea({ actions.setSavedInsightsFilters(nextFilters, false) } }, - }), -}) + })), +]) diff --git a/frontend/src/scenes/sceneLogic.ts b/frontend/src/scenes/sceneLogic.ts index 336a3f83697f6..039e4d9cf8cfd 100644 --- a/frontend/src/scenes/sceneLogic.ts +++ b/frontend/src/scenes/sceneLogic.ts @@ -1,5 +1,5 @@ -import { BuiltLogic, kea } from 'kea' -import { router } from 'kea-router' +import { BuiltLogic, kea, props, path, connect, actions, reducers, selectors, listeners } from 'kea' +import { router, urlToAction } from 'kea-router' import posthog from 'posthog-js' import type { sceneLogicType } from './sceneLogicType' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' @@ -13,6 +13,8 @@ import { LoadedScene, Params, Scene, SceneConfig, SceneExport, SceneParams } fro import { emptySceneParams, preloadedScenes, redirects, routes, sceneConfigurations } from 'scenes/scenes' import { organizationLogic } from './organizationLogic' import { appContextLogic } from './appContextLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' /** Mapping of some scenes that aren't directly accessible from the sidebar to ones that are - for the sidebar. */ const sceneNavAlias: Partial> = { @@ -32,27 +34,32 @@ const sceneNavAlias: Partial> = { [Scene.FeatureFlag]: Scene.FeatureFlags, [Scene.EarlyAccessFeature]: Scene.EarlyAccessFeatures, [Scene.Survey]: Scene.Surveys, - [Scene.DataWarehouseTable]: Scene.DataWarehouse, + [Scene.SurveyTemplates]: Scene.Surveys, [Scene.DataWarehousePosthog]: Scene.DataWarehouse, [Scene.DataWarehouseExternal]: Scene.DataWarehouse, [Scene.DataWarehouseSavedQueries]: Scene.DataWarehouse, - [Scene.AppMetrics]: Scene.Plugins, + [Scene.DataWarehouseSettings]: Scene.DataWarehouse, + [Scene.DataWarehouseTable]: Scene.DataWarehouse, + [Scene.AppMetrics]: Scene.Apps, [Scene.ReplaySingle]: Scene.Replay, [Scene.ReplayPlaylist]: Scene.ReplayPlaylist, + [Scene.Site]: Scene.ToolbarLaunch, } -export const sceneLogic = kea({ - props: {} as { - scenes?: Record any> - }, - connect: () => ({ +export const sceneLogic = kea([ + props( + {} as { + scenes?: Record any> + } + ), + path(['scenes', 'sceneLogic']), + connect(() => ({ logic: [router, userLogic, preflightLogic, appContextLogic], actions: [router, ['locationChanged']], - }), - path: ['scenes', 'sceneLogic'], - actions: { + })), + actions({ /* 1. Prepares to open the scene, as the listener may override and do something - else (e.g. redirecting if unauthenticated), then calls (2) `loadScene`*/ + else (e.g. redirecting if unauthenticated), then calls (2) `loadScene`*/ openScene: (scene: Scene, params: SceneParams, method: string) => ({ scene, params, method }), // 2. Start loading the scene's Javascript and mount any logic, then calls (3) `setScene` loadScene: (scene: Scene, params: SceneParams, method: string) => ({ scene, params, method }), @@ -80,8 +87,8 @@ export const sceneLogic = kea({ ) => ({ featureKey, featureName, featureCaption, featureAvailableCallback, guardOn, currentUsage }), hideUpgradeModal: true, reloadBrowserDueToImportError: true, - }, - reducers: { + }), + reducers({ scene: [ null as Scene | null, { @@ -125,8 +132,8 @@ export const sceneLogic = kea({ reloadBrowserDueToImportError: () => new Date().valueOf(), }, ], - }, - selectors: { + }), + selectors({ sceneConfig: [ (s) => [s.scene], (scene: Scene): SceneConfig | null => { @@ -164,38 +171,8 @@ export const sceneLogic = kea({ params: [(s) => [s.sceneParams], (sceneParams): Record => sceneParams.params || {}], searchParams: [(s) => [s.sceneParams], (sceneParams): Record => sceneParams.searchParams || {}], hashParams: [(s) => [s.sceneParams], (sceneParams): Record => sceneParams.hashParams || {}], - }, - urlToAction: ({ actions }) => { - const mapping: Record< - string, - ( - params: Params, - searchParams: Params, - hashParams: Params, - payload: { - method: string - } - ) => any - > = {} - - for (const path of Object.keys(redirects)) { - mapping[path] = (params, searchParams, hashParams) => { - const redirect = redirects[path] - router.actions.replace( - typeof redirect === 'function' ? redirect(params, searchParams, hashParams) : redirect - ) - } - } - for (const [path, scene] of Object.entries(routes)) { - mapping[path] = (params, searchParams, hashParams, { method }) => - actions.openScene(scene, { params, searchParams, hashParams }, method) - } - - mapping['/*'] = (_, __, { method }) => actions.loadScene(Scene.Error404, emptySceneParams, method) - - return mapping - }, - listeners: ({ values, actions, props, selectors }) => ({ + }), + listeners(({ values, actions, props, selectors }) => ({ showUpgradeModal: ({ featureName }) => { eventUsageLogic.actions.reportUpgradeModalShown(featureName) }, @@ -278,13 +255,28 @@ export const sceneLogic = kea({ } else if ( teamLogic.values.currentTeam && !teamLogic.values.currentTeam.is_demo && - !teamLogic.values.currentTeam.completed_snippet_onboarding && !location.pathname.startsWith('/ingestion') && + !location.pathname.startsWith('/onboarding') && + !location.pathname.startsWith('/products') && !location.pathname.startsWith('/project/settings') ) { - console.warn('Ingestion tutorial not completed, redirecting to it') - router.actions.replace(urls.ingestion()) - return + if ( + featureFlagLogic.values.featureFlags[FEATURE_FLAGS.PRODUCT_SPECIFIC_ONBOARDING] === + 'test' && + !Object.keys(teamLogic.values.currentTeam.has_completed_onboarding_for || {}).length + ) { + console.warn('No onboarding completed, redirecting to products') + router.actions.replace(urls.products()) + return + } else if ( + featureFlagLogic.values.featureFlags[FEATURE_FLAGS.PRODUCT_SPECIFIC_ONBOARDING] !== + 'test' && + !teamLogic.values.currentTeam.completed_snippet_onboarding + ) { + console.warn('Ingestion tutorial not completed, redirecting to it') + router.actions.replace(urls.ingestion()) + return + } } } } @@ -392,5 +384,35 @@ export const sceneLogic = kea({ router.actions.replace(pathname.replace(/(\/+)$/, ''), search, hash) } }, + })), + urlToAction(({ actions }) => { + const mapping: Record< + string, + ( + params: Params, + searchParams: Params, + hashParams: Params, + payload: { + method: string + } + ) => any + > = {} + + for (const path of Object.keys(redirects)) { + mapping[path] = (params, searchParams, hashParams) => { + const redirect = redirects[path] + router.actions.replace( + typeof redirect === 'function' ? redirect(params, searchParams, hashParams) : redirect + ) + } + } + for (const [path, scene] of Object.entries(routes)) { + mapping[path] = (params, searchParams, hashParams, { method }) => + actions.openScene(scene, { params, searchParams, hashParams }, method) + } + + mapping['/*'] = (_, __, { method }) => actions.loadScene(Scene.Error404, emptySceneParams, method) + + return mapping }), -}) +]) diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 589e42354248a..523717aad0ffa 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -2,6 +2,7 @@ import { LogicWrapper } from 'kea' // The enum here has to match the first and only exported component of the scene. // If so, we can preload the scene's required chunks in parallel with the scene itself. + export enum Scene { Error404 = '404', ErrorNetwork = '4xx', @@ -10,6 +11,7 @@ export enum Scene { Dashboard = 'Dashboard', Database = 'Database', Insight = 'Insight', + WebAnalytics = 'WebAnalytics', Cohorts = 'Cohorts', Cohort = 'Cohort', Events = 'Events', @@ -25,26 +27,29 @@ export enum Scene { ReplayPlaylist = 'ReplayPlaylist', Person = 'Person', Persons = 'Persons', + Pipeline = 'Pipeline', Groups = 'Groups', Group = 'Group', Action = 'Action', Actions = 'ActionsTable', Experiments = 'Experiments', Experiment = 'Experiment', - Exports = 'Exports', - CreateExport = 'CreateExport', - ViewExport = 'ViewExport', + BatchExports = 'BatchExports', + BatchExport = 'BatchExport', + BatchExportEdit = 'BatchExportEdit', FeatureFlags = 'FeatureFlags', FeatureFlag = 'FeatureFlag', EarlyAccessFeatures = 'EarlyAccessFeatures', EarlyAccessFeature = 'EarlyAccessFeature', Surveys = 'Surveys', Survey = 'Survey', + SurveyTemplates = 'SurveyTemplates', DataWarehouse = 'DataWarehouse', DataWarehousePosthog = 'DataWarehousePosthog', DataWarehouseExternal = 'DataWarehouseExternal', DataWarehouseSavedQueries = 'DataWarehouseSavedQueries', DataWarehouseTable = 'DataWarehouseTable', + DataWarehouseSettings = 'DataWarehouseSettings', OrganizationSettings = 'OrganizationSettings', OrganizationCreateFirst = 'OrganizationCreate', ProjectHomepage = 'ProjectHomepage', @@ -56,7 +61,7 @@ export enum Scene { MySettings = 'MySettings', Annotations = 'Annotations', Billing = 'Billing', - Plugins = 'Plugins', + Apps = 'Apps', FrontendAppScene = 'FrontendAppScene', AppMetrics = 'AppMetrics', SavedInsights = 'SavedInsights', @@ -77,7 +82,11 @@ export enum Scene { DebugQuery = 'DebugQuery', VerifyEmail = 'VerifyEmail', Feedback = 'Feedback', + Notebooks = 'Notebooks', Notebook = 'Notebook', + Canvas = 'Canvas', + Products = 'Products', + Onboarding = 'Onboarding', } export type SceneProps = Record @@ -120,10 +129,11 @@ export interface SceneConfig { /** * If `app`, navigation is shown, and the scene has default padding. * If `app-raw`, navigation is shown, but the scene has no padding. + * If `app-container`, navigation is shown, and the scene is centered with a max width. * If `plain`, there's no navigation present, and the scene has no padding. * @default 'app' */ - layout?: 'app' | 'app-raw' | 'plain' + layout?: 'app' | 'app-raw' | 'app-container' | 'plain' /** Hides project notice (ProjectNotice.tsx). */ hideProjectNotice?: boolean /** Personal account management (used e.g. by breadcrumbs) */ diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index bbf2fcadcf1a5..e56a65e708e36 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -3,7 +3,7 @@ import { Error404 as Error404Component } from '~/layout/Error404' import { ErrorNetwork as ErrorNetworkComponent } from '~/layout/ErrorNetwork' import { ErrorProjectUnavailable as ErrorProjectUnavailableComponent } from '~/layout/ErrorProjectUnavailable' import { urls } from 'scenes/urls' -import { InsightShortId, PropertyFilterType, ReplayTabs } from '~/types' +import { InsightShortId, PipelineTabs, PropertyFilterType, ReplayTabs } from '~/types' import { combineUrl } from 'kea-router' import { getDefaultEventsSceneQuery } from 'scenes/events/defaults' import { EventsQuery } from '~/queries/schema' @@ -43,6 +43,11 @@ export const sceneConfigurations: Partial> = { projectBased: true, name: 'Insights', }, + [Scene.WebAnalytics]: { + projectBased: true, + name: 'Web Analytics', + layout: 'app-container', + }, [Scene.Cohorts]: { projectBased: true, name: 'Cohorts', @@ -55,17 +60,17 @@ export const sceneConfigurations: Partial> = { projectBased: true, name: 'Event Explorer', }, - [Scene.Exports]: { + [Scene.BatchExports]: { projectBased: true, - name: 'Exports', + name: 'Batch Exports', }, - [Scene.CreateExport]: { + [Scene.BatchExportEdit]: { projectBased: true, - name: 'Create Export', + name: 'Edit Batch Export', }, - [Scene.ViewExport]: { + [Scene.BatchExport]: { projectBased: true, - name: 'View Export', + name: 'Batch Export', }, [Scene.DataManagement]: { projectBased: true, @@ -135,6 +140,10 @@ export const sceneConfigurations: Partial> = { projectBased: true, name: 'Persons & Groups', }, + [Scene.Pipeline]: { + projectBased: true, + name: 'Pipeline', + }, [Scene.Experiments]: { projectBased: true, name: 'Experiments', @@ -158,6 +167,10 @@ export const sceneConfigurations: Partial> = { projectBased: true, name: 'Survey', }, + [Scene.SurveyTemplates]: { + projectBased: true, + name: 'New survey', + }, [Scene.DataWarehouse]: { projectBased: true, name: 'Data Warehouse', @@ -174,6 +187,10 @@ export const sceneConfigurations: Partial> = { projectBased: true, name: 'Data Warehouse', }, + [Scene.DataWarehouseSettings]: { + projectBased: true, + name: 'Data Warehouse Settings', + }, [Scene.DataWarehouseTable]: { projectBased: true, name: 'Data Warehouse Table', @@ -188,7 +205,7 @@ export const sceneConfigurations: Partial> = { projectBased: true, name: 'Annotations', }, - [Scene.Plugins]: { + [Scene.Apps]: { projectBased: true, name: 'Apps', }, @@ -220,6 +237,14 @@ export const sceneConfigurations: Partial> = { projectBased: true, layout: 'plain', }, + [Scene.Products]: { + projectBased: true, + layout: 'plain', + }, + [Scene.Onboarding]: { + projectBased: true, + layout: 'plain', + }, [Scene.ToolbarLaunch]: { projectBased: true, name: 'Launch Toolbar', @@ -306,6 +331,15 @@ export const sceneConfigurations: Partial> = { name: 'Notebook', layout: 'app-raw', }, + [Scene.Notebooks]: { + projectBased: true, + name: 'Notebooks', + }, + [Scene.Canvas]: { + projectBased: true, + name: 'Canvas', + layout: 'app-raw', + }, } const preserveParams = (url: string) => (_params: Params, searchParams: Params, hashParams: Params) => { @@ -335,7 +369,7 @@ export const redirects: Record< const query = getDefaultEventsSceneQuery([ { type: PropertyFilterType.HogQL, - key: `uuid = '${id.replaceAll(/[^a-f0-9\-]/g, '')}'`, + key: `uuid = '${id.replaceAll(/[^a-f0-9-]/g, '')}'`, value: null, }, ]) @@ -361,6 +395,7 @@ export const redirects: Record< return urls.replay() }, '/replay': urls.replay(), + '/exports': urls.batchExports(), } export const routes: Record = { @@ -381,12 +416,14 @@ export const routes: Record = { [urls.insightSubcription(':shortId' as InsightShortId, ':subscriptionId')]: Scene.Insight, [urls.insightSharing(':shortId' as InsightShortId)]: Scene.Insight, [urls.savedInsights()]: Scene.SavedInsights, + [urls.webAnalytics()]: Scene.WebAnalytics, [urls.actions()]: Scene.Actions, // TODO: remove when "simplify-actions" FF is released [urls.eventDefinitions()]: Scene.EventDefinitions, [urls.eventDefinition(':id')]: Scene.EventDefinition, - [urls.exports()]: Scene.Exports, - [urls.createExport()]: Scene.CreateExport, - [urls.viewExport(':id')]: Scene.ViewExport, + [urls.batchExports()]: Scene.BatchExports, + [urls.batchExportNew()]: Scene.BatchExportEdit, + [urls.batchExport(':id')]: Scene.BatchExport, + [urls.batchExportEdit(':id')]: Scene.BatchExportEdit, [urls.propertyDefinitions()]: Scene.PropertyDefinitions, [urls.propertyDefinition(':id')]: Scene.PropertyDefinition, [urls.dataManagementHistory()]: Scene.DataManagementHistory, @@ -400,8 +437,15 @@ export const routes: Record = { }, {} as Record), [urls.replaySingle(':id')]: Scene.ReplaySingle, [urls.replayPlaylist(':id')]: Scene.ReplayPlaylist, - [urls.person('*', false)]: Scene.Person, + [urls.personByDistinctId('*', false)]: Scene.Person, + [urls.personByUUID('*', false)]: Scene.Person, [urls.persons()]: Scene.Persons, + [urls.pipeline()]: Scene.Pipeline, + // One entry for every available tab + ...Object.values(PipelineTabs).reduce((acc, tab) => { + acc[urls.pipeline(tab)] = Scene.Pipeline + return acc + }, {} as Record), [urls.groups(':groupTypeIndex')]: Scene.Groups, [urls.group(':groupTypeIndex', ':groupKey', false)]: Scene.Group, [urls.group(':groupTypeIndex', ':groupKey', false, ':groupTab')]: Scene.Group, @@ -413,25 +457,28 @@ export const routes: Record = { [urls.earlyAccessFeature(':id')]: Scene.EarlyAccessFeature, [urls.surveys()]: Scene.Surveys, [urls.survey(':id')]: Scene.Survey, + [urls.surveyTemplates()]: Scene.SurveyTemplates, [urls.dataWarehouse()]: Scene.DataWarehouse, - [urls.dataWarehouseTable(':id')]: Scene.DataWarehouseTable, + [urls.dataWarehouseTable()]: Scene.DataWarehouseTable, [urls.dataWarehousePosthog()]: Scene.DataWarehousePosthog, [urls.dataWarehouseExternal()]: Scene.DataWarehouseExternal, [urls.dataWarehouseSavedQueries()]: Scene.DataWarehouseSavedQueries, + [urls.dataWarehouseSettings()]: Scene.DataWarehouseSettings, [urls.featureFlags()]: Scene.FeatureFlags, [urls.featureFlag(':id')]: Scene.FeatureFlag, [urls.annotations()]: Scene.Annotations, [urls.annotation(':id')]: Scene.Annotations, [urls.projectHomepage()]: Scene.ProjectHomepage, [urls.projectSettings()]: Scene.ProjectSettings, - [urls.projectApps()]: Scene.Plugins, - [urls.projectApp(':id')]: Scene.Plugins, - [urls.projectAppLogs(':id')]: Scene.Plugins, - [urls.projectAppSource(':id')]: Scene.Plugins, + [urls.projectApps()]: Scene.Apps, + [urls.projectApp(':id')]: Scene.Apps, + [urls.projectAppLogs(':id')]: Scene.Apps, + [urls.projectAppSource(':id')]: Scene.Apps, [urls.frontendApp(':id')]: Scene.FrontendAppScene, [urls.appMetrics(':pluginConfigId')]: Scene.AppMetrics, [urls.appHistoricalExports(':pluginConfigId')]: Scene.AppMetrics, [urls.appHistory(':pluginConfigId')]: Scene.AppMetrics, + [urls.appLogs(':pluginConfigId')]: Scene.AppMetrics, [urls.projectCreateFirst()]: Scene.ProjectCreateFirst, [urls.organizationSettings()]: Scene.OrganizationSettings, [urls.organizationBilling()]: Scene.Billing, @@ -459,6 +506,8 @@ export const routes: Record = { [urls.passwordResetComplete(':uuid', ':token')]: Scene.PasswordResetComplete, [urls.ingestion()]: Scene.Ingestion, [urls.ingestion() + '/*']: Scene.Ingestion, + [urls.products()]: Scene.Products, + [urls.onboarding(':productKey')]: Scene.Onboarding, [urls.verifyEmail()]: Scene.VerifyEmail, [urls.verifyEmail(':uuid')]: Scene.VerifyEmail, [urls.verifyEmail(':uuid', ':token')]: Scene.VerifyEmail, @@ -468,5 +517,6 @@ export const routes: Record = { [urls.feedback()]: Scene.Feedback, [urls.feedback() + '/*']: Scene.Feedback, [urls.notebook(':shortId')]: Scene.Notebook, - [urls.notebookEdit(':shortId')]: Scene.Notebook, + [urls.notebooks()]: Scene.Notebooks, + [urls.canvas()]: Scene.Canvas, } diff --git a/frontend/src/scenes/session-recordings/SessionRecordings.tsx b/frontend/src/scenes/session-recordings/SessionRecordings.tsx index 373d2ef7f1bad..7e44843bf4d92 100644 --- a/frontend/src/scenes/session-recordings/SessionRecordings.tsx +++ b/frontend/src/scenes/session-recordings/SessionRecordings.tsx @@ -3,10 +3,9 @@ import { teamLogic } from 'scenes/teamLogic' import { useActions, useValues } from 'kea' import { urls } from 'scenes/urls' import { SceneExport } from 'scenes/sceneTypes' -import { SessionRecordingsPlaylist } from './playlist/SessionRecordingsPlaylist' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from '@posthog/lemon-ui' -import { AvailableFeature, ReplayTabs } from '~/types' +import { AvailableFeature, NotebookNodeType, ReplayTabs } from '~/types' import { SavedSessionRecordingPlaylists } from './saved-playlists/SavedSessionRecordingPlaylists' import { humanFriendlyTabName, sessionRecordingsLogic } from './sessionRecordingsLogic' import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' @@ -20,8 +19,12 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { sceneLogic } from 'scenes/sceneLogic' import { savedSessionRecordingPlaylistsLogic } from './saved-playlists/savedSessionRecordingPlaylistsLogic' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' -import { sessionRecordingsListLogic } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { VersionCheckerBanner } from 'lib/components/VersionChecker/VersionCheckerBanner' +import { authorizedUrlListLogic, AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic' +import { SessionRecordingsPlaylist } from './playlist/SessionRecordingsPlaylist' +import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' export function SessionsRecordings(): JSX.Element { const { currentTeam } = useValues(teamLogic) @@ -32,27 +35,45 @@ export function SessionsRecordings(): JSX.Element { const playlistsLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Recent }) const { playlists } = useValues(playlistsLogic) + const theAuthorizedUrlsLogic = authorizedUrlListLogic({ + actionId: null, + type: AuthorizedUrlListType.RECORDING_DOMAINS, + }) + const { suggestions, authorizedUrls } = useValues(theAuthorizedUrlsLogic) + const mightBeRefusingRecordings = suggestions.length > 0 && authorizedUrls.length > 0 + const newPlaylistHandler = useAsyncHandler(async () => { await createPlaylist({}, true) reportRecordingPlaylistCreated('new') }) // NB this relies on `updateSearchParams` being the only prop needed to pick the correct "Recent" tab list logic - const { filters, totalFiltersCount } = useValues(sessionRecordingsListLogic({ updateSearchParams: true })) + const { filters, totalFiltersCount } = useValues(sessionRecordingsPlaylistLogic({ updateSearchParams: true })) const saveFiltersPlaylistHandler = useAsyncHandler(async () => { await createPlaylist({ filters }, true) reportRecordingPlaylistCreated('filters') }) + const is3000 = useFeatureFlag('POSTHOG_3000') + return ( // Margin bottom hacks the fact that our wrapping container has an annoyingly large padding -
    +
    Session Replay
    } buttons={ <> {tab === ReplayTabs.Recent && !recordingsDisabled && ( <> + ) : null} + + {!recordingsDisabled && mightBeRefusingRecordings ? ( + , + onClick: () => openSessionRecordingSettingsDialog(), + children: 'Configure', + }} + dismissKey={`session-recordings-authorized-domains-warning/${suggestions.join(',')}`} + > + You have unauthorized domains trying to send recordings. To accept recordings from these + domains, please check your config. + + ) : null} + {!tab ? ( ) : tab === ReplayTabs.Recent ? ( - +
    + +
    ) : tab === ReplayTabs.Playlists ? ( ) : tab === ReplayTabs.FilePlayback ? ( diff --git a/frontend/src/scenes/session-recordings/SessionsRecordings.stories.tsx b/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx similarity index 76% rename from frontend/src/scenes/session-recordings/SessionsRecordings.stories.tsx rename to frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx index 9514c1444ce21..501351427ee2a 100644 --- a/frontend/src/scenes/session-recordings/SessionsRecordings.stories.tsx +++ b/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx @@ -5,19 +5,19 @@ import { mswDecorator } from '~/mocks/browser' import { combineUrl, router } from 'kea-router' import { urls } from 'scenes/urls' import { App } from 'scenes/App' -import recordingSnapshotsJson from 'scenes/session-recordings/__mocks__/recording_snapshots.json' +import { snapshotsAsJSONLines } from 'scenes/session-recordings/__mocks__/recording_snapshots' import recordingMetaJson from 'scenes/session-recordings/__mocks__/recording_meta.json' import recordingEventsJson from 'scenes/session-recordings/__mocks__/recording_events_query' import recording_playlists from './__mocks__/recording_playlists.json' -import { ReplayTabs } from '~/types' -export default { +const meta: Meta = { title: 'Scenes-App/Recordings', parameters: { layout: 'fullscreen', - options: { showPanel: false }, viewMode: 'story', mockDate: '2023-02-01', + waitForSelector: '.PlayerFrame__content .replayer-wrapper iframe', + testOptions: { skip: true }, // TODO: Fix the flakey rendering due to player playback }, decorators: [ mswDecorator({ @@ -82,32 +82,51 @@ export default { }, ] }, - '/api/projects/:team_id/session_recording_playlists/:playlist_id/recordings?limit=100': (req) => { + '/api/projects/:team_id/session_recording_playlists/:playlist_id/recordings': (req) => { const playlistId = req.params.playlist_id const response = playlistId === '1234567' ? recordings : [] return [200, { has_next: false, results: response, version: 1 }] }, // without the session-recording-blob-replay feature flag, we only load via ClickHouse - '/api/projects/:team/session_recordings/:id/snapshots': recordingSnapshotsJson, + '/api/projects/:team/session_recordings/:id/snapshots': (req, res, ctx) => { + // with no sources, returns sources... + if (req.url.searchParams.get('source') === 'blob') { + return res(ctx.text(snapshotsAsJSONLines())) + } + // with no source requested should return sources + return [ + 200, + { + sources: [ + { + source: 'blob', + start_timestamp: '2023-08-11T12:03:36.097000Z', + end_timestamp: '2023-08-11T12:04:52.268000Z', + blob_key: '1691755416097-1691755492268', + }, + ], + }, + ] + }, '/api/projects/:team/session_recordings/:id': recordingMetaJson, + 'api/projects/:team/notebooks': { + count: 0, + next: null, + previous: null, + results: [], + }, }, post: { '/api/projects/:team/query': recordingEventsJson, }, }), ], -} as Meta - -export function RecordingsList(): JSX.Element { - useEffect(() => { - router.actions.push(urls.replay()) - }, []) - return } +export default meta -export function RecordingsPlayLists(): JSX.Element { +export function RecentRecordings(): JSX.Element { useEffect(() => { - router.actions.push(urls.replay(ReplayTabs.Playlists)) + router.actions.push(urls.replay()) }, []) return } diff --git a/frontend/src/scenes/session-recordings/SessionsRecordings-playlist-listing.stories.tsx b/frontend/src/scenes/session-recordings/SessionsRecordings-playlist-listing.stories.tsx new file mode 100644 index 0000000000000..657fbccf4bc29 --- /dev/null +++ b/frontend/src/scenes/session-recordings/SessionsRecordings-playlist-listing.stories.tsx @@ -0,0 +1,48 @@ +import { Meta } from '@storybook/react' +import { useEffect } from 'react' +import { mswDecorator } from '~/mocks/browser' +import { router } from 'kea-router' +import { urls } from 'scenes/urls' +import { App } from 'scenes/App' +import recording_playlists from './__mocks__/recording_playlists.json' +import { ReplayTabs } from '~/types' +import recordings from 'scenes/session-recordings/__mocks__/recordings.json' +import recordingEventsJson from 'scenes/session-recordings/__mocks__/recording_events_query' + +const meta: Meta = { + title: 'Scenes-App/Recordings', + parameters: { + layout: 'fullscreen', + viewMode: 'story', + mockDate: '2023-02-01', + }, + decorators: [ + mswDecorator({ + get: { + '/api/projects/:team_id/session_recording_playlists': recording_playlists, + '/api/projects/:team_id/session_recordings': (req) => { + const version = req.url.searchParams.get('version') + return [ + 200, + { + has_next: false, + results: recordings, + version, + }, + ] + }, + }, + post: { + '/api/projects/:team/query': recordingEventsJson, + }, + }), + ], +} +export default meta + +export function RecordingsPlayLists(): JSX.Element { + useEffect(() => { + router.actions.push(urls.replay(ReplayTabs.Playlists)) + }, []) + return +} diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_events.json b/frontend/src/scenes/session-recordings/__mocks__/recording_events.json index f2db148045646..0afa00a98d244 100644 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_events.json +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_events.json @@ -1,6 +1,6 @@ [ { - "id": "$pageview", + "id": "$pageview1", "event": "$pageview", "name": "$event_before_recording_starts", "type": "events", @@ -14,7 +14,7 @@ "elements_hash": "" }, { - "id": "$pageview", + "id": "$pageview2", "name": "$pageview", "event": "$pageview", "type": "events", diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json b/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json index 1812acfa655e6..aef53e6400da1 100644 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json @@ -19,6 +19,5 @@ "created_at": "2023-05-01T14:46:20.838000Z", "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0" }, - "storage": "clickhouse", - "pinned_count": 0 + "storage": "object_storage" } diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json deleted file mode 100644 index e33e115c9241b..0000000000000 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json +++ /dev/null @@ -1,1321 +0,0 @@ -{ - "next": null, - "snapshot_data_by_window_id": { - "187d7c761a0525d-05f175487d4b65-1d525634-384000-187d7c761a149d0": [ - { - "type": 4, - "data": { "href": "http://localhost:3000/", "width": 2560, "height": 1304 }, - "timestamp": 1682952380877 - }, - { - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { "type": 1, "name": "html", "publicId": "", "systemId": "", "id": 2 }, - { - "type": 2, - "tagName": "html", - "attributes": { "lang": "en" }, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { "charset": "utf-8" }, - "childNodes": [], - "id": 5 - }, - { - "type": 2, - "tagName": "title", - "attributes": {}, - "childNodes": [{ "type": 3, "textContent": "PostHog", "id": 7 }], - "id": 6 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { - "name": "viewport", - "content": "width=device-width, initial-scale=1" - }, - "childNodes": [], - "id": 8 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { "name": "next-head-count", "content": "3" }, - "childNodes": [], - "id": 9 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "data-n-css": "" }, - "childNodes": [], - "id": 10 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "defer": "", - "nomodule": "", - "src": "http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952380635" - }, - "childNodes": [], - "id": 11 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 12 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/main.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 13 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 14 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 15 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 16 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 17 - }, - { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }", - "isStyle": true, - "id": 19 - } - ], - "id": 18 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "id": "__next_css__DO_NOT_USE__" }, - "childNodes": [], - "id": 20 - } - ], - "id": 4 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "div", - "attributes": { "id": "__next" }, - "childNodes": [ - { - "type": 2, - "tagName": "main", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "h1", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "PostHog React", - "id": 25 - } - ], - "id": 24 - }, - { - "type": 2, - "tagName": "div", - "attributes": { "class": "buttons" }, - "childNodes": [ - { - "type": 2, - "tagName": "button", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Capture event", - "id": 28 - } - ], - "id": 27 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "data-attr": "autocapture-button" - }, - "childNodes": [ - { - "type": 3, - "textContent": "Autocapture buttons", - "id": 30 - } - ], - "id": 29 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "class": "ph-no-capture", - "rr_width": "155.3046875px", - "rr_height": "21.5px" - }, - "childNodes": [], - "id": 31 - } - ], - "id": 26 - }, - { - "type": 2, - "tagName": "p", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Feature flag response: ", - "id": 33 - }, - { "type": 3, "textContent": "false", "id": 34 } - ], - "id": 32 - } - ], - "id": 23 - } - ], - "id": 22 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "type": "text/javascript", - "src": "http://localhost:8000/static/recorder-v2.js?v=1.53.1" - }, - "childNodes": [], - "id": 35 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952380635" - }, - "childNodes": [], - "id": 36 - }, - { - "type": 2, - "tagName": "script", - "attributes": { "id": "__NEXT_DATA__", "type": "application/json" }, - "childNodes": [ - { "type": 3, "textContent": "SCRIPT_PLACEHOLDER", "id": 38 } - ], - "id": 37 - }, - { - "type": 2, - "tagName": "div", - "attributes": { - "id": "__next-build-watcher", - "style": "position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;" - }, - "childNodes": [ - { - "type": 2, - "tagName": "div", - "attributes": { "id": "container" }, - "childNodes": [ - { "type": 3, "textContent": "\n ", "id": 41 }, - { - "type": 2, - "tagName": "div", - "attributes": { "id": "icon-wrapper" }, - "childNodes": [ - { "type": 3, "textContent": "\n ", "id": 43 }, - { - "type": 2, - "tagName": "svg", - "attributes": { "viewBox": "0 0 226 200" }, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 45 - }, - { - "type": 2, - "tagName": "defs", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 47 - }, - { - "type": 2, - "tagName": "lineargradient", - "attributes": { - "x1": "114.720775%", - "y1": "181.283245%", - "x2": "39.5399306%", - "y2": "100%", - "id": "linear-gradient" - }, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 49 - }, - { - "type": 2, - "tagName": "stop", - "attributes": { - "stop-color": "#000000", - "offset": "0%" - }, - "childNodes": [], - "isSVG": true, - "id": 50 - }, - { - "type": 3, - "textContent": "\n ", - "id": 51 - }, - { - "type": 2, - "tagName": "stop", - "attributes": { - "stop-color": "#FFFFFF", - "offset": "100%" - }, - "childNodes": [], - "isSVG": true, - "id": 52 - }, - { - "type": 3, - "textContent": "\n ", - "id": 53 - } - ], - "isSVG": true, - "id": 48 - }, - { - "type": 3, - "textContent": "\n ", - "id": 54 - } - ], - "isSVG": true, - "id": 46 - }, - { - "type": 3, - "textContent": "\n ", - "id": 55 - }, - { - "type": 2, - "tagName": "g", - "attributes": { - "id": "icon-group", - "fill": "none", - "stroke": "url(#linear-gradient)", - "stroke-width": "18" - }, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 57 - }, - { - "type": 2, - "tagName": "path", - "attributes": { - "d": "M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z" - }, - "childNodes": [], - "isSVG": true, - "id": 58 - }, - { - "type": 3, - "textContent": "\n ", - "id": 59 - } - ], - "isSVG": true, - "id": 56 - }, - { - "type": 3, - "textContent": "\n ", - "id": 60 - } - ], - "isSVG": true, - "id": 44 - }, - { "type": 3, "textContent": "\n ", "id": 61 } - ], - "id": 42 - }, - { "type": 3, "textContent": "\n ", "id": 62 } - ], - "id": 40, - "isShadow": true - }, - { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \n 0% { bottom: 10px; opacity: 0; }\n 100% { bottom: 20px; opacity: 1; }\n}@keyframes strokedash { \n 0% { stroke-dasharray: 0, 226; }\n 80%, 100% { stroke-dasharray: 659, 226; }\n}", - "isStyle": true, - "id": 64 - } - ], - "id": 63, - "isShadow": true - } - ], - "id": 39, - "isShadowHost": true - }, - { - "type": 2, - "tagName": "next-route-announcer", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "p", - "attributes": { - "aria-live": "assertive", - "id": "__next-route-announcer__", - "role": "alert", - "style": "border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;" - }, - "childNodes": [], - "id": 66 - } - ], - "id": 65 - } - ], - "id": 21 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { "left": 0, "top": 0 } - }, - "timestamp": 1682952380882 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 2027, "y": 120, "id": 22, "timeOffset": 0 }] }, - "timestamp": 1682952383040 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 3, "x": 1618.84765625, "y": 299.01953125 }, - "timestamp": 1682952383262 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 3, "x": 1618.84765625, "y": 299.01953125 }, - "timestamp": 1682952383263 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 3, "x": 1618, "y": 299, "pointerType": 0 }, - "timestamp": 1682952383264 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 1618, "y": 299, "id": 3, "timeOffset": -435 }, - { "x": 1609, "y": 296, "id": 3, "timeOffset": -4 } - ] - }, - "timestamp": 1682952383543 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 1239, "y": 216, "id": 23, "timeOffset": -460 }, - { "x": 847, "y": 210, "id": 23, "timeOffset": -409 }, - { "x": 788, "y": 215, "id": 23, "timeOffset": -142 }, - { "x": 754, "y": 163, "id": 32, "timeOffset": -77 }, - { "x": 735, "y": 135, "id": 27, "timeOffset": -25 } - ] - }, - "timestamp": 1682952384050 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.6875 }, - "timestamp": 1682952384230 - }, - { "type": 3, "data": { "source": 2, "type": 5, "id": 27 }, "timestamp": 1682952384231 }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.5546875 }, - "timestamp": 1682952384310 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384313 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384447 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384460 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384463 - }, - { "type": 3, "data": { "source": 2, "type": 4, "id": 27, "x": 729, "y": 124 }, "timestamp": 1682952384464 }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 729, "y": 125, "id": 27, "timeOffset": -466 }, - { "x": 729, "y": 125, "id": 27, "timeOffset": -399 }, - { "x": 729, "y": 124, "id": 27, "timeOffset": -346 }, - { "x": 729, "y": 124, "id": 27, "timeOffset": -231 } - ] - }, - "timestamp": 1682952384555 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384559 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384675 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384676 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384709 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384810 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384811 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 713, "y": 137, "id": 27, "timeOffset": -49 }] }, - "timestamp": 1682952385058 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 605, "y": 266, "id": 3, "timeOffset": -487 }] }, - "timestamp": 1682952385562 - }, - { "type": 3, "data": { "source": 2, "type": 6, "id": 27 }, "timestamp": 1682952385719 }, - { "type": 3, "data": { "source": 4, "width": 2560, "height": 476 }, "timestamp": 1682952385738 }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 604, "y": 266, "id": 3, "timeOffset": -22 }] }, - "timestamp": 1682952386063 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 453, "y": 173, "id": 22, "timeOffset": -475 }, - { "x": 265, "y": 32, "id": 22, "timeOffset": -418 } - ] - }, - "timestamp": 1682952386571 - } - ], - "187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6": [ - { - "type": 4, - "data": { "href": "http://localhost:3000/", "width": 2560, "height": 1304 }, - "timestamp": 1682952388104 - }, - { - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { "type": 1, "name": "html", "publicId": "", "systemId": "", "id": 2 }, - { - "type": 2, - "tagName": "html", - "attributes": { "lang": "en" }, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "style", - "attributes": { "data-next-hide-fouc": "true" }, - "childNodes": [ - { - "type": 3, - "textContent": "body { display: none; }", - "isStyle": true, - "id": 6 - } - ], - "id": 5 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "data-next-hide-fouc": "true" }, - "childNodes": [ - { - "type": 3, - "textContent": "", - "id": 8 - } - ], - "id": 7 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { "charset": "utf-8" }, - "childNodes": [], - "id": 9 - }, - { - "type": 2, - "tagName": "title", - "attributes": {}, - "childNodes": [{ "type": 3, "textContent": "PostHog", "id": 11 }], - "id": 10 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { - "name": "viewport", - "content": "width=device-width, initial-scale=1" - }, - "childNodes": [], - "id": 12 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { "name": "next-head-count", "content": "3" }, - "childNodes": [], - "id": 13 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "data-n-css": "" }, - "childNodes": [], - "id": 14 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "defer": "", - "nomodule": "", - "src": "http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952387901" - }, - "childNodes": [], - "id": 15 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 16 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/main.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 17 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 18 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 19 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 20 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 21 - }, - { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }", - "isStyle": true, - "id": 23 - } - ], - "id": 22 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "id": "__next_css__DO_NOT_USE__" }, - "childNodes": [], - "id": 24 - } - ], - "id": 4 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "div", - "attributes": { "id": "__next" }, - "childNodes": [ - { - "type": 2, - "tagName": "main", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "h1", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "PostHog React", - "id": 29 - } - ], - "id": 28 - }, - { - "type": 2, - "tagName": "div", - "attributes": { "class": "buttons" }, - "childNodes": [ - { - "type": 2, - "tagName": "button", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Capture event", - "id": 32 - } - ], - "id": 31 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "data-attr": "autocapture-button" - }, - "childNodes": [ - { - "type": 3, - "textContent": "Autocapture buttons", - "id": 34 - } - ], - "id": 33 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "class": "ph-no-capture", - "rr_width": "0px", - "rr_height": "0px" - }, - "childNodes": [], - "id": 35 - } - ], - "id": 30 - }, - { - "type": 2, - "tagName": "p", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Feature flag response: ", - "id": 37 - } - ], - "id": 36 - } - ], - "id": 27 - } - ], - "id": 26 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "type": "text/javascript", - "src": "http://localhost:8000/static/recorder-v2.js?v=1.53.1" - }, - "childNodes": [], - "id": 38 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952387901" - }, - "childNodes": [], - "id": 39 - }, - { - "type": 2, - "tagName": "script", - "attributes": { "id": "__NEXT_DATA__", "type": "application/json" }, - "childNodes": [ - { "type": 3, "textContent": "SCRIPT_PLACEHOLDER", "id": 41 } - ], - "id": 40 - } - ], - "id": 25 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { "left": 0, "top": 0 } - }, - "timestamp": 1682952388106 - }, - { - "type": 3, - "data": { - "source": 0, - "texts": [], - "attributes": [], - "removes": [ - { "parentId": 4, "id": 7 }, - { "parentId": 4, "id": 5 } - ], - "adds": [] - }, - "timestamp": 1682952388108 - }, - { - "type": 3, - "data": { - "source": 0, - "texts": [], - "attributes": [], - "removes": [], - "adds": [ - { - "parentId": 25, - "nextId": null, - "node": { - "type": 2, - "tagName": "div", - "attributes": { - "id": "__next-build-watcher", - "style": "position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;" - }, - "childNodes": [], - "id": 42, - "isShadowHost": true - } - }, - { - "parentId": 42, - "nextId": null, - "node": { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [], - "id": 43, - "isShadow": true - } - }, - { - "parentId": 43, - "nextId": null, - "node": { - "type": 3, - "textContent": "#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \n 0% { bottom: 10px; opacity: 0; }\n 100% { bottom: 20px; opacity: 1; }\n}@keyframes strokedash { \n 0% { stroke-dasharray: 0, 226; }\n 80%, 100% { stroke-dasharray: 659, 226; }\n}", - "isStyle": true, - "id": 44 - } - }, - { - "parentId": 42, - "nextId": 43, - "node": { - "type": 2, - "tagName": "div", - "attributes": { "id": "container" }, - "childNodes": [], - "id": 45, - "isShadow": true - } - }, - { "parentId": 45, "nextId": null, "node": { "type": 3, "textContent": "\n ", "id": 46 } }, - { - "parentId": 45, - "nextId": 46, - "node": { - "type": 2, - "tagName": "div", - "attributes": { "id": "icon-wrapper" }, - "childNodes": [], - "id": 47 - } - }, - { "parentId": 45, "nextId": 47, "node": { "type": 3, "textContent": "\n ", "id": 48 } }, - { "parentId": 47, "nextId": null, "node": { "type": 3, "textContent": "\n ", "id": 49 } }, - { - "parentId": 47, - "nextId": 49, - "node": { - "type": 2, - "tagName": "svg", - "attributes": { "viewBox": "0 0 226 200" }, - "childNodes": [], - "isSVG": true, - "id": 50 - } - }, - { "parentId": 47, "nextId": 50, "node": { "type": 3, "textContent": "\n ", "id": 51 } }, - { "parentId": 50, "nextId": null, "node": { "type": 3, "textContent": "\n ", "id": 52 } }, - { - "parentId": 50, - "nextId": 52, - "node": { - "type": 2, - "tagName": "g", - "attributes": { - "id": "icon-group", - "fill": "none", - "stroke": "url(#linear-gradient)", - "stroke-width": "18" - }, - "childNodes": [], - "isSVG": true, - "id": 53 - } - }, - { "parentId": 50, "nextId": 53, "node": { "type": 3, "textContent": "\n ", "id": 54 } }, - { - "parentId": 50, - "nextId": 54, - "node": { - "type": 2, - "tagName": "defs", - "attributes": {}, - "childNodes": [], - "isSVG": true, - "id": 55 - } - }, - { "parentId": 50, "nextId": 55, "node": { "type": 3, "textContent": "\n ", "id": 56 } }, - { - "parentId": 55, - "nextId": null, - "node": { "type": 3, "textContent": "\n ", "id": 57 } - }, - { - "parentId": 55, - "nextId": 57, - "node": { - "type": 2, - "tagName": "lineargradient", - "attributes": { - "x1": "114.720775%", - "y1": "181.283245%", - "x2": "39.5399306%", - "y2": "100%", - "id": "linear-gradient" - }, - "childNodes": [], - "isSVG": true, - "id": 58 - } - }, - { - "parentId": 55, - "nextId": 58, - "node": { "type": 3, "textContent": "\n ", "id": 59 } - }, - { - "parentId": 58, - "nextId": null, - "node": { "type": 3, "textContent": "\n ", "id": 60 } - }, - { - "parentId": 58, - "nextId": 60, - "node": { - "type": 2, - "tagName": "stop", - "attributes": { "stop-color": "#FFFFFF", "offset": "100%" }, - "childNodes": [], - "isSVG": true, - "id": 61 - } - }, - { - "parentId": 58, - "nextId": 61, - "node": { "type": 3, "textContent": "\n ", "id": 62 } - }, - { - "parentId": 58, - "nextId": 62, - "node": { - "type": 2, - "tagName": "stop", - "attributes": { "stop-color": "#000000", "offset": "0%" }, - "childNodes": [], - "isSVG": true, - "id": 63 - } - }, - { - "parentId": 58, - "nextId": 63, - "node": { "type": 3, "textContent": "\n ", "id": 64 } - }, - { - "parentId": 53, - "nextId": null, - "node": { "type": 3, "textContent": "\n ", "id": 65 } - }, - { - "parentId": 53, - "nextId": 65, - "node": { - "type": 2, - "tagName": "path", - "attributes": { - "d": "M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z" - }, - "childNodes": [], - "isSVG": true, - "id": 66 - } - }, - { "parentId": 53, "nextId": 66, "node": { "type": 3, "textContent": "\n ", "id": 67 } } - ] - }, - "timestamp": 1682952388117 - }, - { - "type": 3, - "data": { - "source": 0, - "texts": [], - "attributes": [], - "removes": [ - { "parentId": 10, "id": 11 }, - { "parentId": 4, "id": 12 } - ], - "adds": [ - { "parentId": 10, "nextId": null, "node": { "type": 3, "textContent": "PostHog", "id": 68 } }, - { - "parentId": 4, - "nextId": 13, - "node": { - "type": 2, - "tagName": "meta", - "attributes": { "name": "viewport", "content": "width=device-width, initial-scale=1" }, - "childNodes": [], - "id": 69 - } - }, - { - "parentId": 25, - "nextId": null, - "node": { - "type": 2, - "tagName": "next-route-announcer", - "attributes": {}, - "childNodes": [], - "id": 70 - } - }, - { - "parentId": 70, - "nextId": null, - "node": { - "type": 2, - "tagName": "p", - "attributes": { - "aria-live": "assertive", - "id": "__next-route-announcer__", - "role": "alert", - "style": "border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;" - }, - "childNodes": [], - "id": 71 - } - }, - { "parentId": 36, "nextId": null, "node": { "type": 3, "textContent": "false", "id": 72 } } - ] - }, - "timestamp": 1682952388132 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 294, "y": 7, "id": 26, "timeOffset": 0 }] }, - "timestamp": 1682952388659 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 577, "y": 269, "id": 3, "timeOffset": -438 }, - { "x": 684, "y": 304, "id": 3, "timeOffset": -239 }, - { "x": 762, "y": 244, "id": 3, "timeOffset": -174 }, - { "x": 815, "y": 203, "id": 27, "timeOffset": -123 } - ] - }, - "timestamp": 1682952389163 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 819, "y": 197, "id": 27, "timeOffset": -427 }, - { "x": 831, "y": 176, "id": 27, "timeOffset": -362 }, - { "x": 842, "y": 157, "id": 36, "timeOffset": -312 }, - { "x": 850, "y": 142, "id": 27, "timeOffset": -261 }, - { "x": 852, "y": 137, "id": 33, "timeOffset": -176 }, - { "x": 852, "y": 133, "id": 33, "timeOffset": -111 }, - { "x": 852, "y": 133, "id": 33, "timeOffset": -28 } - ] - }, - "timestamp": 1682952389668 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952389698 - }, - { "type": 3, "data": { "source": 2, "type": 5, "id": 33 }, "timestamp": 1682952389699 }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952389798 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 33, "x": 852, "y": 133, "pointerType": 0 }, - "timestamp": 1682952389798 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952389943 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952390043 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 33, "x": 852, "y": 133, "pointerType": 0 }, - "timestamp": 1682952390044 - }, - { "type": 3, "data": { "source": 2, "type": 4, "id": 33, "x": 852, "y": 133 }, "timestamp": 1682952390047 }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952390112 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952390243 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 33, "x": 852, "y": 133, "pointerType": 0 }, - "timestamp": 1682952390244 - }, - { "type": 3, "data": { "source": 2, "type": 6, "id": 33 }, "timestamp": 1682952392745 } - ] - }, - "storage": "clickhouse" -} diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts new file mode 100644 index 0000000000000..e2e5a8ec6dd59 --- /dev/null +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts @@ -0,0 +1,29 @@ +import { RecordingSnapshot } from '~/types' + +const lineOne = + '{"window_id":"187d7c761a0525d-05f175487d4b65-1d525634-384000-187d7c761a149d0","data":[{"type":4,"data":{"href":"http://localhost:3000/","width":2560,"height":1304},"timestamp":1682952380877},{"type":2,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{"lang":"en"},"childNodes":[{"type":2,"tagName":"head","attributes":{},"childNodes":[{"type":2,"tagName":"meta","attributes":{"charset":"utf-8"},"childNodes":[],"id":5},{"type":2,"tagName":"title","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog","id":7}],"id":6},{"type":2,"tagName":"meta","attributes":{"name":"viewport","content":"width=device-width, initial-scale=1"},"childNodes":[],"id":8},{"type":2,"tagName":"meta","attributes":{"name":"next-head-count","content":"3"},"childNodes":[],"id":9},{"type":2,"tagName":"noscript","attributes":{"data-n-css":""},"childNodes":[],"id":10},{"type":2,"tagName":"script","attributes":{"defer":"","nomodule":"","src":"http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952380635"},"childNodes":[],"id":11},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952380635","defer":""},"childNodes":[],"id":12},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/main.js?ts=1682952380635","defer":""},"childNodes":[],"id":13},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952380635","defer":""},"childNodes":[],"id":14},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952380635","defer":""},"childNodes":[],"id":15},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952380635","defer":""},"childNodes":[],"id":16},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952380635","defer":""},"childNodes":[],"id":17},{"type":2,"tagName":"style","attributes":{},"childNodes":[{"type":3,"textContent":"main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }","isStyle":true,"id":19}],"id":18},{"type":2,"tagName":"noscript","attributes":{"id":"__next_css__DO_NOT_USE__"},"childNodes":[],"id":20}],"id":4},{"type":2,"tagName":"body","attributes":{},"childNodes":[{"type":2,"tagName":"div","attributes":{"id":"__next"},"childNodes":[{"type":2,"tagName":"main","attributes":{},"childNodes":[{"type":2,"tagName":"h1","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog React","id":25}],"id":24},{"type":2,"tagName":"div","attributes":{"class":"buttons"},"childNodes":[{"type":2,"tagName":"button","attributes":{},"childNodes":[{"type":3,"textContent":"Capture event","id":28}],"id":27},{"type":2,"tagName":"button","attributes":{"data-attr":"autocapture-button"},"childNodes":[{"type":3,"textContent":"Autocapture buttons","id":30}],"id":29},{"type":2,"tagName":"button","attributes":{"class":"ph-no-capture","rr_width":"155.3046875px","rr_height":"21.5px"},"childNodes":[],"id":31}],"id":26},{"type":2,"tagName":"p","attributes":{},"childNodes":[{"type":3,"textContent":"Feature flag response: ","id":33},{"type":3,"textContent":"false","id":34}],"id":32}],"id":23}],"id":22},{"type":2,"tagName":"script","attributes":{"type":"text/javascript","src":"http://localhost:8000/static/recorder-v2.js?v=1.53.1"},"childNodes":[],"id":35},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952380635"},"childNodes":[],"id":36},{"type":2,"tagName":"script","attributes":{"id":"__NEXT_DATA__","type":"application/json"},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":38}],"id":37},{"type":2,"tagName":"div","attributes":{"id":"__next-build-watcher","style":"position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;"},"childNodes":[{"type":2,"tagName":"div","attributes":{"id":"container"},"childNodes":[{"type":3,"textContent":"\\n ","id":41},{"type":2,"tagName":"div","attributes":{"id":"icon-wrapper"},"childNodes":[{"type":3,"textContent":"\\n ","id":43},{"type":2,"tagName":"svg","attributes":{"viewBox":"0 0 226 200"},"childNodes":[{"type":3,"textContent":"\\n ","id":45},{"type":2,"tagName":"defs","attributes":{},"childNodes":[{"type":3,"textContent":"\\n ","id":47},{"type":2,"tagName":"lineargradient","attributes":{"x1":"114.720775%","y1":"181.283245%","x2":"39.5399306%","y2":"100%","id":"linear-gradient"},"childNodes":[{"type":3,"textContent":"\\n ","id":49},{"type":2,"tagName":"stop","attributes":{"stop-color":"#000000","offset":"0%"},"childNodes":[],"isSVG":true,"id":50},{"type":3,"textContent":"\\n ","id":51},{"type":2,"tagName":"stop","attributes":{"stop-color":"#FFFFFF","offset":"100%"},"childNodes":[],"isSVG":true,"id":52},{"type":3,"textContent":"\\n ","id":53}],"isSVG":true,"id":48},{"type":3,"textContent":"\\n ","id":54}],"isSVG":true,"id":46},{"type":3,"textContent":"\\n ","id":55},{"type":2,"tagName":"g","attributes":{"id":"icon-group","fill":"none","stroke":"url(#linear-gradient)","stroke-width":"18"},"childNodes":[{"type":3,"textContent":"\\n ","id":57},{"type":2,"tagName":"path","attributes":{"d":"M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z"},"childNodes":[],"isSVG":true,"id":58},{"type":3,"textContent":"\\n ","id":59}],"isSVG":true,"id":56},{"type":3,"textContent":"\\n ","id":60}],"isSVG":true,"id":44},{"type":3,"textContent":"\\n ","id":61}],"id":42},{"type":3,"textContent":"\\n ","id":62}],"id":40,"isShadow":true},{"type":2,"tagName":"style","attributes":{},"childNodes":[{"type":3,"textContent":"#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \\n 0% { bottom: 10px; opacity: 0; }\\n 100% { bottom: 20px; opacity: 1; }\\n}@keyframes strokedash { \\n 0% { stroke-dasharray: 0, 226; }\\n 80%, 100% { stroke-dasharray: 659, 226; }\\n}","isStyle":true,"id":64}],"id":63,"isShadow":true}],"id":39,"isShadowHost":true},{"type":2,"tagName":"next-route-announcer","attributes":{},"childNodes":[{"type":2,"tagName":"p","attributes":{"aria-live":"assertive","id":"__next-route-announcer__","role":"alert","style":"border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;"},"childNodes":[],"id":66}],"id":65}],"id":21}],"id":3}],"id":1},"initialOffset":{"left":0,"top":0}},"timestamp":1682952380882},{"type":3,"data":{"source":1,"positions":[{"x":2027,"y":120,"id":22,"timeOffset":0}]},"timestamp":1682952383040},{"type":3,"data":{"source":2,"type":1,"id":3,"x":1618.84765625,"y":299.01953125},"timestamp":1682952383262},{"type":3,"data":{"source":2,"type":0,"id":3,"x":1618.84765625,"y":299.01953125},"timestamp":1682952383263},{"type":3,"data":{"source":2,"type":2,"id":3,"x":1618,"y":299,"pointerType":0},"timestamp":1682952383264},{"type":3,"data":{"source":1,"positions":[{"x":1618,"y":299,"id":3,"timeOffset":-435},{"x":1609,"y":296,"id":3,"timeOffset":-4}]},"timestamp":1682952383543},{"type":3,"data":{"source":1,"positions":[{"x":1239,"y":216,"id":23,"timeOffset":-460},{"x":847,"y":210,"id":23,"timeOffset":-409},{"x":788,"y":215,"id":23,"timeOffset":-142},{"x":754,"y":163,"id":32,"timeOffset":-77},{"x":735,"y":135,"id":27,"timeOffset":-25}]},"timestamp":1682952384050},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.6875},"timestamp":1682952384230},{"type":3,"data":{"source":2,"type":5,"id":27},"timestamp":1682952384231},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.5546875},"timestamp":1682952384310},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384313},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384447},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384460},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384463},{"type":3,"data":{"source":2,"type":4,"id":27,"x":729,"y":124},"timestamp":1682952384464},{"type":3,"data":{"source":1,"positions":[{"x":729,"y":125,"id":27,"timeOffset":-466},{"x":729,"y":125,"id":27,"timeOffset":-399},{"x":729,"y":124,"id":27,"timeOffset":-346},{"x":729,"y":124,"id":27,"timeOffset":-231}]},"timestamp":1682952384555},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384559},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384675},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384676},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384709},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384810},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384811},{"type":3,"data":{"source":1,"positions":[{"x":713,"y":137,"id":27,"timeOffset":-49}]},"timestamp":1682952385058},{"type":3,"data":{"source":1,"positions":[{"x":605,"y":266,"id":3,"timeOffset":-487}]},"timestamp":1682952385562},{"type":3,"data":{"source":2,"type":6,"id":27},"timestamp":1682952385719},{"type":3,"data":{"source":4,"width":2560,"height":476},"timestamp":1682952385738},{"type":3,"data":{"source":1,"positions":[{"x":604,"y":266,"id":3,"timeOffset":-22}]},"timestamp":1682952386063},{"type":3,"data":{"source":1,"positions":[{"x":453,"y":173,"id":22,"timeOffset":-475},{"x":265,"y":32,"id":22,"timeOffset":-418}]},"timestamp":1682952386571}]}' +const lineTwo = + '{"window_id":"187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6","data":[{"type":4,"data":{"href":"http://localhost:3000/","width":2560,"height":1304},"timestamp":1682952388104},{"type":2,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{"lang":"en"},"childNodes":[{"type":2,"tagName":"head","attributes":{},"childNodes":[{"type":2,"tagName":"style","attributes":{"data-next-hide-fouc":"true"},"childNodes":[{"type":3,"textContent":"body { display: none; }","isStyle":true,"id":6}],"id":5},{"type":2,"tagName":"noscript","attributes":{"data-next-hide-fouc":"true"},"childNodes":[{"type":3,"textContent":"","id":8}],"id":7},{"type":2,"tagName":"meta","attributes":{"charset":"utf-8"},"childNodes":[],"id":9},{"type":2,"tagName":"title","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog","id":11}],"id":10},{"type":2,"tagName":"meta","attributes":{"name":"viewport","content":"width=device-width, initial-scale=1"},"childNodes":[],"id":12},{"type":2,"tagName":"meta","attributes":{"name":"next-head-count","content":"3"},"childNodes":[],"id":13},{"type":2,"tagName":"noscript","attributes":{"data-n-css":""},"childNodes":[],"id":14},{"type":2,"tagName":"script","attributes":{"defer":"","nomodule":"","src":"http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952387901"},"childNodes":[],"id":15},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952387901","defer":""},"childNodes":[],"id":16},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/main.js?ts=1682952387901","defer":""},"childNodes":[],"id":17},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952387901","defer":""},"childNodes":[],"id":18},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952387901","defer":""},"childNodes":[],"id":19},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952387901","defer":""},"childNodes":[],"id":20},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952387901","defer":""},"childNodes":[],"id":21},{"type":2,"tagName":"style","attributes":{},"childNodes":[{"type":3,"textContent":"main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }","isStyle":true,"id":23}],"id":22},{"type":2,"tagName":"noscript","attributes":{"id":"__next_css__DO_NOT_USE__"},"childNodes":[],"id":24}],"id":4},{"type":2,"tagName":"body","attributes":{},"childNodes":[{"type":2,"tagName":"div","attributes":{"id":"__next"},"childNodes":[{"type":2,"tagName":"main","attributes":{},"childNodes":[{"type":2,"tagName":"h1","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog React","id":29}],"id":28},{"type":2,"tagName":"div","attributes":{"class":"buttons"},"childNodes":[{"type":2,"tagName":"button","attributes":{},"childNodes":[{"type":3,"textContent":"Capture event","id":32}],"id":31},{"type":2,"tagName":"button","attributes":{"data-attr":"autocapture-button"},"childNodes":[{"type":3,"textContent":"Autocapture buttons","id":34}],"id":33},{"type":2,"tagName":"button","attributes":{"class":"ph-no-capture","rr_width":"0px","rr_height":"0px"},"childNodes":[],"id":35}],"id":30},{"type":2,"tagName":"p","attributes":{},"childNodes":[{"type":3,"textContent":"Feature flag response: ","id":37}],"id":36}],"id":27}],"id":26},{"type":2,"tagName":"script","attributes":{"type":"text/javascript","src":"http://localhost:8000/static/recorder-v2.js?v=1.53.1"},"childNodes":[],"id":38},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952387901"},"childNodes":[],"id":39},{"type":2,"tagName":"script","attributes":{"id":"__NEXT_DATA__","type":"application/json"},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":41}],"id":40}],"id":25}],"id":3}],"id":1},"initialOffset":{"left":0,"top":0}},"timestamp":1682952388106},{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":4,"id":7},{"parentId":4,"id":5}],"adds":[]},"timestamp":1682952388108},{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[],"adds":[{"parentId":25,"nextId":null,"node":{"type":2,"tagName":"div","attributes":{"id":"__next-build-watcher","style":"position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;"},"childNodes":[],"id":42,"isShadowHost":true}},{"parentId":42,"nextId":null,"node":{"type":2,"tagName":"style","attributes":{},"childNodes":[],"id":43,"isShadow":true}},{"parentId":43,"nextId":null,"node":{"type":3,"textContent":"#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \\n 0% { bottom: 10px; opacity: 0; }\\n 100% { bottom: 20px; opacity: 1; }\\n}@keyframes strokedash { \\n 0% { stroke-dasharray: 0, 226; }\\n 80%, 100% { stroke-dasharray: 659, 226; }\\n}","isStyle":true,"id":44}},{"parentId":42,"nextId":43,"node":{"type":2,"tagName":"div","attributes":{"id":"container"},"childNodes":[],"id":45,"isShadow":true}},{"parentId":45,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":46}},{"parentId":45,"nextId":46,"node":{"type":2,"tagName":"div","attributes":{"id":"icon-wrapper"},"childNodes":[],"id":47}},{"parentId":45,"nextId":47,"node":{"type":3,"textContent":"\\n ","id":48}},{"parentId":47,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":49}},{"parentId":47,"nextId":49,"node":{"type":2,"tagName":"svg","attributes":{"viewBox":"0 0 226 200"},"childNodes":[],"isSVG":true,"id":50}},{"parentId":47,"nextId":50,"node":{"type":3,"textContent":"\\n ","id":51}},{"parentId":50,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":52}},{"parentId":50,"nextId":52,"node":{"type":2,"tagName":"g","attributes":{"id":"icon-group","fill":"none","stroke":"url(#linear-gradient)","stroke-width":"18"},"childNodes":[],"isSVG":true,"id":53}},{"parentId":50,"nextId":53,"node":{"type":3,"textContent":"\\n ","id":54}},{"parentId":50,"nextId":54,"node":{"type":2,"tagName":"defs","attributes":{},"childNodes":[],"isSVG":true,"id":55}},{"parentId":50,"nextId":55,"node":{"type":3,"textContent":"\\n ","id":56}},{"parentId":55,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":57}},{"parentId":55,"nextId":57,"node":{"type":2,"tagName":"lineargradient","attributes":{"x1":"114.720775%","y1":"181.283245%","x2":"39.5399306%","y2":"100%","id":"linear-gradient"},"childNodes":[],"isSVG":true,"id":58}},{"parentId":55,"nextId":58,"node":{"type":3,"textContent":"\\n ","id":59}},{"parentId":58,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":60}},{"parentId":58,"nextId":60,"node":{"type":2,"tagName":"stop","attributes":{"stop-color":"#FFFFFF","offset":"100%"},"childNodes":[],"isSVG":true,"id":61}},{"parentId":58,"nextId":61,"node":{"type":3,"textContent":"\\n ","id":62}},{"parentId":58,"nextId":62,"node":{"type":2,"tagName":"stop","attributes":{"stop-color":"#000000","offset":"0%"},"childNodes":[],"isSVG":true,"id":63}},{"parentId":58,"nextId":63,"node":{"type":3,"textContent":"\\n ","id":64}},{"parentId":53,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":65}},{"parentId":53,"nextId":65,"node":{"type":2,"tagName":"path","attributes":{"d":"M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z"},"childNodes":[],"isSVG":true,"id":66}},{"parentId":53,"nextId":66,"node":{"type":3,"textContent":"\\n ","id":67}}]},"timestamp":1682952388117},{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":10,"id":11},{"parentId":4,"id":12}],"adds":[{"parentId":10,"nextId":null,"node":{"type":3,"textContent":"PostHog","id":68}},{"parentId":4,"nextId":13,"node":{"type":2,"tagName":"meta","attributes":{"name":"viewport","content":"width=device-width, initial-scale=1"},"childNodes":[],"id":69}},{"parentId":25,"nextId":null,"node":{"type":2,"tagName":"next-route-announcer","attributes":{},"childNodes":[],"id":70}},{"parentId":70,"nextId":null,"node":{"type":2,"tagName":"p","attributes":{"aria-live":"assertive","id":"__next-route-announcer__","role":"alert","style":"border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;"},"childNodes":[],"id":71}},{"parentId":36,"nextId":null,"node":{"type":3,"textContent":"false","id":72}}]},"timestamp":1682952388132},{"type":3,"data":{"source":1,"positions":[{"x":294,"y":7,"id":26,"timeOffset":0}]},"timestamp":1682952388659},{"type":3,"data":{"source":1,"positions":[{"x":577,"y":269,"id":3,"timeOffset":-438},{"x":684,"y":304,"id":3,"timeOffset":-239},{"x":762,"y":244,"id":3,"timeOffset":-174},{"x":815,"y":203,"id":27,"timeOffset":-123}]},"timestamp":1682952389163},{"type":3,"data":{"source":1,"positions":[{"x":819,"y":197,"id":27,"timeOffset":-427},{"x":831,"y":176,"id":27,"timeOffset":-362},{"x":842,"y":157,"id":36,"timeOffset":-312},{"x":850,"y":142,"id":27,"timeOffset":-261},{"x":852,"y":137,"id":33,"timeOffset":-176},{"x":852,"y":133,"id":33,"timeOffset":-111},{"x":852,"y":133,"id":33,"timeOffset":-28}]},"timestamp":1682952389668},{"type":3,"data":{"source":2,"type":1,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952389698},{"type":3,"data":{"source":2,"type":5,"id":33},"timestamp":1682952389699},{"type":3,"data":{"source":2,"type":0,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952389798},{"type":3,"data":{"source":2,"type":2,"id":33,"x":852,"y":133,"pointerType":0},"timestamp":1682952389798},{"type":3,"data":{"source":2,"type":1,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952389943},{"type":3,"data":{"source":2,"type":0,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952390043},{"type":3,"data":{"source":2,"type":2,"id":33,"x":852,"y":133,"pointerType":0},"timestamp":1682952390044},{"type":3,"data":{"source":2,"type":4,"id":33,"x":852,"y":133},"timestamp":1682952390047},{"type":3,"data":{"source":2,"type":1,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952390112},{"type":3,"data":{"source":2,"type":0,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952390243},{"type":3,"data":{"source":2,"type":2,"id":33,"x":852,"y":133,"pointerType":0},"timestamp":1682952390244},{"type":3,"data":{"source":2,"type":6,"id":33},"timestamp":1682952392745}]}' + +export const snapshotsAsJSONLines = (): string => `${lineOne}\n${lineTwo}\n` + +export const sortedRecordingSnapshots = (): { snapshot_data_by_window_id: Record } => { + const sortedRecordingSnapshotsJson = { snapshot_data_by_window_id: {} } + + snapshotsAsJSONLines() + .trim() + .split('\n') + .forEach((line) => { + const j = JSON.parse(line) + sortedRecordingSnapshotsJson.snapshot_data_by_window_id[j.window_id] = j.data + .map((jd: Record) => { + return { + windowId: j.window_id, + ...jd, + } + }) + .sort((a: any, b: any) => a.timestamp - b.timestamp) + }) + + return sortedRecordingSnapshotsJson +} diff --git a/frontend/src/scenes/session-recordings/debug/RecordingDebugInfo.tsx b/frontend/src/scenes/session-recordings/debug/RecordingDebugInfo.tsx deleted file mode 100644 index cc099a5617eda..0000000000000 --- a/frontend/src/scenes/session-recordings/debug/RecordingDebugInfo.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import clsx from 'clsx' -import { useValues } from 'kea' -import { IconInfo } from 'lib/lemon-ui/icons' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { SessionRecordingType } from '~/types' - -export function RecordingDebugInfo({ - recording, - className, -}: { - recording: SessionRecordingType - className?: string -}): JSX.Element | null { - const { featureFlags } = useValues(featureFlagLogic) - const debugMode = !!featureFlags[FEATURE_FLAGS.RECORDING_DEBUGGING] - - if (!debugMode) { - return null - } - - return ( - -
  • - ID: {recording.id} -
  • -
  • - Storage: {recording.storage} -
  • - - } - > - - - ) -} diff --git a/frontend/src/scenes/session-recordings/detail/SessionRecordingDetail.tsx b/frontend/src/scenes/session-recordings/detail/SessionRecordingDetail.tsx index 442ce82b741d2..c39a26afd90f8 100644 --- a/frontend/src/scenes/session-recordings/detail/SessionRecordingDetail.tsx +++ b/frontend/src/scenes/session-recordings/detail/SessionRecordingDetail.tsx @@ -12,6 +12,8 @@ import { } from 'scenes/session-recordings/detail/sessionRecordingDetailLogic' import { RecordingNotFound } from 'scenes/session-recordings/player/RecordingNotFound' +import './SessionRecordingScene.scss' + export const scene: SceneExport = { logic: sessionRecordingDetailLogic, component: SessionRecordingDetail, @@ -23,7 +25,7 @@ export const scene: SceneExport = { export function SessionRecordingDetail({ id }: SessionRecordingDetailLogicProps = {}): JSX.Element { const { currentTeam } = useValues(teamLogic) return ( -
    +
    Recording
    } /> {currentTeam && !currentTeam?.session_recording_opt_in ? (
    diff --git a/frontend/src/scenes/session-recordings/detail/SessionRecordingScene.scss b/frontend/src/scenes/session-recordings/detail/SessionRecordingScene.scss new file mode 100644 index 0000000000000..a5928a38658ea --- /dev/null +++ b/frontend/src/scenes/session-recordings/detail/SessionRecordingScene.scss @@ -0,0 +1,6 @@ +.SessionRecordingScene { + .SessionRecordingPlayer { + height: calc(100vh - 12rem); + min-height: 30rem; + } +} diff --git a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts index 7322097752790..8d4d1ebe5c878 100644 --- a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts +++ b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts @@ -138,7 +138,7 @@ export const sessionRecordingFilePlaybackLogic = kea void showPropertyFilters?: boolean }): JSX.Element => { + const { groupsTaxonomicTypes } = useValues(groupsModel) + return (
    - - setFilters({ filter_test_accounts: testFilters.filter_test_accounts })} - /> - + setFilters({ filter_test_accounts: testFilters.filter_test_accounts })} + /> Time and duration
    @@ -49,7 +51,8 @@ export const AdvancedSessionRecordingsFilters = ({ { key: 'Custom', values: [] }, { key: 'Last 24 hours', values: ['-24h'] }, { key: 'Last 7 days', values: ['-7d'] }, - { key: 'Last 21 days', values: ['-21d'] }, + { key: 'Last 30 days', values: ['-30d'] }, + { key: 'All time', values: ['-90d'] }, ]} dropdownPlacement="bottom-start" /> @@ -87,6 +90,7 @@ export const AdvancedSessionRecordingsFilters = ({ TaxonomicFilterGroupType.EventFeatureFlags, TaxonomicFilterGroupType.Elements, TaxonomicFilterGroupType.HogQLExpression, + ...groupsTaxonomicTypes, ]} propertyFiltersPopover addFilterDefaultOptions={{ @@ -116,78 +120,97 @@ export const AdvancedSessionRecordingsFilters = ({ )} - - Filter by console logs - - - setFilters({ - console_logs: x, - }) - } - /> +
    ) } function ConsoleFilters({ filters, - setConsoleFilters, + setFilters, }: { filters: RecordingFilters - setConsoleFilters: (selection: FilterableLogLevel[]) => void + setFilters: (filterS: RecordingFilters) => void }): JSX.Element { - function updateChoice(checked: boolean, level: FilterableLogLevel): void { + function updateLevelChoice(checked: boolean, level: FilterableLogLevel): void { const newChoice = filters.console_logs?.filter((c) => c !== level) || [] if (checked) { - setConsoleFilters([...newChoice, level]) + setFilters({ + console_logs: [...newChoice, level], + }) } else { - setConsoleFilters(newChoice) + setFilters({ + console_logs: newChoice, + }) } } return ( - - { - updateChoice(checked, 'log') - }} - label={'log'} - /> - updateChoice(checked, 'warn')} - label={'warn'} - /> - updateChoice(checked, 'error')} - label={'error'} - /> - , - ], - actionable: true, - }} - > - {filters.console_logs?.map((x) => `console.${x}`).join(' or ') || ( - Console types to filter for... - )} - + <> + Filter by console logs + +
    + { + setFilters({ + console_search_query: s, + }) + }} + /> + + Filter recordings by console logs. Only matches recordings since October 4th.} + > + Beta + +
    +
    + + { + updateLevelChoice(checked, 'log') + }} + label={'log'} + /> + updateLevelChoice(checked, 'warn')} + label={'warn'} + /> + updateLevelChoice(checked, 'error')} + label={'error'} + /> + , + ], + actionable: true, + }} + > + {filters.console_logs?.map((x) => `console.${x}`).join(' or ') || ( + Console types to filter for... + )} + + ) } diff --git a/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx b/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx index 3afb7d8dc3629..bc1e108bf7c19 100644 --- a/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx +++ b/frontend/src/scenes/session-recordings/filters/SessionRecordingsFilters.tsx @@ -5,9 +5,7 @@ import equal from 'fast-deep-equal' import { LemonButton } from '@posthog/lemon-ui' import { SimpleSessionRecordingsFilters } from './SimpleSessionRecordingsFilters' import { AdvancedSessionRecordingsFilters } from './AdvancedSessionRecordingsFilters' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { useValues } from 'kea' -import { FEATURE_FLAGS } from 'lib/constants' + interface SessionRecordingsFiltersProps { filters: RecordingFilters setFilters: (filters: RecordingFilters) => void @@ -51,12 +49,6 @@ export function SessionRecordingsFilters({ }: SessionRecordingsFiltersProps): JSX.Element { const [localFilters, setLocalFilters] = useState(filtersToLocalFilters(filters)) - const { featureFlags } = useValues(featureFlagLogic) - const sessionReplaySimpleFilters = featureFlags[FEATURE_FLAGS.SESSION_REPLAY_SIMPLE_FILTERS] - useEffect(() => { - setShowAdvancedFilters(sessionReplaySimpleFilters === 'simple_filters' ? hasAdvancedFilters : true) - }, [sessionReplaySimpleFilters]) - // We have a copy of the filters as local state as it stores more properties than we want for playlists useEffect(() => { if (!equal(filters.actions, localFilters.actions) || !equal(filters.events, localFilters.events)) { @@ -76,7 +68,7 @@ export function SessionRecordingsFilters({ }, [filters]) return ( -
    +
    {onReset && ( @@ -104,20 +96,20 @@ export function SessionRecordingsFilters({ /> )} - {sessionReplaySimpleFilters === 'simple_filters' && ( -
    - setShowAdvancedFilters(!showAdvancedFilters)} - disabledReason={ - hasAdvancedFilters && - 'You are only allowed person filters and a single pageview event to switch back to simple filters' - } - > - Show {showAdvancedFilters ? 'simple filters' : 'advanced filters'} - -
    - )} +
    + setShowAdvancedFilters(!showAdvancedFilters)} + disabledReason={ + hasAdvancedFilters + ? 'You are only allowed person filters and a single pageview event (filtered by current url) to switch back to simple filters' + : undefined + } + data-attr={`session-recordings-show-${showAdvancedFilters ? 'simple' : 'advanced'}-filters`} + > + Show {showAdvancedFilters ? 'simple filters' : 'advanced filters'} + +
    ) } diff --git a/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx b/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx index 2054b2d45243a..e943988808d69 100644 --- a/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx +++ b/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx @@ -14,8 +14,10 @@ import { PropertyFilterLogicProps } from 'lib/components/PropertyFilters/types' import { Popover } from 'lib/lemon-ui/Popover/Popover' import { teamLogic } from 'scenes/teamLogic' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' -import { LemonButton } from '@posthog/lemon-ui' +import { LemonButton, Link } from '@posthog/lemon-ui' import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters' +import { urls } from '@posthog/apps-common' +import { IconPlus } from 'lib/lemon-ui/icons' export const SimpleSessionRecordingsFilters = ({ filters, @@ -30,15 +32,16 @@ export const SimpleSessionRecordingsFilters = ({ }): JSX.Element => { const { currentTeam } = useValues(teamLogic) + const displayNameProperties = useMemo(() => currentTeam?.person_display_name_properties ?? [], [currentTeam]) + const personPropertyOptions = useMemo(() => { const properties = [{ label: 'Country', key: '$geoip_country_name' }] - const displayNameProperties = currentTeam?.person_display_name_properties ?? [] return properties.concat( displayNameProperties.slice(0, 2).map((property) => { return { label: property, key: property } }) ) - }, [currentTeam]) + }, [displayNameProperties]) const pageviewEvent = localFilters.events?.find((event) => event.id === '$pageview') @@ -47,7 +50,7 @@ export const SimpleSessionRecordingsFilters = ({ return (
    -
    +
    {personPropertyOptions.map(({ label, key }) => ( + {displayNameProperties.length === 0 && ( + + }> + Add person properties + + + )}
    {personProperties && ( @@ -181,7 +191,7 @@ const SimpleSessionRecordingsFiltersInserter = ({ className="new-prop-filter" type="secondary" size="small" - disabledReason={disabled && 'Add more properties using your existing filter.'} + disabledReason={disabled && 'Add more values using your existing filter.'} sideIcon={null} > {label} diff --git a/frontend/src/scenes/session-recordings/multiRecordingButton/multiRecordingButton.scss b/frontend/src/scenes/session-recordings/multiRecordingButton/multiRecordingButton.scss deleted file mode 100644 index ac75296ca031c..0000000000000 --- a/frontend/src/scenes/session-recordings/multiRecordingButton/multiRecordingButton.scss +++ /dev/null @@ -1,36 +0,0 @@ -.session-recordings-popover { - z-index: 1061; // >1060 to be above the person modal and <1070 to be under the recording - - .anticon-play-circle { - color: var(--primary); - } - - .session-recordings-popover-row { - display: flex; - align-items: center; - - padding: 0.5rem; - - font-weight: var(--font-medium); - color: var(--default); - - &:hover { - color: var(--primary); - } - - .anticon { - font-size: 1.25rem; - margin-right: 0.5rem; - } - } -} - -.session-recordings-button { - .anticon-play-circle { - color: var(--primary); - } - .session-recordings-button__indicator { - font-size: 12px; - color: var(--muted); - } -} diff --git a/frontend/src/scenes/session-recordings/multiRecordingButton/multiRecordingButton.tsx b/frontend/src/scenes/session-recordings/multiRecordingButton/multiRecordingButton.tsx deleted file mode 100644 index e0ab9ae7f55a0..0000000000000 --- a/frontend/src/scenes/session-recordings/multiRecordingButton/multiRecordingButton.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { MutableRefObject, ReactNode, useCallback, useState } from 'react' -import { PlayCircleOutlined, DownOutlined, ArrowRightOutlined } from '@ant-design/icons' -import { MatchedRecording } from '~/types' -import { Button } from 'antd' -import { Popover } from 'lib/lemon-ui/Popover/Popover' -import { Link } from 'lib/lemon-ui/Link' -import './multiRecordingButton.scss' - -interface MultiRecordingButtonProps { - sessionRecordings: MatchedRecording[] - onOpenRecording: (sessionRecording: MatchedRecording) => void -} - -export function MultiRecordingButton({ sessionRecordings, onOpenRecording }: MultiRecordingButtonProps): JSX.Element { - const [areRecordingsShown, setAreRecordingsShown] = useState(false) - - const isSingleRecording = sessionRecordings.length === 1 - - /** A wrapper for the button, that handles differing behavior based on the number of recordings available: - * When there's only one recording, clicking opens the recording. - * When there are more recordings, clicking shows the dropdown. - */ - const ButtonWrapper: (props: { ref?: MutableRefObject; children: ReactNode }) => JSX.Element = - useCallback( - ({ ref, children }) => { - return isSingleRecording ? ( -
    }> - { - event.stopPropagation() - onOpenRecording(sessionRecordings[0]) - }} - > - {children} - -
    - ) : ( -
    } - onClick={(event) => { - event.stopPropagation() - setAreRecordingsShown((previousValue) => !previousValue) - }} - > - {children} -
    - ) - }, - [sessionRecordings, setAreRecordingsShown] - ) - - return ( - ( - { - event.stopPropagation() - setAreRecordingsShown(false) - onOpenRecording(sessionRecording) - }} - > -
    - - Recording {index + 1} -
    - - ))} - onClickOutside={() => { - setAreRecordingsShown(false) - }} - > - - - -
    - ) -} diff --git a/frontend/src/scenes/session-recordings/player/PlayerFrame.scss b/frontend/src/scenes/session-recordings/player/PlayerFrame.scss index 67b9410516d9f..78bbe50f457d0 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerFrame.scss +++ b/frontend/src/scenes/session-recordings/player/PlayerFrame.scss @@ -10,9 +10,6 @@ align-items: center; position: relative; - .posthog-3000 & { - background-color: var(--bg-3000); - } .PlayerFrame__content { position: absolute; diff --git a/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.scss b/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.scss index b09f3af62dee6..4ff8665202c6b 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.scss +++ b/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.scss @@ -18,11 +18,17 @@ align-items: center; z-index: 1; - transition: background-color 100ms; - background-color: rgba(0, 0, 0, 0.08); + transition: opacity 100ms; + background-color: rgba(0, 0, 0, 0.15); + opacity: 0.8; &:hover { - background-color: rgba(0, 0, 0, 0.15); + opacity: 1; + } + + &--only-hover { + // When paused only show on hover to allow people to take screenshots + opacity: 0; } } } diff --git a/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx b/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx index cf820bc724ddf..a1bec5bc8c8e2 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx +++ b/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx @@ -1,18 +1,13 @@ import { useActions, useValues } from 'kea' -import { - sessionRecordingPlayerLogic, - SessionRecordingPlayerLogicProps, -} from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' -import { SessionPlayerState, SessionRecordingType } from '~/types' +import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' +import { SessionPlayerState } from '~/types' import { IconErrorOutline, IconPlay } from 'lib/lemon-ui/icons' import { LemonButton } from 'lib/lemon-ui/LemonButton' import './PlayerFrameOverlay.scss' import { PlayerUpNext } from './PlayerUpNext' import { useState } from 'react' - -export interface PlayerFrameOverlayProps extends SessionRecordingPlayerLogicProps { - nextSessionRecording?: Partial -} +import clsx from 'clsx' +import { getCurrentExporterData } from '~/exporter/exporterViewLogic' const PlayerFrameOverlayContent = ({ currentPlayerState, @@ -20,6 +15,10 @@ const PlayerFrameOverlayContent = ({ currentPlayerState: SessionPlayerState }): JSX.Element | null => { let content = null + const pausedState = + currentPlayerState === SessionPlayerState.PAUSE || currentPlayerState === SessionPlayerState.READY + const isInExportContext = !!getCurrentExporterData() + if (currentPlayerState === SessionPlayerState.ERROR) { content = (
    @@ -52,19 +51,31 @@ const PlayerFrameOverlayContent = ({ ) } if (currentPlayerState === SessionPlayerState.BUFFER) { - content =
    Buffering…
    + content = ( +
    Buffering…
    + ) } - if (currentPlayerState === SessionPlayerState.PAUSE || currentPlayerState === SessionPlayerState.READY) { + if (pausedState) { content = } if (currentPlayerState === SessionPlayerState.SKIP) { content =
    Skipping inactivity
    } - return content ?
    {content}
    : null + return content ? ( +
    + {content} +
    + ) : null } export function PlayerFrameOverlay(): JSX.Element { - const { currentPlayerState } = useValues(sessionRecordingPlayerLogic) + const { currentPlayerState, playlistLogic } = useValues(sessionRecordingPlayerLogic) const { togglePlayPause } = useActions(sessionRecordingPlayerLogic) const [interrupted, setInterrupted] = useState(false) @@ -77,7 +88,13 @@ export function PlayerFrameOverlay(): JSX.Element { onMouseOut={() => setInterrupted(false)} > - setInterrupted(false)} /> + {playlistLogic ? ( + setInterrupted(false)} + /> + ) : undefined}
    ) } diff --git a/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx b/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx index 16572e486bb61..d0fd56e93e16b 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx +++ b/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx @@ -1,30 +1,28 @@ import { - SessionRecordingPlayerMode, sessionRecordingPlayerLogic, + SessionRecordingPlayerMode, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { useActions, useValues } from 'kea' import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton' -import { IconComment, IconDelete, IconLink } from 'lib/lemon-ui/icons' +import { IconComment, IconDelete, IconLink, IconPinFilled, IconPinOutline } from 'lib/lemon-ui/icons' import { openPlayerShareDialog } from 'scenes/session-recordings/player/share/PlayerShare' import { PlaylistPopoverButton } from './playlist-popover/PlaylistPopover' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' -import { buildTimestampCommentContent } from 'scenes/notebooks/Nodes/NotebookNodeReplayTimestamp' +import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' +import { NotebookNodeType } from '~/types' import { useNotebookNode } from 'scenes/notebooks/Nodes/notebookNodeLogic' -import { NotebookNodeType, NotebookTarget } from '~/types' -import { notebooksListLogic } from 'scenes/notebooks/Notebook/notebooksListLogic' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { dayjs } from 'lib/dayjs' +import { sessionPlayerModalLogic } from './modal/sessionPlayerModalLogic' +import { personsModalLogic } from 'scenes/trends/persons-modal/personsModalLogic' +import { IconNotebook } from 'scenes/notebooks/IconNotebook' export function PlayerMetaLinks(): JSX.Element { const { sessionRecordingId, logicProps } = useValues(sessionRecordingPlayerLogic) - const { setPause, deleteRecording } = useActions(sessionRecordingPlayerLogic) - const { createNotebook } = useActions(notebooksListLogic) - const { featureFlags } = useValues(featureFlagLogic) + const { setPause, deleteRecording, maybePersistRecording } = useActions(sessionRecordingPlayerLogic) const nodeLogic = useNotebookNode() + const { closeSessionPlayer } = useActions(sessionPlayerModalLogic()) const getCurrentPlayerTime = (): number => { - // NOTE: We pull this value at call time as otherwise it would trigger rerenders if pulled from the hook + // NOTE: We pull this value at call time as otherwise it would trigger re-renders if pulled from the hook const playerTime = sessionRecordingPlayerLogic.findMounted(logicProps)?.values.currentPlayerTime || 0 return Math.floor(playerTime / 1000) } @@ -52,25 +50,6 @@ export function PlayerMetaLinks(): JSX.Element { }) } - const onComment = (): void => { - const currentPlayerTime = getCurrentPlayerTime() * 1000 - if (nodeLogic) { - nodeLogic.actions.insertAfterLastNodeOfType(NotebookNodeType.ReplayTimestamp, [ - buildTimestampCommentContent(currentPlayerTime, sessionRecordingId), - ]) - } else { - const title = `Session Replay Notes ${dayjs().format('DD/MM')}` - - createNotebook(title, NotebookTarget.Popover, [ - { - type: NotebookNodeType.Recording, - attrs: { id: sessionRecordingId }, - }, - buildTimestampCommentContent(currentPlayerTime, sessionRecordingId), - ]) - } - } - const commonProps: Partial = { size: 'small', } @@ -81,19 +60,71 @@ export function PlayerMetaLinks(): JSX.Element {
    {![SessionRecordingPlayerMode.Sharing].includes(mode) ? ( <> - {featureFlags[FEATURE_FLAGS.NOTEBOOKS] && ( - } onClick={onComment} {...commonProps}> - Comment - - )} + } + resource={{ + type: NotebookNodeType.Recording, + attrs: { id: sessionRecordingId, __init: { expanded: true } }, + }} + onClick={() => setPause()} + onNotebookOpened={(theNotebookLogic, theNodeLogic) => { + const time = getCurrentPlayerTime() * 1000 + + if (theNodeLogic) { + // Node already exists, we just add a comment + theNodeLogic.actions.insertReplayCommentByTimestamp(time, sessionRecordingId) + return + } else { + theNotebookLogic.actions.insertReplayCommentByTimestamp({ + timestamp: time, + sessionRecordingId, + }) + } + + closeSessionPlayer() + personsModalLogic.findMounted()?.actions.closeModal() + }} + > + Comment + } onClick={onShare} {...commonProps}> Share - - Pin - + {nodeLogic?.props.nodeType === NotebookNodeType.RecordingPlaylist ? ( + } + size="small" + onClick={() => { + nodeLogic.actions.insertAfter({ + type: NotebookNodeType.Recording, + attrs: { id: sessionRecordingId }, + }) + }} + /> + ) : null} + + {logicProps.setPinned ? ( + { + if (nodeLogic && !logicProps.pinned) { + // If we are in a node, then pinning should persist the recording + maybePersistRecording() + } + + logicProps.setPinned?.(!logicProps.pinned) + }} + size="small" + tooltip={logicProps.pinned ? 'Unpin from this list' : 'Pin to this list'} + icon={logicProps.pinned ? : } + /> + ) : ( + + Pin + + )} {logicProps.playerKey !== 'modal' && ( interrupted?: boolean clearInterrupted?: () => void } -export function PlayerUpNext({ interrupted, clearInterrupted }: PlayerUpNextProps): JSX.Element | null { +export function PlayerUpNext({ interrupted, clearInterrupted, playlistLogic }: PlayerUpNextProps): JSX.Element | null { const timeoutRef = useRef() - const { endReached, logicProps } = useValues(sessionRecordingPlayerLogic) + const { endReached } = useValues(sessionRecordingPlayerLogic) const { reportNextRecordingTriggered } = useActions(sessionRecordingPlayerLogic) const [animate, setAnimate] = useState(false) - const nextSessionRecording = logicProps.nextSessionRecording + const { nextSessionRecording } = useValues(playlistLogic) + const { setSelectedRecordingId } = useActions(playlistLogic) const goToRecording = (automatic: boolean): void => { + if (!nextSessionRecording?.id) { + return + } reportNextRecordingTriggered(automatic) - router.actions.push( - router.values.currentLocation.pathname, - { - ...router.values.currentLocation.searchParams, - sessionRecordingId: nextSessionRecording?.id, - }, - router.values.currentLocation.hashParams - ) + setSelectedRecordingId(nextSessionRecording.id) } useEffect(() => { diff --git a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx index d78b278eb79c7..d95fc4bdaee0a 100644 --- a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx +++ b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx @@ -21,7 +21,7 @@ import { RecordingNotFound } from 'scenes/session-recordings/player/RecordingNot import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' import { PlayerFrameOverlay } from './PlayerFrameOverlay' import { SessionRecordingPlayerExplorer } from './view-explorer/SessionRecordingPlayerExplorer' -import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' export interface SessionRecordingPlayerProps extends SessionRecordingPlayerLogicProps { noMeta?: boolean @@ -43,14 +43,14 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. sessionRecordingData, playerKey, noMeta = false, - recordingStartTime, // While optional, including recordingStartTime allows the underlying ClickHouse query to be much faster - matching, matchingEventsMatchType, noBorder = false, noInspector = false, autoPlay = true, - nextSessionRecording, + playlistLogic, mode = SessionRecordingPlayerMode.Standard, + pinned, + setPinned, } = props const playerRef = useRef(null) @@ -58,14 +58,14 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. const logicProps: SessionRecordingPlayerLogicProps = { sessionRecordingId, playerKey, - matching, matchingEventsMatchType, sessionRecordingData, - recordingStartTime, autoPlay, - nextSessionRecording, + playlistLogic, mode, playerRef, + pinned, + setPinned, } const { incrementClickCount, @@ -78,7 +78,7 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. closeExplorer, } = useActions(sessionRecordingPlayerLogic(logicProps)) const { isNotFound } = useValues(sessionRecordingDataLogic(logicProps)) - const { isFullScreen, explorerMode } = useValues(sessionRecordingPlayerLogic(logicProps)) + const { isFullScreen, explorerMode, isBuffering } = useValues(sessionRecordingPlayerLogic(logicProps)) const speedHotkeys = useMemo(() => createPlaybackSpeedKey(setSpeed), [setSpeed]) useKeyboardHotkeys( @@ -125,7 +125,8 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. const { size } = useResizeBreakpoints( { - 0: 'small', + 0: 'tiny', + 400: 'small', 1000: 'medium', }, playerRef @@ -148,9 +149,10 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. className={clsx('SessionRecordingPlayer', { 'SessionRecordingPlayer--fullscreen': isFullScreen, 'SessionRecordingPlayer--no-border': noBorder, - 'SessionRecordingPlayer--widescreen': !isFullScreen && size !== 'small', + 'SessionRecordingPlayer--widescreen': !isFullScreen && size === 'medium', 'SessionRecordingPlayer--inspector-focus': inspectorFocus, - 'SessionRecordingPlayer--inspector-hidden': noInspector, + 'SessionRecordingPlayer--inspector-hidden': noInspector || size === 'tiny', + 'SessionRecordingPlayer--buffering': isBuffering, })} onClick={incrementClickCount} > @@ -159,7 +161,7 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. ) : ( <>
    - {!noMeta || isFullScreen ? : null} + {(!noMeta || isFullScreen) && size !== 'tiny' ? : null}
    diff --git a/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap b/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap index 8feae316bda5b..db99fca1fdadc 100644 --- a/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap +++ b/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap @@ -18,7 +18,6 @@ exports[`sessionRecordingPlayerLogic loading session core loads metadata and sna }, "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0", }, - "pinnedCount": 0, "segments": [ { "durationMs": 11868, @@ -51,30 +50,29 @@ exports[`sessionRecordingPlayerLogic loading session core loads metadata and sna }, "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0", }, - "pinnedCount": 0, "segments": [ { - "durationMs": 7255, - "endTimestamp": 1682952388132, + "durationMs": 5694, + "endTimestamp": 1682952386571, "isActive": true, "kind": "window", "startTimestamp": 1682952380877, "windowId": "187d7c761a0525d-05f175487d4b65-1d525634-384000-187d7c761a149d0", }, { - "durationMs": 525, - "endTimestamp": 1682952388658, + "durationMs": 1533, + "endTimestamp": 1682952388104, "isActive": false, "kind": "gap", - "startTimestamp": 1682952388133, - "windowId": "187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6", + "startTimestamp": 1682952386571, + "windowId": undefined, }, { - "durationMs": 4086, + "durationMs": 4641, "endTimestamp": 1682952392745, "isActive": true, "kind": "window", - "startTimestamp": 1682952388659, + "startTimestamp": 1682952388104, "windowId": "187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6", }, ], @@ -2131,7 +2129,6 @@ exports[`sessionRecordingPlayerLogic loading session core loads metadata only by }, "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0", }, - "pinnedCount": 0, "segments": [ { "durationMs": 11868, diff --git a/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx b/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx index 6251fd4d02a4d..20b75e2795fc9 100644 --- a/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx +++ b/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx @@ -13,11 +13,10 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip' import clsx from 'clsx' import { playerSettingsLogic } from '../playerSettingsLogic' import { More } from 'lib/lemon-ui/LemonButton/More' -import { FlaggedFeature } from 'lib/components/FlaggedFeature' -import { FEATURE_FLAGS } from 'lib/constants' +import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut' export function PlayerController(): JSX.Element { - const { currentPlayerState, logicProps, isFullScreen } = useValues(sessionRecordingPlayerLogic) + const { playingState, logicProps, isFullScreen } = useValues(sessionRecordingPlayerLogic) const { togglePlayPause, exportRecordingToFile, openExplorer, setIsFullScreen } = useActions(sessionRecordingPlayerLogic) @@ -26,6 +25,8 @@ export function PlayerController(): JSX.Element { const mode = logicProps.mode ?? SessionRecordingPlayerMode.Standard + const showPause = playingState === SessionPlayerState.PLAY + return (
    @@ -33,14 +34,19 @@ export function PlayerController(): JSX.Element {
    - - {[SessionPlayerState.PLAY, SessionPlayerState.SKIP, SessionPlayerState.BUFFER].includes( - currentPlayerState - ) ? ( - - ) : ( - - )} + + + {showPause ? 'Pause' : 'Play'} + + + } + > + {showPause ? : }
    @@ -85,10 +91,7 @@ export function PlayerController(): JSX.Element { }} > @@ -102,7 +105,7 @@ export function PlayerController(): JSX.Element { }} > @@ -121,16 +124,14 @@ export function PlayerController(): JSX.Element { Export to file - - openExplorer()} - fullWidth - sideIcon={} - > - Explore DOM - - + openExplorer()} + fullWidth + sideIcon={} + > + Explore DOM + } /> diff --git a/frontend/src/scenes/session-recordings/player/controller/PlayerControllerTime.tsx b/frontend/src/scenes/session-recordings/player/controller/PlayerControllerTime.tsx index 9330f5503c575..d999f28bb83c9 100644 --- a/frontend/src/scenes/session-recordings/player/controller/PlayerControllerTime.tsx +++ b/frontend/src/scenes/session-recordings/player/controller/PlayerControllerTime.tsx @@ -7,9 +7,11 @@ import { LemonButton } from '@posthog/lemon-ui' import { useKeyHeld } from 'lib/hooks/useKeyHeld' import { IconSkipBackward } from 'lib/lemon-ui/icons' import clsx from 'clsx' +import { dayjs } from 'lib/dayjs' export function Timestamp(): JSX.Element { - const { logicProps, currentPlayerTime, sessionPlayerData } = useValues(sessionRecordingPlayerLogic) + const { logicProps, currentPlayerTime, currentTimestamp, sessionPlayerData } = + useValues(sessionRecordingPlayerLogic) const { isScrubbing, scrubbingTime } = useValues(seekbarLogic(logicProps)) const startTimeSeconds = ((isScrubbing ? scrubbingTime : currentPlayerTime) ?? 0) / 1000 @@ -19,8 +21,10 @@ export function Timestamp(): JSX.Element { return (
    - {colonDelimitedDuration(startTimeSeconds, fixedUnits)} /{' '} - {colonDelimitedDuration(endTimeSeconds, fixedUnits)} + + {colonDelimitedDuration(startTimeSeconds, fixedUnits)} + {' '} + / {colonDelimitedDuration(endTimeSeconds, fixedUnits)}
    ) } diff --git a/frontend/src/scenes/session-recordings/player/controller/PlayerSeekbarPreview.tsx b/frontend/src/scenes/session-recordings/player/controller/PlayerSeekbarPreview.tsx index 0a26397b7c715..44fd8f6763c69 100644 --- a/frontend/src/scenes/session-recordings/player/controller/PlayerSeekbarPreview.tsx +++ b/frontend/src/scenes/session-recordings/player/controller/PlayerSeekbarPreview.tsx @@ -1,28 +1,34 @@ import { colonDelimitedDuration } from 'lib/utils' -import { useEffect, useRef, useState } from 'react' +import { MutableRefObject, useEffect, useRef, useState } from 'react' import { PlayerFrame } from '../PlayerFrame' import { BindLogic, useActions, useValues } from 'kea' -import { SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' -import { FlaggedFeature } from 'lib/components/FlaggedFeature' -import { FEATURE_FLAGS } from 'lib/constants' +import { + sessionRecordingPlayerLogic, + SessionRecordingPlayerLogicProps, + SessionRecordingPlayerMode, +} from '../sessionRecordingPlayerLogic' import { useDebouncedCallback } from 'use-debounce' +import useIsHovering from 'lib/hooks/useIsHovering' export type PlayerSeekbarPreviewProps = { minMs: number maxMs: number + seekBarRef: MutableRefObject } const PlayerSeekbarPreviewFrame = ({ percentage, minMs, maxMs, -}: { percentage: number } & PlayerSeekbarPreviewProps): JSX.Element => { + isVisible, +}: { percentage: number; isVisible: boolean } & Omit): JSX.Element => { const { sessionRecordingId, logicProps } = useValues(sessionRecordingPlayerLogic) const seekPlayerLogicProps: SessionRecordingPlayerLogicProps = { sessionRecordingId: sessionRecordingId, playerKey: `${logicProps.playerKey}-preview`, autoPlay: false, + mode: SessionRecordingPlayerMode.Preview, } const { setPause, seekToTime } = useActions(sessionRecordingPlayerLogic(seekPlayerLogicProps)) @@ -39,8 +45,10 @@ const PlayerSeekbarPreviewFrame = ({ ) useEffect(() => { - debouncedSeekToTime(minMs + (maxMs - minMs) * percentage) - }, [percentage, minMs, maxMs]) + if (isVisible) { + debouncedSeekToTime(minMs + (maxMs - minMs) * percentage) + } + }, [percentage, minMs, maxMs, isVisible]) return ( @@ -51,19 +59,25 @@ const PlayerSeekbarPreviewFrame = ({ ) } -export function PlayerSeekbarPreview({ minMs, maxMs }: PlayerSeekbarPreviewProps): JSX.Element { +export function PlayerSeekbarPreview({ minMs, maxMs, seekBarRef }: PlayerSeekbarPreviewProps): JSX.Element { const [percentage, setPercentage] = useState(0) const ref = useRef(null) const fixedUnits = maxMs / 1000 > 3600 ? 3 : 2 const content = colonDelimitedDuration(minMs / 1000 + ((maxMs - minMs) / 1000) * percentage, fixedUnits) + const isHovering = useIsHovering(seekBarRef) + useEffect(() => { + if (!seekBarRef?.current) { + return + } + const handleMouseMove = (e: MouseEvent): void => { const rect = ref.current?.getBoundingClientRect() - if (!rect) { return } + const relativeX = e.clientX - rect.x const newPercentage = Math.max(Math.min(relativeX / rect.width, 1), 0) @@ -72,9 +86,11 @@ export function PlayerSeekbarPreview({ minMs, maxMs }: PlayerSeekbarPreviewProps } } - window.addEventListener('mousemove', handleMouseMove) - return () => window.removeEventListener('mousemove', handleMouseMove) - }, []) + seekBarRef.current.addEventListener('mousemove', handleMouseMove) + // fixes react-hooks/exhaustive-deps warning about stale ref elements + const { current } = ref + return () => current?.removeEventListener('mousemove', handleMouseMove) + }, [seekBarRef]) return (
    @@ -86,9 +102,12 @@ export function PlayerSeekbarPreview({ minMs, maxMs }: PlayerSeekbarPreviewProps }} >
    - - - +
    {content}
    diff --git a/frontend/src/scenes/session-recordings/player/controller/Seekbar.tsx b/frontend/src/scenes/session-recordings/player/controller/Seekbar.tsx index 5ebedc330329b..56e771c9531d7 100644 --- a/frontend/src/scenes/session-recordings/player/controller/Seekbar.tsx +++ b/frontend/src/scenes/session-recordings/player/controller/Seekbar.tsx @@ -22,6 +22,7 @@ export function Seekbar(): JSX.Element { const sliderRef = useRef(null) const thumbRef = useRef(null) + const seekBarRef = useRef(null) // Workaround: Something with component and logic mount timing that causes slider and thumb // reducers to be undefined. @@ -38,7 +39,7 @@ export function Seekbar(): JSX.Element {
    -
    +
    {sessionPlayerData.segments?.map((segment: RecordingSegment) => (
    - {/* eslint-disable-next-line react/forbid-dom-props */}
    {/* eslint-disable-next-line react/forbid-dom-props */} @@ -76,7 +77,7 @@ export function Seekbar(): JSX.Element { style={{ transform: `translateX(${thumbLeftPos}px)` }} /> - +
    diff --git a/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts b/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts index 65756888007a5..b736e418e4663 100644 --- a/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts +++ b/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts @@ -2,7 +2,7 @@ import { MutableRefObject } from 'react' import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' import type { seekbarLogicType } from './seekbarLogicType' import { - SessionRecordingLogicProps, + SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { clamp } from 'lib/utils' @@ -11,9 +11,9 @@ import { getXPos, InteractEvent, ReactInteractEvent, THUMB_OFFSET, THUMB_SIZE } export const seekbarLogic = kea([ path((key) => ['scenes', 'session-recordings', 'player', 'seekbarLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ values: [sessionRecordingPlayerLogic(props), ['sessionPlayerData', 'currentPlayerTime']], actions: [sessionRecordingPlayerLogic(props), ['seekToTime', 'startScrub', 'endScrub', 'setCurrentTimestamp']], })), diff --git a/frontend/src/scenes/session-recordings/player/icons.tsx b/frontend/src/scenes/session-recordings/player/icons.tsx index 6fff14ba753b5..fa8a79d631150 100644 --- a/frontend/src/scenes/session-recordings/player/icons.tsx +++ b/frontend/src/scenes/session-recordings/player/icons.tsx @@ -10,7 +10,7 @@ export function IconWindowOld({ value, className = '', size = 'medium' }: IconWi const shortValue = typeof value === 'number' ? value : String(value).charAt(0) return (
    - + {shortValue} }> + + + } />
    {windowIds.length > 1 ? ( diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/InspectorSearchInfo.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/InspectorSearchInfo.tsx new file mode 100644 index 0000000000000..167ecd298a19f --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/inspector/components/InspectorSearchInfo.tsx @@ -0,0 +1,92 @@ +export function InspectorSearchInfo(): JSX.Element { + return ( + <> + Searching is "fuzzy" by default meaning that it will try and match many properties that are close to + the search query. +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TokenMatch typeDescription
    + jscript + fuzzy-match + Items that fuzzy match jscript +
    + =scheme + exact-match + Items that are scheme +
    + 'python + include-match + Items that include python +
    + !ruby + inverse-exact-match + Items that do not include ruby +
    + ^java + prefix-exact-match + Items that start with java +
    + !^earlang + inverse-prefix-exact-match + Items that do not start with earlang +
    + .js$ + suffix-exact-match + Items that end with .js +
    + !.go$ + inverse-suffix-exact-match + Items that do not end with .go +
    + + ) +} diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/SimpleKeyValueList.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/SimpleKeyValueList.tsx index 7b183948b67d3..d189c99e779b0 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/components/SimpleKeyValueList.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/components/SimpleKeyValueList.tsx @@ -1,5 +1,7 @@ // A React component that renders a list of key-value pairs in a simple way. +import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' + export interface SimpleKeyValueListProps { item: Record } @@ -9,7 +11,9 @@ export function SimpleKeyValueList({ item }: SimpleKeyValueListProps): JSX.Eleme
    {Object.entries(item).map(([key, value]) => (
    - {key} + + +
    {JSON.stringify(value, null, 2)}
    ))} diff --git a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts index 507c314e3aee7..bcc5c01b6c275 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts @@ -1,4 +1,4 @@ -import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { actions, connect, events, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' import { MatchedRecordingEvent, PerformanceEvent, @@ -9,15 +9,17 @@ import { } from '~/types' import type { playerInspectorLogicType } from './playerInspectorLogicType' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' -import { SessionRecordingLogicProps, sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' +import { SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' import { sessionRecordingDataLogic } from '../sessionRecordingDataLogic' import FuseClass from 'fuse.js' import { Dayjs, dayjs } from 'lib/dayjs' import { getKeyMapping } from 'lib/taxonomy' -import { eventToDescription } from 'lib/utils' +import { eventToDescription, objectsEqual, toParams } from 'lib/utils' import { eventWithTime } from '@rrweb/types' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' +import { loaders } from 'kea-loaders' +import api from 'lib/api' const CONSOLE_LOG_PLUGIN_NAME = 'rrweb/console@1' const NETWORK_PLUGIN_NAME = 'posthog/network@1' @@ -91,7 +93,6 @@ export const PerformanceEventReverseMapping: { [key: number]: keyof PerformanceE } // Helping kea-typegen navigate the exported default class for Fuse -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Fuse extends FuseClass {} type InspectorListItemBase = { @@ -119,7 +120,7 @@ export type InspectorListItemPerformance = InspectorListItemBase & { export type InspectorListItem = InspectorListItemEvent | InspectorListItemConsole | InspectorListItemPerformance -export interface PlayerInspectorLogicProps extends SessionRecordingLogicProps { +export interface PlayerInspectorLogicProps extends SessionRecordingPlayerLogicProps { matchingEventsMatchType?: MatchingEventsMatchType } @@ -160,7 +161,7 @@ export const playerInspectorLogic = kea([ setItemExpanded: (index: number, expanded: boolean) => ({ index, expanded }), setSyncScrollPaused: (paused: boolean) => ({ paused }), })), - reducers(({}) => ({ + reducers(() => ({ windowIdFilter: [ null as string | null, { @@ -189,23 +190,40 @@ export const playerInspectorLogic = kea([ }, ], })), - selectors(({ props }) => ({ - matchingEvents: [ - () => [(_, props) => props.matching], - (matchingEvents): MatchedRecordingEvent[] => { - // matching events were a dictionary in v1 and v2, but we only used the UUID - // so in v3 we just return the UUIDs - return matchingEvents?.map((x: any) => (typeof x === 'string' ? { uuid: x } : x.events)).flat() ?? [] + loaders(({ props }) => ({ + matchingEventUUIDs: [ + [] as MatchedRecordingEvent[] | null, + { + loadMatchingEvents: async () => { + const matchingEventsMatchType = props.matchingEventsMatchType + const matchType = matchingEventsMatchType?.matchType + if (!matchingEventsMatchType || matchType === 'none' || matchType === 'name') { + return null + } + + if (matchType === 'uuid') { + if (!matchingEventsMatchType?.eventUUIDs) { + console.error('UUID matching events type must include its event ids') + } + return matchingEventsMatchType.eventUUIDs.map((x) => ({ uuid: x } as MatchedRecordingEvent)) + } + + const filters = matchingEventsMatchType?.filters + if (!filters) { + throw new Error('Backend matching events type must include its filters') + } + const params = toParams({ ...filters, session_ids: [props.sessionRecordingId] }) + const response = await api.recordings.getMatchingEvents(params) + return response.results.map((x) => ({ uuid: x } as MatchedRecordingEvent)) + }, }, ], - + })), + selectors(({ props }) => ({ showMatchingEventsFilter: [ - (s) => [s.matchingEvents, s.tab], - (matchingEvents, tab): boolean => { - return ( - tab === SessionRecordingPlayerTab.EVENTS && - (matchingEvents.length > 0 || props.matchingEventsMatchType?.matchType === 'simple') - ) + (s) => [s.tab], + (tab): boolean => { + return tab === SessionRecordingPlayerTab.EVENTS && props.matchingEventsMatchType?.matchType !== 'none' }, ], @@ -290,8 +308,8 @@ export const playerInspectorLogic = kea([ ], allItems: [ - (s) => [s.start, s.allPerformanceEvents, s.consoleLogs, s.sessionEventsData, s.matchingEvents], - (start, performanceEvents, consoleLogs, eventsData, matchingEvents): InspectorListItem[] => { + (s) => [s.start, s.allPerformanceEvents, s.consoleLogs, s.sessionEventsData, s.matchingEventUUIDs], + (start, performanceEvents, consoleLogs, eventsData, matchingEventUUIDs): InspectorListItem[] => { // NOTE: Possible perf improvement here would be to have a selector to parse the items // and then do the filtering of what items are shown, elsewhere // ALSO: We could move the individual filtering logic into the MiniFilters themselves @@ -351,9 +369,9 @@ export const playerInspectorLogic = kea([ for (const event of eventsData || []) { let isMatchingEvent = false - if (!!matchingEvents.length) { - isMatchingEvent = !!matchingEvents.find((x) => x.uuid === String(event.id)) - } else if (props.matchingEventsMatchType?.matchType === 'simple') { + if (matchingEventUUIDs?.length) { + isMatchingEvent = !!matchingEventUUIDs.find((x) => x.uuid === String(event.id)) + } else if (props.matchingEventsMatchType?.matchType === 'name') { isMatchingEvent = props.matchingEventsMatchType?.eventNames?.includes(event.event) } @@ -695,6 +713,7 @@ export const playerInspectorLogic = kea([ findAllMatches: true, ignoreLocation: true, shouldSort: false, + useExtendedSearch: true, }), ], @@ -721,4 +740,14 @@ export const playerInspectorLogic = kea([ } }, })), + events(({ actions }) => ({ + afterMount: () => { + actions.loadMatchingEvents() + }, + })), + propsChanged(({ actions, props }, oldProps) => { + if (!objectsEqual(props.matchingEventsMatchType, oldProps.matchingEventsMatchType)) { + actions.loadMatchingEvents() + } + }), ]) diff --git a/frontend/src/scenes/session-recordings/player/modal/SessionPlayerModal.tsx b/frontend/src/scenes/session-recordings/player/modal/SessionPlayerModal.tsx index a4dd8fd5a6bac..c4300a01c0906 100644 --- a/frontend/src/scenes/session-recordings/player/modal/SessionPlayerModal.tsx +++ b/frontend/src/scenes/session-recordings/player/modal/SessionPlayerModal.tsx @@ -9,11 +9,23 @@ export function SessionPlayerModal(): JSX.Element | null { const { activeSessionRecording } = useValues(sessionPlayerModalLogic()) const { closeSessionPlayer } = useActions(sessionPlayerModalLogic()) + // activeSessionRecording?.matching_events should always be a single element array + // but, we're filtering and using flatMap just in case + const eventUUIDs = + activeSessionRecording?.matching_events + ?.filter((matchingEvents) => { + return matchingEvents.session_id === activeSessionRecording?.id + }) + .flatMap((matchedRecording) => matchedRecording.events.map((x) => x.uuid)) || [] + const logicProps: SessionRecordingPlayerLogicProps = { playerKey: 'modal', sessionRecordingId: activeSessionRecording?.id || '', - matching: activeSessionRecording?.matching_events, autoPlay: true, + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: eventUUIDs, + }, } const { isFullScreen } = useValues(sessionRecordingPlayerLogic(logicProps)) diff --git a/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.ts b/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.ts index d193a39467b20..8f9fa214e4de3 100644 --- a/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.ts +++ b/frontend/src/scenes/session-recordings/player/modal/sessionPlayerModalLogic.ts @@ -74,7 +74,7 @@ export const sessionPlayerModalLogic = kea([ } return { - openSessionPlayer: ({}) => buildURL(false), + openSessionPlayer: () => buildURL(false), closeSessionPlayer: () => buildURL(false), } }), diff --git a/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts b/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts index ccad721c9e713..f6c4b38c3f2b7 100644 --- a/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts @@ -5,7 +5,7 @@ import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/se import { playerMetaLogic } from 'scenes/session-recordings/player/playerMetaLogic' import recordingMetaJson from '../__mocks__/recording_meta.json' import recordingEventsJson from '../__mocks__/recording_events_query' -import recordingSnapshotsJson from '../__mocks__/recording_snapshots.json' +import { snapshotsAsJSONLines } from '../__mocks__/recording_snapshots' import { useMocks } from '~/mocks/jest' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' @@ -18,7 +18,8 @@ describe('playerMetaLogic', () => { useMocks({ get: { '/api/projects/:team/session_recordings/:id': recordingMetaJson, - '/api/projects/:team/session_recordings/:id/snapshots/': recordingSnapshotsJson, + '/api/projects/:team/session_recordings/:id/snapshots/': (_, res, ctx) => + res(ctx.text(snapshotsAsJSONLines())), }, post: { '/api/projects/:team/query': recordingEventsJson, diff --git a/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts b/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts index 9d458cb0d7c19..9c55ee262de76 100644 --- a/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts +++ b/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts @@ -2,7 +2,7 @@ import { connect, kea, key, listeners, path, props, selectors } from 'kea' import type { playerMetaLogicType } from './playerMetaLogicType' import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { - SessionRecordingLogicProps, + SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { eventWithTime } from '@rrweb/types' @@ -12,9 +12,9 @@ import { sessionRecordingsListPropertiesLogic } from '../playlist/sessionRecordi export const playerMetaLogic = kea([ path((key) => ['scenes', 'session-recordings', 'player', 'playerMetaLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ values: [ sessionRecordingDataLogic(props), [ diff --git a/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts b/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts index 7d32dc4a8a608..b8166cc4da64a 100644 --- a/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts +++ b/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts @@ -183,7 +183,7 @@ export const playerSettingsLogic = kea([ setDurationTypeToShow: (type: DurationType) => ({ type }), setShowFilters: (showFilters: boolean) => ({ showFilters }), }), - reducers(({}) => ({ + reducers(() => ({ showFilters: [ true, { diff --git a/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx b/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx index 6b18669a915a1..f862c183bad59 100644 --- a/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx +++ b/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx @@ -13,7 +13,7 @@ import { sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' import { playlistPopoverLogic } from './playlistPopoverLogic' export function PlaylistPopoverButton(props: LemonButtonProps): JSX.Element { - const { sessionRecordingId, logicProps, sessionPlayerData } = useValues(sessionRecordingPlayerLogic) + const { sessionRecordingId, logicProps } = useValues(sessionRecordingPlayerLogic) const logic = playlistPopoverLogic(logicProps) const { playlistsLoading, @@ -23,12 +23,13 @@ export function PlaylistPopoverButton(props: LemonButtonProps): JSX.Element { allPlaylists, currentPlaylistsLoading, modifyingPlaylist, + pinnedCount, } = useValues(logic) const { setSearchQuery, setNewFormShowing, setShowPlaylistPopover, addToPlaylist, removeFromPlaylist } = useActions(logic) return ( - + setShowPlaylistPopover(false)} @@ -97,10 +98,6 @@ export function PlaylistPopoverButton(props: LemonButtonProps): JSX.Element { } > {playlist.name || playlist.derived_name} - - {logicProps.playlistShortId === playlist.short_id && ( - (current) - )} ([ path((key) => ['scenes', 'session-recordings', 'player', 'playlist-popover', 'playlistPopoverLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ actions: [ sessionRecordingPlayerLogic(props), ['setPause'], - sessionRecordingDataLogic(props), - ['addDiffToRecordingMetaPinnedCount'], eventUsageLogic, ['reportRecordingPinnedToList', 'reportRecordingPlaylistCreated'], ], @@ -38,10 +34,6 @@ export const playlistPopoverLogic = kea([ removeFromPlaylist: (playlist: SessionRecordingPlaylistType) => ({ playlist }), setNewFormShowing: (show: boolean) => ({ show }), setShowPlaylistPopover: (show: boolean) => ({ show }), - updateRecordingsPinnedCounts: ( - diffCount: number, - playlistShortId?: SessionRecordingPlaylistType['short_id'] - ) => ({ diffCount, playlistShortId }), })), loaders(({ values, props, actions }) => ({ playlists: { @@ -144,39 +136,18 @@ export const playlistPopoverLogic = kea([ actions.setPause() } }, - - addToPlaylistSuccess: ({ payload }) => { - actions.updateRecordingsPinnedCounts(1, payload?.playlist?.short_id) - }, - - removeFromPlaylistSuccess: ({ payload }) => { - actions.updateRecordingsPinnedCounts(-1, payload?.playlist?.short_id) - }, - - updateRecordingsPinnedCounts: ({ diffCount, playlistShortId }) => { - actions.addDiffToRecordingMetaPinnedCount(diffCount) - - // Handles locally updating recordings sidebar so that we don't have to call expensive load recordings every time. - if (!!playlistShortId && sessionRecordingsListLogic.isMounted({ playlistShortId })) { - // On playlist page - sessionRecordingsListLogic({ playlistShortId }).actions.loadAllRecordings() - } else { - // In any other context (recent recordings, single modal, single recording page) - sessionRecordingsListLogic.findMounted({ updateSearchParams: true })?.actions?.loadAllRecordings() - } - }, })), selectors(() => ({ allPlaylists: [ - (s) => [s.playlists, s.currentPlaylists, s.searchQuery, (_, props) => props.playlistShortId], - (playlists, currentPlaylists, searchQuery, playlistShortId) => { + (s) => [s.playlists, s.currentPlaylists, s.searchQuery], + (playlists, currentPlaylists, searchQuery) => { const otherPlaylists = searchQuery ? playlists : playlists.filter((x) => !currentPlaylists.find((y) => x.short_id === y.short_id)) const selectedPlaylists = !searchQuery ? currentPlaylists : [] - let results: { + const results: { selected: boolean playlist: SessionRecordingPlaylistType }[] = [ @@ -190,15 +161,13 @@ export const playlistPopoverLogic = kea([ })), ] - // If props.playlistShortId exists put it at the beginning of the list - if (playlistShortId) { - results = results.sort((a, b) => - a.playlist.short_id == playlistShortId ? -1 : b.playlist.short_id == playlistShortId ? 1 : 0 - ) - } - return results }, ], + pinnedCount: [(s) => [s.currentPlaylists], (currentPlaylists) => currentPlaylists.length], })), + + afterMount(({ actions }) => { + actions.loadPlaylistsForRecording() + }), ]) diff --git a/frontend/src/scenes/session-recordings/player/rrweb/__snapshots__/index.test.ts.snap b/frontend/src/scenes/session-recordings/player/rrweb/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000000..ecae917dffa9b --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/rrweb/__snapshots__/index.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CorsPlugin should replace font urls in links 1`] = `"https://replay.ph-proxy.com/proxy?url=https://app.posthog.com/fonts/my-font.woff2?t=1234"`; + +exports[`CorsPlugin should replace font urls in links 2`] = `"https://replay.ph-proxy.com/proxy?url=https://app.posthog.com/fonts/my-font.ttf"`; + +exports[`CorsPlugin should replace font urls in stylesheets 1`] = `"@font-face { font-display: fallback; font-family: "Roboto Condensed"; font-weight: 400; font-style: normal; src: url("https://replay.ph-proxy.com/proxy?url=https://posthog.com/assets/fonts/roboto/roboto_condensed_reg-webfont.woff2?11012022") format("woff2"), url("https://replay.ph-proxy.com/proxy?url=https://posthog.com/assets/fonts/roboto/roboto_condensed_reg-webfont.woff?11012022")"`; + +exports[`CorsPlugin should replace font urls in stylesheets 2`] = `"url("https://replay.ph-proxy.com/proxy?url=https://app.posthog.com/fonts/my-font.woff2")"`; diff --git a/frontend/src/scenes/session-recordings/player/rrweb/index.test.ts b/frontend/src/scenes/session-recordings/player/rrweb/index.test.ts new file mode 100644 index 0000000000000..da2684cf38e5a --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/rrweb/index.test.ts @@ -0,0 +1,24 @@ +import { CorsPlugin } from '.' + +describe('CorsPlugin', () => { + it.each([ + `@font-face { font-display: fallback; font-family: "Roboto Condensed"; font-weight: 400; font-style: normal; src: url("https://posthog.com/assets/fonts/roboto/roboto_condensed_reg-webfont.woff2?11012022") format("woff2"), url("https://posthog.com/assets/fonts/roboto/roboto_condensed_reg-webfont.woff?11012022")`, + `url("https://app.posthog.com/fonts/my-font.woff2")`, + ])('should replace font urls in stylesheets', (content: string) => { + expect(CorsPlugin._replaceFontCssUrls(content)).toMatchSnapshot() + }) + + it.each(['https://app.posthog.com/fonts/my-font.woff2?t=1234', 'https://app.posthog.com/fonts/my-font.ttf'])( + 'should replace font urls in links', + (content: string) => { + expect(CorsPlugin._replaceFontUrl(content)).toMatchSnapshot() + } + ) + + it.each(['https://app.posthog.com/my-image.jpeg'])( + 'should not replace non-font urls in links', + (content: string) => { + expect(CorsPlugin._replaceFontUrl(content)).toEqual(content) + } + ) +}) diff --git a/frontend/src/scenes/session-recordings/player/rrweb/index.ts b/frontend/src/scenes/session-recordings/player/rrweb/index.ts new file mode 100644 index 0000000000000..f2032d070d4a0 --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/rrweb/index.ts @@ -0,0 +1,38 @@ +import { ReplayPlugin, playerConfig } from 'rrweb/typings/types' + +const PROXY_URL = 'https://replay.ph-proxy.com' as const + +export const CorsPlugin: ReplayPlugin & { + _replaceFontCssUrls: (value: string) => string + _replaceFontUrl: (value: string) => string +} = { + _replaceFontCssUrls: (value: string): string => { + return value.replace( + /url\("(https:\/\/\S*(?:.eot|.woff2|.ttf|.woff)\S*)"\)/gi, + `url("${PROXY_URL}/proxy?url=$1")` + ) + }, + + _replaceFontUrl: (value: string): string => { + return value.replace(/^(https:\/\/\S*(?:.eot|.woff2|.ttf|.woff)\S*)$/i, `${PROXY_URL}/proxy?url=$1`) + }, + + onBuild: (node) => { + if (node.nodeName === 'STYLE') { + const styleElement = node as HTMLStyleElement + styleElement.innerText = CorsPlugin._replaceFontCssUrls(styleElement.innerText) + } + + if (node.nodeName === 'LINK') { + const linkElement = node as HTMLLinkElement + linkElement.href = CorsPlugin._replaceFontUrl(linkElement.href) + } + }, +} + +export const COMMON_REPLAYER_CONFIG: Partial = { + triggerFocus: false, + insertStyleRules: [ + `.ph-no-capture { background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg=="); }`, + ], +} diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts index 8854498605774..a98649dc2e8b4 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts @@ -7,7 +7,6 @@ import { api, MOCK_TEAM_ID } from 'lib/api.mock' import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import recordingSnapshotsJson from '../__mocks__/recording_snapshots.json' import recordingMetaJson from '../__mocks__/recording_meta.json' import recordingEventsJson from '../__mocks__/recording_events_query' import { resumeKeaLoadersErrors, silenceKeaLoadersErrors } from '~/initKea' @@ -17,20 +16,9 @@ import { userLogic } from 'scenes/userLogic' import { AvailableFeature } from '~/types' import { useAvailableFeatures } from '~/mocks/features' -const createSnapshotEndpoint = (id: number): string => `api/projects/${MOCK_TEAM_ID}/session_recordings/${id}/snapshots` -const EVENTS_SESSION_RECORDING_SNAPSHOTS_ENDPOINT_REGEX = new RegExp( - `api/projects/${MOCK_TEAM_ID}/session_recordings/\\d/snapshots` -) +import { snapshotsAsJSONLines, sortedRecordingSnapshots } from '../__mocks__/recording_snapshots' -const sortedRecordingSnapshotsJson = { - snapshot_data_by_window_id: {}, -} - -Object.keys(recordingSnapshotsJson.snapshot_data_by_window_id).forEach((key) => { - sortedRecordingSnapshotsJson.snapshot_data_by_window_id[key] = [ - ...recordingSnapshotsJson.snapshot_data_by_window_id[key], - ].sort((a, b) => a.timestamp - b.timestamp) -}) +const sortedRecordingSnapshotsJson = sortedRecordingSnapshots() describe('sessionRecordingDataLogic', () => { let logic: ReturnType @@ -39,24 +27,25 @@ describe('sessionRecordingDataLogic', () => { useAvailableFeatures([AvailableFeature.RECORDINGS_PERFORMANCE]) useMocks({ get: { - '/api/projects/:team/session_recordings/:id/snapshots': (req) => { - if (req.params.id === 'forced_upgrade') { - // the API will 302 to the version 2 endpoint, which (in production) fetch auto-follows - return [ - 200, - { - sources: [ - { - source: 'blob', - start_timestamp: '2023-08-11T12:03:36.097000Z', - end_timestamp: '2023-08-11T12:04:52.268000Z', - blob_key: '1691755416097-1691755492268', - }, - ], - }, - ] + '/api/projects/:team/session_recordings/:id/snapshots': async (req, res, ctx) => { + // with no sources, returns sources... + if (req.url.searchParams.get('source') === 'blob') { + return res(ctx.text(snapshotsAsJSONLines())) } - return [200, recordingSnapshotsJson] + // with no source requested should return sources + return [ + 200, + { + sources: [ + { + source: 'blob', + start_timestamp: '2023-08-11T12:03:36.097000Z', + end_timestamp: '2023-08-11T12:04:52.268000Z', + blob_key: '1691755416097-1691755492268', + }, + ], + }, + ] }, '/api/projects/:team/session_recordings/:id': recordingMetaJson, }, @@ -86,7 +75,6 @@ describe('sessionRecordingDataLogic', () => { segments: [], sessionEventsData: null, filters: {}, - chunkPaginationIndex: 0, sessionEventsDataLoading: false, }) }) @@ -101,7 +89,8 @@ describe('sessionRecordingDataLogic', () => { .toDispatchActions(['loadRecordingMetaSuccess', 'loadRecordingSnapshotsSuccess']) .toFinishAllListeners() - expect(logic.values.sessionPlayerData).toMatchObject({ + const actual = logic.values.sessionPlayerData + expect(actual).toMatchObject({ person: recordingMetaJson.person, bufferedToTime: 11868, snapshotsByWindowId: sortedRecordingSnapshotsJson.snapshot_data_by_window_id, @@ -129,7 +118,6 @@ describe('sessionRecordingDataLogic', () => { start: undefined, end: undefined, durationMs: 0, - pinnedCount: 0, segments: [], person: null, snapshotsByWindowId: {}, @@ -203,7 +191,7 @@ describe('sessionRecordingDataLogic', () => { kind: 'EventsQuery', limit: 1000000, orderBy: ['timestamp ASC'], - personId: 11, + personId: undefined, properties: [{ key: '$session_id', operator: 'exact', type: 'event', value: ['2'] }], select: [ 'uuid', @@ -223,189 +211,14 @@ describe('sessionRecordingDataLogic', () => { }) }) - describe('force upgrade of session recording snapshots endpoint', () => { - it('can force upgrade by returning 302', async () => { - logic = sessionRecordingDataLogic({ sessionRecordingId: 'forced_upgrade' }) - logic.mount() - // Most of these tests assume the metadata is being loaded upfront which is the typical case - logic.actions.loadRecordingMeta() - - await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() - }) - .toDispatchActions([ - 'loadRecordingSnapshotsV1Success', - 'loadRecordingSnapshotsV2', - 'loadRecordingSnapshotsV2Success', - ]) - .toMatchValues({ - sessionPlayerSnapshotData: { - snapshots: [], - sources: [ - { - loaded: true, - source: 'blob', - start_timestamp: '2023-08-11T12:03:36.097000Z', - end_timestamp: '2023-08-11T12:04:52.268000Z', - blob_key: '1691755416097-1691755492268', - }, - ], - }, - }) - }) - }) - - describe('loading session snapshots', () => { - beforeEach(async () => { - await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess']) - }) - - it('no next url', async () => { - await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() - }) - .toDispatchActions(['loadRecordingSnapshots', 'loadRecordingSnapshotsSuccess']) - .toNotHaveDispatchedActions(['loadRecordingSnapshots']) - .toFinishAllListeners() - - expect(logic.values).toMatchObject({ - sessionPlayerData: { - person: recordingMetaJson.person, - bufferedToTime: 11868, - durationMs: 11868, - snapshotsByWindowId: sortedRecordingSnapshotsJson.snapshot_data_by_window_id, - }, - sessionPlayerSnapshotData: { - next: null, - }, - }) - }) - - it('fetch all chunks of recording', async () => { - const snapshots1 = { snapshot_data_by_window_id: {} } - const snapshots2 = { snapshot_data_by_window_id: {} } - - Object.keys(sortedRecordingSnapshotsJson.snapshot_data_by_window_id).forEach((windowId) => { - snapshots1.snapshot_data_by_window_id[windowId] = - sortedRecordingSnapshotsJson.snapshot_data_by_window_id[windowId].slice(0, 3) - snapshots2.snapshot_data_by_window_id[windowId] = - sortedRecordingSnapshotsJson.snapshot_data_by_window_id[windowId].slice(3) - }) - - const snapshotUrl = createSnapshotEndpoint(3) - const firstNext = `${snapshotUrl}/?offset=200&limit=200` - let nthSnapshotCall = 0 - logic.unmount() - useAvailableFeatures([]) - useMocks({ - get: { - '/api/projects/:team/session_recordings/:id/snapshots': (req) => { - if (req.url.pathname.match(EVENTS_SESSION_RECORDING_SNAPSHOTS_ENDPOINT_REGEX)) { - const payload = { - ...(nthSnapshotCall === 0 ? snapshots1 : snapshots2), - next: nthSnapshotCall === 0 ? firstNext : undefined, - } - nthSnapshotCall += 1 - return [200, payload] - } - }, - }, - }) - - logic.mount() - logic.actions.loadRecordingMeta() - await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess']) - api.get.mockClear() - logic.actions.loadRecordingSnapshots() - await expectLogic(logic).toMount([eventUsageLogic]).toFinishAllListeners() - await expectLogic(logic).toDispatchActions(['loadRecordingSnapshotsV1', 'loadRecordingSnapshotsV1Success']) - - await expectLogic(logic) - .toDispatchActions([ - logic.actionCreators.loadRecordingSnapshotsV1(firstNext), - 'loadRecordingSnapshotsV1Success', - ]) - .toFinishAllListeners() - - expect(logic.values).toMatchObject({ - sessionPlayerData: { - person: recordingMetaJson.person, - bufferedToTime: 11868, - durationMs: 11868, - }, - sessionPlayerSnapshotData: { - next: undefined, - }, - }) - expect(api.get).toBeCalledTimes(2) // 2 calls to loadRecordingSnapshots - }) - - it('server error mid-way through recording', async () => { - let nthSnapshotCall = 0 - logic.unmount() - useAvailableFeatures([]) - useMocks({ - get: { - '/api/projects/:team/session_recordings/:id/snapshots': (req) => { - if (req.url.pathname.match(EVENTS_SESSION_RECORDING_SNAPSHOTS_ENDPOINT_REGEX)) { - if (nthSnapshotCall === 0) { - const payload = { - ...recordingSnapshotsJson, - next: firstNext, - } - nthSnapshotCall += 1 - return [200, payload] - } else { - throw new Error('Error in second request') - } - } - }, - }, - }) - logic.mount() - logic.actions.loadRecordingMeta() - - await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess']) - await expectLogic(logic).toMount([eventUsageLogic]).toFinishAllListeners() - api.get.mockClear() - - const snapshotUrl = createSnapshotEndpoint(1) - const firstNext = `${snapshotUrl}/?offset=200&limit=200` - silenceKeaLoadersErrors() - - await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() - }).toDispatchActions(['loadRecordingSnapshotsV1', 'loadRecordingSnapshotsV1Success']) - - expect(logic.values).toMatchObject({ - sessionPlayerData: { - person: recordingMetaJson.person, - bufferedToTime: 11868, - snapshotsByWindowId: sortedRecordingSnapshotsJson.snapshot_data_by_window_id, - }, - sessionPlayerSnapshotData: { - next: firstNext, - }, - }) - await expectLogic(logic) - .toDispatchActions([ - logic.actionCreators.loadRecordingSnapshotsV1(firstNext), - 'loadRecordingSnapshotsV1Failure', - ]) - .toFinishAllListeners() - resumeKeaLoadersErrors() - expect(api.get).toHaveBeenCalledWith(firstNext) - }) - }) - describe('report usage', () => { it('send `recording loaded` event only when entire recording has loaded', async () => { await expectLogic(logic, () => { logic.actions.loadRecordingSnapshots() }) .toDispatchActionsInAnyOrder([ - 'loadRecordingSnapshotsV1', - 'loadRecordingSnapshotsV1Success', + 'loadRecordingSnapshots', + 'loadRecordingSnapshotsSuccess', 'loadEvents', 'loadEventsSuccess', ]) @@ -421,15 +234,12 @@ describe('sessionRecordingDataLogic', () => { eventUsageLogic.actionTypes.reportRecording, // viewed eventUsageLogic.actionTypes.reportRecording, // analyzed ]) - .toMatchValues({ - chunkPaginationIndex: 1, - }) }) }) describe('prepareRecordingSnapshots', () => { it('should remove duplicate snapshots and sort by timestamp', () => { - const snapshots = convertSnapshotsByWindowId(recordingSnapshotsJson.snapshot_data_by_window_id) + const snapshots = convertSnapshotsByWindowId(sortedRecordingSnapshotsJson.snapshot_data_by_window_id) const snapshotsWithDuplicates = snapshots .slice(0, 2) .concat(snapshots.slice(0, 2)) @@ -441,7 +251,7 @@ describe('sessionRecordingDataLogic', () => { }) it('should match snapshot', () => { - const snapshots = convertSnapshotsByWindowId(recordingSnapshotsJson.snapshot_data_by_window_id) + const snapshots = convertSnapshotsByWindowId(sortedRecordingSnapshotsJson.snapshot_data_by_window_id) expect(prepareRecordingSnapshots(snapshots)).toMatchSnapshot() }) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts index 67fefbdad259e..5607d4865a443 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts @@ -3,7 +3,11 @@ import { loaders } from 'kea-loaders' import api from 'lib/api' import { toParams } from 'lib/utils' import { + AnyPropertyFilter, EncodedRecordingSnapshot, + PersonType, + PropertyFilterType, + PropertyOperator, RecordingEventsFilters, RecordingEventType, RecordingReportLoadTimes, @@ -12,7 +16,6 @@ import { SessionPlayerData, SessionPlayerSnapshotData, SessionRecordingId, - SessionRecordingSnapshotResponse, SessionRecordingSnapshotSource, SessionRecordingType, SessionRecordingUsageType, @@ -21,20 +24,17 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { eventWithTime } from '@rrweb/types' import { Dayjs, dayjs } from 'lib/dayjs' import type { sessionRecordingDataLogicType } from './sessionRecordingDataLogicType' -import { teamLogic } from 'scenes/teamLogic' -import { userLogic } from 'scenes/userLogic' import { chainToElements } from 'lib/utils/elements-chain' import { captureException } from '@sentry/react' import { createSegments, mapSnapshotsToWindowId } from './utils/segmenter' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { FEATURE_FLAGS } from 'lib/constants' import posthog from 'posthog-js' +import { NodeKind } from '~/queries/schema' const IS_TEST_MODE = process.env.NODE_ENV === 'test' const BUFFER_MS = 60000 // +- before and after start and end of a recording to query for. -const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[]): RecordingSnapshot[] => { - const snapshots: RecordingSnapshot[] = items.flatMap((l) => { +const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[], sessionId: string): RecordingSnapshot[] => + items.flatMap((l) => { try { const snapshotLine = typeof l === 'string' ? (JSON.parse(l) as EncodedRecordingSnapshot) : l const snapshotData = snapshotLine['data'] @@ -44,14 +44,15 @@ const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[]): Re ...d, })) } catch (e) { + posthog.capture('session recording had unparseable line', { + sessionId, + line: l, + }) captureException(e) return [] } }) - return snapshots -} - const getHrefFromSnapshot = (snapshot: RecordingSnapshot): string | undefined => { return (snapshot.data as any)?.href || (snapshot.data as any)?.payload?.href } @@ -133,7 +134,35 @@ const generateRecordingReportDurations = ( export interface SessionRecordingDataLogicProps { sessionRecordingId: SessionRecordingId - recordingStartTime?: string +} + +function makeEventsQuery( + person: PersonType | null, + distinctId: string | null, + start: Dayjs, + end: Dayjs, + properties: AnyPropertyFilter[] +): Promise { + return api.query({ + kind: NodeKind.EventsQuery, + // NOTE: Be careful adding fields here. We want to keep the payload as small as possible to load all events quickly + select: [ + 'uuid', + 'event', + 'timestamp', + 'elements_chain', + 'properties.$window_id', + 'properties.$current_url', + 'properties.$event_type', + ], + orderBy: ['timestamp ASC'], + limit: 1000000, + personId: person ? String(person.id) : undefined, + after: start.subtract(BUFFER_MS, 'ms').format(), + before: end.add(BUFFER_MS, 'ms').format(), + properties: properties, + where: distinctId ? [`distinct_id = ('${distinctId}')`] : undefined, + }) } export const sessionRecordingDataLogic = kea([ @@ -142,7 +171,6 @@ export const sessionRecordingDataLogic = kea([ key(({ sessionRecordingId }) => sessionRecordingId || 'no-session-recording-id'), connect({ logic: [eventUsageLogic], - values: [teamLogic, ['currentTeamId'], userLogic, ['hasAvailableFeature'], featureFlagLogic, ['featureFlags']], }), defaults({ sessionPlayerMetaData: null as SessionRecordingType | null, @@ -151,16 +179,13 @@ export const sessionRecordingDataLogic = kea([ setFilters: (filters: Partial) => ({ filters }), loadRecordingMeta: true, maybeLoadRecordingMeta: true, - addDiffToRecordingMetaPinnedCount: (diffCount: number) => ({ diffCount }), - loadRecordingSnapshotsV1: (nextUrl?: string) => ({ nextUrl }), - loadRecordingSnapshotsV2: (source?: SessionRecordingSnapshotSource) => ({ source }), - loadRecordingSnapshots: true, - loadRecordingSnapshotsSuccess: true, - loadRecordingSnapshotsFailure: true, + loadRecordingSnapshots: (source?: SessionRecordingSnapshotSource) => ({ source }), loadEvents: true, loadFullEventData: (event: RecordingEventType) => ({ event }), reportViewed: true, reportUsageIfFullyLoaded: true, + persistRecording: true, + maybePersistRecording: true, }), reducers(() => ({ filters: [ @@ -169,18 +194,6 @@ export const sessionRecordingDataLogic = kea([ setFilters: (state, { filters }) => ({ ...state, ...filters }), }, ], - chunkPaginationIndex: [ - 0, - { - loadRecordingSnapshotsSuccess: (state) => state + 1, - }, - ], - loadedFromBlobStorage: [ - false as boolean, - { - loadRecordingSnapshotsV2Success: () => true, - }, - ], isNotFound: [ false as boolean, { @@ -204,69 +217,33 @@ export const sessionRecordingDataLogic = kea([ } }, loadRecordingSnapshots: () => { - if (values.sessionPlayerSnapshotDataLoading) { - return - } - if (!values.sessionPlayerSnapshotData?.snapshots) { - if (values.featureFlags[FEATURE_FLAGS.SESSION_RECORDING_BLOB_REPLAY]) { - actions.loadRecordingSnapshotsV2() - } else { - actions.loadRecordingSnapshotsV1() - } - } actions.loadEvents() }, - loadRecordingSnapshotsV2Success: () => { + loadRecordingSnapshotsSuccess: () => { const { snapshots, sources } = values.sessionPlayerSnapshotData ?? {} if (snapshots && !snapshots.length && sources?.length === 1) { - // We got the snapshot response for realtime, and it was empty, so we fall back to the old API - // Until we migrate over we need to fall back to the old API if the new one returns no snapshots - actions.loadRecordingSnapshotsV1() + // We got only a snapshot response for realtime, and it was empty + posthog.capture('recording_snapshots_v2_empty_response', { + source: sources[0], + }) + return } - actions.loadRecordingSnapshotsSuccess() - - const nextSourceToLoad = sources?.find((s) => !s.loaded) - - if (nextSourceToLoad) { - actions.loadRecordingSnapshotsV2(nextSourceToLoad) - } else { - actions.reportUsageIfFullyLoaded() - } - }, - loadRecordingSnapshotsV1Success: ({ sessionPlayerSnapshotData }) => { - if (!!sessionPlayerSnapshotData?.sources?.length) { - // v1 request was force-upgraded to v2 - actions.loadRecordingSnapshotsV2Success(sessionPlayerSnapshotData, undefined) - return + cache.firstPaintDurationRow = { + size: (values.sessionPlayerSnapshotData?.snapshots ?? []).length, + duration: Math.round(performance.now() - cache.snapshotsStartTime), } - actions.loadRecordingSnapshotsSuccess() + actions.reportViewed() + actions.reportUsageIfFullyLoaded() - if (!!values.sessionPlayerSnapshotData?.next) { - actions.loadRecordingSnapshotsV1(values.sessionPlayerSnapshotData?.next) - } else { - actions.reportUsageIfFullyLoaded() - } - if (values.chunkPaginationIndex === 1 || values.loadedFromBlobStorage) { - // Not always accurate that recording is playable after first chunk is loaded, but good guesstimate for now - // when loading from blob storage by the time this is hit the chunkPaginationIndex is already > 1 - // when loading from the API the chunkPaginationIndex is 1 for the first success that reaches this point - cache.firstPaintDurationRow = { - size: (values.sessionPlayerSnapshotData?.snapshots ?? []).length, - duration: Math.round(performance.now() - cache.snapshotsStartTime), - } + const nextSourceToLoad = sources?.find((s) => !s.loaded) - actions.reportViewed() + if (nextSourceToLoad) { + actions.loadRecordingSnapshots(nextSourceToLoad) } }, - loadRecordingSnapshotsV1Failure: () => { - actions.loadRecordingSnapshotsFailure() - }, - loadRecordingSnapshotsV2Failure: () => { - actions.loadRecordingSnapshotsFailure() - }, loadEventsSuccess: () => { actions.reportUsageIfFullyLoaded() }, @@ -294,18 +271,26 @@ export const sessionRecordingDataLogic = kea([ values.sessionPlayerData, durations, SessionRecordingUsageType.VIEWED, - 0, - values.loadedFromBlobStorage + 0 ) await breakpoint(IS_TEST_MODE ? 1 : 10000) eventUsageLogic.actions.reportRecording( values.sessionPlayerData, durations, SessionRecordingUsageType.ANALYZED, - 10, - values.loadedFromBlobStorage + 10 ) }, + + maybePersistRecording: () => { + if (values.sessionPlayerMetaDataLoading) { + return + } + + if (values.sessionPlayerMetaData?.storage === 'object_storage') { + actions.persistRecording() + } + }, })), loaders(({ values, props, cache }) => ({ sessionPlayerMetaData: { @@ -314,79 +299,31 @@ export const sessionRecordingDataLogic = kea([ if (!props.sessionRecordingId) { return null } - const params = toParams({ + const response = await api.recordings.get(props.sessionRecordingId, { save_view: true, - recording_start_time: props.recordingStartTime, }) - const response = await api.recordings.get(props.sessionRecordingId, params) breakpoint() return response }, - addDiffToRecordingMetaPinnedCount: ({ diffCount }) => { + + persistRecording: async (_, breakpoint) => { if (!values.sessionPlayerMetaData) { return null } + breakpoint(100) + await api.recordings.persist(props.sessionRecordingId) return { ...values.sessionPlayerMetaData, - pinned_count: Math.max(values.sessionPlayerMetaData.pinned_count ?? 0 + diffCount, 0), + storage: 'object_storage_lts', } }, }, sessionPlayerSnapshotData: [ null as SessionPlayerSnapshotData | null, { - loadRecordingSnapshotsV1: async ( - { nextUrl }, - breakpoint - ): Promise => { - cache.snapshotsStartTime = performance.now() - - if (!props.sessionRecordingId) { - return values.sessionPlayerSnapshotData - } - await breakpoint(1) - - const params = toParams({ - recording_start_time: props.recordingStartTime, - }) - const apiUrl = - nextUrl || - `api/projects/${values.currentTeamId}/session_recordings/${props.sessionRecordingId}/snapshots?${params}` - - const response: SessionRecordingSnapshotResponse = await api.get(apiUrl) - breakpoint() - - if (response.snapshot_data_by_window_id) { - // NOTE: This might seem backwards as we translate the snapshotsByWindowId to an array and then derive it again later but - // this is for future support of the API that will return them as a simple array - const snapshots = convertSnapshotsResponse( - response.snapshot_data_by_window_id, - nextUrl ? values.sessionPlayerSnapshotData?.snapshots ?? [] : [] - ) - - posthog.capture('recording_snapshot_loaded', { - source: 'clickhouse', - }) - - return { - snapshots, - next: response.next, - } - } else if (response.sources) { - // we've been force-upgraded to V2 by 302 redirect - const data: SessionPlayerSnapshotData = { - ...(values.sessionPlayerSnapshotData || {}), - } - data.sources = response.sources - return data - } else { - throw new Error('Invalid response from snapshots API') - } - }, - - loadRecordingSnapshotsV2: async ({ source }, breakpoint): Promise => { + loadRecordingSnapshots: async ({ source }, breakpoint): Promise => { if (!props.sessionRecordingId) { return values.sessionPlayerSnapshotData } @@ -408,7 +345,7 @@ export const sessionRecordingDataLogic = kea([ source.blob_key ) data.snapshots = prepareRecordingSnapshots( - parseEncodedSnapshots(encodedResponse), + parseEncodedSnapshots(encodedResponse, props.sessionRecordingId), values.sessionPlayerSnapshotData?.snapshots ?? [] ) } else { @@ -420,7 +357,7 @@ export const sessionRecordingDataLogic = kea([ const response = await api.recordings.listSnapshots(props.sessionRecordingId, params) if (response.snapshots) { data.snapshots = prepareRecordingSnapshots( - parseEncodedSnapshots(response.snapshots), + parseEncodedSnapshots(response.snapshots, props.sessionRecordingId), values.sessionPlayerSnapshotData?.snapshots ?? [] ) } @@ -452,51 +389,49 @@ export const sessionRecordingDataLogic = kea([ return null } - const [sessionEvents, relatedEvents]: any[] = await Promise.all( - [ + const [sessionEvents, relatedEvents]: any[] = await Promise.all([ + // make one query for all events that are part of the session + makeEventsQuery(null, null, start, end, [ { key: '$session_id', value: [props.sessionRecordingId], - operator: 'exact', - type: 'event', + operator: PropertyOperator.Exact, + type: PropertyFilterType.Event, }, + ]), + // make a second for all events from that person, + // not marked as part of the session + // but in the same time range + // these are probably e.g. backend events for the session + // but with no session id + // since posthog-js must always add session id we can also + // take advantage of lib being materialized and further filter + makeEventsQuery(null, values.sessionPlayerMetaData?.distinct_id || null, start, end, [ { key: '$session_id', value: '', - operator: 'exact', - type: 'event', + operator: PropertyOperator.Exact, + type: PropertyFilterType.Event, }, - ].map((properties) => - api.query({ - kind: 'EventsQuery', - // NOTE: Be careful adding fields here. We want to keep the payload as small as possible to load all events quickly - select: [ - 'uuid', - 'event', - 'timestamp', - 'elements_chain', - 'properties.$window_id', - 'properties.$current_url', - 'properties.$event_type', - ], - orderBy: ['timestamp ASC'], - limit: 1000000, - personId: person.id, - after: start.subtract(BUFFER_MS, 'ms').format(), - before: end.add(BUFFER_MS, 'ms').format(), - properties: [properties], - }) - ) - ) + { + key: '$lib', + value: ['web'], + operator: PropertyOperator.IsNot, + type: PropertyFilterType.Event, + }, + ]), + ]) - const minimalEvents = [...sessionEvents.results, ...relatedEvents.results].map( + return [...sessionEvents.results, ...relatedEvents.results].map( (event: any): RecordingEventType => { const currentUrl = event[5] // We use the pathname to simplify the UI - we build it here instead of fetching it to keep data usage small - let pathname = undefined + let pathname: string | undefined try { pathname = event[5] ? new URL(event[5]).pathname : undefined - } catch {} + } catch { + pathname = undefined + } return { id: event[0], @@ -514,8 +449,6 @@ export const sessionRecordingDataLogic = kea([ } } ) - - return minimalEvents }, loadFullEventData: async ({ event }) => { @@ -533,7 +466,7 @@ export const sessionRecordingDataLogic = kea([ select: ['properties', 'timestamp'], orderBy: ['timestamp ASC'], limit: 100, - personId: person?.id, + personId: String(person?.id), after: dayjs(event.timestamp).subtract(1000, 'ms').format(), before: dayjs(event.timestamp).add(1000, 'ms').format(), event: existingEvent.event, @@ -578,7 +511,6 @@ export const sessionRecordingDataLogic = kea([ durationMs, fullyLoaded ): SessionPlayerData => ({ - pinnedCount: meta?.pinned_count ?? 0, person: meta?.person ?? null, start, end, @@ -596,7 +528,6 @@ export const sessionRecordingDataLogic = kea([ s.sessionPlayerMetaDataLoading, s.sessionPlayerSnapshotDataLoading, s.sessionEventsDataLoading, - s.hasAvailableFeature, ], ( sessionPlayerSnapshotData, diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts index b97c4f0873900..8b4526d450338 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts @@ -5,24 +5,46 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' import { useMocks } from '~/mocks/jest' -import recordingSnapshotsJson from 'scenes/session-recordings/__mocks__/recording_snapshots.json' +import { snapshotsAsJSONLines } from 'scenes/session-recordings/__mocks__/recording_snapshots' import recordingMetaJson from 'scenes/session-recordings/__mocks__/recording_meta.json' import recordingEventsJson from 'scenes/session-recordings/__mocks__/recording_events_query' import { resumeKeaLoadersErrors, silenceKeaLoadersErrors } from '~/initKea' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import api from 'lib/api' import { MOCK_TEAM_ID } from 'lib/api.mock' -import { sessionRecordingsListLogic } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { router } from 'kea-router' import { urls } from 'scenes/urls' describe('sessionRecordingPlayerLogic', () => { let logic: ReturnType + const mockWarn = jest.fn() beforeEach(() => { + console.warn = mockWarn + mockWarn.mockClear() useMocks({ get: { - '/api/projects/:team/session_recordings/:id/snapshots': recordingSnapshotsJson, + '/api/projects/:team/session_recordings/:id/snapshots/': (req, res, ctx) => { + // with no sources, returns sources... + if (req.url.searchParams.get('source') === 'blob') { + return res(ctx.text(snapshotsAsJSONLines())) + } + // with no source requested should return sources + return [ + 200, + { + sources: [ + { + source: 'blob', + start_timestamp: '2023-08-11T12:03:36.097000Z', + end_timestamp: '2023-08-11T12:04:52.268000Z', + blob_key: '1691755416097-1691755492268', + }, + ], + }, + ] + }, '/api/projects/:team/session_recordings/:id': recordingMetaJson, }, delete: { @@ -80,6 +102,9 @@ describe('sessionRecordingPlayerLogic', () => { expect(logic.values.sessionPlayerData).toMatchSnapshot() await expectLogic(logic).toDispatchActions([ + // once to gather sources + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshots, + // once to load source from that sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshots, sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshotsSuccess, ]) @@ -146,12 +171,12 @@ describe('sessionRecordingPlayerLogic', () => { describe('delete session recording', () => { it('on playlist page', async () => { silenceKeaLoadersErrors() - const listLogic = sessionRecordingsListLogic({ playlistShortId: 'playlist_id' }) + const listLogic = sessionRecordingsPlaylistLogic({}) listLogic.mount() logic = sessionRecordingPlayerLogic({ sessionRecordingId: '3', playerKey: 'test', - playlistShortId: 'playlist_id', + playlistLogic: listLogic, }) logic.mount() jest.spyOn(api, 'delete') @@ -165,7 +190,7 @@ describe('sessionRecordingPlayerLogic', () => { listLogic.actionCreators.setSelectedRecordingId(null), ]) .toNotHaveDispatchedActions([ - sessionRecordingsListLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, + sessionRecordingsPlaylistLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, ]) expect(api.delete).toHaveBeenCalledWith(`api/projects/${MOCK_TEAM_ID}/session_recordings/3`) @@ -174,9 +199,13 @@ describe('sessionRecordingPlayerLogic', () => { it('on any other recordings page with a list', async () => { silenceKeaLoadersErrors() - const listLogic = sessionRecordingsListLogic({ updateSearchParams: true }) + const listLogic = sessionRecordingsPlaylistLogic({ updateSearchParams: true }) listLogic.mount() - logic = sessionRecordingPlayerLogic({ sessionRecordingId: '3', playerKey: 'test' }) + logic = sessionRecordingPlayerLogic({ + sessionRecordingId: '3', + playerKey: 'test', + playlistLogic: listLogic, + }) logic.mount() jest.spyOn(api, 'delete') @@ -204,7 +233,7 @@ describe('sessionRecordingPlayerLogic', () => { }) .toDispatchActions(['deleteRecording']) .toNotHaveDispatchedActions([ - sessionRecordingsListLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, + sessionRecordingsPlaylistLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, ]) .toFinishAllListeners() @@ -225,7 +254,7 @@ describe('sessionRecordingPlayerLogic', () => { }) .toDispatchActions(['deleteRecording']) .toNotHaveDispatchedActions([ - sessionRecordingsListLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, + sessionRecordingsPlaylistLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, ]) .toFinishAllListeners() @@ -242,67 +271,88 @@ describe('sessionRecordingPlayerLogic', () => { { uuid: '2', timestamp: '2022-06-01T12:01:00.000Z', session_id: '1', window_id: '1' }, { uuid: '3', timestamp: '2022-06-01T12:02:00.000Z', session_id: '1', window_id: '1' }, ] - it('starts as empty list', async () => { - await expectLogic(logic).toMatchValues({ - matching: [], - }) - }) + it('initialized through props', async () => { logic = sessionRecordingPlayerLogic({ sessionRecordingId: '3', playerKey: 'test', - matching: [ - { - events: listOfMatchingEvents, - }, - ], + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: listOfMatchingEvents.map((event) => event.uuid), + }, }) logic.mount() await expectLogic(logic).toMatchValues({ - matching: [ - { - events: listOfMatchingEvents, + logicProps: expect.objectContaining({ + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: listOfMatchingEvents.map((event) => event.uuid), }, - ], + }), }) }) it('changes when filter results change', async () => { logic = sessionRecordingPlayerLogic({ sessionRecordingId: '4', playerKey: 'test', - matching: [ - { - events: listOfMatchingEvents, - }, - ], + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: listOfMatchingEvents.map((event) => event.uuid), + }, }) logic.mount() await expectLogic(logic).toMatchValues({ - matching: [ - { - events: listOfMatchingEvents, + logicProps: expect.objectContaining({ + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: listOfMatchingEvents.map((event) => event.uuid), }, - ], + }), }) logic = sessionRecordingPlayerLogic({ sessionRecordingId: '4', playerKey: 'test', - matching: [ - { - events: [listOfMatchingEvents[0]], + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: listOfMatchingEvents.map((event) => event.uuid).slice(0, 1), + }, + }) + logic.mount() + await expectLogic(logic).toMatchValues({ + logicProps: expect.objectContaining({ + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: listOfMatchingEvents.map((event) => event.uuid).slice(0, 1), }, - ], + }), + }) + }) + + it('captures replayer warnings', async () => { + jest.useFakeTimers() + logic = sessionRecordingPlayerLogic({ + sessionRecordingId: '4', + playerKey: 'test', + matchingEventsMatchType: { + matchType: 'uuid', + eventUUIDs: listOfMatchingEvents.map((event) => event.uuid), + }, }) logic.mount() - await expectLogic(logic) - .toDispatchActions(['setMatching']) - .toMatchValues({ - matching: [ - { - events: [listOfMatchingEvents[0]], - }, - ], - }) + + console.warn('[replayer]', 'test') + console.warn('[replayer]', 'test2') + + expect(mockWarn).not.toHaveBeenCalled() + + expect((window as any).__posthog_player_warnings).toEqual([ + ['[replayer]', 'test'], + ['[replayer]', 'test2'], + ]) + jest.runOnlyPendingTimers() + expect(mockWarn).toHaveBeenCalledWith( + '[PostHog Replayer] 2 warnings (window.__posthog_player_warnings to safely log them)' + ) }) }) }) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts index 1c129eb96d7d9..301f1d48e8dfe 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts @@ -1,4 +1,5 @@ import { + BuiltLogic, actions, afterMount, beforeUnmount, @@ -8,7 +9,6 @@ import { listeners, path, props, - propsChanged, reducers, selectors, } from 'kea' @@ -16,29 +16,20 @@ import { windowValues } from 'kea-window-values' import type { sessionRecordingPlayerLogicType } from './sessionRecordingPlayerLogicType' import { Replayer } from 'rrweb' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { - AvailableFeature, - MatchedRecording, - RecordingSegment, - SessionPlayerData, - SessionPlayerState, - SessionRecordingId, - SessionRecordingType, -} from '~/types' +import { AvailableFeature, RecordingSegment, SessionPlayerData, SessionPlayerState } from '~/types' import { getBreakpoint } from 'lib/utils/responsiveUtils' -import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' +import { + SessionRecordingDataLogicProps, + sessionRecordingDataLogic, +} from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { deleteRecording } from './utils/playerUtils' import { playerSettingsLogic } from './playerSettingsLogic' -import equal from 'fast-deep-equal' import { clamp, downloadFile, fromParamsGivenUrl } from 'lib/utils' import { lemonToast } from '@posthog/lemon-ui' import { delay } from 'kea-test-utils' import { userLogic } from 'scenes/userLogic' import { openBillingPopupModal } from 'scenes/billing/BillingPopup' -import { - MatchingEventsMatchType, - sessionRecordingsListLogic, -} from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { router } from 'kea-router' import { urls } from 'scenes/urls' import { wrapConsole } from 'lib/utils/wrapConsole' @@ -46,7 +37,13 @@ import { SessionRecordingPlayerExplorerProps } from './view-explorer/SessionReco import { createExportedSessionRecording } from '../file-playback/sessionRecordingFilePlaybackLogic' import { RefObject } from 'react' import posthog from 'posthog-js' +import { COMMON_REPLAYER_CONFIG, CorsPlugin } from './rrweb' import { now } from 'lib/dayjs' +import { ReplayPlugin } from 'rrweb/typings/types' +import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' +import type { sessionRecordingsPlaylistLogicType } from '../playlist/sessionRecordingsPlaylistLogicType' export const PLAYBACK_SPEEDS = [0.5, 1, 2, 3, 4, 8, 16] export const ONE_FRAME_MS = 100 // We don't really have frames but this feels granular enough @@ -77,24 +74,19 @@ export enum SessionRecordingPlayerMode { Standard = 'standard', Sharing = 'sharing', Notebook = 'notebook', + Preview = 'preview', } -// This is the basic props used by most sub-logics -export interface SessionRecordingLogicProps { - sessionRecordingId: SessionRecordingId +export interface SessionRecordingPlayerLogicProps extends SessionRecordingDataLogicProps { playerKey: string -} - -export interface SessionRecordingPlayerLogicProps extends SessionRecordingLogicProps { sessionRecordingData?: SessionPlayerData - playlistShortId?: string - matching?: MatchedRecording[] matchingEventsMatchType?: MatchingEventsMatchType - recordingStartTime?: string - nextSessionRecording?: Partial + playlistLogic?: BuiltLogic autoPlay?: boolean mode?: SessionRecordingPlayerMode playerRef?: RefObject + pinned?: boolean + setPinned?: (pinned: boolean) => void } const isMediaElementPlaying = (element: HTMLMediaElement): boolean => @@ -116,7 +108,11 @@ export const sessionRecordingPlayerLogic = kea( playerSettingsLogic, ['speed', 'skipInactivitySetting'], userLogic, - ['hasAvailableFeature'], + ['user', 'hasAvailableFeature'], + preflightLogic, + ['preflight'], + featureFlagLogic, + ['featureFlags'], ], actions: [ sessionRecordingDataLogic(props), @@ -126,6 +122,7 @@ export const sessionRecordingPlayerLogic = kea( 'loadRecordingSnapshotsSuccess', 'loadRecordingSnapshotsFailure', 'loadRecordingMetaSuccess', + 'maybePersistRecording', ], playerSettingsLogic, ['setSpeed', 'setSkipInactivitySetting'], @@ -138,12 +135,6 @@ export const sessionRecordingPlayerLogic = kea( ], ], })), - propsChanged(({ actions, props: { matching } }, { matching: oldMatching }) => { - // Ensures that if filter results change, then matching results in this player logic will also change - if (!equal(matching, oldMatching)) { - actions.setMatching(matching) - } - }), actions({ tryInitReplayer: () => true, setPlayer: (player: Player | null) => ({ player }), @@ -175,7 +166,6 @@ export const sessionRecordingPlayerLogic = kea( initializePlayerFromStart: true, incrementErrorCount: true, incrementWarningCount: (count: number = 1) => ({ count }), - setMatching: (matching: SessionRecordingType['matching_events']) => ({ matching }), updateFromMetadata: true, exportRecordingToFile: true, deleteRecording: true, @@ -186,7 +176,7 @@ export const sessionRecordingPlayerLogic = kea( skipPlayerForward: (rrWebPlayerTime: number, skip: number) => ({ rrWebPlayerTime, skip }), incrementClickCount: true, }), - reducers(({ props }) => ({ + reducers(() => ({ clickCount: [ 0, { @@ -316,14 +306,8 @@ export const sessionRecordingPlayerLogic = kea( isErrored: [false, { setErrorPlayerState: (_, { show }) => show }], isScrubbing: [false, { startScrub: () => true, endScrub: () => false }], - errorCount: [0, { incrementErrorCount: (prevErrorCount, {}) => prevErrorCount + 1 }], + errorCount: [0, { incrementErrorCount: (prevErrorCount) => prevErrorCount + 1 }], warningCount: [0, { incrementWarningCount: (prevWarningCount, { count }) => prevWarningCount + count }], - matching: [ - props.matching ?? ([] as SessionRecordingType['matching_events']), - { - setMatching: (_, { matching }) => matching, - }, - ], endReached: [ false, { @@ -350,6 +334,7 @@ export const sessionRecordingPlayerLogic = kea( // Prop references for use by other logics sessionRecordingId: [() => [(_, props) => props], (props): string => props.sessionRecordingId], logicProps: [() => [(_, props) => props], (props): SessionRecordingPlayerLogicProps => props], + playlistLogic: [() => [(_, props) => props], (props) => props.playlistLogic], currentPlayerState: [ (s) => [ @@ -431,14 +416,15 @@ export const sessionRecordingPlayerLogic = kea( ], jumpTimeMs: [(selectors) => [selectors.speed], (speed) => 10 * 1000 * speed], - matchingEvents: [ - (s) => [s.matching], - (matching) => (matching ?? []).map((filterMatches) => filterMatches.events).flat(), - ], playerSpeed: [ - (s) => [s.speed, s.isSkippingInactivity, s.currentSegment, s.currentTimestamp], - (speed, isSkippingInactivity, currentSegment, currentTimestamp) => { + (s) => [s.speed, s.isSkippingInactivity, s.currentSegment, s.currentTimestamp, (_, props) => props.mode], + (speed, isSkippingInactivity, currentSegment, currentTimestamp, mode) => { + if (mode === SessionRecordingPlayerMode.Preview) { + // default max speed in rrweb https://github.com/rrweb-io/rrweb/blob/58c9104eddc8b7994a067a97daae5684e42f892f/packages/rrweb/src/replay/index.ts#L178 + return 360 + } + if (isSkippingInactivity) { const secondsToSkip = ((currentSegment?.endTimestamp ?? 0) - (currentTimestamp ?? 0)) / 1000 return Math.max(50, secondsToSkip) @@ -498,13 +484,32 @@ export const sessionRecordingPlayerLogic = kea( return } + const plugins: ReplayPlugin[] = [] + + // We don't want non-cloud products to talk to our proxy as it likely won't work, but we _do_ want local testing to work + if ( + values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_CORS_PROXY] && + (values.preflight?.cloud || window.location.hostname === 'localhost') + ) { + plugins.push(CorsPlugin) + } + + cache.debug?.('tryInitReplayer', { + windowId, + rootFrame: values.rootFrame, + snapshots: values.sessionPlayerData.snapshotsByWindowId[windowId], + }) + const replayer = new Replayer(values.sessionPlayerData.snapshotsByWindowId[windowId], { root: values.rootFrame, - triggerFocus: false, - insertStyleRules: [ - `.ph-no-capture { background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg=="); }`, - ], + ...COMMON_REPLAYER_CONFIG, + // these two settings are attempts to improve performance of running two Replayers at once + // the main player and a preview player + mouseTail: props.mode !== SessionRecordingPlayerMode.Preview, + useVirtualDom: false, + plugins, }) + actions.setPlayer({ replayer, windowId }) }, setPlayer: ({ player }) => { @@ -597,7 +602,7 @@ export const sessionRecordingPlayerLogic = kea( } // If replayer isn't initialized, it will be initialized with the already loaded snapshots - if (!!values.player?.replayer) { + if (values.player?.replayer) { for (const event of eventsToAdd) { await values.player?.replayer?.addEvent(event) } @@ -657,6 +662,11 @@ export const sessionRecordingPlayerLogic = kea( actions.pauseIframePlayback() actions.syncPlayerSpeed() // hotfix: speed changes on player state change values.player?.replayer?.pause() + + cache.debug?.('pause', { + currentTimestamp: values.currentTimestamp, + currentSegment: values.currentSegment, + }) }, setEndReached: ({ reached }) => { if (reached) { @@ -709,6 +719,9 @@ export const sessionRecordingPlayerLogic = kea( // If not forced to play and if last playing state was pause, pause else if (!forcePlay && values.currentPlayerState === SessionPlayerState.PAUSE) { + // NOTE: when we show a preview pane, this branch runs + // in very large recordings this call to pause + // can consume 100% CPU and freeze the entire page values.player?.replayer?.pause(values.toRRWebPlayerTime(timestamp)) actions.endBuffer() actions.setErrorPlayerState(false) @@ -749,15 +762,8 @@ export const sessionRecordingPlayerLogic = kea( }, togglePlayPause: () => { - // If buffering, toggle is a noop - if (values.currentPlayerState === SessionPlayerState.BUFFER) { - return - } // If paused, start playing - if ( - values.currentPlayerState === SessionPlayerState.PAUSE || - values.currentPlayerState === SessionPlayerState.READY - ) { + if (values.playingState === SessionPlayerState.PAUSE) { actions.setPlay() } // If playing, pause @@ -778,6 +784,23 @@ export const sessionRecordingPlayerLogic = kea( values.currentPlayerState === SessionPlayerState.SKIP) && values.timestampChangeTracking.timestampMatchesPrevious > 10 ) { + // NOTE: We should investigate if this is still happening - logging to posthog recording so we can find this in the future + posthog.sessionRecording?.log( + 'stuck session player detected - this indicates an issue with the segmenter', + 'warn' + ) + cache.debug?.('stuck session player detected', { + timestampChangeTracking: values.timestampChangeTracking, + currentSegment: values.currentSegment, + snapshots: values.sessionPlayerData.snapshotsByWindowId[values.currentSegment?.windowId ?? ''], + player: values.player, + meta: values.player?.replayer.getMetaData(), + rrwebPlayerTime, + segments: values.sessionPlayerData.segments, + segmentIndex: + values.currentSegment && values.sessionPlayerData.segments.indexOf(values.currentSegment), + }) + actions.skipPlayerForward(rrwebPlayerTime, skip) newTimestamp = newTimestamp + skip } @@ -785,7 +808,9 @@ export const sessionRecordingPlayerLogic = kea( if (newTimestamp == undefined && values.currentTimestamp) { // This can happen if the player is not loaded due to us being in a "gap" segment // In this case, we should progress time forward manually + if (values.currentSegment?.kind === 'gap') { + cache.debug?.('gap segment: skipping forward') newTimestamp = values.currentTimestamp + skip } } @@ -798,6 +823,13 @@ export const sessionRecordingPlayerLogic = kea( actions.setCurrentTimestamp(Math.max(newTimestamp, nextSegment.startTimestamp)) actions.setCurrentSegment(nextSegment) } else { + cache.debug('end of recording reached', { + newTimestamp, + segments: values.sessionPlayerData.segments, + currentSegment: values.currentSegment, + nextSegment, + segmentIndex: values.sessionPlayerData.segments.indexOf(values.currentSegment), + }) // At the end of the recording. Pause the player and set fully to the end actions.setEndReached() } @@ -811,6 +843,7 @@ export const sessionRecordingPlayerLogic = kea( values.player?.replayer?.pause() actions.startBuffer() actions.setErrorPlayerState(false) + cache.debug('buffering') return } @@ -850,7 +883,7 @@ export const sessionRecordingPlayerLogic = kea( return } - if (!values.hasAvailableFeature(AvailableFeature.RECORDINGS_FILE_EXPORT)) { + if (!values.user?.is_impersonated && !values.hasAvailableFeature(AvailableFeature.RECORDINGS_FILE_EXPORT)) { openBillingPopupModal({ title: 'Unlock recording exports', description: @@ -873,7 +906,7 @@ export const sessionRecordingPlayerLogic = kea( const payload = createExportedSessionRecording(sessionRecordingDataLogic(props)) const recordingFile = new File( - [JSON.stringify(payload)], + [JSON.stringify(payload, null, 2)], `export-${props.sessionRecordingId}.ph-recording.json`, { type: 'application/json' } ) @@ -891,19 +924,10 @@ export const sessionRecordingPlayerLogic = kea( deleteRecording: async () => { await deleteRecording(props.sessionRecordingId) - // Handles locally updating recordings sidebar so that we don't have to call expensive load recordings every time. - const listLogic = - !!props.playlistShortId && - sessionRecordingsListLogic.isMounted({ playlistShortId: props.playlistShortId }) - ? // On playlist page - sessionRecordingsListLogic({ playlistShortId: props.playlistShortId }) - : // In any other context with a list of recordings (recent recordings) - sessionRecordingsListLogic.findMounted({ updateSearchParams: true }) - - if (listLogic) { - listLogic.actions.loadAllRecordings() + if (props.playlistLogic) { + props.playlistLogic.actions.loadAllRecordings() // Reset selected recording to first one in the list - listLogic.actions.setSelectedRecordingId(null) + props.playlistLogic.actions.setSelectedRecordingId(null) } else if (router.values.location.pathname.includes('/replay')) { // On a page that displays a single recording `replay/:id` that doesn't contain a list router.actions.push(urls.replay()) @@ -939,17 +963,25 @@ export const sessionRecordingPlayerLogic = kea( }, })), windowValues({ - isSmallScreen: (window: any) => window.innerWidth < getBreakpoint('md'), + isSmallScreen: (window: Window) => window.innerWidth < getBreakpoint('md'), }), - beforeUnmount(({ values, actions, cache }) => { - cache.resetConsoleWarn?.() + beforeUnmount(({ values, actions, cache, props }) => { + if (props.mode === SessionRecordingPlayerMode.Preview) { + values.player?.replayer?.destroy() + return + } + + delete (window as any).__debug_player + + actions.stopAnimation() + cache.hasInitialized = false - clearTimeout(cache.consoleWarnDebounceTimer) document.removeEventListener('fullscreenchange', cache.fullScreenListener) cache.pausedMediaElements = [] values.player?.replayer?.pause() actions.setPlayer(null) + cache.unmountConsoleWarns?.() const playTimeMs = values.playingTimeTracking.watchTime || 0 const summaryAnalytics: RecordingViewedSummaryAnalytics = { @@ -971,7 +1003,24 @@ export const sessionRecordingPlayerLogic = kea( ) }), - afterMount(({ props, actions, cache }) => { + afterMount(({ props, actions, cache, values }) => { + cache.debugging = localStorage.getItem('ph_debug_player') === 'true' + cache.debug = (...args: any[]) => { + if (cache.debugging) { + // eslint-disable-next-line no-console + console.log('[⏯️ PostHog Replayer]', ...args) + } + } + ;(window as any).__debug_player = () => { + cache.debugging = !cache.debugging + localStorage.setItem('ph_debug_player', JSON.stringify(cache.debugging)) + cache.debug('player data', values.sessionPlayerData) + } + + if (props.mode === SessionRecordingPlayerMode.Preview) { + return + } + cache.pausedMediaElements = [] cache.fullScreenListener = () => { actions.setIsFullScreen(document.fullscreenElement !== null) @@ -985,28 +1034,57 @@ export const sessionRecordingPlayerLogic = kea( cache.openTime = performance.now() - // NOTE: RRWeb can log _alot_ of warnings, so we debounce the count otherwise we just end up making the performance worse - let warningCount = 0 - cache.consoleWarnDebounceTimer = null + cache.unmountConsoleWarns = manageConsoleWarns(cache, actions.incrementWarningCount) + }), +]) + +export const getCurrentPlayerTime = (logicProps: SessionRecordingPlayerLogicProps): number => { + // NOTE: We pull this value at call time as otherwise it would trigger re-renders if pulled from the hook + const playerTime = sessionRecordingPlayerLogic.findMounted(logicProps)?.values.currentPlayerTime || 0 + return Math.floor(playerTime / 1000) +} - const debouncedCounter = (): void => { - warningCount += 1 +export const manageConsoleWarns = (cache: any, onIncrement: (count: number) => void): (() => void) => { + // NOTE: RRWeb can log _alot_ of warnings, so we debounce the count otherwise we just end up making the performance worse + // We also don't log the warnings directly. Sometimes the sheer size of messages and warnings can cause the browser to crash deserializing it all + ;(window as any).__posthog_player_warnings = [] + const warnings: any[][] = (window as any).__posthog_player_warnings - if (!cache.consoleWarnDebounceTimer) { - cache.consoleWarnDebounceTimer = setTimeout(() => { - cache.consoleWarnDebounceTimer = null - actions.incrementWarningCount(warningCount) - warningCount = 0 - }, 1000) - } + let counter = 0 + + let consoleWarnDebounceTimer: NodeJS.Timeout | null = null + + const actualConsoleWarn = console.warn + + const debouncedCounter = (args: any[]): void => { + warnings.push(args) + counter += 1 + + if (!consoleWarnDebounceTimer) { + consoleWarnDebounceTimer = setTimeout(() => { + consoleWarnDebounceTimer = null + onIncrement(warnings.length) + + actualConsoleWarn( + `[PostHog Replayer] ${counter} warnings (window.__posthog_player_warnings to safely log them)` + ) + counter = 0 + }, 1000) } + } - cache.resetConsoleWarn = wrapConsole('warn', (args) => { - if (typeof args[0] === 'string' && args[0].includes('[replayer]')) { - debouncedCounter() - } + const resetConsoleWarn = wrapConsole('warn', (args) => { + if (typeof args[0] === 'string' && args[0].includes('[replayer]')) { + debouncedCounter(args) + // WARNING: Logging these out can cause the browser to completely crash, so we want to delay it and + return false + } - return true - }) - }), -]) + return true + }) + + return () => { + resetConsoleWarn() + clearTimeout(cache.consoleWarnDebounceTimer) + } +} diff --git a/frontend/src/scenes/session-recordings/player/utils/__snapshots__/segmenter.test.ts.snap b/frontend/src/scenes/session-recordings/player/utils/__snapshots__/segmenter.test.ts.snap index 904cce8e134b2..0e07868a1eb50 100644 --- a/frontend/src/scenes/session-recordings/player/utils/__snapshots__/segmenter.test.ts.snap +++ b/frontend/src/scenes/session-recordings/player/utils/__snapshots__/segmenter.test.ts.snap @@ -1,29 +1,100 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`segmenter ends a segment if it is the last window 1`] = ` +[ + { + "durationMs": 100, + "endTimestamp": 1672531200100, + "isActive": true, + "kind": "window", + "startTimestamp": 1672531200000, + "windowId": "A", + }, + { + "durationMs": 400, + "endTimestamp": 1672531200500, + "isActive": false, + "kind": "gap", + "startTimestamp": 1672531200100, + "windowId": undefined, + }, + { + "durationMs": 500, + "endTimestamp": "2023-01-01T00:00:01.000Z", + "isActive": false, + "kind": "window", + "startTimestamp": 1672531200500, + "windowId": "B", + }, +] +`; + +exports[`segmenter includes inactive events in the active segment until a threshold 1`] = ` +[ + { + "durationMs": 600000, + "endTimestamp": 1672531800000, + "isActive": false, + "kind": "window", + "startTimestamp": 1672531200000, + "windowId": "A", + }, +] +`; + +exports[`segmenter inserts gaps inclusively 1`] = ` +[ + { + "durationMs": 100, + "endTimestamp": 1672531200100, + "isActive": false, + "kind": "window", + "startTimestamp": 1672531200000, + "windowId": "A", + }, + { + "durationMs": 599800, + "endTimestamp": 1672531799900, + "isActive": false, + "kind": "gap", + "startTimestamp": 1672531200100, + "windowId": undefined, + }, + { + "durationMs": 100, + "endTimestamp": 1672531800000, + "isActive": false, + "kind": "window", + "startTimestamp": 1672531799900, + "windowId": "B", + }, +] +`; + exports[`segmenter matches snapshots 1`] = ` [ { - "durationMs": 7255, - "endTimestamp": 1682952388132, + "durationMs": 5694, + "endTimestamp": 1682952386571, "isActive": true, "kind": "window", "startTimestamp": 1682952380877, "windowId": "187d7c761a0525d-05f175487d4b65-1d525634-384000-187d7c761a149d0", }, { - "durationMs": 525, - "endTimestamp": 1682952388658, + "durationMs": 1533, + "endTimestamp": 1682952388104, "isActive": false, "kind": "gap", - "startTimestamp": 1682952388133, - "windowId": "187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6", + "startTimestamp": 1682952386571, + "windowId": undefined, }, { - "durationMs": 4086, + "durationMs": 4641, "endTimestamp": 1682952392745, "isActive": true, "kind": "window", - "startTimestamp": 1682952388659, + "startTimestamp": 1682952388104, "windowId": "187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6", }, ] diff --git a/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts b/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts index 60842a44bb268..4272a67b256fd 100644 --- a/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts +++ b/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts @@ -1,4 +1,4 @@ -import recordingSnapshotsJson from 'scenes/session-recordings/__mocks__/recording_snapshots.json' +import { sortedRecordingSnapshots } from 'scenes/session-recordings/__mocks__/recording_snapshots' import recordingMetaJson from 'scenes/session-recordings/__mocks__/recording_meta.json' import { createSegments } from './segmenter' import { convertSnapshotsResponse } from '../sessionRecordingDataLogic' @@ -7,7 +7,7 @@ import { RecordingSnapshot } from '~/types' describe('segmenter', () => { it('matches snapshots', async () => { - const snapshots = convertSnapshotsResponse(recordingSnapshotsJson.snapshot_data_by_window_id) + const snapshots = convertSnapshotsResponse(sortedRecordingSnapshots().snapshot_data_by_window_id) const segments = createSegments( snapshots, dayjs(recordingMetaJson.start_time), @@ -31,7 +31,9 @@ describe('segmenter', () => { ]) }) - it('inserts gaps', () => { + it('inserts gaps inclusively', () => { + // NOTE: It is important that the segments are "inclusive" of the start and end timestamps as the player logic + // depends on this to choose which segment should be played next const start = dayjs('2023-01-01T00:00:00.000Z') const end = dayjs('2023-01-01T00:10:00.000Z') @@ -44,32 +46,7 @@ describe('segmenter', () => { const segments = createSegments(snapshots, start, end) - expect(segments).toEqual([ - { - kind: 'window', - startTimestamp: 1672531200000, - windowId: 'A', - isActive: true, - endTimestamp: 1672531200100, - durationMs: 100, - }, - { - durationMs: 599798, - endTimestamp: 1672531799899, - isActive: false, - kind: 'gap', - startTimestamp: 1672531200101, - windowId: undefined, - }, - { - kind: 'window', - startTimestamp: 1672531799900, - windowId: 'B', - isActive: false, - endTimestamp: 1672531800000, - durationMs: 100, - }, - ]) + expect(segments).toMatchSnapshot() }) it('includes inactive events in the active segment until a threshold', () => { @@ -86,31 +63,22 @@ describe('segmenter', () => { const segments = createSegments(snapshots, start, end) - expect(segments).toEqual([ - { - kind: 'window', - startTimestamp: start.valueOf(), - windowId: 'A', - isActive: true, - endTimestamp: start.valueOf() + 4000, - durationMs: 4000, - }, - { - kind: 'gap', - startTimestamp: start.valueOf() + 4000 + 1, - endTimestamp: start.valueOf() + 6000 - 1, - windowId: 'A', - isActive: false, - durationMs: 1998, - }, - { - kind: 'window', - startTimestamp: start.valueOf() + 6000, - windowId: 'A', - isActive: false, - endTimestamp: end.valueOf(), - durationMs: 594000, - }, - ]) + expect(segments).toMatchSnapshot() + }) + + it('ends a segment if it is the last window', () => { + const start = dayjs('2023-01-01T00:00:00.000Z') + const end = start.add(1000, 'milliseconds') + + const snapshots: RecordingSnapshot[] = [ + { windowId: 'A', timestamp: start.valueOf(), type: 2, data: {} } as any, + { windowId: 'A', timestamp: start.valueOf() + 100, type: 3, data: {} } as any, + { windowId: 'B', timestamp: start.valueOf() + 500, type: 3, data: {} } as any, + { windowId: 'B', timestamp: end, type: 3, data: {} } as any, + ] + + const segments = createSegments(snapshots, start, end) + + expect(segments).toMatchSnapshot() }) }) diff --git a/frontend/src/scenes/session-recordings/player/utils/segmenter.ts b/frontend/src/scenes/session-recordings/player/utils/segmenter.ts index 39fe407e0fa14..f2e43b459f7a1 100644 --- a/frontend/src/scenes/session-recordings/player/utils/segmenter.ts +++ b/frontend/src/scenes/session-recordings/player/utils/segmenter.ts @@ -24,7 +24,11 @@ const activeSources = [ const ACTIVITY_THRESHOLD_MS = 5000 const isActiveEvent = (event: eventWithTime): boolean => { - return event.type === EventType.IncrementalSnapshot && activeSources.includes(event.data?.source) + return ( + event.type === EventType.FullSnapshot || + event.type === EventType.Meta || + (event.type === EventType.IncrementalSnapshot && activeSources.includes(event.data?.source)) + ) } export const mapSnapshotsToWindowId = (snapshots: RecordingSnapshot[]): Record => { @@ -47,7 +51,10 @@ export const createSegments = (snapshots: RecordingSnapshot[], start?: Dayjs, en const snapshotsByWindowId = mapSnapshotsToWindowId(snapshots) snapshots.forEach((snapshot, index) => { - const eventIsActive = isActiveEvent(snapshot) || index === 0 + const eventIsActive = isActiveEvent(snapshot) + const previousSnapshot = snapshots[index - 1] + const isPreviousSnapshotLastForWindow = + snapshotsByWindowId[previousSnapshot?.windowId]?.slice(-1)[0] === previousSnapshot // When do we create a new segment? // 1. If we don't have one yet @@ -68,6 +75,11 @@ export const createSegments = (snapshots: RecordingSnapshot[], start?: Dayjs, en isNewSegment = true } + // 5. If there are no more snapshots for this windowId + if (isPreviousSnapshotLastForWindow) { + isNewSegment = true + } + // NOTE: We have to make sure that we set this _after_ we use it lastActiveEventTimestamp = eventIsActive ? snapshot.timestamp : lastActiveEventTimestamp @@ -116,10 +128,12 @@ export const createSegments = (snapshots: RecordingSnapshot[], start?: Dayjs, en const previousSegment = segments[index - 1] const list = [...acc] - if (previousSegment && segment.startTimestamp - previousSegment.endTimestamp > 1) { - const startTimestamp = previousSegment.endTimestamp + 1 - const endTimestamp = segment.startTimestamp - 1 - const windowId = findWindowIdForTimestamp(startTimestamp, previousSegment.windowId) + if (previousSegment && segment.startTimestamp !== previousSegment.endTimestamp) { + // If the segments do not immediately follow each other then we add a "gap" segment + const startTimestamp = previousSegment.endTimestamp + const endTimestamp = segment.startTimestamp + // Offset the window ID check so we look for a subsequent segment + const windowId = findWindowIdForTimestamp(startTimestamp + 1, previousSegment.windowId) const gapSegment: Partial = { kind: 'gap', startTimestamp, diff --git a/frontend/src/scenes/session-recordings/player/view-explorer/SessionRecordingPlayerExplorer.scss b/frontend/src/scenes/session-recordings/player/view-explorer/SessionRecordingPlayerExplorer.scss index 2ecc3d9890d10..c7ef3432dab58 100644 --- a/frontend/src/scenes/session-recordings/player/view-explorer/SessionRecordingPlayerExplorer.scss +++ b/frontend/src/scenes/session-recordings/player/view-explorer/SessionRecordingPlayerExplorer.scss @@ -2,7 +2,7 @@ display: flex; flex-direction: column; flex: 1; - height: calc(100vh - 3.5rem - 2rem); + height: 100%; padding: 0.5rem; overflow: hidden; diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx index 5132c0ddb5e9d..41b2eb89817f2 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx @@ -6,20 +6,19 @@ import { IconAutocapture, IconKeyboard, IconPinFilled, IconSchedule } from 'lib/ import { Tooltip } from 'lib/lemon-ui/Tooltip' import { TZLabel } from 'lib/components/TZLabel' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { RecordingDebugInfo } from '../debug/RecordingDebugInfo' import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' import { urls } from 'scenes/urls' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' import { useValues } from 'kea' import { asDisplay } from 'scenes/persons/person-utils' +import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' export interface SessionRecordingPreviewProps { recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean onPropertyClick?: (property: string, value?: string) => void isActive?: boolean onClick?: () => void + pinned?: boolean } function RecordingDuration({ @@ -56,18 +55,19 @@ function RecordingDuration({ function ActivityIndicators({ recording, - recordingProperties, - recordingPropertiesLoading, onPropertyClick, iconClassnames, }: { recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean onPropertyClick?: (property: string, value?: string) => void iconClassnames: string }): JSX.Element { - const iconPropertyKeys = ['$browser', '$device_type', '$os', '$geoip_country_code'] + const { recordingPropertiesById, recordingPropertiesLoading } = useValues(sessionRecordingsListPropertiesLogic) + + const recordingProperties = recordingPropertiesById[recording.id] + const loading = !recordingProperties && recordingPropertiesLoading + + const iconPropertyKeys = ['$geoip_country_code', '$browser', '$device_type', '$os'] const iconProperties = recordingProperties && Object.keys(recordingProperties).length > 0 ? recordingProperties @@ -75,7 +75,7 @@ function ActivityIndicators({ const propertyIcons = (
    - {!recordingPropertiesLoading ? ( + {!loading ? ( iconPropertyKeys.map((property) => { let value = iconProperties?.[property] if (property === '$device_type') { @@ -90,13 +90,18 @@ function ActivityIndicators({ return ( { + if (e.altKey) { + e.stopPropagation() + onPropertyClick?.(property, value) + } + }} className={iconClassnames} property={property} value={value} tooltipTitle={() => (
    - Click to filter for + Alt + Click to filter for
    {tooltipValue ?? 'N/A'}
    @@ -146,18 +151,18 @@ function FirstURL(props: { startUrl: string | undefined }): JSX.Element { ) } -function PinnedIndicator(props: { pinnedCount: number | undefined }): JSX.Element | null { - return (props.pinnedCount ?? 0) > 0 ? ( - +function PinnedIndicator(): JSX.Element | null { + return ( + - ) : null + ) } function ViewedIndicator(props: { viewed: boolean }): JSX.Element | null { return !props.viewed ? ( -
    +
    ) : null } @@ -175,35 +180,23 @@ export function SessionRecordingPreview({ isActive, onClick, onPropertyClick, - recordingProperties, - recordingPropertiesLoading, + pinned, }: SessionRecordingPreviewProps): JSX.Element { const { durationTypeToShow } = useValues(playerSettingsLogic) - const iconClassnames = clsx( - 'SessionRecordingPreview__property-icon text-base text-muted-alt', - !isActive && 'opacity-75' - ) + const iconClassnames = clsx('SessionRecordingPreview__property-icon text-base text-muted-alt') return (
    onClick?.()} > -
    - -
    - -
    +
    {asDisplay(recording.person)}
    @@ -219,17 +212,22 @@ export function SessionRecordingPreview({ - +
    - +
    + + {pinned ? : null} +
    ) diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsList.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsList.tsx deleted file mode 100644 index 5c63c5ceb9b72..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsList.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { LemonButton } from '@posthog/lemon-ui' -import clsx from 'clsx' -import { IconUnfoldLess, IconUnfoldMore, IconInfo } from 'lib/lemon-ui/icons' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { range } from 'lib/utils' -import React, { Fragment, useEffect, useRef } from 'react' -import { SessionRecordingType } from '~/types' -import { - SessionRecordingPlaylistItem, - SessionRecordingPlaylistItemProps, - SessionRecordingPlaylistItemSkeleton, -} from './SessionRecordingsPlaylistItem' -import { useActions, useValues } from 'kea' -import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' -import { LemonTableLoader } from 'lib/lemon-ui/LemonTable/LemonTableLoader' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' - -const SCROLL_TRIGGER_OFFSET = 100 - -export type SessionRecordingsListProps = { - listKey: string - title: React.ReactNode - titleRight?: React.ReactNode - titleActions?: React.ReactNode - info?: React.ReactNode - recordings?: SessionRecordingType[] - onRecordingClick: (recording: SessionRecordingType) => void - onPropertyClick: SessionRecordingPlaylistItemProps['onPropertyClick'] - activeRecordingId?: SessionRecordingType['id'] - loading?: boolean - loadingSkeletonCount?: number - collapsed?: boolean - onCollapse?: (collapsed: boolean) => void - empty?: React.ReactNode - className?: string - footer?: React.ReactNode - subheader?: React.ReactNode - onScrollToStart?: () => void - onScrollToEnd?: () => void - draggableHref?: string -} - -export function SessionRecordingsList({ - listKey, - titleRight, - titleActions, - recordings, - collapsed, - onCollapse, - title, - loading, - loadingSkeletonCount = 1, - info, - empty, - onRecordingClick, - onPropertyClick, - activeRecordingId, - className, - footer, - subheader, - onScrollToStart, - onScrollToEnd, - draggableHref, -}: SessionRecordingsListProps): JSX.Element { - const { reportRecordingListVisibilityToggled } = useActions(eventUsageLogic) - const lastScrollPositionRef = useRef(0) - const contentRef = useRef(null) - const { recordingPropertiesById, recordingPropertiesLoading } = useValues(sessionRecordingsListPropertiesLogic) - - const titleContent = ( - - {title} - {info ? ( - - - - ) : null} - - ) - - const setCollapsedWrapper = (val: boolean): void => { - onCollapse?.(val) - reportRecordingListVisibilityToggled(listKey, !val) - } - - const handleScroll = - onScrollToEnd || onScrollToStart - ? (e: React.UIEvent): void => { - // If we are scrolling down then check if we are at the bottom of the list - if (e.currentTarget.scrollTop > lastScrollPositionRef.current) { - const scrollPosition = e.currentTarget.scrollTop + e.currentTarget.clientHeight - if (e.currentTarget.scrollHeight - scrollPosition < SCROLL_TRIGGER_OFFSET) { - onScrollToEnd?.() - } - } - - // Same again but if scrolling to the top - if (e.currentTarget.scrollTop < lastScrollPositionRef.current) { - if (e.currentTarget.scrollTop < SCROLL_TRIGGER_OFFSET) { - onScrollToStart?.() - } - } - - lastScrollPositionRef.current = e.currentTarget.scrollTop - } - : undefined - - useEffect(() => { - if (subheader && contentRef.current) { - contentRef.current.scrollTop = 0 - } - }, [!!subheader]) - - return ( -
    - -
    - {onCollapse ? ( - : } - size="small" - onClick={() => setCollapsedWrapper(!collapsed)} - > - {titleContent} - - ) : ( - - {titleContent} - {titleRight} - - )} - {titleActions} - -
    -
    - {!collapsed ? ( -
    - {subheader} - {recordings?.length ? ( -
      - {recordings.map((rec, i) => ( - - {i > 0 &&
      } - onRecordingClick(rec)} - onPropertyClick={onPropertyClick} - isActive={activeRecordingId === rec.id} - /> - - ))} - - {footer} -
    - ) : loading ? ( - <> - {range(loadingSkeletonCount).map((i) => ( - - ))} - - ) : ( -
    {empty || info}
    - )} -
    - ) : null} -
    - ) -} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss index 005c1d3a75e8b..afdbf12c51d5c 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss @@ -3,22 +3,31 @@ .SessionRecordingsPlaylist { display: flex; - flex-direction: column-reverse; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; - gap: 1rem; overflow: hidden; - padding-bottom: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + height: 100%; - .SessionRecordingsPlaylist__left-column { + .SessionRecordingsPlaylist__list { flex-shrink: 0; - width: 100%; display: flex; flex-direction: column; + max-width: 350px; + min-width: 300px; + width: 25%; + overflow: hidden; + height: 100%; } - .SessionRecordingsPlaylist__right-column { + + .SessionRecordingsPlaylist__player { + flex: 1; + height: 100%; overflow: hidden; width: 100%; - height: 30rem; .SessionRecordingsPlaylist__loading { display: flex; @@ -28,35 +37,41 @@ } } - &--wide { - flex-direction: row; - justify-content: flex-start; - height: calc(100vh - 5rem); - - .SessionRecordingsPlaylist__left-column { - max-width: 350px; - min-width: 300px; - width: 25%; - overflow: hidden; - height: 100%; - } + &--embedded { + border: none; + } - .SessionRecordingsPlaylist__right-column { + &--wide { + .SessionRecordingsPlaylist__player { flex: 1; height: 100%; } } } -.SessionRecordingsPlaylist__lists { - display: flex; - flex-direction: column; - flex: 1; - overflow: hidden; - gap: 0.5rem; +.SessionRecordingPlaylistHeightWrapper { + // NOTE: Somewhat random way to offset the various headers and tabs above the playlist + height: calc(100vh - 15rem); + min-height: 41rem; } .SessionRecordingPreview { + display: flex; + padding: 0.5rem 0 0.5rem 0.5rem; + cursor: pointer; + position: relative; + overflow: hidden; + border-left: 6px solid transparent; + transition: background-color 200ms ease, border 200ms ease; + + &--active { + border-left-color: var(--primary); + } + + &:hover { + background-color: var(--primary-highlight); + } + .SessionRecordingPreview__property-icon:hover { transition: opacity 200ms; opacity: 1; diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx index 3856e79ca8a68..ec3aa4b9a723c 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx @@ -1,19 +1,18 @@ -import React, { useState } from 'react' -import { useActions, useValues } from 'kea' -import { RecordingFilters, SessionRecordingType, ReplayTabs, ProductKey } from '~/types' +import React, { useEffect, useRef } from 'react' +import { BindLogic, useActions, useValues } from 'kea' +import { SessionRecordingType, ReplayTabs } from '~/types' import { DEFAULT_RECORDING_FILTERS, defaultPageviewPropertyEntityFilter, RECORDINGS_LIMIT, - SessionRecordingListLogicProps, - sessionRecordingsListLogic, -} from './sessionRecordingsListLogic' + SessionRecordingPlaylistLogicProps, + sessionRecordingsPlaylistLogic, +} from './sessionRecordingsPlaylistLogic' import './SessionRecordingsPlaylist.scss' import { SessionRecordingPlayer } from '../player/SessionRecordingPlayer' import { EmptyMessage } from 'lib/components/EmptyMessage/EmptyMessage' -import { LemonButton, LemonDivider, Link } from '@posthog/lemon-ui' -import { IconFilter, IconMagnifier, IconSettings, IconWithCount } from 'lib/lemon-ui/icons' -import { SessionRecordingsList } from './SessionRecordingsList' +import { LemonButton, Link } from '@posthog/lemon-ui' +import { IconFilter, IconSettings, IconWithCount } from 'lib/lemon-ui/icons' import clsx from 'clsx' import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' import { Spinner } from 'lib/lemon-ui/Spinner' @@ -21,16 +20,16 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip' import { SessionRecordingsFilters } from '../filters/SessionRecordingsFilters' import { urls } from 'scenes/urls' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' -import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' -import { openSessionRecordingSettingsDialog } from '../settings/SessionRecordingSettings' -import { teamLogic } from 'scenes/teamLogic' -import { router } from 'kea-router' -import { userLogic } from 'scenes/userLogic' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' import { SessionRecordingsPlaylistSettings } from './SessionRecordingsPlaylistSettings' import { SessionRecordingsPlaylistTroubleshooting } from './SessionRecordingsPlaylistTroubleshooting' +import { useNotebookNode } from 'scenes/notebooks/Nodes/notebookNodeLogic' +import { LemonTableLoader } from 'lib/lemon-ui/LemonTable/LemonTableLoader' +import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' +import { range } from 'd3' +import { SessionRecordingPreview, SessionRecordingPreviewSkeleton } from './SessionRecordingPreview' + +const SCROLL_TRIGGER_OFFSET = 100 const CounterBadge = ({ children }: { children: React.ReactNode }): JSX.Element => ( {children} @@ -57,36 +56,23 @@ function UnusableEventsWarning(props: { unusableEventsInFilter: string[] }): JSX ) } -export function RecordingsLists({ - playlistShortId, - personUUID, - filters: defaultFilters, - updateSearchParams, -}: SessionRecordingsPlaylistProps): JSX.Element { - const logicProps = { - playlistShortId, - personUUID, - filters: defaultFilters, - updateSearchParams, - } - const logic = sessionRecordingsListLogic(logicProps) +function RecordingsLists(): JSX.Element { const { filters, hasNext, - visibleRecordings, + pinnedRecordings, + otherRecordings, sessionRecordingsResponseLoading, - activeSessionRecording, + activeSessionRecordingId, showFilters, showSettings, - pinnedRecordingsResponse, - pinnedRecordingsResponseLoading, totalFiltersCount, sessionRecordingsAPIErrored, - pinnedRecordingsAPIErrored, unusableEventsInFilter, showAdvancedFilters, hasAdvancedFilters, - } = useValues(logic) + logicProps, + } = useValues(sessionRecordingsPlaylistLogic) const { setSelectedRecordingId, setFilters, @@ -95,8 +81,7 @@ export function RecordingsLists({ setShowSettings, resetFilters, setShowAdvancedFilters, - } = useActions(logic) - const [collapsed, setCollapsed] = useState({ pinned: false, other: false }) + } = useActions(sessionRecordingsPlaylistLogic) const onRecordingClick = (recording: SessionRecordingType): void => { setSelectedRecordingId(recording.id) @@ -106,131 +91,174 @@ export function RecordingsLists({ setFilters(defaultPageviewPropertyEntityFilter(filters, property, value)) } - return ( - <> -
    - {/* Pinned recordings */} - {!!playlistShortId ? ( - {pinnedRecordingsResponse.results.length} - ) : null - } - onRecordingClick={onRecordingClick} - onPropertyClick={onPropertyClick} - collapsed={collapsed.pinned} - onCollapse={() => setCollapsed({ ...collapsed, pinned: !collapsed.pinned })} - recordings={pinnedRecordingsResponse?.results} - loading={pinnedRecordingsResponseLoading} - info={ - <> - You can pin recordings to a playlist to easily keep track of relevant recordings for the - task at hand. Pinned recordings are always shown, regardless of filters. - - } - activeRecordingId={activeSessionRecording?.id} - empty={ - pinnedRecordingsAPIErrored ? ( - Error while trying to load pinned recordings. - ) : !!unusableEventsInFilter.length ? ( - - ) : undefined - } - /> - ) : null} + const lastScrollPositionRef = useRef(0) + const contentRef = useRef(null) - {/* Other recordings */} - - {visibleRecordings.length ? ( - - Showing {visibleRecordings.length} results. -
    - Scrolling to the bottom or the top of the list will load older or newer - recordings respectively. - - } - > - - {Math.min(999, visibleRecordings.length)}+ - -
    + const handleScroll = (e: React.UIEvent): void => { + // If we are scrolling down then check if we are at the bottom of the list + if (e.currentTarget.scrollTop > lastScrollPositionRef.current) { + const scrollPosition = e.currentTarget.scrollTop + e.currentTarget.clientHeight + if (e.currentTarget.scrollHeight - scrollPosition < SCROLL_TRIGGER_OFFSET) { + maybeLoadSessionRecordings('older') + } + } + + // Same again but if scrolling to the top + if (e.currentTarget.scrollTop < lastScrollPositionRef.current) { + if (e.currentTarget.scrollTop < SCROLL_TRIGGER_OFFSET) { + maybeLoadSessionRecordings('newer') + } + } + + lastScrollPositionRef.current = e.currentTarget.scrollTop + } + + useEffect(() => { + if (contentRef.current) { + contentRef.current.scrollTop = 0 + } + }, [showFilters, showSettings]) + + const notebookNode = useNotebookNode() + + return ( +
    + { + +
    + + {!notebookNode ? ( + + Recordings + ) : null} - - } - titleActions={ - <> - - - + + Showing {otherRecordings.length + pinnedRecordings.length} results. +
    + Scrolling to the bottom or the top of the list will load older or newer + recordings respectively. + } - onClick={() => setShowFilters(!showFilters)} > - Filter -
    - } - onClick={() => setShowSettings(!showSettings)} - /> - - } - subheader={ - showFilters ? ( - resetFilters() : undefined} - hasAdvancedFilters={hasAdvancedFilters} - showAdvancedFilters={showAdvancedFilters} - setShowAdvancedFilters={setShowAdvancedFilters} - /> - ) : showSettings ? ( - - ) : null - } - onRecordingClick={onRecordingClick} - onPropertyClick={onPropertyClick} - collapsed={collapsed.other} - onCollapse={ - !!playlistShortId ? () => setCollapsed({ ...collapsed, other: !collapsed.other }) : undefined - } - recordings={visibleRecordings} - loading={sessionRecordingsResponseLoading} - loadingSkeletonCount={RECORDINGS_LIMIT} - empty={ - sessionRecordingsAPIErrored ? ( + + + {Math.min(999, otherRecordings.length + pinnedRecordings.length)}+ + + + +
    + + + + } + onClick={() => { + if (notebookNode) { + notebookNode.actions.toggleEditing() + } else { + setShowFilters(!showFilters) + } + }} + > + Filter + + } + onClick={() => setShowSettings(!showSettings)} + /> + +
    +
    + } + +
    + {!notebookNode && showFilters ? ( +
    + resetFilters() : undefined} + hasAdvancedFilters={hasAdvancedFilters} + showAdvancedFilters={showAdvancedFilters} + setShowAdvancedFilters={setShowAdvancedFilters} + /> +
    + ) : showSettings ? ( + + ) : null} + + {pinnedRecordings.length || otherRecordings.length ? ( +
      + {pinnedRecordings.map((rec) => ( +
      + onRecordingClick(rec)} + onPropertyClick={onPropertyClick} + isActive={activeSessionRecordingId === rec.id} + pinned={true} + /> +
      + ))} + + {pinnedRecordings.length && otherRecordings.length ? ( +
      + Other recordings +
      + ) : null} + + {otherRecordings.map((rec) => ( +
      + onRecordingClick(rec)} + onPropertyClick={onPropertyClick} + isActive={activeSessionRecordingId === rec.id} + pinned={false} + /> +
      + ))} + +
      + {sessionRecordingsResponseLoading ? ( + <> + Loading older recordings + + ) : hasNext ? ( + maybeLoadSessionRecordings('older')}> + Load more + + ) : ( + 'No more results' + )} +
      +
    + ) : sessionRecordingsResponseLoading ? ( + <> + {range(RECORDINGS_LIMIT).map((i) => ( + + ))} + + ) : ( +
    + {sessionRecordingsAPIErrored ? ( Error while trying to load recordings. - ) : !!unusableEventsInFilter.length ? ( + ) : unusableEventsInFilter.length ? ( ) : (
    @@ -242,174 +270,88 @@ export function RecordingsLists({ data-attr={'expand-replay-listing-from-default-seven-days-to-twenty-one'} onClick={() => { setFilters({ - date_from: '-21d', + date_from: '-30d', }) }} > - Search over the last 21 days + Search over the last 30 days ) : ( )}
    - ) - } - activeRecordingId={activeSessionRecording?.id} - onScrollToEnd={() => maybeLoadSessionRecordings('older')} - onScrollToStart={() => maybeLoadSessionRecordings('newer')} - footer={ - <> - -
    - {sessionRecordingsResponseLoading ? ( - <> - Loading older recordings - - ) : hasNext ? ( - maybeLoadSessionRecordings('older')}> - Load more - - ) : ( - 'No more results' - )} -
    - - } - draggableHref={urls.replay(ReplayTabs.Recent, filters)} - /> + )} +
    + )}
    - +
    ) } -export type SessionRecordingsPlaylistProps = { - playlistShortId?: string - personUUID?: string - filters?: RecordingFilters - updateSearchParams?: boolean - onFiltersChange?: (filters: RecordingFilters) => void - autoPlay?: boolean - mode?: 'standard' | 'notebook' -} - -export function SessionRecordingsPlaylist(props: SessionRecordingsPlaylistProps): JSX.Element { - const { - playlistShortId, - personUUID, - filters: defaultFilters, - updateSearchParams, - onFiltersChange, - autoPlay = true, - } = props - - const logicProps: SessionRecordingListLogicProps = { - playlistShortId, - personUUID, - filters: defaultFilters, - updateSearchParams, - autoPlay, - onFiltersChange, +export function SessionRecordingsPlaylist(props: SessionRecordingPlaylistLogicProps): JSX.Element { + const logicProps: SessionRecordingPlaylistLogicProps = { + ...props, + autoPlay: props.autoPlay ?? true, } - const logic = sessionRecordingsListLogic(logicProps) - const { - activeSessionRecording, - nextSessionRecording, - shouldShowEmptyState, - sessionRecordingsResponseLoading, - matchingEventsMatchType, - } = useValues(logic) - const { currentTeam } = useValues(teamLogic) - const recordingsDisabled = currentTeam && !currentTeam?.session_recording_opt_in - const { user } = useValues(userLogic) - const { featureFlags } = useValues(featureFlagLogic) - const shouldShowProductIntroduction = - !sessionRecordingsResponseLoading && - !user?.has_seen_product_intro_for?.[ProductKey.SESSION_REPLAY] && - !!featureFlags[FEATURE_FLAGS.SHOW_PRODUCT_INTRO_EXISTING_PRODUCTS] + const logic = sessionRecordingsPlaylistLogic(logicProps) + const { activeSessionRecording, activeSessionRecordingId, matchingEventsMatchType, pinnedRecordings } = + useValues(logic) const { ref: playlistRef, size } = useResizeBreakpoints({ 0: 'small', 750: 'medium', }) + const notebookNode = useNotebookNode() + return ( <> - {/* This was added around Jun 23 so at some point can just be removed */} - - Filters have moved! You can now find all filters including time and duration by clicking the{' '} - - - - icon at the top of the list of recordings. - - {(shouldShowProductIntroduction || shouldShowEmptyState) && ( - } - onClick={() => openSessionRecordingSettingsDialog()} - > - Enable recordings - - ) : ( - router.actions.push(urls.projectSettings() + '#snippet')} - > - Get the PostHog snippet - - ) - } - /> - )} -
    -
    - -
    -
    - {activeSessionRecording?.id ? ( - - ) : ( -
    - +
    +
    + +
    +
    + {activeSessionRecordingId ? ( + x.id === activeSessionRecordingId)} + setPinned={ + props.onPinnedChange + ? (pinned) => { + if (!activeSessionRecording?.id) { + return + } + props.onPinnedChange?.(activeSessionRecording, pinned) + } + : undefined + } /> -
    - )} + ) : ( +
    + +
    + )} +
    -
    + ) } diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistItem.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistItem.tsx deleted file mode 100644 index 3a2a662c2419b..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistItem.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { DurationType, SessionRecordingType } from '~/types' -import { colonDelimitedDuration } from 'lib/utils' -import clsx from 'clsx' -import { PropertyIcon } from 'lib/components/PropertyIcon' -import { IconAutocapture, IconKeyboard, IconPinFilled, IconSchedule } from 'lib/lemon-ui/icons' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { TZLabel } from 'lib/components/TZLabel' -import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { RecordingDebugInfo } from '../debug/RecordingDebugInfo' -import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' -import { urls } from 'scenes/urls' -import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' -import { useValues } from 'kea' -import { asDisplay } from 'scenes/persons/person-utils' - -export interface SessionRecordingPlaylistItemProps { - recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean - onPropertyClick: (property: string, value?: string) => void - isActive: boolean - onClick: () => void -} - -function RecordingDuration({ - iconClassNames, - recordingDuration, -}: { - iconClassNames: string - recordingDuration: number | undefined -}): JSX.Element { - if (recordingDuration === undefined) { - return
    -
    - } - - const formattedDuration = colonDelimitedDuration(recordingDuration) - const [hours, minutes, seconds] = formattedDuration.split(':') - - return ( -
    - - - {hours}: - - {minutes}: - - {seconds} - -
    - ) -} - -function ActivityIndicators({ - recording, - recordingProperties, - recordingPropertiesLoading, - onPropertyClick, - iconClassnames, -}: { - recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean - onPropertyClick: (property: string, value?: string) => void - iconClassnames: string -}): JSX.Element { - const iconPropertyKeys = ['$browser', '$device_type', '$os', '$geoip_country_code'] - const iconProperties = - recordingProperties && Object.keys(recordingProperties).length > 0 - ? recordingProperties - : recording.person?.properties || {} - - const propertyIcons = ( -
    - {!recordingPropertiesLoading ? ( - iconPropertyKeys.map((property) => { - let value = iconProperties?.[property] - if (property === '$device_type') { - value = iconProperties?.['$device_type'] || iconProperties?.['$initial_device_type'] - } - - let tooltipValue = value - if (property === '$geoip_country_code') { - tooltipValue = `${iconProperties?.['$geoip_country_name']} (${value})` - } - - return ( - ( -
    - Click to filter for -
    - {tooltipValue ?? 'N/A'} -
    - )} - /> - ) - }) - ) : ( - - )} -
    - ) - - return ( -
    - {propertyIcons} - - - - {recording.click_count} - - - - - {recording.keypress_count} - -
    - ) -} - -function FirstURL(props: { startUrl: string | undefined }): JSX.Element { - const firstPath = props.startUrl?.replace(/https?:\/\//g, '').split(/[?|#]/)[0] - return ( -
    - - - {firstPath} - - -
    - ) -} - -function PinnedIndicator(props: { pinnedCount: number | undefined }): JSX.Element | null { - return (props.pinnedCount ?? 0) > 0 ? ( - - - - ) : null -} - -function ViewedIndicator(props: { viewed: boolean }): JSX.Element | null { - return !props.viewed ? ( - -
    - - ) : null -} - -function durationToShow(recording: SessionRecordingType, durationType: DurationType | undefined): number | undefined { - return { - duration: recording.recording_duration, - active_seconds: recording.active_seconds, - inactive_seconds: recording.inactive_seconds, - }[durationType || 'duration'] -} - -export function SessionRecordingPlaylistItem({ - recording, - isActive, - onClick, - onPropertyClick, - recordingProperties, - recordingPropertiesLoading, -}: SessionRecordingPlaylistItemProps): JSX.Element { - const { durationTypeToShow } = useValues(playerSettingsLogic) - - const iconClassnames = clsx( - 'SessionRecordingsPlaylist__list-item__property-icon text-base text-muted-alt', - !isActive && 'opacity-75' - ) - - return ( - -
  • onClick()} - > -
    - -
    -
    -
    -
    - -
    - {asDisplay(recording.person)} -
    -
    -
    - - -
    - -
    - - -
    - - -
    - - -
  • -
    - ) -} - -export function SessionRecordingPlaylistItemSkeleton(): JSX.Element { - return ( -
    - - -
    - ) -} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx index 17c091b1d1fff..447fe87662123 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx @@ -5,24 +5,28 @@ import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' import { SceneExport } from 'scenes/sceneTypes' import { EditableField } from 'lib/components/EditableField/EditableField' import { PageHeader } from 'lib/components/PageHeader' -import { sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' +import { sessionRecordingsPlaylistSceneLogic } from './sessionRecordingsPlaylistSceneLogic' import { NotFound } from 'lib/components/NotFound' import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' import { More } from 'lib/lemon-ui/LemonButton/More' -import { SessionRecordingsPlaylist } from './SessionRecordingsPlaylist' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' +import { SessionRecordingsPlaylist } from './SessionRecordingsPlaylist' export const scene: SceneExport = { component: SessionRecordingsPlaylistScene, - logic: sessionRecordingsPlaylistLogic, + logic: sessionRecordingsPlaylistSceneLogic, paramsToProps: ({ params: { id } }) => { return { shortId: id as string } }, } export function SessionRecordingsPlaylistScene(): JSX.Element { - const { playlist, playlistLoading, hasChanges, derivedName } = useValues(sessionRecordingsPlaylistLogic) - const { setFilters, updatePlaylist, duplicatePlaylist, deletePlaylist } = useActions(sessionRecordingsPlaylistLogic) + const { playlist, playlistLoading, pinnedRecordings, hasChanges, derivedName } = useValues( + sessionRecordingsPlaylistSceneLogic + ) + const { setFilters, updatePlaylist, duplicatePlaylist, deletePlaylist, onPinnedChange } = useActions( + sessionRecordingsPlaylistSceneLogic + ) const { showFilters } = useValues(playerSettingsLogic) const { setShowFilters } = useActions(playerSettingsLogic) @@ -58,7 +62,7 @@ export function SessionRecordingsPlaylistScene(): JSX.Element { return ( // Margin bottom hacks the fact that our wrapping container has an annoyingly large padding -
    +
    updatePlaylist({ description: value })} @@ -141,11 +146,14 @@ export function SessionRecordingsPlaylistScene(): JSX.Element { } /> {playlist.short_id ? ( - +
    + +
    ) : null}
    ) diff --git a/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts b/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts index 0862e5e8a799a..9767d4b41809a 100644 --- a/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts +++ b/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts @@ -6,7 +6,7 @@ import { convertPropertyGroupToProperties, deleteWithUndo, genericOperatorMap } import { getKeyMapping } from 'lib/taxonomy' import api from 'lib/api' import { lemonToast } from 'lib/lemon-ui/lemonToast' -import { DEFAULT_RECORDING_FILTERS } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { DEFAULT_RECORDING_FILTERS } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { router } from 'kea-router' import { urls } from 'scenes/urls' import { openBillingPopupModal } from 'scenes/billing/BillingPopup' diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.test.ts deleted file mode 100644 index 4e49627a5612b..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { - sessionRecordingsListLogic, - RECORDINGS_LIMIT, - DEFAULT_RECORDING_FILTERS, - defaultRecordingDurationFilter, -} from './sessionRecordingsListLogic' -import { expectLogic } from 'kea-test-utils' -import { initKeaTests } from '~/test/init' -import { router } from 'kea-router' -import { PropertyFilterType, PropertyOperator, RecordingFilters } from '~/types' -import { useMocks } from '~/mocks/jest' -import { sessionRecordingDataLogic } from '../player/sessionRecordingDataLogic' - -describe('sessionRecordingsListLogic', () => { - let logic: ReturnType - const aRecording = { id: 'abc', viewed: false, recording_duration: 10 } - const listOfSessionRecordings = [aRecording] - - describe('with no recordings to load', () => { - beforeEach(() => { - useMocks({ - get: { - '/api/projects/:team/session_recordings/properties': { - results: [], - }, - - '/api/projects/:team/session_recordings': { has_next: false, results: [] }, - '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': { - results: [], - }, - }, - }) - initKeaTests() - logic = sessionRecordingsListLogic({ - key: 'tests', - updateSearchParams: true, - }) - logic.mount() - }) - - describe('should show empty state', () => { - it('starts out false', async () => { - await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) - }) - - it('is true if after API call is made there are no results', async () => { - await expectLogic(logic, () => { - // load is called on mount - // logic.actions.loadSessionRecordings() - logic.actions.setSelectedRecordingId('abc') - }) - .toDispatchActionsInAnyOrder(['loadSessionRecordings', 'loadSessionRecordingsSuccess']) - .toMatchValues({ shouldShowEmptyState: true }) - }) - - it('is false after API call error', async () => { - await expectLogic(logic, () => { - // load is called on mount - // logic.actions.loadSessionRecordings() - logic.actions.loadSessionRecordingsFailure('abc') - }).toMatchValues({ shouldShowEmptyState: false }) - }) - }) - }) - - describe('with recordings to load', () => { - beforeEach(() => { - useMocks({ - get: { - '/api/projects/:team/session_recordings/properties': { - results: [ - { id: 's1', properties: { blah: 'blah1' } }, - { id: 's2', properties: { blah: 'blah2' } }, - ], - }, - - '/api/projects/:team/session_recordings': (req) => { - const { searchParams } = req.url - if ( - (searchParams.get('events')?.length || 0) > 0 && - JSON.parse(searchParams.get('events') || '[]')[0]?.['id'] === '$autocapture' - ) { - return [ - 200, - { - results: ['List of recordings filtered by events'], - }, - ] - } else if (searchParams.get('person_uuid') === 'cool_user_99') { - return [ - 200, - { - results: ["List of specific user's recordings from server"], - }, - ] - } else if (searchParams.get('offset') === `${RECORDINGS_LIMIT}`) { - return [ - 200, - { - results: [`List of recordings offset by ${RECORDINGS_LIMIT}`], - }, - ] - } else if ( - searchParams.get('date_from') === '2021-10-05' && - searchParams.get('date_to') === '2021-10-20' - ) { - return [ - 200, - { - results: ['Recordings filtered by date'], - }, - ] - } else if ( - JSON.parse(searchParams.get('session_recording_duration') ?? '{}')['value'] === 600 - ) { - return [ - 200, - { - results: ['Recordings filtered by duration'], - }, - ] - } - return [ - 200, - { - results: listOfSessionRecordings, - }, - ] - }, - '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': () => { - return [ - 200, - { - results: ['Pinned recordings'], - }, - ] - }, - }, - }) - initKeaTests() - }) - - describe('global logic', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'tests', - playlistShortId: 'playlist-test', - updateSearchParams: true, - }) - logic.mount() - }) - - describe('core assumptions', () => { - it('loads recent recordings and pinned recordings after mounting', async () => { - await expectLogic(logic) - .toDispatchActionsInAnyOrder(['loadSessionRecordingsSuccess', 'loadPinnedRecordingsSuccess']) - .toMatchValues({ - sessionRecordings: listOfSessionRecordings, - pinnedRecordingsResponse: { - results: ['Pinned recordings'], - }, - }) - }) - }) - - describe('should show empty state', () => { - it('starts out false', async () => { - await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) - }) - }) - - describe('activeSessionRecording', () => { - it('starts as null', () => { - expectLogic(logic).toMatchValues({ activeSessionRecording: undefined }) - }) - it('is set by setSessionRecordingId', async () => { - expectLogic(logic, () => logic.actions.setSelectedRecordingId('abc')) - .toDispatchActions(['loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'abc', - activeSessionRecording: listOfSessionRecordings[0], - }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') - }) - - it('is partial if sessionRecordingId not in list', async () => { - expectLogic(logic, () => logic.actions.setSelectedRecordingId('not-in-list')) - .toDispatchActions(['loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'not-in-list', - activeSessionRecording: { id: 'not-in-list' }, - }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'not-in-list') - }) - - it('is read from the URL on the session recording page', async () => { - router.actions.push('/replay', {}, { sessionRecordingId: 'abc' }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') - - await expectLogic(logic) - .toDispatchActionsInAnyOrder(['setSelectedRecordingId', 'loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'abc', - activeSessionRecording: listOfSessionRecordings[0], - }) - }) - - it('mounts and loads the recording when a recording is opened', () => { - expectLogic(logic, async () => await logic.actions.setSelectedRecordingId('abcd')) - .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' })) - .toDispatchActions(['loadEntireRecording']) - }) - - it('returns the first session recording if none selected', () => { - expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ - selectedRecordingId: undefined, - activeSessionRecording: listOfSessionRecordings[0], - }) - expect(router.values.searchParams).not.toHaveProperty('sessionRecordingId', 'not-in-list') - }) - }) - - describe('entityFilters', () => { - it('starts with default values', () => { - expectLogic(logic).toMatchValues({ filters: DEFAULT_RECORDING_FILTERS }) - }) - - it('is set by setFilters and loads filtered results and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], - }) - }) - .toDispatchActions(['setFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) - .toMatchValues({ - sessionRecordings: ['List of recordings filtered by events'], - }) - expect(router.values.searchParams.filters).toHaveProperty('events', [ - { id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }, - ]) - }) - }) - - describe('date range', () => { - it('is set by setFilters and fetches results from server and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - date_from: '2021-10-05', - date_to: '2021-10-20', - }) - }) - .toMatchValues({ - filters: expect.objectContaining({ - date_from: '2021-10-05', - date_to: '2021-10-20', - }), - }) - .toDispatchActions(['setFilters', 'loadSessionRecordingsSuccess']) - .toMatchValues({ sessionRecordings: ['Recordings filtered by date'] }) - - expect(router.values.searchParams.filters).toHaveProperty('date_from', '2021-10-05') - expect(router.values.searchParams.filters).toHaveProperty('date_to', '2021-10-20') - }) - }) - describe('duration filter', () => { - it('is set by setFilters and fetches results from server and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - session_recording_duration: { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, - }, - }) - }) - .toMatchValues({ - filters: expect.objectContaining({ - session_recording_duration: { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, - }, - }), - }) - .toDispatchActions(['setFilters', 'loadSessionRecordingsSuccess']) - .toMatchValues({ sessionRecordings: ['Recordings filtered by duration'] }) - - expect(router.values.searchParams.filters).toHaveProperty('session_recording_duration', { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, - }) - }) - }) - - describe('fetch pinned recordings', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'static-tests', - playlistShortId: 'static-playlist-test', - }) - logic.mount() - }) - it('calls list session recordings for static playlists', async () => { - await expectLogic(logic) - .toDispatchActions(['loadPinnedRecordingsSuccess']) - .toMatchValues({ - pinnedRecordingsResponse: { - results: ['Pinned recordings'], - }, - }) - }) - }) - - describe('set recording from hash param', () => { - it('loads the correct recording from the hash params', async () => { - router.actions.push('/replay/recent', {}, { sessionRecordingId: 'abc' }) - - logic = sessionRecordingsListLogic({ - key: 'hash-recording-tests', - updateSearchParams: true, - }) - logic.mount() - - await expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ - selectedRecordingId: 'abc', - }) - - logic.actions.setSelectedRecordingId('1234') - }) - }) - - describe('sessionRecording.viewed', () => { - it('changes when setSelectedRecordingId is called', async () => { - await expectLogic(logic) - .toFinishAllListeners() - .toMatchValues({ - sessionRecordingsResponse: { - results: [{ ...aRecording }], - has_next: undefined, - }, - sessionRecordings: [ - { - ...aRecording, - }, - ], - }) - - await expectLogic(logic, () => { - logic.actions.setSelectedRecordingId('abc') - }) - .toFinishAllListeners() - .toMatchValues({ - sessionRecordingsResponse: { - results: [ - { - ...aRecording, - // at this point the view hasn't updated this object - viewed: false, - }, - ], - }, - sessionRecordings: [ - { - ...aRecording, - viewed: true, - }, - ], - }) - }) - - it('is set by setFilters and loads filtered results', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], - }) - }) - .toDispatchActions(['setFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) - .toMatchValues({ - sessionRecordings: ['List of recordings filtered by events'], - }) - }) - }) - - it('reads filters from the URL', async () => { - router.actions.push('/replay', { - filters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], - date_from: '2021-10-01', - date_to: '2021-10-10', - offset: 50, - session_recording_duration: { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, - }, - }, - }) - - await expectLogic(logic) - .toDispatchActions(['setFilters']) - .toMatchValues({ - filters: { - events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - date_from: '2021-10-01', - date_to: '2021-10-10', - offset: 50, - console_logs: [], - properties: [], - session_recording_duration: { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, - }, - }, - }) - }) - - it('reads filters from the URL and defaults the duration filter', async () => { - router.actions.push('/replay', { - filters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - }, - }) - - await expectLogic(logic) - .toDispatchActions(['setFilters']) - .toMatchValues({ - customFilters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - }, - filters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - session_recording_duration: defaultRecordingDurationFilter, - console_logs: [], - date_from: '-7d', - date_to: null, - events: [], - properties: [], - }, - }) - }) - }) - - describe('person specific logic', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, - }) - logic.mount() - }) - - it('loads session recordings for a specific user', async () => { - await expectLogic(logic) - .toDispatchActions(['loadSessionRecordingsSuccess']) - .toMatchValues({ sessionRecordings: ["List of specific user's recordings from server"] }) - }) - - it('reads sessionRecordingId from the URL on the person page', async () => { - router.actions.push('/person/123', {}, { sessionRecordingId: 'abc' }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') - - await expectLogic(logic).toDispatchActions([logic.actionCreators.setSelectedRecordingId('abc')]) - }) - }) - - describe('total filters count', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, - }) - logic.mount() - }) - it('starts with a count of zero', async () => { - await expectLogic(logic).toMatchValues({ totalFiltersCount: 0 }) - }) - - it('counts console log filters', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - console_logs: ['warn', 'error'], - } satisfies Partial) - }).toMatchValues({ totalFiltersCount: 2 }) - }) - }) - - describe('resetting filters', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, - }) - logic.mount() - }) - - it('resets console log filters', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - console_logs: ['warn', 'error'], - } satisfies Partial) - logic.actions.resetFilters() - }).toMatchValues({ totalFiltersCount: 0 }) - }) - }) - }) -}) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.ts deleted file mode 100644 index f0ce6630fc48d..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.ts +++ /dev/null @@ -1,646 +0,0 @@ -import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' -import api from 'lib/api' -import { objectClean, toParams } from 'lib/utils' -import { - AnyPropertyFilter, - PropertyFilterType, - PropertyOperator, - RecordingDurationFilter, - RecordingFilters, - SessionRecordingId, - SessionRecordingsResponse, - SessionRecordingType, -} from '~/types' -import type { sessionRecordingsListLogicType } from './sessionRecordingsListLogicType' -import { actionToUrl, router, urlToAction } from 'kea-router' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import equal from 'fast-deep-equal' -import { loaders } from 'kea-loaders' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' -import { playerSettingsLogic } from '../player/playerSettingsLogic' -import posthog from 'posthog-js' - -export type PersonUUID = string - -interface Params { - filters?: RecordingFilters - sessionRecordingId?: SessionRecordingId -} - -interface NoEventsToMatch { - matchType: 'none' -} - -interface SimpleEventsMatching { - matchType: 'simple' - eventNames: string[] -} - -interface BackendEventsMatching { - matchType: 'backend' - filters: RecordingFilters -} - -export type MatchingEventsMatchType = NoEventsToMatch | SimpleEventsMatching | BackendEventsMatching - -export const RECORDINGS_LIMIT = 20 -export const PINNED_RECORDINGS_LIMIT = 100 // NOTE: This is high but avoids the need for pagination for now... - -export const defaultRecordingDurationFilter: RecordingDurationFilter = { - type: PropertyFilterType.Recording, - key: 'duration', - value: 60, - operator: PropertyOperator.GreaterThan, -} - -export const DEFAULT_RECORDING_FILTERS: RecordingFilters = { - session_recording_duration: defaultRecordingDurationFilter, - properties: [], - events: [], - actions: [], - date_from: '-7d', - date_to: null, - console_logs: [], -} - -const DEFAULT_PERSON_RECORDING_FILTERS: RecordingFilters = { - ...DEFAULT_RECORDING_FILTERS, - date_from: '-21d', - session_recording_duration: { - type: PropertyFilterType.Recording, - key: 'duration', - value: 1, - operator: PropertyOperator.GreaterThan, - }, -} - -const getDefaultFilters = (personUUID?: PersonUUID): RecordingFilters => { - return personUUID ? DEFAULT_PERSON_RECORDING_FILTERS : DEFAULT_RECORDING_FILTERS -} - -const addedAdvancedFilters = (filters: RecordingFilters | undefined, defaultFilters: RecordingFilters): boolean => { - if (!filters) { - return false - } - - const hasActions = filters.actions ? filters.actions.length > 0 : false - const hasChangedDateFrom = filters.date_from != defaultFilters.date_from - const hasChangedDateTo = filters.date_to != defaultFilters.date_to - const hasConsoleLogsFilters = filters.console_logs ? filters.console_logs.length > 0 : false - const hasChangedDuration = !equal(filters.session_recording_duration, defaultFilters.session_recording_duration) - const eventsFilters = filters.events || [] - const hasAdvancedEvents = eventsFilters.length > 1 || (!!eventsFilters[0] && eventsFilters[0].name != '$pageview') - - return ( - hasActions || - hasAdvancedEvents || - hasChangedDuration || - hasChangedDateFrom || - hasChangedDateTo || - hasConsoleLogsFilters - ) -} - -export const defaultPageviewPropertyEntityFilter = ( - filters: RecordingFilters, - property: string, - value?: string -): Partial => { - const existingPageview = filters.events?.find(({ name }) => name === '$pageview') - const eventEntityFilters = filters.events ?? [] - const propToAdd = value - ? { - key: property, - value: [value], - operator: PropertyOperator.Exact, - type: 'event', - } - : { - key: property, - value: PropertyOperator.IsNotSet, - operator: PropertyOperator.IsNotSet, - type: 'event', - } - - // If pageview exists, add property to the first pageview event - if (existingPageview) { - return { - events: eventEntityFilters.map((eventFilter) => - eventFilter.order === existingPageview.order - ? { - ...eventFilter, - properties: [ - ...(eventFilter.properties?.filter(({ key }: AnyPropertyFilter) => key !== property) ?? - []), - propToAdd, - ], - } - : eventFilter - ), - } - } else { - return { - events: [ - ...eventEntityFilters, - { - id: '$pageview', - name: '$pageview', - type: 'events', - order: eventEntityFilters.length, - properties: [propToAdd], - }, - ], - } - } -} - -export function generateSessionRecordingListLogicKey(props: SessionRecordingListLogicProps): string { - return `${props.key}-${props.playlistShortId}-${props.personUUID}-${props.updateSearchParams ? '-with-search' : ''}` -} - -export interface SessionRecordingListLogicProps { - key?: string - playlistShortId?: string - personUUID?: PersonUUID - filters?: RecordingFilters - updateSearchParams?: boolean - autoPlay?: boolean - onFiltersChange?: (filters: RecordingFilters) => void -} - -export const sessionRecordingsListLogic = kea([ - path((key) => ['scenes', 'session-recordings', 'playlist', 'sessionRecordingsListLogic', key]), - props({} as SessionRecordingListLogicProps), - key(generateSessionRecordingListLogicKey), - connect({ - actions: [ - eventUsageLogic, - ['reportRecordingsListFetched', 'reportRecordingsListFilterAdded'], - sessionRecordingsListPropertiesLogic, - ['maybeLoadPropertiesForSessions'], - ], - values: [ - featureFlagLogic, - ['featureFlags'], - playerSettingsLogic, - ['autoplayDirection', 'hideViewedRecordings'], - ], - }), - actions({ - setFilters: (filters: Partial) => ({ filters }), - setShowFilters: (showFilters: boolean) => ({ showFilters }), - setShowAdvancedFilters: (showAdvancedFilters: boolean) => ({ showAdvancedFilters }), - setShowSettings: (showSettings: boolean) => ({ showSettings }), - resetFilters: true, - setSelectedRecordingId: (id: SessionRecordingType['id'] | null) => ({ - id, - }), - loadAllRecordings: true, - loadPinnedRecordings: true, - loadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), - maybeLoadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), - loadNext: true, - loadPrev: true, - }), - loaders(({ props, values, actions }) => ({ - eventsHaveSessionId: [ - {} as Record, - { - loadEventsHaveSessionId: async () => { - const events = values.filters.events - if (events === undefined || events.length === 0) { - return {} - } - - return await api.propertyDefinitions.seenTogether({ - eventNames: events.map((event) => event.name), - propertyDefinitionName: '$session_id', - }) - }, - }, - ], - sessionRecordingsResponse: [ - { - results: [], - has_next: false, - } as SessionRecordingsResponse, - { - loadSessionRecordings: async ({ direction }, breakpoint) => { - const paramsDict = { - ...values.filters, - person_uuid: props.personUUID ?? '', - limit: RECORDINGS_LIMIT, - } - - if (direction === 'older') { - paramsDict['date_to'] = - values.sessionRecordings[values.sessionRecordings.length - 1]?.start_time - } - - if (direction === 'newer') { - paramsDict['date_from'] = values.sessionRecordings[0]?.start_time - } - - const params = toParams(paramsDict) - - await breakpoint(100) // Debounce for lots of quick filter changes - - const startTime = performance.now() - const response = await api.recordings.list(params) - const loadTimeMs = performance.now() - startTime - - actions.reportRecordingsListFetched(loadTimeMs) - - breakpoint() - - return { - has_next: - direction === 'newer' - ? values.sessionRecordingsResponse?.has_next ?? true - : response.has_next, - results: response.results, - } - }, - }, - ], - pinnedRecordingsResponse: [ - null as SessionRecordingsResponse | null, - { - loadPinnedRecordings: async (_, breakpoint) => { - if (!props.playlistShortId) { - return null - } - - const paramsDict = { - limit: PINNED_RECORDINGS_LIMIT, - } - - const params = toParams(paramsDict) - await breakpoint(100) - const response = await api.recordings.listPlaylistRecordings(props.playlistShortId, params) - breakpoint() - return response - }, - }, - ], - })), - reducers(({ props }) => ({ - unusableEventsInFilter: [ - [] as string[], - { - loadEventsHaveSessionIdSuccess: (_, { eventsHaveSessionId }) => { - return Object.entries(eventsHaveSessionId) - .filter(([, hasSessionId]) => !hasSessionId) - .map(([eventName]) => eventName) - }, - }, - ], - customFilters: [ - (props.filters ?? null) as RecordingFilters | null, - { - setFilters: (state, { filters }) => ({ - ...state, - ...filters, - }), - resetFilters: () => null, - }, - ], - showFilters: [ - true, - { - persist: true, - }, - { - setShowFilters: (_, { showFilters }) => showFilters, - setShowSettings: () => false, - }, - ], - showSettings: [ - false, - { - persist: true, - }, - { - setShowSettings: (_, { showSettings }) => showSettings, - setShowFilters: () => false, - }, - ], - showAdvancedFilters: [ - addedAdvancedFilters(props.filters, getDefaultFilters(props.personUUID)), - { - setShowAdvancedFilters: (_, { showAdvancedFilters }) => showAdvancedFilters, - }, - ], - sessionRecordings: [ - [] as SessionRecordingType[], - { - loadSessionRecordings: (state, { direction }) => { - // Reset if we are not paginating - return direction ? state : [] - }, - - loadSessionRecordingsSuccess: (state, { sessionRecordingsResponse }) => { - const mergedResults: SessionRecordingType[] = [...state] - - sessionRecordingsResponse.results.forEach((recording) => { - if (!state.find((r) => r.id === recording.id)) { - mergedResults.push(recording) - } - }) - - mergedResults.sort((a, b) => (a.start_time > b.start_time ? -1 : 1)) - - return mergedResults - }, - setSelectedRecordingId: (state, { id }) => - state.map((s) => { - if (s.id === id) { - return { - ...s, - viewed: true, - } - } else { - return { ...s } - } - }), - }, - ], - selectedRecordingId: [ - null as SessionRecordingType['id'] | null, - { - setSelectedRecordingId: (_, { id }) => id ?? null, - }, - ], - sessionRecordingsAPIErrored: [ - false, - { - loadSessionRecordingsFailure: () => true, - loadSessionRecordingSuccess: () => false, - setFilters: () => false, - loadNext: () => false, - loadPrev: () => false, - }, - ], - pinnedRecordingsAPIErrored: [ - false, - { - loadPinnedRecordingsFailure: () => true, - loadPinnedRecordingsSuccess: () => false, - setFilters: () => false, - loadNext: () => false, - loadPrev: () => false, - }, - ], - })), - listeners(({ props, actions, values }) => ({ - loadAllRecordings: () => { - actions.loadSessionRecordings() - actions.loadPinnedRecordings() - }, - setFilters: ({ filters }) => { - actions.loadSessionRecordings() - props.onFiltersChange?.(values.filters) - - // capture only the partial filters applied (not the full filters object) - // take each key from the filter and change it to `partial_filter_chosen_${key}` - const partialFilters = Object.keys(filters).reduce((acc, key) => { - acc[`partial_filter_chosen_${key}`] = filters[key] - return acc - }, {}) - posthog.capture('recording list filters changed', { ...partialFilters }) - - actions.loadEventsHaveSessionId() - }, - - resetFilters: () => { - actions.loadSessionRecordings() - props.onFiltersChange?.(values.filters) - }, - - maybeLoadSessionRecordings: ({ direction }) => { - if (direction === 'older' && !values.hasNext) { - return // Nothing more to load - } - if (values.sessionRecordingsResponseLoading) { - return // We don't want to load if we are currently loading - } - actions.loadSessionRecordings(direction) - }, - - loadSessionRecordingsSuccess: () => { - actions.maybeLoadPropertiesForSessions(values.sessionRecordings) - }, - - setSelectedRecordingId: () => { - // If we are at the end of the list then try to load more - const recordingIndex = values.sessionRecordings.findIndex((s) => s.id === values.selectedRecordingId) - if (recordingIndex === values.sessionRecordings.length - 1) { - actions.maybeLoadSessionRecordings('older') - } - }, - })), - selectors({ - shouldShowEmptyState: [ - (s) => [ - s.sessionRecordings, - s.customFilters, - s.sessionRecordingsResponseLoading, - s.sessionRecordingsAPIErrored, - s.pinnedRecordingsAPIErrored, - (_, props) => props.personUUID, - ], - ( - sessionRecordings, - customFilters, - sessionRecordingsResponseLoading, - sessionRecordingsAPIErrored, - pinnedRecordingsAPIErrored, - personUUID - ): boolean => { - return ( - !sessionRecordingsAPIErrored && - !pinnedRecordingsAPIErrored && - !sessionRecordingsResponseLoading && - sessionRecordings.length === 0 && - !customFilters && - !personUUID - ) - }, - ], - - filters: [ - (s) => [s.customFilters, (_, props) => props.personUUID], - (customFilters, personUUID): RecordingFilters => { - const defaultFilters = getDefaultFilters(personUUID) - return { - ...defaultFilters, - ...customFilters, - } - }, - ], - - matchingEventsMatchType: [ - (s) => [s.filters], - (filters: RecordingFilters | undefined): MatchingEventsMatchType => { - if (!filters) { - return { matchType: 'none' } - } - - const hasActions = !!filters.actions?.length - const hasEvents = !!filters.events?.length - const simpleEvents = (filters.events || []) - .filter((e) => !e.properties || !e.properties.length) - .map((e) => e.name.toString()) - const hasSimpleEvents = !!simpleEvents.length - - if (hasActions) { - return { matchType: 'backend', filters } - } else { - if (!hasEvents) { - return { matchType: 'none' } - } - - if (hasEvents && hasSimpleEvents && simpleEvents.length === filters.events?.length) { - return { - matchType: 'simple', - eventNames: simpleEvents, - } - } else { - return { - matchType: 'backend', - filters, - } - } - } - }, - ], - activeSessionRecording: [ - (s) => [s.selectedRecordingId, s.sessionRecordings, (_, props) => props.autoPlay], - (selectedRecordingId, sessionRecordings, autoPlay): Partial | undefined => { - return selectedRecordingId - ? sessionRecordings.find((sessionRecording) => sessionRecording.id === selectedRecordingId) || { - id: selectedRecordingId, - } - : autoPlay - ? sessionRecordings[0] - : undefined - }, - ], - nextSessionRecording: [ - (s) => [s.activeSessionRecording, s.sessionRecordings, s.autoplayDirection], - ( - activeSessionRecording, - sessionRecordings, - autoplayDirection - ): Partial | undefined => { - if (!activeSessionRecording || !autoplayDirection) { - return - } - const activeSessionRecordingIndex = sessionRecordings.findIndex( - (x) => x.id === activeSessionRecording.id - ) - return autoplayDirection === 'older' - ? sessionRecordings[activeSessionRecordingIndex + 1] - : sessionRecordings[activeSessionRecordingIndex - 1] - }, - ], - hasNext: [ - (s) => [s.sessionRecordingsResponse], - (sessionRecordingsResponse) => sessionRecordingsResponse.has_next, - ], - totalFiltersCount: [ - (s) => [s.filters, (_, props) => props.personUUID], - (filters, personUUID) => { - const defaultFilters = getDefaultFilters(personUUID) - - return ( - (filters?.actions?.length || 0) + - (filters?.events?.length || 0) + - (filters?.properties?.length || 0) + - (equal(filters.session_recording_duration, defaultFilters.session_recording_duration) ? 0 : 1) + - (filters.date_from === defaultFilters.date_from && filters.date_to === defaultFilters.date_to - ? 0 - : 1) + - (filters.console_logs?.length || 0) - ) - }, - ], - hasAdvancedFilters: [ - (s) => [s.filters, (_, props) => props.personUUID], - (filters, personUUID) => { - const defaultFilters = getDefaultFilters(personUUID) - return addedAdvancedFilters(filters, defaultFilters) - }, - ], - visibleRecordings: [ - (s) => [s.sessionRecordings, s.hideViewedRecordings], - (sessionRecordings, hideViewedRecordings) => { - return hideViewedRecordings ? sessionRecordings.filter((r) => !r.viewed) : sessionRecordings - }, - ], - }), - - actionToUrl(({ props, values }) => { - if (!props.updateSearchParams) { - return {} - } - const buildURL = ( - replace: boolean - ): [ - string, - Params, - Record, - { - replace: boolean - } - ] => { - const params: Params = objectClean({ - filters: values.customFilters ?? undefined, - sessionRecordingId: values.selectedRecordingId ?? undefined, - }) - - // We used to have sessionRecordingId in the hash, so we keep it there for backwards compatibility - if (router.values.hashParams.sessionRecordingId) { - delete router.values.hashParams.sessionRecordingId - } - - return [router.values.location.pathname, params, router.values.hashParams, { replace }] - } - - return { - setSelectedRecordingId: () => buildURL(false), - setFilters: () => buildURL(true), - resetFilters: () => buildURL(true), - } - }), - - urlToAction(({ actions, values, props }) => { - const urlToAction = (_: any, params: Params, hashParams: Params): void => { - if (!props.updateSearchParams) { - return - } - - // We changed to have the sessionRecordingId in the query params, but it used to be in the hash so backwards compatibility - const nulledSessionRecordingId = params.sessionRecordingId ?? hashParams.sessionRecordingId ?? null - if (nulledSessionRecordingId !== values.selectedRecordingId) { - actions.setSelectedRecordingId(nulledSessionRecordingId) - } - - if (params.filters) { - if (!equal(params.filters, values.customFilters)) { - actions.setFilters(params.filters) - } - } - } - return { - '*': urlToAction, - } - }), - - // NOTE: It is important this comes after urlToAction, as it will override the default behavior - afterMount(({ actions }) => { - actions.loadSessionRecordings() - actions.loadPinnedRecordings() - }), -]) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts index 5aa7701de99c7..bbe331b1f9c08 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts @@ -1,81 +1,491 @@ +import { + sessionRecordingsPlaylistLogic, + RECORDINGS_LIMIT, + DEFAULT_RECORDING_FILTERS, + defaultRecordingDurationFilter, +} from './sessionRecordingsPlaylistLogic' import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' +import { router } from 'kea-router' +import { PropertyFilterType, PropertyOperator, RecordingFilters } from '~/types' import { useMocks } from '~/mocks/jest' -import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' +import { sessionRecordingDataLogic } from '../player/sessionRecordingDataLogic' describe('sessionRecordingsPlaylistLogic', () => { let logic: ReturnType - const mockPlaylist = { - id: 'abc', - short_id: 'short_abc', - name: 'Test Playlist', - filters: { - events: [], - date_from: '2022-10-18', - session_recording_duration: { - key: 'duration', - type: 'recording', - value: 60, - operator: 'gt', - }, - }, - } - - beforeEach(() => { - useMocks({ - get: { - '/api/projects/:team/session_recording_playlists/:id': mockPlaylist, - }, - patch: { - '/api/projects/:team/session_recording_playlists/:id': () => { - return [ - 200, - { - updated_playlist: 'blah', - }, - ] + const aRecording = { id: 'abc', viewed: false, recording_duration: 10 } + const listOfSessionRecordings = [aRecording] + + describe('with no recordings to load', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/session_recordings/properties': { + results: [], + }, + + '/api/projects/:team/session_recordings': { has_next: false, results: [] }, + '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': { + results: [], + }, }, - }, + }) + initKeaTests() + logic = sessionRecordingsPlaylistLogic({ + key: 'tests', + updateSearchParams: true, + }) + logic.mount() }) - initKeaTests() - }) - beforeEach(() => { - logic = sessionRecordingsPlaylistLogic({ shortId: mockPlaylist.short_id }) - logic.mount() - }) + describe('should show empty state', () => { + it('starts out false', async () => { + await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) + }) + + it('is true if after API call is made there are no results', async () => { + await expectLogic(logic, () => { + logic.actions.setSelectedRecordingId('abc') + }) + .toDispatchActionsInAnyOrder(['loadSessionRecordings', 'loadSessionRecordingsSuccess']) + .toMatchValues({ shouldShowEmptyState: true }) + }) - describe('core assumptions', () => { - it('loads playlist after mounting', async () => { - await expectLogic(logic).toDispatchActions(['getPlaylistSuccess']) - expect(logic.values.playlist).toEqual(mockPlaylist) + it('is false after API call error', async () => { + await expectLogic(logic, () => { + logic.actions.loadSessionRecordingsFailure('abc') + }).toMatchValues({ shouldShowEmptyState: false }) + }) }) }) - describe('update playlist', () => { - it('set new filter then update playlist', () => { - const newFilter = { - events: [ - { - id: '$autocapture', - type: 'events', - order: 0, - name: '$autocapture', + describe('with recordings to load', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/session_recordings/properties': { + results: [ + { id: 's1', properties: { blah: 'blah1' } }, + { id: 's2', properties: { blah: 'blah2' } }, + ], + }, + + '/api/projects/:team/session_recordings': (req) => { + const { searchParams } = req.url + if ( + (searchParams.get('events')?.length || 0) > 0 && + JSON.parse(searchParams.get('events') || '[]')[0]?.['id'] === '$autocapture' + ) { + return [ + 200, + { + results: ['List of recordings filtered by events'], + }, + ] + } else if (searchParams.get('person_uuid') === 'cool_user_99') { + return [ + 200, + { + results: ["List of specific user's recordings from server"], + }, + ] + } else if (searchParams.get('offset') === `${RECORDINGS_LIMIT}`) { + return [ + 200, + { + results: [`List of recordings offset by ${RECORDINGS_LIMIT}`], + }, + ] + } else if ( + searchParams.get('date_from') === '2021-10-05' && + searchParams.get('date_to') === '2021-10-20' + ) { + return [ + 200, + { + results: ['Recordings filtered by date'], + }, + ] + } else if ( + JSON.parse(searchParams.get('session_recording_duration') ?? '{}')['value'] === 600 + ) { + return [ + 200, + { + results: ['Recordings filtered by duration'], + }, + ] + } + return [ + 200, + { + results: listOfSessionRecordings, + }, + ] + }, + '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': () => { + return [ + 200, + { + results: ['Pinned recordings'], + }, + ] + }, + }, + }) + initKeaTests() + }) + + describe('global logic', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'tests', + updateSearchParams: true, + }) + logic.mount() + }) + + describe('core assumptions', () => { + it('loads recent recordings after mounting', async () => { + await expectLogic(logic) + .toDispatchActionsInAnyOrder(['loadSessionRecordingsSuccess']) + .toMatchValues({ + sessionRecordings: listOfSessionRecordings, + }) + }) + }) + + describe('should show empty state', () => { + it('starts out false', async () => { + await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) + }) + }) + + describe('activeSessionRecording', () => { + it('starts as null', () => { + expectLogic(logic).toMatchValues({ activeSessionRecording: undefined }) + }) + it('is set by setSessionRecordingId', async () => { + expectLogic(logic, () => logic.actions.setSelectedRecordingId('abc')) + .toDispatchActions(['loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'abc', + activeSessionRecording: listOfSessionRecordings[0], + }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') + }) + + it('is partial if sessionRecordingId not in list', async () => { + expectLogic(logic, () => logic.actions.setSelectedRecordingId('not-in-list')) + .toDispatchActions(['loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'not-in-list', + activeSessionRecording: { id: 'not-in-list' }, + }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'not-in-list') + }) + + it('is read from the URL on the session recording page', async () => { + router.actions.push('/replay', {}, { sessionRecordingId: 'abc' }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') + + await expectLogic(logic) + .toDispatchActionsInAnyOrder(['setSelectedRecordingId', 'loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'abc', + activeSessionRecording: listOfSessionRecordings[0], + }) + }) + + it('mounts and loads the recording when a recording is opened', () => { + expectLogic(logic, async () => await logic.actions.setSelectedRecordingId('abcd')) + .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' })) + .toDispatchActions(['loadEntireRecording']) + }) + + it('returns the first session recording if none selected', () => { + expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ + selectedRecordingId: undefined, + activeSessionRecording: listOfSessionRecordings[0], + }) + expect(router.values.searchParams).not.toHaveProperty('sessionRecordingId', 'not-in-list') + }) + }) + + describe('entityFilters', () => { + it('starts with default values', () => { + expectLogic(logic).toMatchValues({ filters: DEFAULT_RECORDING_FILTERS }) + }) + + it('is set by setFilters and loads filtered results and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + }) + }) + .toDispatchActions(['setFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) + .toMatchValues({ + sessionRecordings: ['List of recordings filtered by events'], + }) + expect(router.values.searchParams.filters).toHaveProperty('events', [ + { id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }, + ]) + }) + }) + + describe('date range', () => { + it('is set by setFilters and fetches results from server and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + date_from: '2021-10-05', + date_to: '2021-10-20', + }) + }) + .toMatchValues({ + filters: expect.objectContaining({ + date_from: '2021-10-05', + date_to: '2021-10-20', + }), + }) + .toDispatchActions(['setFilters', 'loadSessionRecordingsSuccess']) + .toMatchValues({ sessionRecordings: ['Recordings filtered by date'] }) + + expect(router.values.searchParams.filters).toHaveProperty('date_from', '2021-10-05') + expect(router.values.searchParams.filters).toHaveProperty('date_to', '2021-10-20') + }) + }) + describe('duration filter', () => { + it('is set by setFilters and fetches results from server and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + session_recording_duration: { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, + }, + }) + }) + .toMatchValues({ + filters: expect.objectContaining({ + session_recording_duration: { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, + }, + }), + }) + .toDispatchActions(['setFilters', 'loadSessionRecordingsSuccess']) + .toMatchValues({ sessionRecordings: ['Recordings filtered by duration'] }) + + expect(router.values.searchParams.filters).toHaveProperty('session_recording_duration', { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, + }) + }) + }) + + describe('set recording from hash param', () => { + it('loads the correct recording from the hash params', async () => { + router.actions.push('/replay/recent', {}, { sessionRecordingId: 'abc' }) + + logic = sessionRecordingsPlaylistLogic({ + key: 'hash-recording-tests', + updateSearchParams: true, + }) + logic.mount() + + await expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ + selectedRecordingId: 'abc', + }) + + logic.actions.setSelectedRecordingId('1234') + }) + }) + + describe('sessionRecording.viewed', () => { + it('changes when setSelectedRecordingId is called', async () => { + await expectLogic(logic) + .toFinishAllListeners() + .toMatchValues({ + sessionRecordingsResponse: { + results: [{ ...aRecording }], + has_next: undefined, + }, + sessionRecordings: [ + { + ...aRecording, + }, + ], + }) + + await expectLogic(logic, () => { + logic.actions.setSelectedRecordingId('abc') + }) + .toFinishAllListeners() + .toMatchValues({ + sessionRecordingsResponse: { + results: [ + { + ...aRecording, + // at this point the view hasn't updated this object + viewed: false, + }, + ], + }, + sessionRecordings: [ + { + ...aRecording, + viewed: true, + }, + ], + }) + }) + + it('is set by setFilters and loads filtered results', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + }) + }) + .toDispatchActions(['setFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) + .toMatchValues({ + sessionRecordings: ['List of recordings filtered by events'], + }) + }) + }) + + it('reads filters from the URL', async () => { + router.actions.push('/replay', { + filters: { + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + date_from: '2021-10-01', + date_to: '2021-10-10', + offset: 50, + session_recording_duration: { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, + }, }, - ], - } - expectLogic(logic, async () => { - await logic.actions.setFilters(newFilter) - await logic.actions.updatePlaylist({}) - }) - .toDispatchActions(['setFilters']) - .toMatchValues({ filters: expect.objectContaining(newFilter), hasChanges: true }) - .toDispatchActions(['saveChanges', 'updatePlaylist', 'updatePlaylistSuccess']) - .toMatchValues({ - playlist: { - updated_playlist: 'blah', + }) + + await expectLogic(logic) + .toDispatchActions(['setFilters']) + .toMatchValues({ + filters: { + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + date_from: '2021-10-01', + date_to: '2021-10-10', + offset: 50, + console_logs: [], + properties: [], + session_recording_duration: { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, + }, + }, + }) + }) + + it('reads filters from the URL and defaults the duration filter', async () => { + router.actions.push('/replay', { + filters: { + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], }, }) + + await expectLogic(logic) + .toDispatchActions(['setFilters']) + .toMatchValues({ + customFilters: { + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + }, + filters: { + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + session_recording_duration: defaultRecordingDurationFilter, + console_logs: [], + date_from: '-7d', + date_to: null, + events: [], + properties: [], + }, + }) + }) + }) + + describe('person specific logic', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, + }) + logic.mount() + }) + + it('loads session recordings for a specific user', async () => { + await expectLogic(logic) + .toDispatchActions(['loadSessionRecordingsSuccess']) + .toMatchValues({ sessionRecordings: ["List of specific user's recordings from server"] }) + }) + + it('reads sessionRecordingId from the URL on the person page', async () => { + router.actions.push('/person/123', {}, { sessionRecordingId: 'abc' }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') + + await expectLogic(logic).toDispatchActions([logic.actionCreators.setSelectedRecordingId('abc')]) + }) + }) + + describe('total filters count', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, + }) + logic.mount() + }) + it('starts with a count of zero', async () => { + await expectLogic(logic).toMatchValues({ totalFiltersCount: 0 }) + }) + + it('counts console log filters', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + console_logs: ['warn', 'error'], + } satisfies Partial) + }).toMatchValues({ totalFiltersCount: 2 }) + }) + }) + + describe('resetting filters', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, + }) + logic.mount() + }) + + it('resets console log filters', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + console_logs: ['warn', 'error'], + } satisfies Partial) + logic.actions.resetFilters() + }).toMatchValues({ totalFiltersCount: 0 }) + }) }) }) }) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index 781a601c2db97..b07d55e3f9d10 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -1,128 +1,720 @@ -import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' -import { Breadcrumb, RecordingFilters, SessionRecordingPlaylistType, ReplayTabs } from '~/types' -import type { sessionRecordingsPlaylistLogicType } from './sessionRecordingsPlaylistLogicType' -import { urls } from 'scenes/urls' -import equal from 'fast-deep-equal' -import { beforeUnload, router } from 'kea-router' -import { cohortsModel } from '~/models/cohortsModel' +import { actions, afterMount, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' +import api from 'lib/api' +import { objectClean, objectsEqual } from 'lib/utils' import { - deletePlaylist, - duplicatePlaylist, - getPlaylist, - summarizePlaylistFilters, - updatePlaylist, -} from 'scenes/session-recordings/playlist/playlistUtils' + AnyPropertyFilter, + PropertyFilterType, + PropertyOperator, + RecordingDurationFilter, + RecordingFilters, + SessionRecordingId, + SessionRecordingsResponse, + SessionRecordingType, +} from '~/types' +import { actionToUrl, router, urlToAction } from 'kea-router' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import equal from 'fast-deep-equal' import { loaders } from 'kea-loaders' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' +import { playerSettingsLogic } from '../player/playerSettingsLogic' +import posthog from 'posthog-js' + +import type { sessionRecordingsPlaylistLogicType } from './sessionRecordingsPlaylistLogicType' + +export type PersonUUID = string + +interface Params { + filters?: RecordingFilters + sessionRecordingId?: SessionRecordingId +} + +interface NoEventsToMatch { + matchType: 'none' +} + +interface EventNamesMatching { + matchType: 'name' + eventNames: string[] +} -export interface SessionRecordingsPlaylistLogicProps { - shortId: string +interface EventUUIDsMatching { + matchType: 'uuid' + eventUUIDs: string[] +} + +interface BackendEventsMatching { + matchType: 'backend' + filters: RecordingFilters +} + +export type MatchingEventsMatchType = NoEventsToMatch | EventNamesMatching | EventUUIDsMatching | BackendEventsMatching + +export const RECORDINGS_LIMIT = 20 +export const PINNED_RECORDINGS_LIMIT = 100 // NOTE: This is high but avoids the need for pagination for now... + +export const defaultRecordingDurationFilter: RecordingDurationFilter = { + type: PropertyFilterType.Recording, + key: 'duration', + value: 1, + operator: PropertyOperator.GreaterThan, +} + +export const DEFAULT_RECORDING_FILTERS: RecordingFilters = { + session_recording_duration: defaultRecordingDurationFilter, + properties: [], + events: [], + actions: [], + date_from: '-7d', + date_to: null, + console_logs: [], +} + +const DEFAULT_PERSON_RECORDING_FILTERS: RecordingFilters = { + ...DEFAULT_RECORDING_FILTERS, + date_from: '-30d', +} + +export const getDefaultFilters = (personUUID?: PersonUUID): RecordingFilters => { + return personUUID ? DEFAULT_PERSON_RECORDING_FILTERS : DEFAULT_RECORDING_FILTERS +} + +function isPageViewFilter(filter: Record): boolean { + return filter.name === '$pageview' +} +function isCurrentURLPageViewFilter(eventsFilter: Record): boolean { + const hasSingleProperty = Array.isArray(eventsFilter.properties) && eventsFilter.properties?.length === 1 + const isCurrentURLProperty = hasSingleProperty && eventsFilter.properties[0].key === '$current_url' + return isPageViewFilter(eventsFilter) && isCurrentURLProperty +} + +// checks are stored against filter keys so that the type system enforces adding a check when we add new filters +const advancedFilterChecks: Record< + keyof RecordingFilters, + (filters: RecordingFilters, defaultFilters: RecordingFilters) => boolean +> = { + actions: (filters) => (filters.actions ? filters.actions.length > 0 : false), + events: function (filters: RecordingFilters): boolean { + const eventsFilters = filters.events || [] + // simple filters allow a single $pageview event filter with $current_url as the selected property + // anything else is advanced + return ( + eventsFilters.length > 1 || + (!!eventsFilters[0] && + (!isPageViewFilter(eventsFilters[0]) || !isCurrentURLPageViewFilter(eventsFilters[0]))) + ) + }, + properties: function (): boolean { + // TODO is this right? should we ever care about properties for choosing between advanced and simple? + return false + }, + date_from: (filters, defaultFilters) => filters.date_from != defaultFilters.date_from, + date_to: (filters, defaultFilters) => filters.date_to != defaultFilters.date_to, + session_recording_duration: (filters, defaultFilters) => + !equal(filters.session_recording_duration, defaultFilters.session_recording_duration), + duration_type_filter: (filters, defaultFilters) => + filters.duration_type_filter !== defaultFilters.duration_type_filter, + console_search_query: (filters) => + filters.console_search_query ? filters.console_search_query.trim().length > 0 : false, + console_logs: (filters) => (filters.console_logs ? filters.console_logs.length > 0 : false), + filter_test_accounts: (filters) => filters.filter_test_accounts ?? false, +} + +export const addedAdvancedFilters = ( + filters: RecordingFilters | undefined, + defaultFilters: RecordingFilters +): boolean => { + // if there are no filters or if some filters are not present then the page is still booting up + if (!filters || filters.session_recording_duration === undefined || filters.date_from === undefined) { + return false + } + + // keeps results with the keys for printing when debugging + const checkResults = Object.keys(advancedFilterChecks).map((key) => ({ + key, + result: advancedFilterChecks[key](filters, defaultFilters), + })) + + // if any check is true, then this is an advanced filter + return checkResults.some((checkResult) => checkResult.result) +} + +export const defaultPageviewPropertyEntityFilter = ( + filters: RecordingFilters, + property: string, + value?: string +): Partial => { + const existingPageview = filters.events?.find(({ name }) => name === '$pageview') + const eventEntityFilters = filters.events ?? [] + const propToAdd = value + ? { + key: property, + value: [value], + operator: PropertyOperator.Exact, + type: 'event', + } + : { + key: property, + value: PropertyOperator.IsNotSet, + operator: PropertyOperator.IsNotSet, + type: 'event', + } + + // If pageview exists, add property to the first pageview event + if (existingPageview) { + return { + events: eventEntityFilters.map((eventFilter) => + eventFilter.order === existingPageview.order + ? { + ...eventFilter, + properties: [ + ...(eventFilter.properties?.filter(({ key }: AnyPropertyFilter) => key !== property) ?? + []), + propToAdd, + ], + } + : eventFilter + ), + } + } else { + return { + events: [ + ...eventEntityFilters, + { + id: '$pageview', + name: '$pageview', + type: 'events', + order: eventEntityFilters.length, + properties: [propToAdd], + }, + ], + } + } +} + +export interface SessionRecordingPlaylistLogicProps { + logicKey?: string + personUUID?: PersonUUID + updateSearchParams?: boolean + autoPlay?: boolean + filters?: RecordingFilters + onFiltersChange?: (filters: RecordingFilters) => void + pinnedRecordings?: (SessionRecordingType | string)[] + onPinnedChange?: (recording: SessionRecordingType, pinned: boolean) => void } export const sessionRecordingsPlaylistLogic = kea([ path((key) => ['scenes', 'session-recordings', 'playlist', 'sessionRecordingsPlaylistLogic', key]), - props({} as SessionRecordingsPlaylistLogicProps), - key((props) => props.shortId), + props({} as SessionRecordingPlaylistLogicProps), + key( + (props: SessionRecordingPlaylistLogicProps) => + `${props.logicKey}-${props.personUUID}-${props.updateSearchParams ? '-with-search' : ''}` + ), connect({ - values: [cohortsModel, ['cohortsById']], + actions: [ + eventUsageLogic, + ['reportRecordingsListFetched', 'reportRecordingsListFilterAdded'], + sessionRecordingsListPropertiesLogic, + ['maybeLoadPropertiesForSessions'], + ], + values: [ + featureFlagLogic, + ['featureFlags'], + playerSettingsLogic, + ['autoplayDirection', 'hideViewedRecordings'], + ], }), actions({ - updatePlaylist: (properties?: Partial, silent = false) => ({ - properties, - silent, + setFilters: (filters: Partial) => ({ filters }), + setShowFilters: (showFilters: boolean) => ({ showFilters }), + setShowAdvancedFilters: (showAdvancedFilters: boolean) => ({ showAdvancedFilters }), + setShowSettings: (showSettings: boolean) => ({ showSettings }), + resetFilters: true, + setSelectedRecordingId: (id: SessionRecordingType['id'] | null) => ({ + id, }), - setFilters: (filters: RecordingFilters | null) => ({ filters }), + loadAllRecordings: true, + loadPinnedRecordings: true, + loadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), + maybeLoadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), + loadNext: true, + loadPrev: true, + }), + propsChanged(({ actions, props }, oldProps) => { + if (!objectsEqual(props.filters, oldProps.filters)) { + props.filters ? actions.setFilters(props.filters) : actions.resetFilters() + } + + // If the defined list changes, we need to call the loader to either load the new items or change the list + if (props.pinnedRecordings !== oldProps.pinnedRecordings) { + actions.loadPinnedRecordings() + } }), - loaders(({ values, props }) => ({ - playlist: [ - null as SessionRecordingPlaylistType | null, + + loaders(({ props, values, actions }) => ({ + eventsHaveSessionId: [ + {} as Record, { - getPlaylist: async () => { - return getPlaylist(props.shortId) - }, - updatePlaylist: async ({ properties, silent }) => { - if (!values.playlist?.short_id) { - return values.playlist + loadEventsHaveSessionId: async () => { + const events = values.filters.events + if (events === undefined || events.length === 0) { + return {} } - return updatePlaylist( - values.playlist?.short_id, - properties ?? { filters: values.filters || undefined }, - silent - ) + + return await api.propertyDefinitions.seenTogether({ + eventNames: events.map((event) => event.name), + propertyDefinitionName: '$session_id', + }) }, - duplicatePlaylist: async () => { - return duplicatePlaylist(values.playlist ?? {}, true) + }, + ], + sessionRecordingsResponse: [ + { + results: [], + has_next: false, + } as SessionRecordingsResponse, + { + loadSessionRecordings: async ({ direction }, breakpoint) => { + const params = { + ...values.filters, + person_uuid: props.personUUID ?? '', + limit: RECORDINGS_LIMIT, + } + + if (direction === 'older') { + params['date_to'] = values.sessionRecordings[values.sessionRecordings.length - 1]?.start_time + } + + if (direction === 'newer') { + params['date_from'] = values.sessionRecordings[0]?.start_time + } + + await breakpoint(400) // Debounce for lots of quick filter changes + + const startTime = performance.now() + const response = await api.recordings.list(params) + const loadTimeMs = performance.now() - startTime + + actions.reportRecordingsListFetched(loadTimeMs) + + breakpoint() + + return { + has_next: + direction === 'newer' + ? values.sessionRecordingsResponse?.has_next ?? true + : response.has_next, + results: response.results, + } }, - deletePlaylist: async () => { - if (values.playlist) { - return deletePlaylist(values.playlist, () => { - router.actions.replace(urls.replay(ReplayTabs.Playlists)) + }, + ], + + pinnedRecordings: [ + [] as SessionRecordingType[], + { + loadPinnedRecordings: async (_, breakpoint) => { + await breakpoint(100) + + // props.pinnedRecordings can be strings or objects. + // If objects we can simply use them, if strings we need to fetch them + + const pinnedRecordings = props.pinnedRecordings ?? [] + + let recordings = pinnedRecordings.filter((x) => typeof x !== 'string') as SessionRecordingType[] + const recordingIds = pinnedRecordings.filter((x) => typeof x === 'string') as string[] + + if (recordingIds.length) { + const fetchedRecordings = await api.recordings.list({ + session_ids: recordingIds, }) + + recordings = [...recordings, ...fetchedRecordings.results] } - return null + // TODO: Check for pinnedRecordings being IDs and fetch them, returnig the merged list + + return recordings }, }, ], })), - reducers(({}) => ({ - filters: [ - null as RecordingFilters | null, + reducers(({ props }) => ({ + unusableEventsInFilter: [ + [] as string[], + { + loadEventsHaveSessionIdSuccess: (_, { eventsHaveSessionId }) => { + return Object.entries(eventsHaveSessionId) + .filter(([, hasSessionId]) => !hasSessionId) + .map(([eventName]) => eventName) + }, + }, + ], + customFilters: [ + (props.filters ?? null) as RecordingFilters | null, { - getPlaylistSuccess: (_, { playlist }) => playlist?.filters || null, - updatePlaylistSuccess: (_, { playlist }) => playlist?.filters || null, - setFilters: (_, { filters }) => filters, + setFilters: (state, { filters }) => ({ + ...state, + ...filters, + }), + resetFilters: () => null, + }, + ], + showFilters: [ + true, + { + persist: true, + }, + { + setShowFilters: (_, { showFilters }) => showFilters, + setShowSettings: () => false, + }, + ], + showSettings: [ + false, + { + persist: true, + }, + { + setShowSettings: (_, { showSettings }) => showSettings, + setShowFilters: () => false, + }, + ], + showAdvancedFilters: [ + addedAdvancedFilters(props.filters, getDefaultFilters(props.personUUID)), + { + persist: true, + }, + { + setFilters: (showingAdvancedFilters, { filters }) => { + return addedAdvancedFilters(filters, getDefaultFilters(props.personUUID)) + ? true + : showingAdvancedFilters + }, + setShowAdvancedFilters: (_, { showAdvancedFilters }) => showAdvancedFilters, + }, + ], + sessionRecordings: [ + [] as SessionRecordingType[], + { + loadSessionRecordings: (state, { direction }) => { + // Reset if we are not paginating + return direction ? state : [] + }, + + loadSessionRecordingsSuccess: (state, { sessionRecordingsResponse }) => { + const mergedResults: SessionRecordingType[] = [...state] + + sessionRecordingsResponse.results.forEach((recording) => { + if (!state.find((r) => r.id === recording.id)) { + mergedResults.push(recording) + } + }) + + mergedResults.sort((a, b) => (a.start_time > b.start_time ? -1 : 1)) + + return mergedResults + }, + setSelectedRecordingId: (state, { id }) => + state.map((s) => { + if (s.id === id) { + return { + ...s, + viewed: true, + } + } else { + return { ...s } + } + }), + }, + ], + selectedRecordingId: [ + null as SessionRecordingType['id'] | null, + { + setSelectedRecordingId: (_, { id }) => id ?? null, + }, + ], + sessionRecordingsAPIErrored: [ + false, + { + loadSessionRecordingsFailure: () => true, + loadSessionRecordingSuccess: () => false, + setFilters: () => false, + loadNext: () => false, + loadPrev: () => false, }, ], })), + listeners(({ props, actions, values }) => ({ + loadAllRecordings: () => { + actions.loadSessionRecordings() + actions.loadPinnedRecordings() + }, + setFilters: ({ filters }) => { + actions.loadSessionRecordings() + props.onFiltersChange?.(values.filters) + + // capture only the partial filters applied (not the full filters object) + // take each key from the filter and change it to `partial_filter_chosen_${key}` + const partialFilters = Object.keys(filters).reduce((acc, key) => { + acc[`partial_filter_chosen_${key}`] = filters[key] + return acc + }, {}) - listeners(({ actions, values }) => ({ - getPlaylistSuccess: () => { - if (values.playlist?.derived_name !== values.derivedName) { - // This keeps the derived name up to date if the playlist changes - actions.updatePlaylist({ derived_name: values.derivedName }, true) + posthog.capture('recording list filters changed', { + ...partialFilters, + showing_advanced_filters: values.showAdvancedFilters, + }) + + actions.loadEventsHaveSessionId() + }, + + resetFilters: () => { + actions.loadSessionRecordings() + props.onFiltersChange?.(values.filters) + }, + + maybeLoadSessionRecordings: ({ direction }) => { + if (direction === 'older' && !values.hasNext) { + return // Nothing more to load + } + if (values.sessionRecordingsResponseLoading) { + return // We don't want to load if we are currently loading } + actions.loadSessionRecordings(direction) }, - })), - beforeUnload(({ values, actions }) => ({ - enabled: (newLocation) => values.hasChanges && newLocation?.pathname !== router.values.location.pathname, - message: 'Leave playlist?\nChanges you made will be discarded.', - onConfirm: () => { - actions.setFilters(values.playlist?.filters || null) + loadSessionRecordingsSuccess: () => { + actions.maybeLoadPropertiesForSessions(values.sessionRecordings) }, - })), - selectors(({}) => ({ - breadcrumbs: [ - (s) => [s.playlist], - (playlist): Breadcrumb[] => [ - { - name: 'Recordings', - path: urls.replay(), - }, - { - name: 'Playlists', - path: urls.replay(ReplayTabs.Playlists), - }, - { - name: playlist?.name || playlist?.derived_name || '(Untitled)', - path: urls.replayPlaylist(playlist?.short_id || ''), - }, + setSelectedRecordingId: () => { + // If we are at the end of the list then try to load more + const recordingIndex = values.sessionRecordings.findIndex((s) => s.id === values.selectedRecordingId) + if (recordingIndex === values.sessionRecordings.length - 1) { + actions.maybeLoadSessionRecordings('older') + } + }, + })), + selectors({ + logicProps: [() => [(_, props) => props], (props): SessionRecordingPlaylistLogicProps => props], + shouldShowEmptyState: [ + (s) => [ + s.sessionRecordings, + s.customFilters, + s.sessionRecordingsResponseLoading, + s.sessionRecordingsAPIErrored, + (_, props) => props.personUUID, ], + ( + sessionRecordings, + customFilters, + sessionRecordingsResponseLoading, + sessionRecordingsAPIErrored, + personUUID + ): boolean => { + return ( + !sessionRecordingsAPIErrored && + !sessionRecordingsResponseLoading && + sessionRecordings.length === 0 && + !customFilters && + !personUUID + ) + }, + ], + + filters: [ + (s) => [s.customFilters, (_, props) => props.personUUID], + (customFilters, personUUID): RecordingFilters => { + const defaultFilters = getDefaultFilters(personUUID) + return { + ...defaultFilters, + ...customFilters, + } + }, ], - hasChanges: [ - (s) => [s.playlist, s.filters], - (playlist, filters): boolean => { - return !equal(playlist?.filters, filters) + + matchingEventsMatchType: [ + (s) => [s.filters], + (filters: RecordingFilters | undefined): MatchingEventsMatchType => { + if (!filters) { + return { matchType: 'none' } + } + + const hasActions = !!filters.actions?.length + const hasEvents = !!filters.events?.length + const simpleEventsFilters = (filters.events || []) + .filter((e) => !e.properties || !e.properties.length) + .map((e) => e.name.toString()) + const hasSimpleEventsFilters = !!simpleEventsFilters.length + + if (hasActions) { + return { matchType: 'backend', filters } + } else { + if (!hasEvents) { + return { matchType: 'none' } + } + + if (hasEvents && hasSimpleEventsFilters && simpleEventsFilters.length === filters.events?.length) { + return { + matchType: 'name', + eventNames: simpleEventsFilters, + } + } else { + return { + matchType: 'backend', + filters, + } + } + } }, ], - derivedName: [ - (s) => [s.filters, s.cohortsById], - (filters, cohortsById) => - summarizePlaylistFilters(filters || {}, cohortsById)?.slice(0, 400) || '(Untitled)', + activeSessionRecordingId: [ + (s) => [s.selectedRecordingId, s.recordings, (_, props) => props.autoPlay], + (selectedRecordingId, recordings, autoPlay): SessionRecordingId | undefined => { + return selectedRecordingId + ? recordings.find((rec) => rec.id === selectedRecordingId)?.id || selectedRecordingId + : autoPlay + ? recordings[0]?.id + : undefined + }, ], - })), + activeSessionRecording: [ + (s) => [s.activeSessionRecordingId, s.recordings], + (activeSessionRecordingId, recordings): SessionRecordingType | undefined => { + return recordings.find((rec) => rec.id === activeSessionRecordingId) + }, + ], + nextSessionRecording: [ + (s) => [s.activeSessionRecording, s.recordings, s.autoplayDirection], + (activeSessionRecording, recordings, autoplayDirection): Partial | undefined => { + if (!activeSessionRecording || !autoplayDirection) { + return + } + const activeSessionRecordingIndex = recordings.findIndex((x) => x.id === activeSessionRecording.id) + return autoplayDirection === 'older' + ? recordings[activeSessionRecordingIndex + 1] + : recordings[activeSessionRecordingIndex - 1] + }, + ], + hasNext: [ + (s) => [s.sessionRecordingsResponse], + (sessionRecordingsResponse) => sessionRecordingsResponse.has_next, + ], + totalFiltersCount: [ + (s) => [s.filters, (_, props) => props.personUUID], + (filters, personUUID) => { + const defaultFilters = getDefaultFilters(personUUID) + + return ( + (filters?.actions?.length || 0) + + (filters?.events?.length || 0) + + (filters?.properties?.length || 0) + + (equal(filters.session_recording_duration, defaultFilters.session_recording_duration) ? 0 : 1) + + (filters.date_from === defaultFilters.date_from && filters.date_to === defaultFilters.date_to + ? 0 + : 1) + + (filters.console_logs?.length || 0) + ) + }, + ], + hasAdvancedFilters: [ + (s) => [s.filters, (_, props) => props.personUUID], + (filters, personUUID) => { + const defaultFilters = getDefaultFilters(personUUID) + return addedAdvancedFilters(filters, defaultFilters) + }, + ], + + otherRecordings: [ + (s) => [s.sessionRecordings, s.hideViewedRecordings, s.pinnedRecordings, s.selectedRecordingId], + ( + sessionRecordings, + hideViewedRecordings, + pinnedRecordings, + selectedRecordingId + ): SessionRecordingType[] => { + return sessionRecordings.filter((rec) => { + if (pinnedRecordings.find((pinned) => pinned.id === rec.id)) { + return false + } + + if (hideViewedRecordings && rec.viewed && rec.id !== selectedRecordingId) { + return false + } + + return true + }) + }, + ], + + recordings: [ + (s) => [s.pinnedRecordings, s.otherRecordings], + (pinnedRecordings, otherRecordings): SessionRecordingType[] => { + return [...pinnedRecordings, ...otherRecordings] + }, + ], + }), + + actionToUrl(({ props, values }) => { + if (!props.updateSearchParams) { + return {} + } + const buildURL = ( + replace: boolean + ): [ + string, + Params, + Record, + { + replace: boolean + } + ] => { + const params: Params = objectClean({ + filters: values.customFilters ?? undefined, + sessionRecordingId: values.selectedRecordingId ?? undefined, + }) + + // We used to have sessionRecordingId in the hash, so we keep it there for backwards compatibility + if (router.values.hashParams.sessionRecordingId) { + delete router.values.hashParams.sessionRecordingId + } + + return [router.values.location.pathname, params, router.values.hashParams, { replace }] + } + + return { + setSelectedRecordingId: () => buildURL(false), + setFilters: () => buildURL(true), + resetFilters: () => buildURL(true), + } + }), + + urlToAction(({ actions, values, props }) => { + const urlToAction = (_: any, params: Params, hashParams: Params): void => { + if (!props.updateSearchParams) { + return + } + + // We changed to have the sessionRecordingId in the query params, but it used to be in the hash so backwards compatibility + const nulledSessionRecordingId = params.sessionRecordingId ?? hashParams.sessionRecordingId ?? null + if (nulledSessionRecordingId !== values.selectedRecordingId) { + actions.setSelectedRecordingId(nulledSessionRecordingId) + } + + if (params.filters) { + if (!equal(params.filters, values.customFilters)) { + actions.setFilters(params.filters) + } + } + } + return { + '*': urlToAction, + } + }), + // NOTE: It is important this comes after urlToAction, as it will override the default behavior afterMount(({ actions }) => { - actions.getPlaylist() + actions.loadSessionRecordings() + actions.loadPinnedRecordings() }), ]) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.test.ts new file mode 100644 index 0000000000000..4530486fb5ed0 --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.test.ts @@ -0,0 +1,81 @@ +import { expectLogic } from 'kea-test-utils' +import { initKeaTests } from '~/test/init' +import { useMocks } from '~/mocks/jest' +import { sessionRecordingsPlaylistSceneLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic' + +describe('sessionRecordingsPlaylistSceneLogic', () => { + let logic: ReturnType + const mockPlaylist = { + id: 'abc', + short_id: 'short_abc', + name: 'Test Playlist', + filters: { + events: [], + date_from: '2022-10-18', + session_recording_duration: { + key: 'duration', + type: 'recording', + value: 60, + operator: 'gt', + }, + }, + } + + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/session_recording_playlists/:id': mockPlaylist, + }, + patch: { + '/api/projects/:team/session_recording_playlists/:id': () => { + return [ + 200, + { + updated_playlist: 'blah', + }, + ] + }, + }, + }) + initKeaTests() + }) + + beforeEach(() => { + logic = sessionRecordingsPlaylistSceneLogic({ shortId: mockPlaylist.short_id }) + logic.mount() + }) + + describe('core assumptions', () => { + it('loads playlist after mounting', async () => { + await expectLogic(logic).toDispatchActions(['getPlaylistSuccess']) + expect(logic.values.playlist).toEqual(mockPlaylist) + }) + }) + + describe('update playlist', () => { + it('set new filter then update playlist', () => { + const newFilter = { + events: [ + { + id: '$autocapture', + type: 'events', + order: 0, + name: '$autocapture', + }, + ], + } + expectLogic(logic, async () => { + await logic.actions.setFilters(newFilter) + await logic.actions.updatePlaylist({}) + }) + .toDispatchActions(['setFilters']) + .toMatchValues({ filters: expect.objectContaining(newFilter), hasChanges: true }) + .toDispatchActions(['saveChanges', 'updatePlaylist', 'updatePlaylistSuccess']) + .toMatchValues({ + playlist: { + updated_playlist: 'blah', + }, + }) + }) + }) +}) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts new file mode 100644 index 0000000000000..f5e310872f570 --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts @@ -0,0 +1,168 @@ +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { Breadcrumb, RecordingFilters, SessionRecordingPlaylistType, ReplayTabs, SessionRecordingType } from '~/types' +import { urls } from 'scenes/urls' +import equal from 'fast-deep-equal' +import { beforeUnload, router } from 'kea-router' +import { cohortsModel } from '~/models/cohortsModel' +import { + deletePlaylist, + duplicatePlaylist, + getPlaylist, + summarizePlaylistFilters, + updatePlaylist, +} from 'scenes/session-recordings/playlist/playlistUtils' +import { loaders } from 'kea-loaders' + +import type { sessionRecordingsPlaylistSceneLogicType } from './sessionRecordingsPlaylistSceneLogicType' +import { PINNED_RECORDINGS_LIMIT } from './sessionRecordingsPlaylistLogic' +import api from 'lib/api' +import { addRecordingToPlaylist, removeRecordingFromPlaylist } from '../player/utils/playerUtils' + +export interface SessionRecordingsPlaylistLogicProps { + shortId: string +} + +export const sessionRecordingsPlaylistSceneLogic = kea([ + path((key) => ['scenes', 'session-recordings', 'playlist', 'sessionRecordingsPlaylistSceneLogic', key]), + props({} as SessionRecordingsPlaylistLogicProps), + key((props) => props.shortId), + connect({ + values: [cohortsModel, ['cohortsById']], + }), + actions({ + updatePlaylist: (properties?: Partial, silent = false) => ({ + properties, + silent, + }), + setFilters: (filters: RecordingFilters | null) => ({ filters }), + loadPinnedRecordings: true, + onPinnedChange: (recording: SessionRecordingType, pinned: boolean) => ({ pinned, recording }), + }), + loaders(({ values, props }) => ({ + playlist: [ + null as SessionRecordingPlaylistType | null, + { + getPlaylist: async () => { + return getPlaylist(props.shortId) + }, + updatePlaylist: async ({ properties, silent }) => { + if (!values.playlist?.short_id) { + return values.playlist + } + return updatePlaylist( + values.playlist?.short_id, + properties ?? { filters: values.filters || undefined }, + silent + ) + }, + duplicatePlaylist: async () => { + return duplicatePlaylist(values.playlist ?? {}, true) + }, + deletePlaylist: async () => { + if (values.playlist) { + return deletePlaylist(values.playlist, () => { + router.actions.replace(urls.replay(ReplayTabs.Playlists)) + }) + } + return null + }, + }, + ], + + pinnedRecordings: [ + null as SessionRecordingType[] | null, + { + loadPinnedRecordings: async (_, breakpoint) => { + if (!props.shortId) { + return null + } + + await breakpoint(100) + const response = await api.recordings.listPlaylistRecordings(props.shortId, { + limit: PINNED_RECORDINGS_LIMIT, + }) + breakpoint() + return response.results + }, + + onPinnedChange: async ({ recording, pinned }) => { + let newResults = values.pinnedRecordings ?? [] + + newResults = newResults.filter((r) => r.id !== recording.id) + + if (pinned) { + await addRecordingToPlaylist(props.shortId, recording.id) + newResults.push(recording) + } else { + await removeRecordingFromPlaylist(props.shortId, recording.id) + } + + return newResults + }, + }, + ], + })), + reducers(() => ({ + filters: [ + null as RecordingFilters | null, + { + getPlaylistSuccess: (_, { playlist }) => playlist?.filters || null, + updatePlaylistSuccess: (_, { playlist }) => playlist?.filters || null, + setFilters: (_, { filters }) => filters, + }, + ], + })), + + listeners(({ actions, values }) => ({ + getPlaylistSuccess: () => { + if (values.playlist?.derived_name !== values.derivedName) { + // This keeps the derived name up to date if the playlist changes + actions.updatePlaylist({ derived_name: values.derivedName }, true) + } + }, + })), + + beforeUnload(({ values, actions }) => ({ + enabled: (newLocation) => values.hasChanges && newLocation?.pathname !== router.values.location.pathname, + message: 'Leave playlist?\nChanges you made will be discarded.', + onConfirm: () => { + actions.setFilters(values.playlist?.filters || null) + }, + })), + + selectors(() => ({ + breadcrumbs: [ + (s) => [s.playlist], + (playlist): Breadcrumb[] => [ + { + name: 'Replay', + path: urls.replay(), + }, + { + name: 'Playlists', + path: urls.replay(ReplayTabs.Playlists), + }, + { + name: playlist?.name || playlist?.derived_name || '(Untitled)', + path: urls.replayPlaylist(playlist?.short_id || ''), + }, + ], + ], + hasChanges: [ + (s) => [s.playlist, s.filters], + (playlist, filters): boolean => { + return !equal(playlist?.filters, filters) + }, + ], + derivedName: [ + (s) => [s.filters, s.cohortsById], + (filters, cohortsById) => + summarizePlaylistFilters(filters || {}, cohortsById)?.slice(0, 400) || '(Untitled)', + ], + })), + + afterMount(({ actions }) => { + actions.getPlaylist() + actions.loadPinnedRecordings() + }), +]) diff --git a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx index d4f2adba93632..ebbdfa03727f8 100644 --- a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx +++ b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx @@ -17,6 +17,23 @@ export type SavedSessionRecordingPlaylistsProps = { tab: ReplayTabs.Playlists } +function nameColumn(): LemonTableColumn { + return { + title: 'Name', + dataIndex: 'name', + render: function Render(name, { short_id, derived_name, description }) { + return ( + <> + + {name || derived_name || '(Untitled)'} + + {description ?
    {description}
    : null} + + ) + }, + } +} + export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPlaylistsProps): JSX.Element { const logic = savedSessionRecordingPlaylistsLogic({ tab }) const { playlists, playlistsLoading, filters, sorting, pagination } = useValues(logic) @@ -38,20 +55,7 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla ) }, }, - { - title: 'Name', - dataIndex: 'name', - render: function Render(name, { short_id, derived_name, description }) { - return ( - <> - - {name || derived_name || '(Untitled)'} - - {description ?
    {description}
    : null} - - ) - }, - }, + nameColumn() as LemonTableColumn, { ...(createdByColumn() as LemonTableColumn< SessionRecordingPlaylistType, diff --git a/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.test.ts b/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.test.ts index d58a07fb70627..52ec11c4db620 100644 --- a/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.test.ts +++ b/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.test.ts @@ -75,7 +75,7 @@ describe('savedSessionRecordingPlaylistsLogic', () => { results: [`List of playlists filtered by createdBy`], }, ] - } else if (!!searchParams.get('pinned')) { + } else if (searchParams.get('pinned')) { return [ 200, { diff --git a/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts b/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts index 4526b3d09b8a7..970d191b5227e 100644 --- a/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts +++ b/frontend/src/scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic.ts @@ -61,7 +61,7 @@ export const savedSessionRecordingPlaylistsLogic = kea ({ playlist }), duplicatePlaylist: (playlist: SessionRecordingPlaylistType) => ({ playlist }), })), - reducers(({}) => ({ + reducers(() => ({ filters: [ DEFAULT_PLAYLIST_FILTERS as SavedSessionRecordingPlaylistsFilters | Record, { diff --git a/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts b/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts index 2c45463eccfd9..10d58cdcb3d07 100644 --- a/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts +++ b/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts @@ -26,7 +26,7 @@ export const sessionRecordingsLogic = kea([ actions({ setTab: (tab: ReplayTabs = ReplayTabs.Recent) => ({ tab }), }), - reducers(({}) => ({ + reducers(() => ({ tab: [ ReplayTabs.Recent as ReplayTabs, { @@ -41,7 +41,7 @@ export const sessionRecordingsLogic = kea([ } }), - selectors(({}) => ({ + selectors(() => ({ breadcrumbs: [ (s) => [s.tab], (tab): Breadcrumb[] => { diff --git a/frontend/src/scenes/session-recordings/settings/SessionRecordingSettings.tsx b/frontend/src/scenes/session-recordings/settings/SessionRecordingSettings.tsx index 35f538bb13b13..7ff2582193790 100644 --- a/frontend/src/scenes/session-recordings/settings/SessionRecordingSettings.tsx +++ b/frontend/src/scenes/session-recordings/settings/SessionRecordingSettings.tsx @@ -1,16 +1,216 @@ import { useActions, useValues } from 'kea' import { teamLogic } from 'scenes/teamLogic' -import { LemonSwitch, Link } from '@posthog/lemon-ui' +import { LemonBanner, LemonButton, LemonSelect, LemonSwitch, LemonTag, Link } from '@posthog/lemon-ui' import { urls } from 'scenes/urls' import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUrlList' import { AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel' +import { FlaggedFeature } from 'lib/components/FlaggedFeature' +import { FEATURE_FLAGS } from 'lib/constants' +import { IconCancel } from 'lib/lemon-ui/icons' +import { FlagSelector } from 'lib/components/FlagSelector' export type SessionRecordingSettingsProps = { inModal?: boolean } +function ReplayCostControl(): JSX.Element { + const { updateCurrentTeam } = useActions(teamLogic) + const { currentTeam } = useValues(teamLogic) + + return ( + + <> +

    + Replay ingestion controls BETA +

    +

    + PostHog offers several tools to let you control the number of recordings you collect and which users + you collect recordings for.{' '} + + Learn more in our docs + +

    + Requires posthog-js version 1.85.0 or greater +
    + Sampling + { + updateCurrentTeam({ session_recording_sample_rate: v }) + }} + dropdownMatchSelectWidth={false} + options={[ + { + label: '100% (no sampling)', + value: '1.00', + }, + { + label: '95%', + value: '0.95', + }, + { + label: '90%', + value: '0.90', + }, + { + label: '85%', + value: '0.85', + }, + { + label: '80%', + value: '0.80', + }, + { + label: '75%', + value: '0.75', + }, + { + label: '70%', + value: '0.70', + }, + { + label: '65%', + value: '0.65', + }, + { + label: '60%', + value: '0.60', + }, + { + label: '55%', + value: '0.55', + }, + { + label: '50%', + value: '0.50', + }, + { + label: '45%', + value: '0.45', + }, + { + label: '40%', + value: '0.40', + }, + { + label: '35%', + value: '0.35', + }, + { + label: '30%', + value: '0.30', + }, + { + label: '25%', + value: '0.25', + }, + { + label: '20%', + value: '0.20', + }, + { + label: '15%', + value: '0.15', + }, + { + label: '10%', + value: '0.10', + }, + { + label: '5%', + value: '0.05', + }, + { + label: '0% (replay disabled)', + value: '0.00', + }, + ]} + value={ + typeof currentTeam?.session_recording_sample_rate === 'string' + ? currentTeam?.session_recording_sample_rate + : '1.00' + } + /> +
    +

    + Use this setting to restrict the percentage of sessions that will be recorded. This is useful if you + want to reduce the amount of data you collect. 100% means all sessions will be collected. 50% means + roughly half of sessions will be collected. +

    +
    + Minimum session duration (seconds) + { + updateCurrentTeam({ session_recording_minimum_duration_milliseconds: v }) + }} + options={[ + { + label: 'no minimum', + value: null, + }, + { + label: '1', + value: 1000, + }, + { + label: '2', + value: 2000, + }, + { + label: '5', + value: 5000, + }, + { + label: '10', + value: 10000, + }, + { + label: '15', + value: 15000, + }, + ]} + value={currentTeam?.session_recording_minimum_duration_milliseconds} + /> +
    +

    + Setting a minimum session duration will ensure that only sessions that last longer than that value + are collected. This helps you avoid collecting sessions that are too short to be useful. +

    +
    + Enable recordings using feature flag +
    + { + updateCurrentTeam({ session_recording_linked_flag: { id, key } }) + }} + /> + {currentTeam?.session_recording_linked_flag && ( + } + size="small" + status="stealth" + onClick={() => updateCurrentTeam({ session_recording_linked_flag: null })} + title="Clear selected flag" + /> + )} +
    +
    +

    + Linking a flag means that recordings will only be collected for users who have the flag enabled. + Only supports release toggles (boolean flags). +

    + +
    + ) +} + export function SessionRecordingSettings({ inModal = false }: SessionRecordingSettingsProps): JSX.Element { const { updateCurrentTeam } = useActions(teamLogic) const { currentTeam } = useValues(teamLogic) @@ -63,9 +263,7 @@ export function SessionRecordingSettings({ inModal = false }: SessionRecordingSe labelClassName={inModal ? 'text-base font-semibold' : ''} bordered={!inModal} fullWidth={inModal} - checked={ - !!currentTeam?.session_recording_opt_in ? !!currentTeam?.capture_console_log_opt_in : false - } + checked={currentTeam?.session_recording_opt_in ? !!currentTeam?.capture_console_log_opt_in : false} disabled={!currentTeam?.session_recording_opt_in} />

    @@ -83,9 +281,7 @@ export function SessionRecordingSettings({ inModal = false }: SessionRecordingSe labelClassName={inModal ? 'text-base font-semibold' : ''} bordered={!inModal} fullWidth={inModal} - checked={ - !!currentTeam?.session_recording_opt_in ? !!currentTeam?.capture_performance_opt_in : false - } + checked={currentTeam?.session_recording_opt_in ? !!currentTeam?.capture_performance_opt_in : false} disabled={!currentTeam?.session_recording_opt_in} />

    @@ -106,6 +302,7 @@ export function SessionRecordingSettings({ inModal = false }: SessionRecordingSe

    +
    ) } diff --git a/frontend/src/scenes/surveys/EditSurvey.scss b/frontend/src/scenes/surveys/EditSurvey.scss new file mode 100644 index 0000000000000..2995d06667887 --- /dev/null +++ b/frontend/src/scenes/surveys/EditSurvey.scss @@ -0,0 +1,9 @@ +.presentation-preview .CodeSnippet__actions { + display: none; +} + +.SurveyForm { + .LemonCollapsePanel__header { + background: var(--border-light); + } +} diff --git a/frontend/src/scenes/surveys/Survey.tsx b/frontend/src/scenes/surveys/Survey.tsx index bb233e4489796..a6a74a9ff1877 100644 --- a/frontend/src/scenes/surveys/Survey.tsx +++ b/frontend/src/scenes/surveys/Survey.tsx @@ -1,30 +1,20 @@ import { SceneExport } from 'scenes/sceneTypes' -import { NewSurvey, defaultSurveyAppearance, surveyLogic } from './surveyLogic' +import { surveyLogic } from './surveyLogic' import { BindLogic, useActions, useValues } from 'kea' -import { Form, Group } from 'kea-forms' +import { Form } from 'kea-forms' import { PageHeader } from 'lib/components/PageHeader' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { LemonButton, LemonDivider, LemonInput, LemonSelect, LemonTextArea, Link } from '@posthog/lemon-ui' +import { LemonButton, LemonDivider, Link } from '@posthog/lemon-ui' import { router } from 'kea-router' import { urls } from 'scenes/urls' -import { Field, PureField } from 'lib/forms/Field' -import { - SurveyQuestion, - Survey, - SurveyQuestionType, - SurveyType, - LinkSurveyQuestion, - RatingSurveyQuestion, -} from '~/types' -import { FlagSelector } from 'scenes/early-access-features/EarlyAccessFeature' -import { IconCancel, IconDelete, IconPlusMini } from 'lib/lemon-ui/icons' +import { Survey, SurveyUrlMatchType } from '~/types' import { SurveyView } from './SurveyView' -import { SurveyAppearance } from './SurveyAppearance' -import { FeatureFlagReleaseConditions } from 'scenes/feature-flags/FeatureFlag' -import { SurveyAPIEditor } from './SurveyAPIEditor' -import { featureFlagLogic as enabledFeaturesLogic } from 'lib/logic/featureFlagLogic' import { featureFlagLogic } from 'scenes/feature-flags/featureFlagLogic' -import { FEATURE_FLAGS } from 'lib/constants' +import { NewSurvey, SurveyUrlMatchTypeLabels } from './constants' +import { FeatureFlagReleaseConditions } from 'scenes/feature-flags/FeatureFlagReleaseConditions' +import SurveyEdit from './SurveyEdit' +import { NotFound } from 'lib/components/NotFound' +import { FlagSelector } from 'lib/components/FlagSelector' export const scene: SceneExport = { component: SurveyComponent, @@ -35,8 +25,13 @@ export const scene: SceneExport = { } export function SurveyComponent({ id }: { id?: string } = {}): JSX.Element { - const { isEditingSurvey } = useValues(surveyLogic) + const { isEditingSurvey, surveyMissing } = useValues(surveyLogic) const showSurveyForm = id === 'new' || isEditingSurvey + + if (surveyMissing) { + return + } + return (
    {!id ? ( @@ -52,8 +47,7 @@ export function SurveyComponent({ id }: { id?: string } = {}): JSX.Element { export function SurveyForm({ id }: { id: string }): JSX.Element { const { survey, surveyLoading, isEditingSurvey, hasTargetingFlag } = useValues(surveyLogic) - const { loadSurvey, editingSurvey, setHasTargetingFlag } = useActions(surveyLogic) - const { featureFlags } = useValues(enabledFeaturesLogic) + const { loadSurvey, editingSurvey } = useActions(surveyLogic) return (
    @@ -88,270 +82,7 @@ export function SurveyForm({ id }: { id: string }): JSX.Element { } /> -
    -
    - - - - - - - - - - - {survey.questions.map( - (question: LinkSurveyQuestion | SurveyQuestion | RatingSurveyQuestion, index: number) => ( - - - - - - - - {question.type === SurveyQuestionType.Link && ( - - - - )} - - - - {question.type === SurveyQuestionType.Rating && ( -
    -
    - - - - - - -
    -
    - - - - - - -
    -
    - )} - {(question.type === SurveyQuestionType.SingleChoice || - question.type === SurveyQuestionType.MultipleChoice) && ( -
    - - {({ value, onChange }) => ( -
    - {(value || []).map((choice: string, index: number) => ( -
    - { - const newChoices = [...value] - newChoices[index] = val - onChange(newChoices) - }} - /> - } - size="small" - status="muted" - noPadding - onClick={() => { - const newChoices = [...value] - newChoices.splice(index, 1) - onChange(newChoices) - }} - /> -
    - ))} -
    - {(value || []).length < 6 && ( - } - type="secondary" - fullWidth={false} - onClick={() => { - if (!value) { - onChange(['']) - } else { - onChange([...value, '']) - } - }} - > - Add choice - - )} -
    -
    - )} -
    -
    - )} -
    - ) - )} - - - - If targeting options are set, the survey will be released to users who match all of - the conditions. If no targeting options are set, the survey{' '} - will be released to everyone. - - - Connecting to a feature flag will automatically enable this survey for everyone in - the feature flag. - - } - > - {({ value, onChange }) => ( -
    - - {value && ( - } - size="small" - status="stealth" - onClick={() => onChange(undefined)} - aria-label="close" - /> - )} -
    - )} -
    - - {({ value, onChange }) => ( - <> - - onChange({ ...value, url: urlVal })} - placeholder="ex: https://app.posthog.com" - /> - - - onChange({ ...value, selector: selectorVal })} - placeholder="ex: .className or #id" - /> - - - )} - - - - {!hasTargetingFlag && ( - setHasTargetingFlag(true)} - > - Add user targeting - - )} - {hasTargetingFlag && ( - <> -
    - -
    - {id === 'new' && ( - setHasTargetingFlag(false)} - > - Remove all user properties - - )} - - )} -
    -
    -
    -
    - -
    - {survey.type !== SurveyType.API ? ( - - {({ value, onChange }) => ( - { - onChange(appearance) - }} - link={ - survey.questions[0].type === SurveyQuestionType.Link - ? survey.questions[0].link - : undefined - } - appearance={value || defaultSurveyAppearance} - /> - )} - - ) : ( - - )} -
    -
    + @@ -390,14 +121,20 @@ export function SurveyReleaseSummary({ }): JSX.Element { return (
    -
    Release conditions
    +
    Release conditions summary
    By default surveys will be released to everyone unless targeting options are set. {survey.conditions?.url && (
    - URL contains:{' '} + + URL{' '} + {SurveyUrlMatchTypeLabels[ + survey.conditions?.urlMatchType || SurveyUrlMatchType.Contains + ].slice(2)} + : + {' '} {survey.conditions.url}
    diff --git a/frontend/src/scenes/surveys/SurveyAPIEditor.tsx b/frontend/src/scenes/surveys/SurveyAPIEditor.tsx index 192d35c73b2b1..c474d973db1a9 100644 --- a/frontend/src/scenes/surveys/SurveyAPIEditor.tsx +++ b/frontend/src/scenes/surveys/SurveyAPIEditor.tsx @@ -1,5 +1,5 @@ import { Survey } from '~/types' -import { NewSurvey } from './surveyLogic' +import { NewSurvey } from './constants' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' export function SurveyAPIEditor({ survey }: { survey: Survey | NewSurvey }): JSX.Element { @@ -8,7 +8,7 @@ export function SurveyAPIEditor({ survey }: { survey: Survey | NewSurvey }): JSX id: survey.id, name: survey.name, description: survey.description, - type: survey.type, + type: 'api', linked_flag_key: survey.linked_flag ? survey.linked_flag.key : null, targeting_flag_key: survey.targeting_flag ? survey.targeting_flag.key : null, questions: survey.questions, @@ -18,11 +18,8 @@ export function SurveyAPIEditor({ survey }: { survey: Survey | NewSurvey }): JSX } return ( -
    -

    API survey response

    - - {JSON.stringify(apiSurvey, null, 2)} - -
    + + {JSON.stringify(apiSurvey, null, 2)} + ) } diff --git a/frontend/src/scenes/surveys/SurveyAppearance.scss b/frontend/src/scenes/surveys/SurveyAppearance.scss index 3ce36cbcee4be..5c2e1081e8fd5 100644 --- a/frontend/src/scenes/surveys/SurveyAppearance.scss +++ b/frontend/src/scenes/surveys/SurveyAppearance.scss @@ -1,206 +1,246 @@ -// @import '../../../styles/mixins'; - .survey-form { + margin: 0px; color: black; font-weight: normal; font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; text-align: left; - z-index: 99999; //changeable: zIndex + width: 320px; + border-bottom: 0px; flex-direction: column; - background: white; //changeable: backgroundColor - border: 1px solid #f0f0f0; - border-radius: 8px; - padding-top: 5px; - max-width: 320px; //changeable: maxWidth box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); + border-radius: 10px; + line-height: 1.4; + position: relative; + box-sizing: border-box; +} +.form-submit[disabled] { + opacity: 0.6; + filter: grayscale(100%); + cursor: not-allowed; +} +.survey-form textarea { + color: #2d2d2d; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + background: white; + color: black; + outline: none; + padding-left: 10px; + padding-right: 10px; + padding-top: 10px; + border-radius: 6px; + margin-top: 14px; +} +.form-submit { + box-sizing: border-box; + margin: 0; + font-family: inherit; + overflow: visible; + text-transform: none; + position: relative; + display: inline-block; + font-weight: 700; + white-space: nowrap; + text-align: center; + border: 1.5px solid transparent; + cursor: pointer; + user-select: none; + touch-action: manipulation; + padding: 12px; + font-size: 14px; + border-radius: 6px; + outline: 0; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045); + width: 100%; +} +.form-cancel { + float: right; + border: none; + background: none; + cursor: pointer; +} +.cancel-btn-wrapper { + position: absolute; + width: 35px; + height: 35px; + border-radius: 100%; + top: 0; + right: 0; + transform: translate(50%, -50%); + background: white; + display: flex; + justify-content: center; + align-items: center; +} +.bolded { + font-weight: 600; +} +.buttons { + display: flex; + justify-content: center; +} +.footer-branding { + font-size: 11px; + margin-top: 10px; + text-align: center; + display: flex; + justify-content: center; + gap: 4px; + align-items: center; + font-weight: 500; + text-decoration: none; + color: inherit !important; +} +.survey-box { + padding: 20px 25px 10px; + display: flex; + flex-direction: column; +} +.survey-question { + font-weight: 500; + font-size: 14px; +} +.question-textarea-wrapper { + display: flex; + flex-direction: column; +} +.description { + font-size: 13px; + margin-top: 5px; + opacity: 0.6; +} +.ratings-number { + font-size: 14px; + padding: 8px 0px; + border: none; +} +.ratings-number:hover { + cursor: pointer; +} +.rating-options { + margin-top: 14px; +} +.rating-options-buttons { + display: grid; + border-radius: 6px; + overflow: hidden; +} +.rating-options-buttons > .ratings-number { + border-right: 1px solid; +} +.rating-options-buttons > .ratings-number:last-of-type { + border-right: 0px !important; +} +.rating-options-emoji { + display: flex; + justify-content: space-between; +} +.ratings-emoji { + font-size: 16px; + background-color: transparent; + border: none; + padding: 0px; +} +.ratings-emoji:hover { + cursor: pointer; +} +.rating-text { + display: flex; + flex-direction: row; + font-size: 11px; + justify-content: space-between; + margin-top: 6px; + opacity: 0.6; +} +.multiple-choice-options { + margin-top: 13px; + font-size: 14px; +} +.multiple-choice-options .choice-option { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + cursor: pointer; + margin-bottom: 5px; + position: relative; +} +.multiple-choice-options > .choice-option:last-of-type { + margin-bottom: 0px; +} - .button { - width: 64px; - height: 64px; - border-radius: 100%; - text-align: center; - line-height: 60px; - font-size: 32px; - border: none; - cursor: pointer; - } - .button:hover { - filter: brightness(1.2); - } - .form-submit[disabled] { - opacity: 0.6; - filter: grayscale(100%); - cursor: not-allowed; - } - - .survey-box { - padding: 0.5rem 1rem; - display: flex; - flex-direction: column; - } - - .survey-question { - padding-top: 4px; - padding-bottom: 4px; - font-size: 16px; - font-weight: 500; - color: black; //changeable: textColor - } - - .question-textarea-wrapper { - display: flex; - flex-direction: column; - padding-bottom: 4px; - } - - .survey-textarea { - color: #2d2d2d; - font-size: 14px; - font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; - background: white; - color: black; - border: 1px solid; - padding-left: 10px; - padding-right: 10px; - padding-top: 10px; - border-radius: 6px; - margin: 0.5rem; - } - - .buttons { - display: flex; - justify-content: center; - } - - .footer-branding { - color: #6a6b69; - font-size: 10.5px; - padding-top: 0.5rem; - text-align: center; - } - - .form-submit { - box-sizing: border-box; - margin: 0; - font-family: inherit; - overflow: visible; - text-transform: none; - line-height: 1.5715; - position: relative; - display: inline-block; - font-weight: 400; - white-space: nowrap; - text-align: center; - border: 1px solid transparent; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); - user-select: none; - touch-action: manipulation; - height: 32px; - padding: 4px 15px; - font-size: 14px; - border-radius: 4px; - outline: 0; - background: #2c2c2c; // changeable: submitButtonColor - color: #e5e7e0; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); - box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045); - } - - .form-submit:hover { - filter: brightness(1.2); - } - - .form-cancel { - background: white; // changeable: backgroundColor - float: right; - border: none; - cursor: pointer; - } - - .bottom-section { - padding-bottom: 0.5rem; - } - - .description { - font-size: 14px; - margin-top: 0.5rem; - margin-bottom: 0.5rem; - color: #4b4b52; //changeable: descriptionTextColor - } - .rating-options { - margin-top: 0.5rem; - } - .ratings-number { - background-color: #e0e2e8; - font-size: 14px; - border-radius: 6px; - border: 1px solid #e0e2e8; - padding: 8px; - } - .ratings-number:hover { - cursor: pointer; - filter: brightness(0.75); - } - .rating-options-buttons { - display: flex; - justify-content: space-evenly; - } - .max-numbers { - min-width: 280px; - } - .rating-options-emoji { - display: flex; - justify-content: space-evenly; - } - .ratings-emoji { - font-size: 16px; - background-color: transparent; - border: none; - } - .ratings-emoji:hover { - cursor: pointer; - fill: coral; //changeable: ratingButtonHoverColor - } - .rating-text { - display: flex; - flex-direction: row; - font-size: 12px; - justify-content: space-between; - margin-top: 0.5rem; - margin-bottom: 0.5rem; - color: #4b4b52; - } - .rating-section { - margin-bottom: 0.5rem; - } - .multiple-choice-options { - margin-bottom: 0.5rem; - margin-top: 0.5rem; - font-size: 14px; - } - .multiple-choice-options .choice-option { - display: flex; - align-items: center; - gap: 4px; - background: #00000003; - font-size: 14px; - padding: 10px 20px 10px 15px; - border: 1px solid #0000000d; - border-radius: 4px; - cursor: pointer; - margin-bottom: 6px; - } - .multiple-choice-options .choice-option:hover { - background: #0000000a; - } - .multiple-choice-options input { - cursor: pointer; - } - .multiple-choice-options label { - width: 100%; - cursor: pointer; - } +.multiple-choice-options input { + cursor: pointer; + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + inset: 0; +} +.choice-check { + position: absolute; + right: 10px; + background: white; +} +.choice-check svg { + display: none; +} +.multiple-choice-options .choice-option:hover .choice-check svg { + display: inline-block; + opacity: 0.25; +} +.multiple-choice-options input:checked + label + .choice-check svg { + display: inline-block; + opacity: 100% !important; +} +.multiple-choice-options input[type='checkbox']:checked + label { + font-weight: bold; +} +.multiple-choice-options input:checked + label { + border: 1.5px solid rgba(0, 0, 0); +} +.multiple-choice-options label { + width: 100%; + cursor: pointer; + padding: 10px; + border: 1.5px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; + background: white; +} +.thank-you-message { + position: relative; + bottom: 0px; + box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + border-radius: 10px; + padding: 20px 25px 10px; + text-align: center; + width: 320px; + min-width: 150px; + line-height: 1.4; + box-sizing: border-box; +} +.thank-you-message-body { + margin-top: 6px; + font-size: 14px; +} +.thank-you-message-header { + margin: 10px 0px 0px; + font-weight: bold; + font-size: 19px; + color: inherit; +} +.thank-you-message-container .form-submit { + margin-top: 20px; + margin-bottom: 10px; +} +.thank-you-message-countdown { + margin-left: 6px; +} +.bottom-section { + margin-top: 14px; } diff --git a/frontend/src/scenes/surveys/SurveyAppearance.tsx b/frontend/src/scenes/surveys/SurveyAppearance.tsx index a4479705dba01..af90fcad98b51 100644 --- a/frontend/src/scenes/surveys/SurveyAppearance.tsx +++ b/frontend/src/scenes/surveys/SurveyAppearance.tsx @@ -1,32 +1,90 @@ import './SurveyAppearance.scss' -import { LemonInput } from '@posthog/lemon-ui' +import { LemonButton, LemonCheckbox, LemonInput, Link } from '@posthog/lemon-ui' import { SurveyAppearance as SurveyAppearanceType, SurveyQuestion, RatingSurveyQuestion, SurveyQuestionType, MultipleSurveyQuestion, + AvailableFeature, } from '~/types' -import { defaultSurveyAppearance } from './surveyLogic' +import { defaultSurveyAppearance } from './constants' import { + cancel, + check, dissatisfiedEmoji, + getTextColor, neutralEmoji, posthogLogoSVG, satisfiedEmoji, veryDissatisfiedEmoji, verySatisfiedEmoji, } from './SurveyAppearanceUtils' +import { surveysLogic } from './surveysLogic' +import { useValues } from 'kea' +import React, { useEffect, useRef, useState } from 'react' +import { sanitize } from 'dompurify' +import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' interface SurveyAppearanceProps { type: SurveyQuestionType question: string appearance: SurveyAppearanceType - surveyQuestionItem: RatingSurveyQuestion | SurveyQuestion | MultipleSurveyQuestion + surveyQuestionItem: SurveyQuestion description?: string | null link?: string | null - readOnly?: boolean + preview?: boolean +} + +interface CustomizationProps { + appearance: SurveyAppearanceType + surveyQuestionItem: RatingSurveyQuestion | SurveyQuestion | MultipleSurveyQuestion onAppearanceChange: (appearance: SurveyAppearanceType) => void } + +interface ButtonProps { + link?: string | null + type?: SurveyQuestionType + onSubmit: () => void + appearance: SurveyAppearanceType + children: React.ReactNode +} + +const Button = ({ + link, + type, + onSubmit, + appearance, + children, + ...other +}: ButtonProps & React.HTMLProps): JSX.Element => { + const [textColor, setTextColor] = useState('black') + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + const textColor = getTextColor(ref.current) + setTextColor(textColor) + } + }, [appearance.submitButtonColor]) + + return ( + + ) +} + export function SurveyAppearance({ type, question, @@ -34,308 +92,587 @@ export function SurveyAppearance({ surveyQuestionItem, description, link, - readOnly, - onAppearanceChange, + preview, }: SurveyAppearanceProps): JSX.Element { return ( - <> -

    Preview

    +
    {type === SurveyQuestionType.Rating && ( undefined} /> )} {(surveyQuestionItem.type === SurveyQuestionType.SingleChoice || surveyQuestionItem.type === SurveyQuestionType.MultipleChoice) && ( undefined} /> )} {(surveyQuestionItem.type === SurveyQuestionType.Open || surveyQuestionItem.type === SurveyQuestionType.Link) && ( undefined} /> )} - {!readOnly && ( -
    -
    Background color
    +
    + ) +} + +export function Customization({ appearance, surveyQuestionItem, onAppearanceChange }: CustomizationProps): JSX.Element { + const { whitelabelAvailable, surveysStylingAvailable } = useValues(surveysLogic) + + return ( +
    + {!surveysStylingAvailable && ( + + <> + + )} +
    Button text
    + onAppearanceChange({ ...appearance, submitButtonText })} + /> +
    Background color
    + onAppearanceChange({ ...appearance, backgroundColor })} + disabled={!surveysStylingAvailable} + /> +
    Border color
    + onAppearanceChange({ ...appearance, borderColor })} + disabled={!surveysStylingAvailable} + /> + <> +
    Position
    +
    + {['left', 'center', 'right'].map((position) => { + return ( + onAppearanceChange({ ...appearance, position })} + active={appearance.position === position} + disabledReason={ + surveysStylingAvailable + ? null + : 'Subscribe to surveys to customize survey position.' + } + > + {position} + + ) + })} +
    + + {surveyQuestionItem.type === SurveyQuestionType.Rating && ( + <> +
    Rating button color
    onAppearanceChange({ ...appearance, backgroundColor })} + value={appearance?.ratingButtonColor} + onChange={(ratingButtonColor) => onAppearanceChange({ ...appearance, ratingButtonColor })} + disabled={!surveysStylingAvailable} /> -
    Question text color
    +
    Rating button active color
    onAppearanceChange({ ...appearance, textColor })} + value={appearance?.ratingButtonActiveColor} + onChange={(ratingButtonActiveColor) => + onAppearanceChange({ ...appearance, ratingButtonActiveColor }) + } + disabled={!surveysStylingAvailable} /> -
    Description text color
    + + )} +
    Button color
    + onAppearanceChange({ ...appearance, submitButtonColor })} + disabled={!surveysStylingAvailable} + /> + {surveyQuestionItem.type === SurveyQuestionType.Open && ( + <> +
    Placeholder
    onAppearanceChange({ ...appearance, descriptionTextColor })} + value={appearance?.placeholder || defaultSurveyAppearance.placeholder} + onChange={(placeholder) => onAppearanceChange({ ...appearance, placeholder })} + disabled={!surveysStylingAvailable} /> - {surveyQuestionItem.type === SurveyQuestionType.Rating && ( - <> -
    Rating button color
    - - onAppearanceChange({ ...appearance, ratingButtonColor }) - } - /> - {surveyQuestionItem.display === 'emoji' && ( - <> -
    Rating button hover color
    - - onAppearanceChange({ ...appearance, ratingButtonHoverColor }) - } - /> - - )} - - )} - {(type === SurveyQuestionType.Open || type === SurveyQuestionType.Link) && ( - <> -
    Button color
    - - onAppearanceChange({ ...appearance, submitButtonColor }) - } - /> -
    Button text
    - onAppearanceChange({ ...appearance, submitButtonText })} - /> - - )} -
    + )} - +
    + + Hide PostHog branding +
    + } + onChange={(checked) => onAppearanceChange({ ...appearance, whiteLabel: checked })} + disabledReason={!whitelabelAvailable ? 'Upgrade to any paid plan to hide PostHog branding' : null} + /> +
    +
    ) } // This should be synced to the UI of the surveys app plugin -function BaseAppearance({ +export function BaseAppearance({ type, question, appearance, + onSubmit, description, link, + preview, }: { type: SurveyQuestionType question: string appearance: SurveyAppearanceType + onSubmit: () => void description?: string | null link?: string | null + preview?: boolean }): JSX.Element { + const [textColor, setTextColor] = useState('black') + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + const textColor = getTextColor(ref.current) + setTextColor(textColor) + } + }, [appearance.backgroundColor]) + return ( - +
    -
    - -
    -
    -
    - {question} +
    + )} +
    +
    + {/* Using dangerouslySetInnerHTML is safe here, because it's taking the user's input and showing it to the same user. + They can try passing in arbitrary scripts, but it would show up only for them, so it's like trying to XSS yourself, where + you already have all the data. Furthermore, sanitization should catch all obvious attempts */} {description && ( -
    - {description} -
    +
    )} {type === SurveyQuestionType.Open && ( -