diff --git a/.eslintrc.js b/.eslintrc.js index 23591985e5a81..a9eaca7209d46 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -191,6 +191,10 @@ module.exports = { element: 'Badge', message: 'use LemonBadge instead', }, + { + element: 'InputNumber', + message: 'use LemonInput with type="number" instead', + }, { element: 'Collapse', message: 'use instead', diff --git a/.github/actions/build-n-cache-image/action.yml b/.github/actions/build-n-cache-image/action.yml index e3bf01ea78f52..76ccd7f877ca9 100644 --- a/.github/actions/build-n-cache-image/action.yml +++ b/.github/actions/build-n-cache-image/action.yml @@ -16,9 +16,6 @@ outputs: 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' @@ -42,16 +39,3 @@ runs: 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: posthog/posthog:unit-${{ github.sha }} - 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 }} diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 57893e87f83bd..51fee4d0a0d0c 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -79,7 +79,6 @@ jobs: 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' @@ -171,7 +170,7 @@ jobs: if: needs.changes.outputs.shouldTriggerCypress == 'true' uses: depot/pull-action@v1 with: - build-id: ${{ needs.container.outputs.unit-build-id }} + build-id: ${{ needs.container.outputs.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 diff --git a/.github/workflows/container-images-cd.yml b/.github/workflows/container-images-cd.yml index b534c522e2588..286dc81133570 100644 --- a/.github/workflows/container-images-cd.yml +++ b/.github/workflows/container-images-cd.yml @@ -72,17 +72,6 @@ jobs: 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/arm64,linux/amd64 - build-args: COMMIT_HASH=${{ github.sha }} - - name: get deployer token id: deployer uses: getsentry/action-github-app-token@v2 @@ -102,7 +91,7 @@ jobs: message: | { "image_tag": "${{ steps.build.outputs.digest }}", - "image_tag_unit": "${{ steps.build-unit.outputs.digest }}", + "image_tag_unit": "${{ steps.build.outputs.digest }}", "context": ${{ toJson(github) }} } diff --git a/.github/workflows/pr-deploy.yml b/.github/workflows/pr-deploy.yml index 8a827d16f2c5b..184b1ca9d0480 100644 --- a/.github/workflows/pr-deploy.yml +++ b/.github/workflows/pr-deploy.yml @@ -56,7 +56,7 @@ jobs: uses: aws-actions/amazon-ecr-login@v2 - name: Build and push PR test image - id: build-unit + id: build uses: depot/build-push-action@v1 with: buildx-fallback: false # the fallback is so slow it's better to just fail diff --git a/cypress/e2e/dashboard.cy.ts b/cypress/e2e/dashboard.cy.ts index 38bb896ab0497..9ea1f65c4358e 100644 --- a/cypress/e2e/dashboard.cy.ts +++ b/cypress/e2e/dashboard.cy.ts @@ -14,9 +14,9 @@ describe('Dashboard', () => { it('Dashboards loaded', () => { cy.get('h1').should('contain', 'Dashboards') // Breadcrumbs work - cy.get('[data-attr=breadcrumb-0]').should('contain', 'Hogflix') - cy.get('[data-attr=breadcrumb-1]').should('contain', 'Hogflix Demo App') - cy.get('[data-attr=breadcrumb-2]').should('have.text', 'Dashboards') + cy.get('[data-attr=breadcrumb-organization]').should('contain', 'Hogflix') + cy.get('[data-attr=breadcrumb-project]').should('contain', 'Hogflix Demo App') + cy.get('[data-attr=breadcrumb-Dashboards]').should('have.text', 'Dashboards') }) // FIXME: this test works in real, but not in cypress @@ -123,10 +123,10 @@ describe('Dashboard', () => { cy.get('.InsightCard').its('length').should('be.gte', 2) // Breadcrumbs work - cy.get('[data-attr=breadcrumb-0]').should('contain', 'Hogflix') - cy.get('[data-attr=breadcrumb-1]').should('contain', 'Hogflix Demo App') - cy.get('[data-attr=breadcrumb-2]').should('have.text', 'Dashboards') - cy.get('[data-attr=breadcrumb-3]').should('have.text', TEST_DASHBOARD_NAME + 'UnnamedCancelSave') + cy.get('[data-attr=breadcrumb-organization]').should('contain', 'Hogflix') + cy.get('[data-attr=breadcrumb-project]').should('contain', 'Hogflix Demo App') + cy.get('[data-attr=breadcrumb-Dashboards]').should('have.text', 'Dashboards') + cy.get('[data-attr^="breadcrumb-Dashboard:"]').should('have.text', TEST_DASHBOARD_NAME + 'UnnamedCancelSave') }) it('Click on a dashboard item dropdown and view graph', () => { diff --git a/cypress/e2e/insights.cy.ts b/cypress/e2e/insights.cy.ts index a0a0fa47b7418..d75c80cdbc59e 100644 --- a/cypress/e2e/insights.cy.ts +++ b/cypress/e2e/insights.cy.ts @@ -23,10 +23,10 @@ describe('Insights', () => { it('Saving an insight sets breadcrumbs', () => { createInsight('insight name') - cy.get('[data-attr=breadcrumb-0]').should('contain', 'Hogflix') - cy.get('[data-attr=breadcrumb-1]').should('contain', 'Hogflix Demo App') - cy.get('[data-attr=breadcrumb-2]').should('have.text', 'Product analytics') - cy.get('[data-attr=breadcrumb-3]').should('have.text', 'insight name') + cy.get('[data-attr=breadcrumb-organization]').should('contain', 'Hogflix') + cy.get('[data-attr=breadcrumb-project]').should('contain', 'Hogflix Demo App') + cy.get('[data-attr=breadcrumb-SavedInsights]').should('have.text', 'Product analytics') + cy.get('[data-attr^="breadcrumb-Insight:"]').should('have.text', 'insight name') }) it('Can change insight name', () => { diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts index 1f9d7aef72049..9a75337b67356 100644 --- a/cypress/e2e/surveys.cy.ts +++ b/cypress/e2e/surveys.cy.ts @@ -96,7 +96,7 @@ describe('Surveys', () => { cy.get('[data-attr=prop-val] .ant-select-selector').click({ force: true }) cy.get('[data-attr=prop-val-0]').click({ force: true }) - cy.get('.ant-input-number-input-wrap>input').type('{backspace}') + cy.get('[data-attr="rollout-percentage"]').type('{backspace}') // save cy.get('[data-attr="save-survey"]').click() diff --git a/ee/clickhouse/models/test/test_property.py b/ee/clickhouse/models/test/test_property.py index f55578878f91a..d12d4daf3eeac 100644 --- a/ee/clickhouse/models/test/test_property.py +++ b/ee/clickhouse/models/test/test_property.py @@ -1198,21 +1198,30 @@ def test_parse_groups_persons_edge_case_with_single_filter(snapshot): "events", "prop", "properties", - "replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^\"|\"$', '') AS prop", + ( + "replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', '') AS prop", + {"breakdown_param_1": "$browser"}, + ), ), ( ["$browser"], "events", "value", "properties", - "array(replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^\"|\"$', '')) AS value", + ( + "array(replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', '')) AS value", + {"breakdown_param_1": "$browser"}, + ), ), ( ["$browser", "$browser_version"], "events", "prop", "properties", - "array(replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^\"|\"$', ''),replaceRegexpAll(JSONExtractRaw(properties, '$browser_version'), '^\"|\"$', '')) AS prop", + ( + "array(replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', ''),replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_2)s), '^\"|\"$', '')) AS prop", + {"breakdown_param_1": "$browser", "breakdown_param_2": "$browser_version"}, + ), ), ] @@ -1239,8 +1248,11 @@ def test_breakdown_query_expression( "value", "properties", "person_properties", - "array(replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^\"|\"$', '')) AS value", - 'array("mat_pp_$browser") AS value', + ( + "array(replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', '')) AS value", + {"breakdown_param_1": "$browser"}, + ), + ('array("mat_pp_$browser") AS value', {"breakdown_param_1": "$browser"}), ), ( ["$browser", "$browser_version"], @@ -1248,8 +1260,14 @@ def test_breakdown_query_expression( "prop", "properties", "group2_properties", - "array(replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^\"|\"$', ''),replaceRegexpAll(JSONExtractRaw(properties, '$browser_version'), '^\"|\"$', '')) AS prop", - """array("mat_gp2_$browser",replaceRegexpAll(JSONExtractRaw(properties, '$browser_version'), '^\"|\"$', '')) AS prop""", + ( + "array(replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', ''),replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_2)s), '^\"|\"$', '')) AS prop", + {"breakdown_param_1": "$browser", "breakdown_param_2": "$browser_version"}, + ), + ( + """array("mat_gp2_$browser",replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_2)s), '^\"|\"$', '')) AS prop""", + {"breakdown_param_1": "$browser", "breakdown_param_2": "$browser_version"}, + ), ), ] @@ -1282,7 +1300,6 @@ def test_breakdown_query_expression_materialised( column, materialised_table_column=materialise_column, ) - assert actual == expected_with materialize(table, breakdown[0], table_column=materialise_column) # type: ignore diff --git a/ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap b/ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap index 07dc030451f9c..748782fe59b10 100644 --- a/ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap +++ b/ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap @@ -28,11 +28,14 @@ exports[`replay/transform transform can convert images 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -40,44 +43,39 @@ exports[`replay/transform transform can convert images 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12345, + "style": "color: #ffffff;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;", + }, "childNodes": [ { - "attributes": { - "style": "color: #ffffff;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [ - { - "id": 12345, - "textContent": "Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ", - "type": 3, - }, - ], - "id": 104, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "height": 30, - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=", - "style": "width: 100px;height: 30px;position: fixed;left: 25px;top: 42px;", - "width": 100, - }, - "childNodes": [], - "id": 12345, - "tagName": "img", - "type": 2, + "id": 101, + "textContent": "Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ", + "type": 3, }, ], - "id": 103, + "id": 12345, "tagName": "div", "type": 2, }, + { + "attributes": { + "data-rrweb-id": 12345, + "height": 30, + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=", + "style": "width: 100px;height: 30px;position: fixed;left: 25px;top: 42px;", + "width": 100, + }, + "childNodes": [], + "id": 12345, + "tagName": "img", + "type": 2, + }, ], "id": 5, "tagName": "body", @@ -127,11 +125,14 @@ exports[`replay/transform transform can convert rect with text 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -139,38 +140,33 @@ exports[`replay/transform transform can convert rect with text 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12345, + "style": "color: #ee3ee4;border-width: 4px;border-radius: 10px;border-color: #ee3ee4;border-style: solid;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;", + }, + "childNodes": [], + "id": 12345, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 12345, + "style": "width: 100px;height: 30px;position: fixed;left: 13px;top: 17px;overflow:hidden;white-space:nowrap;", + }, "childNodes": [ { - "attributes": { - "style": "color: #ee3ee4;border-width: 4px;border-radius: 10px;border-color: #ee3ee4;border-style: solid;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;", - }, - "childNodes": [], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 13px;top: 17px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [ - { - "id": 12345, - "textContent": "i am in the box", - "type": 3, - }, - ], - "id": 106, - "tagName": "div", - "type": 2, + "id": 102, + "textContent": "i am in the box", + "type": 3, }, ], - "id": 105, + "id": 12345, "tagName": "div", "type": 2, }, @@ -223,11 +219,14 @@ exports[`replay/transform transform can ignore unknown wireframe types 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -235,17 +234,10 @@ exports[`replay/transform transform can ignore unknown wireframe types 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [], - "id": 102, - "tagName": "div", - "type": 2, - }, - ], + "childNodes": [], "id": 5, "tagName": "body", "type": 2, @@ -306,11 +298,14 @@ exports[`replay/transform transform can process unknown types without error 1`] }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -318,29 +313,23 @@ exports[`replay/transform transform can process unknown types without error 1`] }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12345, + "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 25px;top: 42px;align-items: center;justify-content: center;display: flex;", + }, "childNodes": [ { - "attributes": { - "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 25px;top: 42px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 101, - "textContent": "image", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, + "id": 100, + "textContent": "image", + "type": 3, }, ], - "id": 100, + "id": 12345, "tagName": "div", "type": 2, }, @@ -416,11 +405,14 @@ exports[`replay/transform transform child wireframes are processed 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -428,79 +420,77 @@ exports[`replay/transform transform child wireframes are processed 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 123456789, + "style": "position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", + }, "childNodes": [ { "attributes": { + "data-rrweb-id": 98765, "style": "position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", }, "childNodes": [ { "attributes": { - "style": "position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", + "data-rrweb-id": 12345, + "style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;", }, "childNodes": [ { - "attributes": { - "style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [ - { - "id": 12345, - "textContent": "first nested", - "type": 3, - }, - ], - "id": 108, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [ - { - "id": 12345, - "textContent": "second nested", - "type": 3, - }, - ], - "id": 109, - "tagName": "div", - "type": 2, + "id": 103, + "textContent": "first nested", + "type": 3, }, ], - "id": 98765, + "id": 12345, "tagName": "div", "type": 2, }, { "attributes": { + "data-rrweb-id": 12345, "style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;", }, "childNodes": [ { - "id": 12345, - "textContent": "third (different level) nested", + "id": 104, + "textContent": "second nested", "type": 3, }, ], - "id": 110, + "id": 12345, "tagName": "div", "type": 2, }, ], - "id": 123456789, + "id": 98765, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 12345, + "style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;", + }, + "childNodes": [ + { + "id": 105, + "textContent": "third (different level) nested", + "type": 3, + }, + ], + "id": 12345, "tagName": "div", "type": 2, }, ], - "id": 107, + "id": 123456789, "tagName": "div", "type": 2, }, @@ -543,11 +533,14 @@ exports[`replay/transform transform inputs buttons with nested elements 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -555,41 +548,36 @@ exports[`replay/transform transform inputs buttons with nested elements 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12359, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "button", + }, + "childNodes": [], + "id": 12359, + "tagName": "button", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 12361, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "button", + }, "childNodes": [ { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "button", - }, - "childNodes": [], - "id": 12359, - "tagName": "button", - "type": 2, - }, - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "button", - }, - "childNodes": [ - { - "id": 114, - "textContent": "click me", - "type": 3, - }, - ], - "id": 12361, - "tagName": "button", - "type": 2, + "id": 107, + "textContent": "click me", + "type": 3, }, ], - "id": 113, - "tagName": "div", + "id": 12361, + "tagName": "button", "type": 2, }, ], @@ -649,11 +637,14 @@ exports[`replay/transform transform inputs input - $inputType - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -661,17 +652,10 @@ exports[`replay/transform transform inputs input - $inputType - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [], - "id": 322, - "tagName": "div", - "type": 2, - }, - ], + "childNodes": [], "id": 5, "tagName": "body", "type": 2, @@ -709,11 +693,14 @@ exports[`replay/transform transform inputs input - button - click me 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -721,31 +708,25 @@ exports[`replay/transform transform inputs input - button - click me 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12358, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "button", + }, "childNodes": [ { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "button", - }, - "childNodes": [ - { - "id": 314, - "textContent": "click me", - "type": 3, - }, - ], - "id": 12358, - "tagName": "button", - "type": 2, + "id": 284, + "textContent": "click me", + "type": 3, }, ], - "id": 313, - "tagName": "div", + "id": 12358, + "tagName": "button", "type": 2, }, ], @@ -786,11 +767,14 @@ exports[`replay/transform transform inputs input - checkbox - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -798,41 +782,36 @@ exports[`replay/transform transform inputs input - checkbox - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 261, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "checked": true, + "data-rrweb-id": 12357, + "style": null, + "type": "checkbox", }, - "childNodes": [ - { - "attributes": { - "checked": true, - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - { - "id": 282, - "textContent": "first", - "type": 3, - }, - ], - "id": 283, - "tagName": "label", + "childNodes": [], + "id": 12357, + "tagName": "input", "type": 2, }, + { + "id": 260, + "textContent": "first", + "type": 3, + }, ], - "id": 281, - "tagName": "div", + "id": 261, + "tagName": "label", "type": 2, }, ], @@ -873,11 +852,14 @@ exports[`replay/transform transform inputs input - checkbox - $value 2`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -885,40 +867,35 @@ exports[`replay/transform transform inputs input - checkbox - $value 2`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 263, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 12357, + "style": null, + "type": "checkbox", }, - "childNodes": [ - { - "attributes": { - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - { - "id": 285, - "textContent": "second", - "type": 3, - }, - ], - "id": 286, - "tagName": "label", + "childNodes": [], + "id": 12357, + "tagName": "input", "type": 2, }, + { + "id": 262, + "textContent": "second", + "type": 3, + }, ], - "id": 284, - "tagName": "div", + "id": 263, + "tagName": "label", "type": 2, }, ], @@ -959,11 +936,14 @@ exports[`replay/transform transform inputs input - checkbox - $value 3`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -971,42 +951,37 @@ exports[`replay/transform transform inputs input - checkbox - $value 3`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 265, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "checked": true, + "data-rrweb-id": 12357, + "disabled": true, + "style": null, + "type": "checkbox", }, - "childNodes": [ - { - "attributes": { - "checked": true, - "disabled": true, - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - { - "id": 288, - "textContent": "third", - "type": 3, - }, - ], - "id": 289, - "tagName": "label", + "childNodes": [], + "id": 12357, + "tagName": "input", "type": 2, }, + { + "id": 264, + "textContent": "third", + "type": 3, + }, ], - "id": 287, - "tagName": "div", + "id": 265, + "tagName": "label", "type": 2, }, ], @@ -1047,11 +1022,14 @@ exports[`replay/transform transform inputs input - checkbox - $value 4`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1059,26 +1037,20 @@ exports[`replay/transform transform inputs input - checkbox - $value 4`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "checked": true, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - ], - "id": 290, - "tagName": "div", + "attributes": { + "checked": true, + "data-rrweb-id": 12357, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "checkbox", + }, + "childNodes": [], + "id": 12357, + "tagName": "input", "type": 2, }, ], @@ -1119,11 +1091,14 @@ exports[`replay/transform transform inputs input - email - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1131,26 +1106,20 @@ exports[`replay/transform transform inputs input - email - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "email", - "value": "", - }, - "childNodes": [], - "id": 12349, - "tagName": "input", - "type": 2, - }, - ], - "id": 274, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12349, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "email", + "value": "", + }, + "childNodes": [], + "id": 12349, + "tagName": "input", "type": 2, }, ], @@ -1191,38 +1160,35 @@ exports[`replay/transform transform inputs input - number - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [], + "attributes": { + "data-rrweb-id": 4, + }, + "childNodes": [], "id": 4, "tagName": "head", "type": 2, }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "number", - "value": "", - }, - "childNodes": [], - "id": 12350, - "tagName": "input", - "type": 2, - }, - ], - "id": 275, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12350, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "number", + "value": "", + }, + "childNodes": [], + "id": 12350, + "tagName": "input", "type": 2, }, ], @@ -1263,11 +1229,14 @@ exports[`replay/transform transform inputs input - password - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1275,26 +1244,20 @@ exports[`replay/transform transform inputs input - password - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "password", - "value": "", - }, - "childNodes": [], - "id": 12348, - "tagName": "input", - "type": 2, - }, - ], - "id": 273, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12348, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "password", + "value": "", + }, + "childNodes": [], + "id": 12348, + "tagName": "input", "type": 2, }, ], @@ -1335,11 +1298,14 @@ exports[`replay/transform transform inputs input - progress - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1347,49 +1313,44 @@ exports[`replay/transform transform inputs input - progress - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12365, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 291, + "style": "background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;border: 4px solid #35373e;border-radius: 50%;border-top: 4px solid #fff;animation: spin 2s linear infinite;", }, "childNodes": [ { "attributes": { - "style": "background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;border: 4px solid #35373e;border-radius: 50%;border-top: 4px solid #fff;animation: spin 2s linear infinite;", + "type": "text/css", }, "childNodes": [ { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 325, - "textContent": "@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }", - "type": 3, - }, - ], - "id": 324, - "tagName": "style", - "type": 2, + "id": 290, + "textContent": "@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }", + "type": 3, }, ], - "id": 326, - "tagName": "div", + "id": 289, + "tagName": "style", "type": 2, }, ], - "id": 12365, + "id": 291, "tagName": "div", "type": 2, }, ], - "id": 323, + "id": 12365, "tagName": "div", "type": 2, }, @@ -1431,11 +1392,14 @@ exports[`replay/transform transform inputs input - progress - $value 2`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1443,27 +1407,21 @@ exports[`replay/transform transform inputs input - progress - $value 2`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "max": null, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": null, - "value": null, - }, - "childNodes": [], - "id": 12365, - "tagName": "progress", - "type": 2, - }, - ], - "id": 327, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12365, + "max": null, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": null, + "value": null, + }, + "childNodes": [], + "id": 12365, + "tagName": "progress", "type": 2, }, ], @@ -1504,11 +1462,14 @@ exports[`replay/transform transform inputs input - progress - 0.75 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1516,27 +1477,21 @@ exports[`replay/transform transform inputs input - progress - 0.75 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "max": null, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": null, - "value": 0.75, - }, - "childNodes": [], - "id": 12365, - "tagName": "progress", - "type": 2, - }, - ], - "id": 328, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12365, + "max": null, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": null, + "value": 0.75, + }, + "childNodes": [], + "id": 12365, + "tagName": "progress", "type": 2, }, ], @@ -1577,11 +1532,14 @@ exports[`replay/transform transform inputs input - progress - 0.75 2`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1589,27 +1547,21 @@ exports[`replay/transform transform inputs input - progress - 0.75 2`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "max": 2.5, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": null, - "value": 0.75, - }, - "childNodes": [], - "id": 12365, - "tagName": "progress", - "type": 2, - }, - ], - "id": 329, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12365, + "max": 2.5, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": null, + "value": 0.75, + }, + "childNodes": [], + "id": 12365, + "tagName": "progress", "type": 2, }, ], @@ -1650,11 +1602,14 @@ exports[`replay/transform transform inputs input - search - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1662,26 +1617,20 @@ exports[`replay/transform transform inputs input - search - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "search", - "value": "", - }, - "childNodes": [], - "id": 12351, - "tagName": "input", - "type": 2, - }, - ], - "id": 276, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12351, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "search", + "value": "", + }, + "childNodes": [], + "id": 12351, + "tagName": "input", "type": 2, }, ], @@ -1722,11 +1671,14 @@ exports[`replay/transform transform inputs input - select - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1734,55 +1686,52 @@ exports[`replay/transform transform inputs input - select - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12365, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "select", + "value": "hello", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "select", - "value": "hello", + "data-rrweb-id": 285, + "selected": true, }, "childNodes": [ { - "attributes": { - "selected": true, - }, - "childNodes": [ - { - "id": 319, - "textContent": "hello", - "type": 3, - }, - ], - "id": 318, - "tagName": "option", - "type": 2, + "id": 286, + "textContent": "hello", + "type": 3, }, + ], + "id": 285, + "tagName": "option", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 287, + }, + "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 321, - "textContent": "world", - "type": 3, - }, - ], - "id": 320, - "tagName": "option", - "type": 2, + "id": 288, + "textContent": "world", + "type": 3, }, ], - "id": 12365, - "tagName": "select", + "id": 287, + "tagName": "option", "type": 2, }, ], - "id": 317, - "tagName": "div", + "id": 12365, + "tagName": "select", "type": 2, }, ], @@ -1823,11 +1772,14 @@ exports[`replay/transform transform inputs input - tel - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1835,27 +1787,21 @@ exports[`replay/transform transform inputs input - tel - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "disabled": true, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "tel", - "value": "", - }, - "childNodes": [], - "id": 12352, - "tagName": "input", - "type": 2, - }, - ], - "id": 277, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12352, + "disabled": true, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "tel", + "value": "", + }, + "childNodes": [], + "id": 12352, + "tagName": "input", "type": 2, }, ], @@ -1896,11 +1842,14 @@ exports[`replay/transform transform inputs input - text - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1908,26 +1857,20 @@ exports[`replay/transform transform inputs input - text - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "text", - "value": "", - }, - "childNodes": [], - "id": 12347, - "tagName": "input", - "type": 2, - }, - ], - "id": 272, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12347, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "text", + "value": "", + }, + "childNodes": [], + "id": 12347, + "tagName": "input", "type": 2, }, ], @@ -1968,11 +1911,14 @@ exports[`replay/transform transform inputs input - text - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -1980,26 +1926,20 @@ exports[`replay/transform transform inputs input - text - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "text", - "value": "hello", - }, - "childNodes": [], - "id": 12346, - "tagName": "input", - "type": 2, - }, - ], - "id": 271, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12346, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "text", + "value": "hello", + }, + "childNodes": [], + "id": 12346, + "tagName": "input", "type": 2, }, ], @@ -2040,11 +1980,14 @@ exports[`replay/transform transform inputs input - textArea - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -2052,26 +1995,20 @@ exports[`replay/transform transform inputs input - textArea - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "textArea", - "value": "", - }, - "childNodes": [], - "id": 12364, - "tagName": "input", - "type": 2, - }, - ], - "id": 316, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12364, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "textArea", + "value": "", + }, + "childNodes": [], + "id": 12364, + "tagName": "input", "type": 2, }, ], @@ -2112,11 +2049,14 @@ exports[`replay/transform transform inputs input - textArea - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -2124,26 +2064,20 @@ exports[`replay/transform transform inputs input - textArea - hello 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "textArea", - "value": "hello", - }, - "childNodes": [], - "id": 12363, - "tagName": "input", - "type": 2, - }, - ], - "id": 315, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12363, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "textArea", + "value": "hello", + }, + "childNodes": [], + "id": 12363, + "tagName": "input", "type": 2, }, ], @@ -2184,11 +2118,14 @@ exports[`replay/transform transform inputs input - toggle - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -2196,70 +2133,68 @@ exports[`replay/transform transform inputs input - toggle - $value 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 270, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ + { + "id": 269, + "textContent": "first", + "type": 3, + }, { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 12357, + "style": "height:100%;flex:1", }, "childNodes": [ - { - "id": 295, - "textContent": "first", - "type": 3, - }, { "attributes": { - "style": "height:100%;flex:1", + "data-rrweb-id": 266, + "style": "position:relative;width:100%;height:100%;", }, "childNodes": [ { "attributes": { - "style": "position:relative;width:100%;height:100%;", + "data-rrweb-id": 267, + "data-toggle-part": "slider", + "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#1d4aff;opacity: 0.2;border-radius:7.5%;", }, - "childNodes": [ - { - "attributes": { - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#1d4aff;opacity: 0.2;border-radius:7.5%;", - }, - "childNodes": [], - "id": 293, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#1d4aff;border:2px solid #1d4aff;border-radius:50%;", - }, - "childNodes": [], - "id": 294, - "tagName": "div", - "type": 2, - }, - ], - "id": 292, + "childNodes": [], + "id": 267, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 268, + "data-toggle-part": "handle", + "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#1d4aff;border:2px solid #1d4aff;border-radius:50%;", + }, + "childNodes": [], + "id": 268, "tagName": "div", "type": 2, }, ], - "id": 12357, + "id": 266, "tagName": "div", "type": 2, }, ], - "id": 296, - "tagName": "label", + "id": 12357, + "tagName": "div", "type": 2, }, ], - "id": 291, - "tagName": "div", + "id": 270, + "tagName": "label", "type": 2, }, ], @@ -2300,11 +2235,14 @@ exports[`replay/transform transform inputs input - toggle - $value 2`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -2312,70 +2250,68 @@ exports[`replay/transform transform inputs input - toggle - $value 2`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 275, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ + { + "id": 274, + "textContent": "second", + "type": 3, + }, { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 12357, + "style": "height:100%;flex:1", }, "childNodes": [ - { - "id": 301, - "textContent": "second", - "type": 3, - }, { "attributes": { - "style": "height:100%;flex:1", + "data-rrweb-id": 271, + "style": "position:relative;width:100%;height:100%;", }, "childNodes": [ { "attributes": { - "style": "position:relative;width:100%;height:100%;", + "data-rrweb-id": 272, + "data-toggle-part": "slider", + "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#f3f4ef;opacity: 0.2;border-radius:7.5%;", }, - "childNodes": [ - { - "attributes": { - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#f3f4ef;opacity: 0.2;border-radius:7.5%;", - }, - "childNodes": [], - "id": 299, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;left:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#f3f4ef;border:2px solid #f3f4ef;border-radius:50%;", - }, - "childNodes": [], - "id": 300, - "tagName": "div", - "type": 2, - }, - ], - "id": 298, + "childNodes": [], + "id": 272, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 273, + "data-toggle-part": "handle", + "style": "position:absolute;top:1.5%;left:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#f3f4ef;border:2px solid #f3f4ef;border-radius:50%;", + }, + "childNodes": [], + "id": 273, "tagName": "div", "type": 2, }, ], - "id": 12357, + "id": 271, "tagName": "div", "type": 2, }, ], - "id": 302, - "tagName": "label", + "id": 12357, + "tagName": "div", "type": 2, }, ], - "id": 297, - "tagName": "div", + "id": 275, + "tagName": "label", "type": 2, }, ], @@ -2416,11 +2352,14 @@ exports[`replay/transform transform inputs input - toggle - $value 3`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -2428,70 +2367,68 @@ exports[`replay/transform transform inputs input - toggle - $value 3`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 280, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ + { + "id": 279, + "textContent": "third", + "type": 3, + }, { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 12357, + "style": "height:100%;flex:1", }, "childNodes": [ - { - "id": 307, - "textContent": "third", - "type": 3, - }, { "attributes": { - "style": "height:100%;flex:1", + "data-rrweb-id": 276, + "style": "position:relative;width:100%;height:100%;", }, "childNodes": [ { "attributes": { - "style": "position:relative;width:100%;height:100%;", + "data-rrweb-id": 277, + "data-toggle-part": "slider", + "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#1d4aff;opacity: 0.2;border-radius:7.5%;", }, - "childNodes": [ - { - "attributes": { - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#1d4aff;opacity: 0.2;border-radius:7.5%;", - }, - "childNodes": [], - "id": 305, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#1d4aff;border:2px solid #1d4aff;border-radius:50%;", - }, - "childNodes": [], - "id": 306, - "tagName": "div", - "type": 2, - }, - ], - "id": 304, + "childNodes": [], + "id": 277, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 278, + "data-toggle-part": "handle", + "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#1d4aff;border:2px solid #1d4aff;border-radius:50%;", + }, + "childNodes": [], + "id": 278, "tagName": "div", "type": 2, }, ], - "id": 12357, + "id": 276, "tagName": "div", "type": 2, }, ], - "id": 308, - "tagName": "label", + "id": 12357, + "tagName": "div", "type": 2, }, ], - "id": 303, - "tagName": "div", + "id": 280, + "tagName": "label", "type": 2, }, ], @@ -2532,11 +2469,14 @@ exports[`replay/transform transform inputs input - toggle - $value 4`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -2544,54 +2484,51 @@ exports[`replay/transform transform inputs input - toggle - $value 4`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12357, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 281, + "style": "position:relative;width:100%;height:100%;", }, "childNodes": [ { "attributes": { - "style": "position:relative;width:100%;height:100%;", + "data-rrweb-id": 282, + "data-toggle-part": "slider", + "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#1d4aff;opacity: 0.2;border-radius:7.5%;", }, - "childNodes": [ - { - "attributes": { - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:#1d4aff;opacity: 0.2;border-radius:7.5%;", - }, - "childNodes": [], - "id": 311, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#1d4aff;border:2px solid #1d4aff;border-radius:50%;", - }, - "childNodes": [], - "id": 312, - "tagName": "div", - "type": 2, - }, - ], - "id": 310, + "childNodes": [], + "id": 282, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 283, + "data-toggle-part": "handle", + "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;background-color:#1d4aff;border:2px solid #1d4aff;border-radius:50%;", + }, + "childNodes": [], + "id": 283, "tagName": "div", "type": 2, }, ], - "id": 12357, + "id": 281, "tagName": "div", "type": 2, }, ], - "id": 309, + "id": 12357, "tagName": "div", "type": 2, }, @@ -2633,11 +2570,14 @@ exports[`replay/transform transform inputs input - url - https://example.io 1`] }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -2645,26 +2585,20 @@ exports[`replay/transform transform inputs input - url - https://example.io 1`] }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "url", - "value": "https://example.io", - }, - "childNodes": [], - "id": 12352, - "tagName": "input", - "type": 2, - }, - ], - "id": 278, - "tagName": "div", + "attributes": { + "data-rrweb-id": 12352, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "type": "url", + "value": "https://example.io", + }, + "childNodes": [], + "id": 12352, + "tagName": "input", "type": 2, }, ], @@ -2695,968 +2629,2540 @@ exports[`replay/transform transform inputs isolated add mutation 1`] = ` "nextId": null, "node": { "attributes": { + "data-rrweb-id": 12365, "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", }, - "childNodes": [ - { - "attributes": { - "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", - }, - "childNodes": [ - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 174, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 173, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 175, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 172, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 178, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 177, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 179, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 176, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 182, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 181, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 183, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 180, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 186, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 185, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 187, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 184, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 190, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 189, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 191, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 188, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 194, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 193, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 195, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 192, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 198, - "textContent": "half-filled star", - "type": 3, - }, - ], - "id": 197, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 199, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 196, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 202, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 201, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 203, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 200, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 206, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 205, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 207, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 204, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 210, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 209, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 211, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 208, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 214, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 213, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 215, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 212, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 218, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 217, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 219, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 216, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - ], - "id": 220, - "tagName": "div", - "type": 2, - }, - ], + "childNodes": [], "id": 12365, "tagName": "div", "type": 2, }, "parentId": 54321, }, - ], - "attributes": [], - "removes": [], - "source": 0, - "texts": [], - }, - "timestamp": 1, - "type": 3, -} -`; - -exports[`replay/transform transform inputs isolated remove mutation 1`] = ` -{ - "data": { - "removes": [ { - "id": 12345, - "parentId": 54321, + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 210, + "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", + }, + "childNodes": [], + "id": 210, + "tagName": "div", + "type": 2, + }, + "parentId": 12365, }, - ], - "source": 0, - }, - "timestamp": 1, - "type": 3, -} -`; - -exports[`replay/transform transform inputs isolated update mutation 1`] = ` -{ - "data": { - "adds": [ { "nextId": null, "node": { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 210, + "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", }, - "childNodes": [ - { - "attributes": { - "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", - }, - "childNodes": [ - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 223, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 222, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 224, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 221, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 227, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 226, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 228, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 225, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 231, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 230, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 232, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 229, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 235, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 234, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 236, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 233, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 239, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 238, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 240, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 237, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 243, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 242, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 244, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 241, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 247, - "textContent": "half-filled star", - "type": 3, - }, - ], - "id": 246, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 248, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 245, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 251, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 250, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 252, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 249, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 255, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 254, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 256, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 253, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 259, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 258, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 260, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 257, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 263, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 262, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 264, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 261, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 267, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 266, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 268, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 265, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - ], - "id": 269, - "tagName": "div", - "type": 2, - }, - ], - "id": 12365, + "childNodes": [], + "id": 210, "tagName": "div", "type": 2, }, - "parentId": 54321, + "parentId": 12365, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 162, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 162, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 162, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 162, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 163, + }, + "childNodes": [], + "id": 163, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 162, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 163, + }, + "childNodes": [], + "id": 163, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 162, + }, + { + "nextId": null, + "node": { + "id": 165, + "textContent": "filled star", + "type": 3, + }, + "parentId": 163, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 164, + }, + "childNodes": [], + "id": 164, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 162, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 164, + }, + "childNodes": [], + "id": 164, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 162, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 166, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 166, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 166, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 166, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 167, + }, + "childNodes": [], + "id": 167, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 166, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 167, + }, + "childNodes": [], + "id": 167, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 166, + }, + { + "nextId": null, + "node": { + "id": 169, + "textContent": "filled star", + "type": 3, + }, + "parentId": 167, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 168, + }, + "childNodes": [], + "id": 168, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 166, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 168, + }, + "childNodes": [], + "id": 168, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 166, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 170, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 170, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 170, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 170, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 171, + }, + "childNodes": [], + "id": 171, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 170, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 171, + }, + "childNodes": [], + "id": 171, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 170, + }, + { + "nextId": null, + "node": { + "id": 173, + "textContent": "filled star", + "type": 3, + }, + "parentId": 171, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 172, + }, + "childNodes": [], + "id": 172, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 170, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 172, + }, + "childNodes": [], + "id": 172, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 170, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 174, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 174, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 174, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 174, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 175, + }, + "childNodes": [], + "id": 175, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 174, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 175, + }, + "childNodes": [], + "id": 175, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 174, + }, + { + "nextId": null, + "node": { + "id": 177, + "textContent": "filled star", + "type": 3, + }, + "parentId": 175, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 176, + }, + "childNodes": [], + "id": 176, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 174, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 176, + }, + "childNodes": [], + "id": 176, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 174, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 178, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 178, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 178, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 178, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 179, + }, + "childNodes": [], + "id": 179, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 178, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 179, + }, + "childNodes": [], + "id": 179, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 178, + }, + { + "nextId": null, + "node": { + "id": 181, + "textContent": "filled star", + "type": 3, + }, + "parentId": 179, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 180, + }, + "childNodes": [], + "id": 180, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 178, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 180, + }, + "childNodes": [], + "id": 180, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 178, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 182, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 182, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 182, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 182, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 183, + }, + "childNodes": [], + "id": 183, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 182, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 183, + }, + "childNodes": [], + "id": 183, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 182, + }, + { + "nextId": null, + "node": { + "id": 185, + "textContent": "filled star", + "type": 3, + }, + "parentId": 183, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 184, + }, + "childNodes": [], + "id": 184, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 182, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 184, + }, + "childNodes": [], + "id": 184, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 182, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 186, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 186, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 186, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 186, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 187, + }, + "childNodes": [], + "id": 187, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 186, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 187, + }, + "childNodes": [], + "id": 187, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 186, + }, + { + "nextId": null, + "node": { + "id": 189, + "textContent": "half-filled star", + "type": 3, + }, + "parentId": 187, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 188, + }, + "childNodes": [], + "id": 188, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 186, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 188, + }, + "childNodes": [], + "id": 188, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 186, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 190, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 190, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 190, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 190, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 191, + }, + "childNodes": [], + "id": 191, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 190, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 191, + }, + "childNodes": [], + "id": 191, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 190, + }, + { + "nextId": null, + "node": { + "id": 193, + "textContent": "empty star", + "type": 3, + }, + "parentId": 191, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 192, + }, + "childNodes": [], + "id": 192, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 190, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 192, + }, + "childNodes": [], + "id": 192, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 190, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 194, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 194, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 194, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 194, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 195, + }, + "childNodes": [], + "id": 195, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 194, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 195, + }, + "childNodes": [], + "id": 195, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 194, + }, + { + "nextId": null, + "node": { + "id": 197, + "textContent": "empty star", + "type": 3, + }, + "parentId": 195, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 196, + }, + "childNodes": [], + "id": 196, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 194, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 196, + }, + "childNodes": [], + "id": 196, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 194, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 198, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 198, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 198, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 198, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 199, + }, + "childNodes": [], + "id": 199, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 198, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 199, + }, + "childNodes": [], + "id": 199, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 198, + }, + { + "nextId": null, + "node": { + "id": 201, + "textContent": "empty star", + "type": 3, + }, + "parentId": 199, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 200, + }, + "childNodes": [], + "id": 200, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 198, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 200, + }, + "childNodes": [], + "id": 200, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 198, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 202, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 202, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 202, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 202, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 203, + }, + "childNodes": [], + "id": 203, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 202, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 203, + }, + "childNodes": [], + "id": 203, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 202, + }, + { + "nextId": null, + "node": { + "id": 205, + "textContent": "empty star", + "type": 3, + }, + "parentId": 203, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 204, + }, + "childNodes": [], + "id": 204, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 202, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 204, + }, + "childNodes": [], + "id": 204, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 202, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 206, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 206, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 206, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 206, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 210, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 207, + }, + "childNodes": [], + "id": 207, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 206, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 207, + }, + "childNodes": [], + "id": 207, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 206, + }, + { + "nextId": null, + "node": { + "id": 209, + "textContent": "empty star", + "type": 3, + }, + "parentId": 207, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 208, + }, + "childNodes": [], + "id": 208, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 206, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 208, + }, + "childNodes": [], + "id": 208, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 206, + }, + ], + "attributes": [], + "removes": [], + "source": 0, + "texts": [], + }, + "timestamp": 1, + "type": 3, +} +`; + +exports[`replay/transform transform inputs isolated remove mutation 1`] = ` +{ + "data": { + "removes": [ + { + "id": 12345, + "parentId": 54321, + }, + ], + "source": 0, + }, + "timestamp": 1, + "type": 3, +} +`; + +exports[`replay/transform transform inputs isolated update mutation 1`] = ` +{ + "data": { + "adds": [ + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 12365, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, + "childNodes": [], + "id": 12365, + "tagName": "div", + "type": 2, + }, + "parentId": 54321, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 259, + "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", + }, + "childNodes": [], + "id": 259, + "tagName": "div", + "type": 2, + }, + "parentId": 12365, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 259, + "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", + }, + "childNodes": [], + "id": 259, + "tagName": "div", + "type": 2, + }, + "parentId": 12365, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 211, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 211, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 211, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 211, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 212, + }, + "childNodes": [], + "id": 212, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 211, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 212, + }, + "childNodes": [], + "id": 212, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 211, + }, + { + "nextId": null, + "node": { + "id": 214, + "textContent": "filled star", + "type": 3, + }, + "parentId": 212, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 213, + }, + "childNodes": [], + "id": 213, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 211, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 213, + }, + "childNodes": [], + "id": 213, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 211, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 215, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 215, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 215, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 215, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 216, + }, + "childNodes": [], + "id": 216, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 215, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 216, + }, + "childNodes": [], + "id": 216, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 215, + }, + { + "nextId": null, + "node": { + "id": 218, + "textContent": "filled star", + "type": 3, + }, + "parentId": 216, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 217, + }, + "childNodes": [], + "id": 217, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 215, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 217, + }, + "childNodes": [], + "id": 217, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 215, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 219, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 219, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 219, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 219, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 220, + }, + "childNodes": [], + "id": 220, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 219, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 220, + }, + "childNodes": [], + "id": 220, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 219, + }, + { + "nextId": null, + "node": { + "id": 222, + "textContent": "filled star", + "type": 3, + }, + "parentId": 220, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 221, + }, + "childNodes": [], + "id": 221, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 219, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 221, + }, + "childNodes": [], + "id": 221, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 219, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 223, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 223, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 223, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 223, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 224, + }, + "childNodes": [], + "id": 224, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 223, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 224, + }, + "childNodes": [], + "id": 224, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 223, + }, + { + "nextId": null, + "node": { + "id": 226, + "textContent": "filled star", + "type": 3, + }, + "parentId": 224, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 225, + }, + "childNodes": [], + "id": 225, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 223, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 225, + }, + "childNodes": [], + "id": 225, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 223, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 227, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 227, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 227, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 227, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 228, + }, + "childNodes": [], + "id": 228, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 227, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 228, + }, + "childNodes": [], + "id": 228, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 227, + }, + { + "nextId": null, + "node": { + "id": 230, + "textContent": "filled star", + "type": 3, + }, + "parentId": 228, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 229, + }, + "childNodes": [], + "id": 229, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 227, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 229, + }, + "childNodes": [], + "id": 229, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 227, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 231, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 231, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 231, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 231, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 232, + }, + "childNodes": [], + "id": 232, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 231, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 232, + }, + "childNodes": [], + "id": 232, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 231, + }, + { + "nextId": null, + "node": { + "id": 234, + "textContent": "filled star", + "type": 3, + }, + "parentId": 232, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 233, + }, + "childNodes": [], + "id": 233, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 231, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 233, + }, + "childNodes": [], + "id": 233, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 231, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 235, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 235, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 235, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 235, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 236, + }, + "childNodes": [], + "id": 236, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 235, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 236, + }, + "childNodes": [], + "id": 236, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 235, + }, + { + "nextId": null, + "node": { + "id": 238, + "textContent": "half-filled star", + "type": 3, + }, + "parentId": 236, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 237, + }, + "childNodes": [], + "id": 237, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 235, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 237, + }, + "childNodes": [], + "id": 237, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 235, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 239, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 239, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 239, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 239, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 240, + }, + "childNodes": [], + "id": 240, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 239, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 240, + }, + "childNodes": [], + "id": 240, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 239, + }, + { + "nextId": null, + "node": { + "id": 242, + "textContent": "empty star", + "type": 3, + }, + "parentId": 240, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 241, + }, + "childNodes": [], + "id": 241, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 239, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 241, + }, + "childNodes": [], + "id": 241, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 239, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 243, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 243, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 243, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 243, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 244, + }, + "childNodes": [], + "id": 244, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 243, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 244, + }, + "childNodes": [], + "id": 244, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 243, + }, + { + "nextId": null, + "node": { + "id": 246, + "textContent": "empty star", + "type": 3, + }, + "parentId": 244, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 245, + }, + "childNodes": [], + "id": 245, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 243, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 245, + }, + "childNodes": [], + "id": 245, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 243, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 247, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 247, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 247, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 247, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 248, + }, + "childNodes": [], + "id": 248, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 247, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 248, + }, + "childNodes": [], + "id": 248, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 247, + }, + { + "nextId": null, + "node": { + "id": 250, + "textContent": "empty star", + "type": 3, + }, + "parentId": 248, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 249, + }, + "childNodes": [], + "id": 249, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 247, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 249, + }, + "childNodes": [], + "id": 249, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 247, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 251, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 251, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 251, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 251, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 252, + }, + "childNodes": [], + "id": 252, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 251, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 252, + }, + "childNodes": [], + "id": 252, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 251, + }, + { + "nextId": null, + "node": { + "id": 254, + "textContent": "empty star", + "type": 3, + }, + "parentId": 252, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 253, + }, + "childNodes": [], + "id": 253, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 251, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 253, + }, + "childNodes": [], + "id": 253, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 251, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 255, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 255, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 255, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [], + "id": 255, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + "parentId": 259, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 256, + }, + "childNodes": [], + "id": 256, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 255, + }, + { + "nextId": null, + "node": { + "attributes": { + "data-rrweb-id": 256, + }, + "childNodes": [], + "id": 256, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + "parentId": 255, + }, + { + "nextId": null, + "node": { + "id": 258, + "textContent": "empty star", + "type": 3, + }, + "parentId": 256, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 257, + }, + "childNodes": [], + "id": 257, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 255, + }, + { + "nextId": null, + "node": { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 257, + }, + "childNodes": [], + "id": 257, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + "parentId": 255, }, ], "attributes": [], @@ -3682,11 +5188,12 @@ exports[`replay/transform transform inputs open keyboard custom event 1`] = ` "nextId": null, "node": { "attributes": { + "data-rrweb-id": 6, "style": "color: #35373e;background-color: #f3f4ef;width: 100vw;height: 150px;bottom: 0;position: fixed;align-items: center;justify-content: center;display: flex;", }, "childNodes": [ { - "id": 170, + "id": 160, "textContent": "keyboard", "type": 3, }, @@ -3700,7 +5207,7 @@ exports[`replay/transform transform inputs open keyboard custom event 1`] = ` { "nextId": null, "node": { - "id": 171, + "id": 161, "textContent": "keyboard", "type": 3, }, @@ -3735,11 +5242,14 @@ exports[`replay/transform transform inputs placeholder - $inputType - $value 1`] }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -3747,29 +5257,23 @@ exports[`replay/transform transform inputs placeholder - $inputType - $value 1`] }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12365, + "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", + }, "childNodes": [ { - "attributes": { - "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 331, - "textContent": "hello", - "type": 3, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, + "id": 292, + "textContent": "hello", + "type": 3, }, ], - "id": 330, + "id": 12365, "tagName": "div", "type": 2, }, @@ -3811,11 +5315,14 @@ exports[`replay/transform transform inputs progress rating 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -3823,478 +5330,521 @@ exports[`replay/transform transform inputs progress rating 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12365, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 159, + "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", }, "childNodes": [ { "attributes": { - "style": "position: relative; display: flex; flex-direction: row; padding: 2px 4px;", + "data-rrweb-id": 111, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 112, + }, + "childNodes": [ + { + "id": 114, + "textContent": "filled star", + "type": 3, + }, + ], + "id": 112, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 113, + }, + "childNodes": [], + "id": 113, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + ], + "id": 111, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 115, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 116, + }, + "childNodes": [ + { + "id": 118, + "textContent": "filled star", + "type": 3, + }, + ], + "id": 116, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 117, + }, + "childNodes": [], + "id": 117, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + ], + "id": 115, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 119, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 120, + }, + "childNodes": [ + { + "id": 122, + "textContent": "filled star", + "type": 3, + }, + ], + "id": 120, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 121, + }, + "childNodes": [], + "id": 121, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + ], + "id": 119, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 123, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 124, + }, + "childNodes": [ + { + "id": 126, + "textContent": "filled star", + "type": 3, + }, + ], + "id": 124, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 125, + }, + "childNodes": [], + "id": 125, + "isSVG": true, + "tagName": "path", + "type": 2, + }, + ], + "id": 123, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 127, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", }, "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 128, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 123, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 122, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 124, - "isSVG": true, - "tagName": "path", - "type": 2, + "id": 130, + "textContent": "filled star", + "type": 3, }, ], - "id": 121, + "id": 128, "isSVG": true, - "tagName": "svg", + "tagName": "title", "type": 2, }, { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 129, }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 127, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 126, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 128, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 125, + "childNodes": [], + "id": 129, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, + ], + "id": 127, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 131, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 132, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 131, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 130, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 132, - "isSVG": true, - "tagName": "path", - "type": 2, + "id": 134, + "textContent": "filled star", + "type": 3, }, ], - "id": 129, + "id": 132, "isSVG": true, - "tagName": "svg", + "tagName": "title", "type": 2, }, { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", + "data-rrweb-id": 133, }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 135, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 134, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 136, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], + "childNodes": [], "id": 133, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, + ], + "id": 131, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 135, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 136, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 139, - "textContent": "filled star", - "type": 3, - }, - ], "id": 138, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 140, - "isSVG": true, - "tagName": "path", - "type": 2, + "textContent": "half-filled star", + "type": 3, }, ], + "id": 136, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 137, + }, + "childNodes": [], "id": 137, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, + ], + "id": 135, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 139, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 140, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 143, - "textContent": "filled star", - "type": 3, - }, - ], "id": 142, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - }, - "childNodes": [], - "id": 144, - "isSVG": true, - "tagName": "path", - "type": 2, + "textContent": "empty star", + "type": 3, }, ], + "id": 140, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 141, + }, + "childNodes": [], "id": 141, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, + ], + "id": 139, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 143, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 144, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 147, - "textContent": "half-filled star", - "type": 3, - }, - ], "id": 146, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 148, - "isSVG": true, - "tagName": "path", - "type": 2, + "textContent": "empty star", + "type": 3, }, ], + "id": 144, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 145, + }, + "childNodes": [], "id": 145, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, + ], + "id": 143, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 147, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 148, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 151, - "textContent": "empty star", - "type": 3, - }, - ], "id": 150, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 152, - "isSVG": true, - "tagName": "path", - "type": 2, + "textContent": "empty star", + "type": 3, }, ], + "id": 148, + "isSVG": true, + "tagName": "title", + "type": 2, + }, + { + "attributes": { + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 149, + }, + "childNodes": [], "id": 149, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, + ], + "id": 147, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 151, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 152, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 155, - "textContent": "empty star", - "type": 3, - }, - ], "id": 154, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 156, - "isSVG": true, - "tagName": "path", - "type": 2, + "textContent": "empty star", + "type": 3, }, ], - "id": 153, + "id": 152, "isSVG": true, - "tagName": "svg", + "tagName": "title", "type": 2, }, { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 153, }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 159, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 158, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 160, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 157, + "childNodes": [], + "id": 153, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, + ], + "id": 151, + "isSVG": true, + "tagName": "svg", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 155, + "fill": "currentColor", + "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", + "viewBox": "0 0 24 24", + }, + "childNodes": [ { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "data-rrweb-id": 156, }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "id": 163, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 162, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 164, - "isSVG": true, - "tagName": "path", - "type": 2, + "id": 158, + "textContent": "empty star", + "type": 3, }, ], - "id": 161, + "id": 156, "isSVG": true, - "tagName": "svg", + "tagName": "title", "type": 2, }, { "attributes": { - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden", - "viewBox": "0 0 24 24", + "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", + "data-rrweb-id": 157, }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 167, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 166, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - }, - "childNodes": [], - "id": 168, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 165, + "childNodes": [], + "id": 157, "isSVG": true, - "tagName": "svg", + "tagName": "path", "type": 2, }, ], - "id": 169, - "tagName": "div", + "id": 155, + "isSVG": true, + "tagName": "svg", "type": 2, }, ], - "id": 12365, + "id": 159, "tagName": "div", "type": 2, }, ], - "id": 120, + "id": 12365, "tagName": "div", "type": 2, }, @@ -4336,11 +5886,14 @@ exports[`replay/transform transform inputs radio group - $inputType - $value 1`] }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -4348,17 +5901,10 @@ exports[`replay/transform transform inputs radio group - $inputType - $value 1`] }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [], - "id": 280, - "tagName": "div", - "type": 2, - }, - ], + "childNodes": [], "id": 5, "tagName": "body", "type": 2, @@ -4396,11 +5942,14 @@ exports[`replay/transform transform inputs radio_group - $inputType - $value 1`] }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -4408,23 +5957,17 @@ exports[`replay/transform transform inputs radio_group - $inputType - $value 1`] }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [], - "id": 123123, - "tagName": "div", - "type": 2, - }, - ], - "id": 279, + "attributes": { + "data-rrweb-id": 123123, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, + "childNodes": [], + "id": 123123, "tagName": "div", "type": 2, }, @@ -4466,11 +6009,14 @@ exports[`replay/transform transform inputs radio_group 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -4478,23 +6024,17 @@ exports[`replay/transform transform inputs radio_group 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, - "childNodes": [ - { - "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [], - "id": 54321, - "tagName": "div", - "type": 2, - }, - ], - "id": 270, + "attributes": { + "data-rrweb-id": 54321, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, + "childNodes": [], + "id": 54321, "tagName": "div", "type": 2, }, @@ -4536,11 +6076,14 @@ exports[`replay/transform transform inputs web_view - $inputType - $value 1`] = }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -4548,29 +6091,23 @@ exports[`replay/transform transform inputs web_view - $inputType - $value 1`] = }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12365, + "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", + }, "childNodes": [ { - "attributes": { - "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 333, - "textContent": "web_view", - "type": 3, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, + "id": 293, + "textContent": "web_view", + "type": 3, }, ], - "id": 332, + "id": 12365, "tagName": "div", "type": 2, }, @@ -4612,11 +6149,14 @@ exports[`replay/transform transform inputs web_view with URL 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -4624,29 +6164,23 @@ exports[`replay/transform transform inputs web_view with URL 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12365, + "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", + }, "childNodes": [ { - "attributes": { - "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 119, - "textContent": "https://example.com", - "type": 3, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, + "id": 110, + "textContent": "https://example.com", + "type": 3, }, ], - "id": 118, + "id": 12365, "tagName": "div", "type": 2, }, @@ -4688,11 +6222,14 @@ exports[`replay/transform transform inputs wrapping with labels 1`] = ` }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -4700,40 +6237,35 @@ exports[`replay/transform transform inputs wrapping with labels 1`] = ` }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 109, + "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + }, "childNodes": [ { "attributes": { - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", + "data-rrweb-id": 12359, + "style": null, + "type": "checkbox", }, - "childNodes": [ - { - "attributes": { - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12359, - "tagName": "input", - "type": 2, - }, - { - "id": 116, - "textContent": "i will wrap the checkbox", - "type": 3, - }, - ], - "id": 117, - "tagName": "label", + "childNodes": [], + "id": 12359, + "tagName": "input", "type": 2, }, + { + "id": 108, + "textContent": "i will wrap the checkbox", + "type": 3, + }, ], - "id": 115, - "tagName": "div", + "id": 109, + "tagName": "label", "type": 2, }, ], @@ -4775,11 +6307,14 @@ exports[`replay/transform transform omitting x and y is equivalent to setting th }, { "attributes": { + "data-rrweb-id": 3, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 4, + }, "childNodes": [], "id": 4, "tagName": "head", @@ -4787,29 +6322,23 @@ exports[`replay/transform transform omitting x and y is equivalent to setting th }, { "attributes": { + "data-rrweb-id": 5, "style": "height: 100vh; width: 100vw;", }, "childNodes": [ { - "attributes": {}, + "attributes": { + "data-rrweb-id": 12345, + "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", + }, "childNodes": [ { - "attributes": { - "style": "color: #35373e;background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 112, - "textContent": "image", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, + "id": 106, + "textContent": "image", + "type": 3, }, ], - "id": 111, + "id": 12345, "tagName": "div", "type": 2, }, diff --git a/ee/frontend/mobile-replay/mobile.types.ts b/ee/frontend/mobile-replay/mobile.types.ts index bdf080b40e0ee..f519ab1dcd132 100644 --- a/ee/frontend/mobile-replay/mobile.types.ts +++ b/ee/frontend/mobile-replay/mobile.types.ts @@ -1,5 +1,5 @@ // copied from rrweb-snapshot, not included in rrweb types -import { customEvent, EventType, IncrementalSource } from '@rrweb/types' +import { customEvent, EventType, IncrementalSource, removedNodeMutation } from '@rrweb/types' export enum NodeType { Document = 0, @@ -290,17 +290,17 @@ export type MobileNodeMutation = { wireframe: wireframe } -export type MobileAddedNodeMutationData = { - source: IncrementalSource.Mutation - adds: MobileNodeMutation[] -} - -export type MobileUpdatedNodeMutationData = { +export type MobileNodeMutationData = { source: IncrementalSource.Mutation /** * @description An update is implemented as a remove and then an add, so the updates array contains the ID of the removed node and the wireframe for the added node */ - updates: MobileNodeMutation[] + updates?: MobileNodeMutation[] + adds?: MobileNodeMutation[] + /** + * @description A mobile remove is identical to a web remove + */ + removes?: removedNodeMutation[] } export type MobileIncrementalSnapshotEvent = { @@ -308,7 +308,7 @@ export type MobileIncrementalSnapshotEvent = { /** * @description This sits alongside the RRWeb incremental snapshot event type, mobile replay can send any of the RRWeb incremental snapshot event types, which will be passed unchanged to the player - for example to send touch events. removed node mutations are passed unchanged to the player. */ - data: MobileAddedNodeMutationData | MobileUpdatedNodeMutationData + data: MobileNodeMutationData } export type metaEvent = { diff --git a/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json b/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json index 3f5e4265dcf87..3c7a42e671ecf 100644 --- a/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json +++ b/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json @@ -124,14 +124,7 @@ "additionalProperties": false, "properties": { "data": { - "anyOf": [ - { - "$ref": "#/definitions/MobileAddedNodeMutationData" - }, - { - "$ref": "#/definitions/MobileUpdatedNodeMutationData" - } - ], + "$ref": "#/definitions/MobileNodeMutationData", "description": "This sits alongside the RRWeb incremental snapshot event type, mobile replay can send any of the RRWeb incremental snapshot event types, which will be passed unchanged to the player - for example to send touch events. removed node mutations are passed unchanged to the player." }, "delay": { @@ -238,7 +231,20 @@ "const": 0, "type": "number" }, - "MobileAddedNodeMutationData": { + "MobileNodeMutation": { + "additionalProperties": false, + "properties": { + "parentId": { + "type": "number" + }, + "wireframe": { + "$ref": "#/definitions/wireframe" + } + }, + "required": ["parentId", "wireframe"], + "type": "object" + }, + "MobileNodeMutationData": { "additionalProperties": false, "properties": { "adds": { @@ -247,24 +253,25 @@ }, "type": "array" }, + "removes": { + "description": "A mobile remove is identical to a web remove", + "items": { + "$ref": "#/definitions/removedNodeMutation" + }, + "type": "array" + }, "source": { "$ref": "#/definitions/IncrementalSource.Mutation" - } - }, - "required": ["source", "adds"], - "type": "object" - }, - "MobileNodeMutation": { - "additionalProperties": false, - "properties": { - "parentId": { - "type": "number" }, - "wireframe": { - "$ref": "#/definitions/wireframe" + "updates": { + "description": "An update is implemented as a remove and then an add, so the updates array contains the ID of the removed node and the wireframe for the added node", + "items": { + "$ref": "#/definitions/MobileNodeMutation" + }, + "type": "array" } }, - "required": ["parentId", "wireframe"], + "required": ["source"], "type": "object" }, "MobileNodeType": { @@ -315,21 +322,20 @@ }, "type": "object" }, - "MobileUpdatedNodeMutationData": { + "removedNodeMutation": { "additionalProperties": false, "properties": { - "source": { - "$ref": "#/definitions/IncrementalSource.Mutation" + "id": { + "type": "number" }, - "updates": { - "description": "An update is implemented as a remove and then an add, so the updates array contains the ID of the removed node and the wireframe for the added node", - "items": { - "$ref": "#/definitions/MobileNodeMutation" - }, - "type": "array" + "isShadow": { + "type": "boolean" + }, + "parentId": { + "type": "number" } }, - "required": ["source", "updates"], + "required": ["parentId", "id"], "type": "object" }, "wireframe": { diff --git a/ee/frontend/mobile-replay/transformers.ts b/ee/frontend/mobile-replay/transformers.ts index d68df59db2b5e..427619c97d9b6 100644 --- a/ee/frontend/mobile-replay/transformers.ts +++ b/ee/frontend/mobile-replay/transformers.ts @@ -14,6 +14,7 @@ import { isObject } from 'lib/utils' import { attributes, + documentNode, elementNode, fullSnapshotEvent as MobileFullSnapshotEvent, keyboardEvent, @@ -189,6 +190,7 @@ function makeDivElement(wireframe: wireframeDiv, children: serializedNodeWithId[ tagName: 'div', attributes: { style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;', + 'data-rrweb-id': _id, }, id: _id, childNodes: children, @@ -203,18 +205,21 @@ function makeTextElement(wireframe: wireframeText, children: serializedNodeWithI // because we might have to style the text, we always wrap it in a div // and apply styles to that + const id = idSequence.next().value return { type: NodeType.Element, tagName: 'div', attributes: { style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;', + 'data-rrweb-id': wireframe.id, }, - id: idSequence.next().value, + id: wireframe.id, childNodes: [ { type: NodeType.Text, textContent: wireframe.text, - id: wireframe.id, + // since the text node is wrapped, we assign it a synthetic id + id: id, }, ...children, ], @@ -247,11 +252,13 @@ function makePlaceholderElement( color: wireframe.style?.color || FOREGROUND, ...styleOverride, }), + 'data-rrweb-id': wireframe.id, }, id: wireframe.id, childNodes: [ { type: NodeType.Text, + // since the text node is wrapped, we assign it a synthetic id id: idSequence.next().value, textContent: txt, }, @@ -276,6 +283,7 @@ function makeImageElement(wireframe: wireframeImage, children: serializedNodeWit width: wireframe.width, height: wireframe.height, style: makeStylesString(wireframe), + 'data-rrweb-id': wireframe.id, }, id: wireframe.id, childNodes: children, @@ -287,6 +295,7 @@ function inputAttributes(wireframe: T): attri style: makeStylesString(wireframe), type: wireframe.inputType, ...(wireframe.disabled ? { disabled: wireframe.disabled } : {}), + 'data-rrweb-id': wireframe.id, } switch (wireframe.inputType) { @@ -357,13 +366,15 @@ function makeButtonElement(wireframe: wireframeButton, children: serializedNodeW } function makeSelectOptionElement(option: string, selected: boolean): serializedNodeWithId { + const optionId = idSequence.next().value return { type: NodeType.Element, tagName: 'option', attributes: { ...(selected ? { selected: selected } : {}), + 'data-rrweb-id': optionId, }, - id: idSequence.next().value, + id: optionId, childNodes: [ { type: NodeType.Text, @@ -395,6 +406,7 @@ function groupRadioButtons(children: serializedNodeWithId[], radioGroupName: str attributes: { ...child.attributes, name: radioGroupName, + 'data-rrweb-id': child.id, }, } } @@ -412,6 +424,7 @@ function makeRadioGroupElement( tagName: 'div', attributes: { style: makeStylesString(wireframe), + 'data-rrweb-id': wireframe.id, }, id: wireframe.id, childNodes: groupRadioButtons(children, radioGroupName), @@ -419,6 +432,9 @@ function makeRadioGroupElement( } function makeStar(title: string, path: string): serializedNodeWithId { + const svgId = idSequence.next().value + const titleId = idSequence.next().value + const pathId = idSequence.next().value return { type: NodeType.Element, tagName: 'svg', @@ -427,15 +443,18 @@ function makeStar(title: string, path: string): serializedNodeWithId { style: 'height: 100%;overflow-clip-margin: content-box;overflow:hidden', viewBox: '0 0 24 24', fill: 'currentColor', + 'data-rrweb-id': svgId, }, - id: idSequence.next().value, + id: svgId, childNodes: [ { type: NodeType.Element, tagName: 'title', isSVG: true, - attributes: {}, - id: idSequence.next().value, + attributes: { + 'data-rrweb-id': titleId, + }, + id: titleId, childNodes: [ { type: NodeType.Text, @@ -450,8 +469,9 @@ function makeStar(title: string, path: string): serializedNodeWithId { isSVG: true, attributes: { d: path, + 'data-rrweb-id': pathId, }, - id: idSequence.next().value, + id: pathId, childNodes: [], }, ], @@ -501,14 +521,16 @@ function makeRatingBar(wireframe: wireframeProgress, children: serializedNodeWit .fill(undefined) .map(() => emptyStar()) + const ratingBarId = idSequence.next().value const ratingBar = { type: NodeType.Element, tagName: 'div', - id: idSequence.next().value, + id: ratingBarId, attributes: { style: makeColorStyles(wireframe) + 'position: relative; display: flex; flex-direction: row; padding: 2px 4px;', + 'data-rrweb-id': ratingBarId, }, childNodes: [...filledStars, ...halfStars, ...emptyStars], } as serializedNodeWithId @@ -518,6 +540,7 @@ function makeRatingBar(wireframe: wireframeProgress, children: serializedNodeWit tagName: 'div', attributes: { style: makeStylesString(wireframe), + 'data-rrweb-id': wireframe.id, }, id: wireframe.id, childNodes: [ratingBar, ...children], @@ -565,11 +588,13 @@ function makeProgressElement( }, ] + const wrappingDivId = idSequence.next().value return { type: NodeType.Element, tagName: 'div', attributes: { style: makeMinimalStyles(wireframe), + 'data-rrweb-id': wireframe.id, }, id: wireframe.id, childNodes: [ @@ -581,8 +606,9 @@ function makeProgressElement( style: _isPositiveInteger(value) ? makeDeterminateProgressStyles(wireframe, styleOverride) : makeIndeterminateProgressStyles(wireframe, styleOverride), + 'data-rrweb-id': wrappingDivId, }, - id: idSequence.next().value, + id: wrappingDivId, childNodes: stylingChildren, }, ...children, @@ -604,6 +630,8 @@ function makeProgressElement( function makeToggleParts(wireframe: wireframeToggle): serializedNodeWithId[] { const togglePosition = wireframe.checked ? 'right' : 'left' const defaultColor = wireframe.checked ? '#1d4aff' : BACKGROUND + const sliderPartId = idSequence.next().value + const handlePartId = idSequence.next().value return [ { type: NodeType.Element, @@ -613,8 +641,9 @@ function makeToggleParts(wireframe: wireframeToggle): serializedNodeWithId[] { style: `position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;background-color:${ wireframe.style?.color || defaultColor };opacity: 0.2;border-radius:7.5%;`, + 'data-rrweb-id': sliderPartId, }, - id: idSequence.next().value, + id: sliderPartId, childNodes: [], }, { @@ -627,8 +656,9 @@ function makeToggleParts(wireframe: wireframeToggle): serializedNodeWithId[] { };border:2px solid ${ wireframe.style?.borderColor || wireframe.style?.color || defaultColor };border-radius:50%;`, + 'data-rrweb-id': handlePartId, }, - id: idSequence.next().value, + id: handlePartId, childNodes: [], }, ] @@ -636,12 +666,14 @@ function makeToggleParts(wireframe: wireframeToggle): serializedNodeWithId[] { function makeToggleElement(wireframe: wireframeToggle): (elementNode & { id: number }) | null { const isLabelled = 'label' in wireframe + const wrappingDivId = idSequence.next().value return { type: NodeType.Element, tagName: 'div', attributes: { // if labelled take up available space, otherwise use provided positioning style: isLabelled ? `height:100%;flex:1` : makePositionStyles(wireframe), + 'data-rrweb-id': wireframe.id, }, id: wireframe.id, childNodes: [ @@ -651,8 +683,9 @@ function makeToggleElement(wireframe: wireframeToggle): (elementNode & { id: num attributes: { // relative position, fills parent style: 'position:relative;width:100%;height:100%;', + 'data-rrweb-id': wrappingDivId, }, - id: idSequence.next().value, + id: wrappingDivId, childNodes: makeToggleParts(wireframe), }, ], @@ -671,13 +704,15 @@ function makeLabelledInput( const orderedChildren = wireframe.inputType === 'toggle' ? [theLabel, theInputElement] : [theInputElement, theLabel] + const labelId = idSequence.next().value return { type: NodeType.Element, tagName: 'label', attributes: { style: makeStylesString(wireframe), + 'data-rrweb-id': labelId, }, - id: idSequence.next().value, + id: labelId, childNodes: orderedChildren, } } @@ -740,6 +775,7 @@ function makeRectangleElement( tagName: 'div', attributes: { style: makeStylesString(wireframe), + 'data-rrweb-id': wireframe.id, }, id: wireframe.id, childNodes: children, @@ -809,31 +845,91 @@ function isMobileIncrementalSnapshotEvent(x: unknown): x is MobileIncrementalSna return hasMutationSource && (hasAddedWireframe || hasUpdatedWireframe) } -function makeIncrementalAdd(add: MobileNodeMutation): addedNodeMutation | null { +function makeIncrementalAdd(add: MobileNodeMutation): addedNodeMutation[] | null { const converted = convertWireframe(add.wireframe) - return converted - ? { - parentId: add.parentId, - nextId: null, - node: converted, - } - : null + if (!converted) { + return null + } + + const addition: addedNodeMutation = { + parentId: add.parentId, + nextId: null, + node: converted, + } + const adds: addedNodeMutation[] = [] + if (addition) { + const flattened = flattenMutationAdds(addition) + flattened.forEach((x) => adds.push(x)) + return adds + } else { + return null + } } -function makeIncrementalRemove(update: MobileNodeMutation): removedNodeMutation { +/** + * When processing an update we remove the entire item, and then add it back in. + */ +function makeIncrementalRemoveForUpdate(update: MobileNodeMutation): removedNodeMutation { return { parentId: update.parentId, id: update.wireframe.id, } } +function isNode(x: unknown): x is serializedNodeWithId { + // KLUDGE: really we should check that x.type is valid, but we're safe enough already + return isObject(x) && 'type' in x && 'id' in x +} + +function isNodeWithChildren(x: unknown): x is elementNode | documentNode { + return isNode(x) && 'childNodes' in x && Array.isArray(x.childNodes) +} + +/** + * when creating incremental adds we have to flatten the node tree structure + * there's no point, then keeping those child nodes in place + */ +function cloneWithoutChildren(converted: addedNodeMutation): addedNodeMutation { + const cloned = { ...converted } + const clonedNode: serializedNodeWithId = { ...converted.node } + if (isNodeWithChildren(clonedNode)) { + clonedNode.childNodes = [] + } + cloned.node = clonedNode + return cloned +} + +function flattenMutationAdds(converted: addedNodeMutation): addedNodeMutation[] { + const flattened: addedNodeMutation[] = [] + + flattened.push(cloneWithoutChildren(converted)) + + const node: unknown = converted.node + const newParentId = converted.node.id + if (isNodeWithChildren(node)) { + node.childNodes.forEach((child) => { + flattened.push( + cloneWithoutChildren({ + parentId: newParentId, + nextId: null, + node: child, + }) + ) + if (isNodeWithChildren(child)) { + flattened.push(...flattenMutationAdds({ parentId: newParentId, nextId: null, node: child })) + } + }) + } + return flattened +} + /** * We want to ensure that any events don't use id = 0. * They must always represent a valid ID from the dom, so we swap in the body id when the id = 0. * * For "removes", we don't need to do anything, the id of the element to be removed remains valid. We won't try and remove other elements that we added during transformation in order to show that element. * - * "adds" are converted from wireframes to nodes and converted to incrementalSnapshotEvent.adds + * "adds" are converted from wireframes to nodes and converted to `incrementalSnapshotEvent.adds` * * "updates" are converted to a remove and an add. * @@ -857,26 +953,19 @@ export const makeIncrementalEvent = ( if (isMobileIncrementalSnapshotEvent(mobileEvent)) { const adds: addedNodeMutation[] = [] - const removes: removedNodeMutation[] = [] + const removes: removedNodeMutation[] = mobileEvent.data.removes || [] if ('adds' in mobileEvent.data && Array.isArray(mobileEvent.data.adds)) { mobileEvent.data.adds.forEach((add) => { - const converted = makeIncrementalAdd(add) - if (converted) { - // TODO when implementing keyboard placeholder we had to flatten the mutations not nest them - adds.push(converted) - } + makeIncrementalAdd(add)?.forEach((x) => adds.push(x)) }) } if ('updates' in mobileEvent.data && Array.isArray(mobileEvent.data.updates)) { mobileEvent.data.updates.forEach((update) => { - const removal = makeIncrementalRemove(update) + const removal = makeIncrementalRemoveForUpdate(update) if (removal) { removes.push(removal) } - const addition = makeIncrementalAdd(update) - if (addition) { - adds.push(addition) - } + makeIncrementalAdd(update)?.forEach((x) => adds.push(x)) }) } @@ -926,30 +1015,22 @@ export const makeFullEvent = ( { type: NodeType.Element, tagName: 'html', - attributes: { style: makeHTMLStyles() }, + attributes: { style: makeHTMLStyles(), 'data-rrweb-id': HTML_ELEMENT_ID }, id: HTML_ELEMENT_ID, childNodes: [ { type: NodeType.Element, tagName: 'head', - attributes: {}, + attributes: { 'data-rrweb-id': HEAD_ID }, id: HEAD_ID, childNodes: [], }, { type: NodeType.Element, tagName: 'body', - attributes: { style: makeBodyStyles() }, + attributes: { style: makeBodyStyles(), 'data-rrweb-id': BODY_ID }, id: BODY_ID, - childNodes: [ - { - type: NodeType.Element, - tagName: 'div', - attributes: {}, - id: idSequence.next().value, - childNodes: convertWireframesFor(mobileEvent.data.wireframes), - }, - ], + childNodes: convertWireframesFor(mobileEvent.data.wireframes) || [], }, ], }, diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png index aac284dfb17c8..020efc7597215 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png index 64867a9377dec..61c388ecf927e 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-base--light.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--dark.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--dark.png index 94af478a29d3d..b4b1df27e3244 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--dark.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--light.png index 08ab4d791ad81..d8b872127daa3 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-app-metrics-error-modal--light.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--dark.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--dark.png index 3ecf75c3178c4..30fb6435562a7 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--dark.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--light.png index 12c74b23cfabb..1672406e82083 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-transformations-page--light.png differ diff --git a/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal--dark.png b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal--dark.png new file mode 100644 index 0000000000000..d070e01a147a7 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal--light.png b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal--light.png new file mode 100644 index 0000000000000..c8f73691c44ff Binary files /dev/null and b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal--light.png differ diff --git a/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal-data-pipelines--dark.png b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal-data-pipelines--dark.png new file mode 100644 index 0000000000000..4d197913f55a7 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal-data-pipelines--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal-data-pipelines--light.png b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal-data-pipelines--light.png new file mode 100644 index 0000000000000..dd97e5486b7e9 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-billing-v2--billing-unsubscribe-modal-data-pipelines--light.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--actions--dark.png b/frontend/__snapshots__/scenes-other-toolbar--actions--dark.png index 62ed6d63ea4f3..554199d2d6a5b 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--actions--dark.png and b/frontend/__snapshots__/scenes-other-toolbar--actions--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--actions--light.png b/frontend/__snapshots__/scenes-other-toolbar--actions--light.png index 902a9e240c02e..936bb15b075c2 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--actions--light.png and b/frontend/__snapshots__/scenes-other-toolbar--actions--light.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--actions-dark--dark.png b/frontend/__snapshots__/scenes-other-toolbar--actions-dark--dark.png index b4563f4f7d442..829ddc3a6ab4a 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--actions-dark--dark.png and b/frontend/__snapshots__/scenes-other-toolbar--actions-dark--dark.png differ diff --git a/frontend/public/hubspot-logo.svg b/frontend/public/hubspot-logo.svg new file mode 100644 index 0000000000000..4be6b49537e4f --- /dev/null +++ b/frontend/public/hubspot-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.test.ts b/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.test.ts new file mode 100644 index 0000000000000..f0ff2f6404de7 --- /dev/null +++ b/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.test.ts @@ -0,0 +1,31 @@ +import { expectLogic } from 'kea-test-utils' +import { MOCK_DEFAULT_USER } from 'lib/api.mock' +import { userLogic } from 'scenes/userLogic' + +import { initKeaTests } from '~/test/init' + +import { featurePreviewsLogic } from './featurePreviewsLogic' + +describe('featurePreviewsLogic', () => { + let logic: ReturnType + + beforeEach(() => { + initKeaTests() + logic = featurePreviewsLogic() + logic.mount() + userLogic.actions.loadUserSuccess(MOCK_DEFAULT_USER) + }) + + test('submitting feedback', async () => { + logic.actions.beginEarlyAccessFeatureFeedback('test') + const promise = logic.asyncActions.submitEarlyAccessFeatureFeedback('test') + await expectLogic(logic) + .toMatchValues({ activeFeedbackFlagKeyLoading: true }) + .toDispatchActions(['submitEarlyAccessFeatureFeedback']) + .toNotHaveDispatchedActions(['submitEarlyAccessFeatureFeedbackSuccess']) + await promise + await expectLogic(logic) + .toMatchValues({ activeFeedbackFlagKeyLoading: false }) + .toDispatchActions(['submitEarlyAccessFeatureFeedbackSuccess']) + }) +}) diff --git a/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.tsx b/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.tsx index bc69b5148c774..e408a229d90aa 100644 --- a/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.tsx +++ b/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.tsx @@ -18,10 +18,10 @@ export interface EnrichedEarlyAccessFeature extends Omit([ - path(['layout', 'navigation', 'TopBar', 'FeaturePreviewsModal']), + path(['layout', 'FeaturePreviews', 'featurePreviewsLogic']), connect({ values: [featureFlagLogic, ['featureFlags'], userLogic, ['user']], - asyncActions: [supportLogic, ['submitZendeskTicket']], + actions: [supportLogic, ['submitZendeskTicket']], }), actions({ showFeaturePreviewsModal: true, diff --git a/frontend/src/layout/navigation-3000/components/TopBar.tsx b/frontend/src/layout/navigation-3000/components/TopBar.tsx index 97bcf22130150..cfe87b292b61f 100644 --- a/frontend/src/layout/navigation-3000/components/TopBar.tsx +++ b/frontend/src/layout/navigation-3000/components/TopBar.tsx @@ -12,7 +12,7 @@ import React, { useLayoutEffect, useState } from 'react' import { breadcrumbsLogic } from '~/layout/navigation/Breadcrumbs/breadcrumbsLogic' import { navigationLogic } from '~/layout/navigation/navigationLogic' -import { FinalizedBreadcrumb } from '~/types' +import { Breadcrumb as IBreadcrumb } from '~/types' import { navigation3000Logic } from '../navigationLogic' @@ -88,17 +88,13 @@ export function TopBar(): JSX.Element | null {
{breadcrumbs.length > 1 && (
- {breadcrumbs.slice(0, -1).map((breadcrumb, index) => ( - - + {breadcrumbs.slice(0, -1).map((breadcrumb) => ( + +
))} - +
)} @@ -110,30 +106,31 @@ export function TopBar(): JSX.Element | null { } interface BreadcrumbProps { - breadcrumb: FinalizedBreadcrumb - index: number + breadcrumb: IBreadcrumb here?: boolean } -function Breadcrumb({ breadcrumb, index, here }: BreadcrumbProps): JSX.Element { +function Breadcrumb({ breadcrumb, here }: BreadcrumbProps): JSX.Element { const { renameState } = useValues(breadcrumbsLogic) const { tentativelyRename, finishRenaming } = useActions(breadcrumbsLogic) const [popoverShown, setPopoverShown] = useState(false) + const joinedKey = joinBreadcrumbKey(breadcrumb.key) + let nameElement: JSX.Element if (breadcrumb.name != null && breadcrumb.onRename) { nameElement = ( tentativelyRename(breadcrumb.globalKey, newName)} + value={renameState && renameState[0] === joinedKey ? renameState[1] : breadcrumb.name} + onChange={(newName) => tentativelyRename(joinedKey, newName)} onSave={(newName) => { void breadcrumb.onRename?.(newName) }} - mode={renameState && renameState[0] === breadcrumb.globalKey ? 'edit' : 'view'} + mode={renameState && renameState[0] === joinedKey ? 'edit' : 'view'} onModeToggle={(newMode) => { if (newMode === 'edit') { - tentativelyRename(breadcrumb.globalKey, breadcrumb.name as string) + tentativelyRename(joinedKey, breadcrumb.name as string) } else { finishRenaming() } @@ -160,7 +157,7 @@ function Breadcrumb({ breadcrumb, index, here }: BreadcrumbProps): JSX.Element { onClick={() => { breadcrumb.popover && setPopoverShown(!popoverShown) }} - data-attr={`breadcrumb-${index}`} + data-attr={`breadcrumb-${joinedKey}`} to={breadcrumb.path} > {nameElement} @@ -193,13 +190,15 @@ function Breadcrumb({ breadcrumb, index, here }: BreadcrumbProps): JSX.Element { } interface HereProps { - breadcrumb: FinalizedBreadcrumb + breadcrumb: IBreadcrumb } function Here({ breadcrumb }: HereProps): JSX.Element { const { renameState } = useValues(breadcrumbsLogic) const { tentativelyRename, finishRenaming } = useActions(breadcrumbsLogic) + const joinedKey = joinBreadcrumbKey(breadcrumb.key) + return (

{breadcrumb.name == null ? ( @@ -207,9 +206,9 @@ function Here({ breadcrumb }: HereProps): JSX.Element { ) : breadcrumb.onRename ? ( { - tentativelyRename(breadcrumb.globalKey, newName) + tentativelyRename(joinedKey, newName) if (breadcrumb.forceEditMode) { // In this case there's no "Save" button, we update on input void breadcrumb.onRename?.(newName) @@ -218,16 +217,12 @@ function Here({ breadcrumb }: HereProps): JSX.Element { onSave={(newName) => { void breadcrumb.onRename?.(newName) }} - mode={ - breadcrumb.forceEditMode || (renameState && renameState[0] === breadcrumb.globalKey) - ? 'edit' - : 'view' - } + mode={breadcrumb.forceEditMode || (renameState && renameState[0] === joinedKey) ? 'edit' : 'view'} onModeToggle={ !breadcrumb.forceEditMode ? (newMode) => { if (newMode === 'edit') { - tentativelyRename(breadcrumb.globalKey, breadcrumb.name as string) + tentativelyRename(joinedKey, breadcrumb.name as string) } else { finishRenaming() } @@ -245,3 +240,7 @@ function Here({ breadcrumb }: HereProps): JSX.Element {

) } + +function joinBreadcrumbKey(key: IBreadcrumb['key']): string { + return Array.isArray(key) ? key.map(String).join(':') : String(key) +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx index 941475db69748..0ae6b786ccc96 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx @@ -12,6 +12,7 @@ import { NotebookPanel } from 'scenes/notebooks/NotebookPanel/NotebookPanel' import { SidePanelTab } from '~/types' import { SidePanelActivity, SidePanelActivityIcon } from './panels/activity/SidePanelActivity' +import { SidePanelDiscussion, SidePanelDiscussionIcon } from './panels/discussion/SidePanelDiscussion' import { SidePanelActivation, SidePanelActivationIcon } from './panels/SidePanelActivation' import { SidePanelDocs } from './panels/SidePanelDocs' import { SidePanelFeaturePreviews } from './panels/SidePanelFeaturePreviews' @@ -60,6 +61,11 @@ export const SIDE_PANEL_TABS: Record { - const { unreadCount } = useValues(notificationsLogic) + const { unreadCount } = useValues(sidePanelActivityLogic) return ( @@ -36,9 +50,19 @@ export const SidePanelActivity = (): JSX.Element => { allActivityHasNext, importantChangesLoading, hasUnread, - } = useValues(notificationsLogic) - const { togglePolling, setActiveTab, maybeLoadOlderActivity, markAllAsRead, loadImportantChanges } = - useActions(notificationsLogic) + filters, + filtersForCurrentPage, + showDetails, + } = useValues(sidePanelActivityLogic) + const { + togglePolling, + setActiveTab, + maybeLoadOlderActivity, + markAllAsRead, + loadImportantChanges, + setFilters, + toggleShowDetails, + } = useActions(sidePanelActivityLogic) usePageVisibility((pageIsVisible) => { togglePolling(pageIsVisible) @@ -67,12 +91,37 @@ export const SidePanelActivity = (): JSX.Element => { lastScrollPositionRef.current = e.currentTarget.scrollTop } + const scopeMenuOptions: LemonSelectOption[] = [ + { value: null, label: 'All activity' }, + ...Object.values(ActivityScope).map((x) => ({ + value: x, + label: humanizeScope(x), + })), + ] + + const activeScopeMenuOption = filters?.scope ? filters.scope + `${filters.item_id ?? ''}` : null + + // Add a special option for the current page context if we have one + if (filtersForCurrentPage?.scope && filtersForCurrentPage?.item_id) { + scopeMenuOptions.unshift({ + value: `${filtersForCurrentPage.scope}${filtersForCurrentPage.item_id ?? ''}` as any, + label: `This ${humanizeScope(filtersForCurrentPage.scope, true)}`, + }) + } + + const toggleExtendedDescription = ( + <> + + + ) + return ( -
+
-
+
setActiveTab(key)} tabs={[ @@ -88,10 +137,11 @@ export const SidePanelActivity = (): JSX.Element => { />
-
+ {/* Controls */} +
{activeTab === SidePanelActivityTab.Unread ? ( -
- + <> + Notifications shows you changes others make to{' '} Insights and{' '} Feature Flags that you created. Come join{' '} @@ -99,58 +149,120 @@ export const SidePanelActivity = (): JSX.Element => { else should be here! - {hasUnread ? ( -
+
+ {toggleExtendedDescription} + {hasUnread ? ( markAllAsRead()}> Mark all as read -
- ) : null} - - {importantChangesLoading && !hasNotifications ? ( - - ) : hasNotifications ? ( - notifications.map((logItem, index) => ( - - )) - ) : ( -

You're all caught up!

- )} + ) : null} +
+ + ) : activeTab === SidePanelActivityTab.All ? ( +
+
+ {toggleExtendedDescription} + {allActivityResponseLoading ? : null} +
+ +
+ Filter for activity on: + + setFilters({ + ...filters, + scope: value ?? undefined, + item_id: undefined, + }) + } + dropdownMatchSelectWidth={false} + /> + + by + + setFilters({ + ...filters, + user: user?.id ?? undefined, + }) + } + /> +
- ) : ( -
- {allActivityResponseLoading && !allActivity.length ? ( - - ) : allActivity.length ? ( - <> - {allActivity.map((logItem, index) => ( - - ))} - -
- {allActivityResponseLoading ? ( - <> - Loading older activity - - ) : allActivityHasNext ? ( - maybeLoadOlderActivity()} - > - Load more + ) : null} +
+ +
+ + {activeTab === SidePanelActivityTab.Unread ? ( + <> + {importantChangesLoading && !hasNotifications ? ( + + ) : hasNotifications ? ( + notifications.map((logItem, index) => ( + + )) + ) : ( +
+ You're all caught up! +
+ )} + + ) : activeTab === SidePanelActivityTab.All ? ( + <> + {allActivityResponseLoading && !allActivity.length ? ( + + ) : allActivity.length ? ( + <> + {allActivity.map((logItem, index) => ( + + ))} + +
+ {allActivityResponseLoading ? ( + <> + Loading older activity + + ) : allActivityHasNext ? ( + maybeLoadOlderActivity()} + > + Load more + + ) : ( + 'No more results' + )} +
+ + ) : ( +
+ No activity yet + {filters ? ( + setFilters(null)}> + Clear filters - ) : ( - 'No more results' - )} + ) : null}
- - ) : ( -

You're all caught up!

- )} -
- )} + )} + + ) : null} +
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts new file mode 100644 index 0000000000000..d0f4861a65fdb --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts @@ -0,0 +1,56 @@ +import { connect, kea, path, selectors } from 'kea' +import { router } from 'kea-router' +import { objectsEqual } from 'kea-test-utils' +import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' +import { sceneLogic } from 'scenes/sceneLogic' +import { SceneConfig } from 'scenes/sceneTypes' + +import { ActivityScope, UserBasicType } from '~/types' + +import type { activityForSceneLogicType } from './activityForSceneLogicType' + +export type ActivityFilters = { + scope?: ActivityScope + item_id?: ActivityLogItem['item_id'] + user?: UserBasicType['id'] +} + +export const activityFiltersForScene = (sceneConfig: SceneConfig | null): ActivityFilters | null => { + if (sceneConfig?.activityScope) { + // NOTE: - HACKY, we are just parsing the item_id from the url optimistically... + const pathParts = router.values.currentLocation.pathname.split('/') + const item_id = pathParts[2] + + return { scope: sceneConfig.activityScope, item_id } + } + return null +} + +export const activityForSceneLogic = kea([ + path(['scenes', 'navigation', 'sidepanel', 'activityForSceneLogic']), + connect({ + values: [sceneLogic, ['sceneConfig']], + }), + selectors({ + sceneActivityFilters: [ + (s) => [ + // Similar to "breadcrumbs" + (state, props) => { + const activeSceneLogic = sceneLogic.selectors.activeSceneLogic(state, props) + const sceneConfig = s.sceneConfig(state, props) + if (activeSceneLogic && 'activityFilters' in activeSceneLogic.selectors) { + const activeLoadedScene = sceneLogic.selectors.activeLoadedScene(state, props) + return activeSceneLogic.selectors.activityFilters( + state, + activeLoadedScene?.paramsToProps?.(activeLoadedScene?.sceneParams) || props + ) + } else { + return activityFiltersForScene(sceneConfig) + } + }, + ], + (filters): ActivityFilters | null => filters, + { equalityCheck: objectsEqual }, + ], + }), +]) diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/notificationsLogic.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx similarity index 78% rename from frontend/src/layout/navigation-3000/sidepanel/panels/activity/notificationsLogic.tsx rename to frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx index 7c812e2da8275..f1ad08ff772cf 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/notificationsLogic.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx @@ -1,6 +1,7 @@ -import { actions, events, kea, listeners, path, reducers, selectors } from 'kea' +import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' -import api from 'lib/api' +import { subscriptions } from 'kea-subscriptions' +import api, { PaginatedResponse } from 'lib/api' import { describerFor } from 'lib/components/ActivityLog/activityLogLogic' import { ActivityLogItem, humanize, HumanizedActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' import { dayjs } from 'lib/dayjs' @@ -9,7 +10,8 @@ import { toParams } from 'lib/utils' import posthog from 'posthog-js' import { teamLogic } from 'scenes/teamLogic' -import type { notificationsLogicType } from './notificationsLogicType' +import { ActivityFilters, activityForSceneLogic } from './activityForSceneLogic' +import type { sidePanelActivityLogicType } from './sidePanelActivityLogicType' const POLL_TIMEOUT = 5 * 60 * 1000 @@ -29,10 +31,12 @@ export enum SidePanelActivityTab { All = 'all', } -export const notificationsLogic = kea([ - path(['layout', 'navigation', 'TopBar', 'notificationsLogic']), +export const sidePanelActivityLogic = kea([ + path(['scenes', 'navigation', 'sidepanel', 'sidePanelActivityLogic']), + connect({ + values: [activityForSceneLogic, ['sceneActivityFilters']], + }), actions({ - toggleNotificationsPopover: true, togglePolling: (pageIsVisible: boolean) => ({ pageIsVisible }), incrementErrorCount: true, clearErrorCount: true, @@ -42,6 +46,44 @@ export const notificationsLogic = kea([ loadOlderActivity: true, maybeLoadOlderActivity: true, loadImportantChanges: (onlyUnread = true) => ({ onlyUnread }), + setFilters: (filters: ActivityFilters | null) => ({ filters }), + setFiltersForCurrentPage: (filters: ActivityFilters | null) => ({ filters }), + toggleShowDetails: (showing?: boolean) => ({ showing }), + }), + reducers({ + activeTab: [ + SidePanelActivityTab.Unread as SidePanelActivityTab, + { + setActiveTab: (_, { tab }) => tab, + }, + ], + errorCounter: [ + 0, + { + incrementErrorCount: (state) => (state >= 5 ? 5 : state + 1), + clearErrorCount: () => 0, + }, + ], + filters: [ + null as ActivityFilters | null, + { + setFilters: (_, { filters }) => filters, + setFiltersForCurrentPage: (_, { filters }) => filters, + }, + ], + filtersForCurrentPage: [ + null as ActivityFilters | null, + { + setFiltersForCurrentPage: (_, { filters }) => filters, + }, + ], + showDetails: [ + false, + { persist: true }, + { + toggleShowDetails: (state, { showing }) => showing ?? !state, + }, + ], }), loaders(({ actions, values, cache }) => ({ importantChanges: [ @@ -103,14 +145,12 @@ export const notificationsLogic = kea([ }, ], allActivityResponse: [ - null as ChangesResponse | null, + null as PaginatedResponse | null, { loadAllActivity: async (_, breakpoint) => { - await breakpoint(1) + const response = await api.activity.list(values.filters ?? {}) - const response = await api.get( - `api/projects/${teamLogic.values.currentTeamId}/activity_log` - ) + breakpoint() return response }, @@ -121,7 +161,7 @@ export const notificationsLogic = kea([ return values.allActivityResponse } - const response = await api.get(values.allActivityResponse.next) + const response = await api.get>(values.allActivityResponse.next) response.results = [...values.allActivityResponse.results, ...response.results] @@ -130,33 +170,8 @@ export const notificationsLogic = kea([ }, ], })), - reducers({ - activeTab: [ - SidePanelActivityTab.Unread as SidePanelActivityTab, - { - setActiveTab: (_, { tab }) => tab, - }, - ], - errorCounter: [ - 0, - { - incrementErrorCount: (state) => (state >= 5 ? 5 : state + 1), - clearErrorCount: () => 0, - }, - ], - isNotificationPopoverOpen: [ - false, - { - toggleNotificationsPopover: (state) => !state, - }, - ], - }), + listeners(({ values, actions }) => ({ - toggleNotificationsPopover: () => { - if (!values.isNotificationPopoverOpen) { - actions.markAllAsRead() - } - }, setActiveTab: ({ tab }) => { if (tab === SidePanelActivityTab.All && !values.allActivityResponseLoading) { actions.loadAllActivity() @@ -236,10 +251,26 @@ export const notificationsLogic = kea([ unreadCount: [(s) => [s.unread], (unread) => (unread || []).length], hasUnread: [(s) => [s.unreadCount], (unreadCount) => unreadCount > 0], }), - events(({ actions, cache }) => ({ - afterMount: () => actions.loadImportantChanges(), - beforeUnmount: () => { - clearTimeout(cache.pollTimeout) + + subscriptions(({ actions, values }) => ({ + sceneActivityFilters: (activityFilters) => { + actions.setFiltersForCurrentPage(activityFilters ? { ...values.filters, ...activityFilters } : null) + }, + filters: () => { + if (values.activeTab === SidePanelActivityTab.All) { + actions.loadAllActivity() + } }, })), + + afterMount(({ actions, values }) => { + actions.loadImportantChanges() + + const activityFilters = values.sceneActivityFilters + actions.setFiltersForCurrentPage(activityFilters ? { ...values.filters, ...activityFilters } : null) + }), + + beforeUnmount(({ cache }) => { + clearTimeout(cache.pollTimeout) + }), ]) diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/SidePanelDiscussion.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/SidePanelDiscussion.tsx new file mode 100644 index 0000000000000..398f0c387a8c5 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/SidePanelDiscussion.tsx @@ -0,0 +1,84 @@ +import { IconChat } from '@posthog/icons' +import { useActions, useValues } from 'kea' +import { humanizeScope } from 'lib/components/ActivityLog/humanizeActivity' +import { WarningHog } from 'lib/components/hedgehogs' +import { IconWithCount } from 'lib/lemon-ui/icons' +import { useEffect } from 'react' +import { CommentComposer } from 'scenes/comments/CommentComposer' +import { CommentsList } from 'scenes/comments/CommentsList' +import { commentsLogic, CommentsLogicProps } from 'scenes/comments/commentsLogic' + +import { SidePanelPaneHeader } from '../../components/SidePanelPaneHeader' +import { sidePanelStateLogic } from '../../sidePanelStateLogic' +import { sidePanelDiscussionLogic } from './sidePanelDiscussionLogic' + +export const SidePanelDiscussionIcon = (props: { className?: string }): JSX.Element => { + const { commentCount } = useValues(sidePanelDiscussionLogic) + + return ( + + + + ) +} + +const DiscussionContent = ({ logicProps }: { logicProps: CommentsLogicProps }): JSX.Element => { + const { selectedTabOptions } = useValues(sidePanelStateLogic) + const { setReplyingComment } = useActions(commentsLogic(logicProps)) + + useEffect(() => { + if (selectedTabOptions) { + setReplyingComment(selectedTabOptions) + } + }, [selectedTabOptions]) + + return ( +
+
+ +
+ +
+ +
+
+ ) +} + +export const SidePanelDiscussion = (): JSX.Element => { + const { commentsLogicProps } = useValues(sidePanelDiscussionLogic) + + const { scope, item_id } = commentsLogicProps ?? {} + + return ( +
+ + Discussion{' '} + {scope ? ( + + about {item_id ? 'this' : ''} {humanizeScope(scope, !!item_id)} + + ) : null} + + } + /> + + {commentsLogicProps ? ( + + ) : ( +
+
+ +
+

Discussions aren't supported here yet...

+

+ This a beta feature that is currently only available when viewing things like an Insight, + Dashboard or Notebook. +

+
+ )} +
+ ) +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts new file mode 100644 index 0000000000000..7e11e6ee502f3 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts @@ -0,0 +1,63 @@ +import { actions, connect, kea, path, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import { subscriptions } from 'kea-subscriptions' +import api from 'lib/api' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { CommentsLogicProps } from 'scenes/comments/commentsLogic' + +import { activityForSceneLogic } from '../activity/activityForSceneLogic' +import type { sidePanelDiscussionLogicType } from './sidePanelDiscussionLogicType' + +export const sidePanelDiscussionLogic = kea([ + path(['scenes', 'navigation', 'sidepanel', 'sidePanelDiscussionLogic']), + actions({ + loadCommentCount: true, + }), + connect({ + values: [featureFlagLogic, ['featureFlags'], activityForSceneLogic, ['sceneActivityFilters']], + }), + loaders(({ values }) => ({ + commentCount: [ + 0, + { + loadCommentCount: async (_, breakpoint) => { + if (!values.featureFlags[FEATURE_FLAGS.DISCUSSIONS] || !values.commentsLogicProps) { + return 0 + } + + await breakpoint(100) + const response = await api.comments.getCount({ + ...values.commentsLogicProps, + }) + + breakpoint() + + return response + }, + }, + ], + })), + + selectors({ + commentsLogicProps: [ + (s) => [s.sceneActivityFilters], + (activityFilters): CommentsLogicProps | null => { + return activityFilters?.scope + ? { + scope: activityFilters.scope, + item_id: activityFilters.item_id, + } + : null + }, + ], + }), + + subscriptions(({ actions }) => ({ + commentsLogicProps: (props) => { + if (props) { + actions.loadCommentCount() + } + }, + })), +]) diff --git a/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx b/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx index 63833886edbcd..8f08d0e972ff1 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx @@ -8,7 +8,8 @@ import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { SidePanelTab } from '~/types' -import { notificationsLogic } from './panels/activity/notificationsLogic' +import { sidePanelActivityLogic } from './panels/activity/sidePanelActivityLogic' +import { sidePanelDiscussionLogic } from './panels/discussion/sidePanelDiscussionLogic' import type { sidePanelLogicType } from './sidePanelLogicType' import { sidePanelStateLogic } from './sidePanelStateLogic' @@ -32,8 +33,10 @@ export const sidePanelLogic = kea([ sidePanelStateLogic, ['selectedTab', 'sidePanelOpen'], // We need to mount this to ensure that marking as read works when the panel closes - notificationsLogic, + sidePanelActivityLogic, ['unreadCount'], + sidePanelDiscussionLogic, + ['commentCount', 'commentCountLoading'], ], actions: [sidePanelStateLogic, ['closeSidePanel', 'openSidePanel']], }), @@ -73,8 +76,8 @@ export const sidePanelLogic = kea([ ], enabledTabs: [ - (s) => [s.isCloudOrDev, s.isReady, s.hasCompletedAllTasks], - (isCloudOrDev, isReady, hasCompletedAllTasks) => { + (s) => [s.isCloudOrDev, s.isReady, s.hasCompletedAllTasks, s.featureFlags], + (isCloudOrDev, isReady, hasCompletedAllTasks, featureflags) => { const tabs: SidePanelTab[] = [] tabs.push(SidePanelTab.Notebooks) @@ -87,6 +90,9 @@ export const sidePanelLogic = kea([ if (isReady && !hasCompletedAllTasks) { tabs.push(SidePanelTab.Activation) } + if (featureflags[FEATURE_FLAGS.DISCUSSIONS]) { + tabs.push(SidePanelTab.Discussion) + } tabs.push(SidePanelTab.FeaturePreviews) tabs.push(SidePanelTab.Welcome) @@ -95,13 +101,17 @@ export const sidePanelLogic = kea([ ], visibleTabs: [ - (s) => [s.enabledTabs, s.selectedTab, s.sidePanelOpen, s.unreadCount], - (enabledTabs, selectedTab, sidePanelOpen, unreadCount): SidePanelTab[] => { + (s) => [s.enabledTabs, s.selectedTab, s.sidePanelOpen, s.commentCount, s.unreadCount], + (enabledTabs, selectedTab, sidePanelOpen, commentCount, unreadCount): SidePanelTab[] => { return enabledTabs.filter((tab) => { if (tab === selectedTab && sidePanelOpen) { return true } + if (tab === SidePanelTab.Discussion) { + return commentCount > 0 + } + if (tab === SidePanelTab.Activity && unreadCount) { return true } diff --git a/frontend/src/layout/navigation/Breadcrumbs/breadcrumbsLogic.tsx b/frontend/src/layout/navigation/Breadcrumbs/breadcrumbsLogic.tsx index 7d15f44db066d..0fb5a2ef44721 100644 --- a/frontend/src/layout/navigation/Breadcrumbs/breadcrumbsLogic.tsx +++ b/frontend/src/layout/navigation/Breadcrumbs/breadcrumbsLogic.tsx @@ -11,7 +11,7 @@ import { userLogic } from 'scenes/userLogic' import { OrganizationSwitcherOverlay } from '~/layout/navigation/OrganizationSwitcher' import { ProjectSwitcherOverlay } from '~/layout/navigation/ProjectSwitcher' -import { Breadcrumb, FinalizedBreadcrumb } from '~/types' +import { Breadcrumb } from '~/types' import type { breadcrumbsLogicType } from './breadcrumbsLogicType' @@ -67,20 +67,18 @@ export const breadcrumbsLogic = kea([ (s) => [ // We're effectively passing the selector through to the scene logic, and "recalculating" // this every time it's rendered. Caching will happen within the scene's breadcrumb selector. - (state, props) => { + (state, props): Breadcrumb[] => { const activeSceneLogic = sceneLogic.selectors.activeSceneLogic(state, props) const activeScene = s.activeScene(state, props) - const sceneConfig = s.sceneConfig(state, props) if (activeSceneLogic && 'breadcrumbs' in activeSceneLogic.selectors) { const activeLoadedScene = sceneLogic.selectors.activeLoadedScene(state, props) return activeSceneLogic.selectors.breadcrumbs( state, activeLoadedScene?.paramsToProps?.(activeLoadedScene?.sceneParams) || props ) - } else if (sceneConfig?.name) { - return [{ name: sceneConfig.name }] } else if (activeScene) { - return [{ name: identifierToHuman(activeScene) }] + const sceneConfig = s.sceneConfig(state, props) + return [{ name: sceneConfig?.name ?? identifierToHuman(activeScene), key: activeScene }] } else { return [] } @@ -168,24 +166,8 @@ export const breadcrumbsLogic = kea([ ], breadcrumbs: [ (s) => [s.appBreadcrumbs, s.sceneBreadcrumbs], - (appBreadcrumbs, sceneBreadcrumbs): FinalizedBreadcrumb[] => { - const breadcrumbs = Array(appBreadcrumbs.length + sceneBreadcrumbs.length) - const globalPathSoFar: string[] = [] - for (let i = 0; i < appBreadcrumbs.length; i++) { - globalPathSoFar.push(String(appBreadcrumbs[i].key)) - breadcrumbs[i] = { - ...appBreadcrumbs[i], - globalKey: globalPathSoFar.join('.'), - } - } - for (let i = 0; i < sceneBreadcrumbs.length; i++) { - globalPathSoFar.push(String(sceneBreadcrumbs[i].key)) - breadcrumbs[i + appBreadcrumbs.length] = { - ...sceneBreadcrumbs[i], - globalKey: globalPathSoFar.join('.'), - } - } - return breadcrumbs + (appBreadcrumbs, sceneBreadcrumbs): Breadcrumb[] => { + return appBreadcrumbs.concat(sceneBreadcrumbs) }, ], firstBreadcrumb: [(s) => [s.breadcrumbs], (breadcrumbs) => breadcrumbs[0]], diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index de94171d3d311..502946a9a0779 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,7 @@ import { decompressSync, strFromU8 } from 'fflate' import { encodeParams } from 'kea-router' import { ActivityLogProps } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityLogItem, ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' +import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' import { objectClean, toParams } from 'lib/utils' import posthog from 'posthog-js' import { SavedSessionRecordingPlaylistsResult } from 'scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic' @@ -10,10 +10,12 @@ import { getCurrentExporterData } from '~/exporter/exporterViewLogic' import { QuerySchema, QueryStatus } from '~/queries/schema' import { ActionType, + ActivityScope, BatchExportConfiguration, BatchExportLogEntry, BatchExportRun, CohortType, + CommentType, DashboardCollaboratorType, DashboardTemplateEditorType, DashboardTemplateListParams, @@ -29,9 +31,9 @@ import { EventType, Experiment, ExportedAssetType, + ExternalDataSourceCreatePayload, ExternalDataSourceSchema, ExternalDataStripeSource, - ExternalDataStripeSourceCreatePayload, FeatureFlagAssociatedRoleType, FeatureFlagType, Group, @@ -287,6 +289,14 @@ class ApiRequest { return this.actions(teamId).addPathComponent(actionId) } + // # Comments + public comments(teamId?: TeamType['id']): ApiRequest { + return this.projectsDetail(teamId).addPathComponent('comments') + } + public comment(id: CommentType['id'], teamId?: TeamType['id']): ApiRequest { + return this.comments(teamId).addPathComponent(id) + } + // # Exports public exports(teamId?: TeamType['id']): ApiRequest { return this.projectsDetail(teamId).addPathComponent('exports') @@ -691,6 +701,11 @@ class ApiRequest { return this.externalDataSchemas(teamId).addPathComponent(schemaId) } + // ActivityLog + public activity_log(teamId?: TeamType['id']): ApiRequest { + return this.projectsDetail(teamId).addPathComponent('activity_log') + } + // Request finalization public async get(options?: ApiMethodOptions): Promise { return await api.get(this.assembleFullUrl(), options) @@ -857,11 +872,19 @@ const api = { activity: { list( + filters: Partial & { user?: UserBasicType['id'] }>, + teamId: TeamType['id'] = ApiConfig.getCurrentTeamId() + ): Promise> { + return new ApiRequest().activity_log(teamId).withQueryString(toParams(filters)).get() + }, + + listLegacy( activityLogProps: ActivityLogProps, page: number = 1, teamId: TeamType['id'] = ApiConfig.getCurrentTeamId() ): Promise> { - const requestForScope: Record ApiRequest | null> = { + // TODO: Can we replace all these endpoint specific implementations with the generic REST endpoint above? + const requestForScope: { [key in ActivityScope]?: (props: ActivityLogProps) => ApiRequest | null } = { [ActivityScope.FEATURE_FLAG]: (props) => { return new ApiRequest().featureFlagsActivity((props.id ?? null) as number | null, teamId) }, @@ -896,13 +919,44 @@ const api = { } const pagingParameters = { page: page || 1, limit: ACTIVITY_PAGE_SIZE } - const request = requestForScope[activityLogProps.scope](activityLogProps) - return request !== null + const request = requestForScope[activityLogProps.scope]?.(activityLogProps) + return request && request !== null ? request.withQueryString(toParams(pagingParameters)).get() : Promise.resolve({ results: [], count: 0 }) }, }, + comments: { + async create( + data: Partial, + params: Record = {}, + teamId: TeamType['id'] = ApiConfig.getCurrentTeamId() + ): Promise { + return new ApiRequest().comments(teamId).withQueryString(toParams(params)).create({ data }) + }, + + async update( + id: CommentType['id'], + data: Partial, + params: Record = {}, + teamId: TeamType['id'] = ApiConfig.getCurrentTeamId() + ): Promise { + return new ApiRequest().comment(id, teamId).withQueryString(toParams(params)).update({ data }) + }, + + async get(id: CommentType['id'], teamId: TeamType['id'] = ApiConfig.getCurrentTeamId()): Promise { + return new ApiRequest().comment(id, teamId).get() + }, + + async list(params: Partial = {}): Promise> { + return new ApiRequest().comments().withQueryString(params).get() + }, + + async getCount(params: Partial): Promise { + return (await new ApiRequest().comments().withAction('count').withQueryString(params).get()).count + }, + }, + exports: { determineExportUrl(exportId: number, teamId: TeamType['id'] = ApiConfig.getCurrentTeamId()): string { return new ApiRequest() @@ -1756,9 +1810,7 @@ const api = { async list(): Promise> { return await new ApiRequest().externalDataSources().get() }, - async create( - data: Partial - ): Promise { + async create(data: Partial): Promise { return await new ApiRequest().externalDataSources().create({ data }) }, async delete(sourceId: ExternalDataStripeSource['id']): Promise { diff --git a/frontend/src/lib/components/ActivationSidebar/activationLogic.ts b/frontend/src/lib/components/ActivationSidebar/activationLogic.ts index 449b93f710f53..a46e6478c2fcf 100644 --- a/frontend/src/lib/components/ActivationSidebar/activationLogic.ts +++ b/frontend/src/lib/components/ActivationSidebar/activationLogic.ts @@ -377,6 +377,10 @@ export const activationLogic = kea([ if (params?.onboarding_completed && !values.hasCompletedAllTasks) { actions.toggleActivationSideBar() actions.openSidePanel(SidePanelTab.Activation) + router.actions.replace(router.values.location.pathname, { + ...router.values.searchParams, + onboarding_completed: undefined, + }) } else { actions.hideActivationSideBar() } diff --git a/frontend/src/lib/components/ActivityLog/ActivityLog.stories.tsx b/frontend/src/lib/components/ActivityLog/ActivityLog.stories.tsx index c15951310c0db..c048adb88355e 100644 --- a/frontend/src/lib/components/ActivityLog/ActivityLog.stories.tsx +++ b/frontend/src/lib/components/ActivityLog/ActivityLog.stories.tsx @@ -5,9 +5,9 @@ import { personActivityResponseJson, } from 'lib/components/ActivityLog/__mocks__/activityLogMocks' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { mswDecorator } from '~/mocks/browser' +import { ActivityScope } from '~/types' const meta: Meta = { title: 'Components/ActivityLog', diff --git a/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts b/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts index 1e5502d8e8766..8cd97356fd826 100644 --- a/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts +++ b/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts @@ -1,6 +1,6 @@ -import { ActivityLogItem, ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' +import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' -import { InsightShortId } from '~/types' +import { ActivityScope, InsightShortId } from '~/types' export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ { @@ -14,7 +14,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'name', before: 'starting', @@ -36,7 +36,9 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ detail: { merge: null, trigger: null, - changes: [{ type: 'FeatureFlag', action: 'changed', field: 'active', before: false, after: true }], + changes: [ + { type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'active', before: false, after: true }, + ], name: 'cloud-announcement', }, created_at: '2022-03-20T15:26:58.006900Z', @@ -49,7 +51,9 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ detail: { merge: null, trigger: null, - changes: [{ type: 'FeatureFlag', action: 'changed', field: 'active', before: true, after: false }], + changes: [ + { type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'active', before: true, after: false }, + ], name: 'cloud-announcement', }, created_at: '2022-03-20T15:26:58.006900Z', @@ -64,7 +68,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -80,7 +84,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ properties: [ { key: 'email', - type: 'person', + type: ActivityScope.PERSON, value: ['paul.dambra@gmail.com'], operator: 'exact', }, @@ -91,7 +95,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ properties: [ { key: 'email', - type: 'person', + type: ActivityScope.PERSON, value: ['christopher@imusician.pro'], operator: 'exact', }, @@ -118,7 +122,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ properties: [ { key: 'email', - type: 'person', + type: ActivityScope.PERSON, value: ['paul.dambra@gmail.com'], operator: 'exact', }, @@ -129,7 +133,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ properties: [ { key: 'email', - type: 'person', + type: ActivityScope.PERSON, value: ['christopher@imusician.pro'], operator: 'exact', }, @@ -159,13 +163,15 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { groups: [ { - properties: [{ key: 'realm', type: 'person', value: ['cloud'], operator: 'exact' }], + properties: [ + { key: 'realm', type: ActivityScope.PERSON, value: ['cloud'], operator: 'exact' }, + ], rollout_percentage: null, }, ], @@ -182,7 +188,9 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ after: { groups: [ { - properties: [{ key: 'realm', type: 'person', value: ['cloud'], operator: 'exact' }], + properties: [ + { key: 'realm', type: ActivityScope.PERSON, value: ['cloud'], operator: 'exact' }, + ], rollout_percentage: null, }, ], @@ -212,7 +220,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -253,7 +261,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -306,7 +314,9 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ detail: { merge: null, trigger: null, - changes: [{ type: 'FeatureFlag', action: 'changed', field: 'deleted', before: false, after: true }], + changes: [ + { type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'deleted', before: false, after: true }, + ], name: 'test-ff', }, created_at: '2022-03-21T15:50:25.894422Z', @@ -319,7 +329,9 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ detail: { merge: null, trigger: null, - changes: [{ type: 'FeatureFlag', action: 'changed', field: 'deleted', before: true, after: false }], + changes: [ + { type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'deleted', before: true, after: false }, + ], name: 'test-ff', }, created_at: '2022-03-21T15:50:25.894422Z', @@ -334,7 +346,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'key', before: 'the-original-key', @@ -355,7 +367,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'ensure_experience_continuity', before: false, @@ -376,7 +388,7 @@ export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'ensure_experience_continuity', before: true, @@ -409,7 +421,15 @@ export const personActivityResponseJson: ActivityLogItem[] = [ scope: ActivityScope.PERSON, item_id: '502792727', detail: { - changes: [{ type: 'Person', action: 'changed', field: 'properties', before: undefined, after: undefined }], + changes: [ + { + type: ActivityScope.PERSON, + action: 'changed', + field: 'properties', + before: undefined, + after: undefined, + }, + ], merge: null, trigger: null, name: null, @@ -425,7 +445,7 @@ export const personActivityResponseJson: ActivityLogItem[] = [ detail: { changes: null, merge: { - type: 'Person', + type: ActivityScope.PERSON, source: [ { id: '01819231-75a0-0000-467e-a4be57b44a37', @@ -595,7 +615,7 @@ export const personActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Person', + type: ActivityScope.PERSON, action: 'split', field: undefined, before: undefined, @@ -632,14 +652,14 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ merge: null, changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'derived_name', before: "Pageview count by event's $feature/auto-redirect", after: "Pageview count by event's Browser Version", }, { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'filters', before: { @@ -701,14 +721,14 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ merge: null, changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'derived_name', before: 'from HogQL into a data table.', after: "Pageview unique sessions by person's $email", }, { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'filters', before: {}, @@ -729,12 +749,12 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ breakdown: '$email', new_entity: [], properties: [], - breakdown_type: 'person', + breakdown_type: ActivityScope.PERSON, filter_test_accounts: false, }, }, { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'deleted', field: 'query', before: { @@ -760,7 +780,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ scope: ActivityScope.INSIGHT, item_id: '738061', detail: { - changes: [{ type: 'Insight', action: 'changed', field: 'deleted', before: false, after: true }], + changes: [{ type: ActivityScope.INSIGHT, action: 'changed', field: 'deleted', before: false, after: true }], merge: null, trigger: null, name: 'Pageview count', @@ -780,7 +800,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'exported', field: 'export_format', before: null, @@ -802,7 +822,13 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ item_id: '738061', detail: { changes: [ - { type: 'Insight', action: 'changed', field: 'name', before: 'original name', after: 'cool insight' }, + { + type: ActivityScope.INSIGHT, + action: 'changed', + field: 'name', + before: 'original name', + after: 'cool insight', + }, ], merge: null, trigger: null, @@ -817,7 +843,15 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ scope: ActivityScope.INSIGHT, item_id: '738061', detail: { - changes: [{ type: 'Insight', action: 'changed', field: 'short_id', before: 'wr34th', after: 'iVXqSrre' }], + changes: [ + { + type: ActivityScope.INSIGHT, + action: 'changed', + field: 'short_id', + before: 'wr34th', + after: 'iVXqSrre', + }, + ], merge: null, trigger: null, name: 'Pageview count', @@ -833,7 +867,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'description', before: 'something', @@ -855,7 +889,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'favorited', before: false, @@ -877,7 +911,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'favorited', before: 'TRUe', @@ -902,7 +936,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'exported', field: 'tags', before: ['one', 'two', 'wrong', 'three'], @@ -927,7 +961,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'exported', field: 'export_format', before: undefined, @@ -952,7 +986,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ trigger: null, changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, after: { events: [], actions: [ @@ -1043,7 +1077,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ funnel_window_days: 14, }, }, - { type: 'Insight', action: 'changed', field: 'name', before: 'cool insight', after: 'DAU' }, + { type: ActivityScope.INSIGHT, action: 'changed', field: 'name', before: 'cool insight', after: 'DAU' }, ], short_id: 'eRY9-Frr' as InsightShortId, }, @@ -1060,7 +1094,7 @@ export const insightsActivityResponseJson: ActivityLogItem[] = [ detail: { changes: [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'exported', field: 'dashboards', before: [ diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.feature-flag.test.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.feature-flag.test.tsx index df0cc4c530029..070bbc2fd0b47 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.feature-flag.test.tsx +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.feature-flag.test.tsx @@ -2,7 +2,8 @@ import '@testing-library/jest-dom' import { render } from '@testing-library/react' import { MOCK_TEAM_ID } from 'lib/api.mock' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' + +import { ActivityScope } from '~/types' import { makeTestSetup } from './activityLogLogic.test.setup' @@ -16,7 +17,7 @@ describe('the activity log logic', () => { it('can handle change of key', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'key', before: 'the-first-key', @@ -33,7 +34,7 @@ describe('the activity log logic', () => { it('can handle soft deletion', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'deleted', after: 'true', @@ -47,7 +48,7 @@ describe('the activity log logic', () => { it('can handle soft un-deletion', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'deleted', after: 'false', @@ -61,7 +62,7 @@ describe('the activity log logic', () => { it('can handle soft enabling flag', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'active', after: 'true', @@ -75,7 +76,7 @@ describe('the activity log logic', () => { it('can handle soft disabling flag', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'active', after: 'false', @@ -89,7 +90,7 @@ describe('the activity log logic', () => { it('can handle enabling experience continuity for a flag', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'ensure_experience_continuity', after: 'true', @@ -105,7 +106,7 @@ describe('the activity log logic', () => { it('can handle disabling experience continuity for a flag', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'ensure_experience_continuity', after: 'false', @@ -121,7 +122,7 @@ describe('the activity log logic', () => { it('can handle deleting several groups from a flag', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -176,7 +177,7 @@ describe('the activity log logic', () => { it('can handle deleting a group from a flag', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -227,7 +228,7 @@ describe('the activity log logic', () => { it('can handle rollout percentage change', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'rollout_percentage', after: '36', @@ -244,7 +245,7 @@ describe('the activity log logic', () => { it('can handle deleting the first of several groups from a flag', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -288,13 +289,13 @@ describe('the activity log logic', () => { it('can humanize more than one change', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'rollout_percentage', after: '36', }, { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'name', after: 'strawberry', @@ -311,7 +312,7 @@ describe('the activity log logic', () => { it('can handle filter change - boolean value, no conditions', async () => { const logic = await featureFlagsTestSetup('test flag', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', after: { groups: [{ properties: [], rollout_percentage: 99 }], multivariate: null }, @@ -328,7 +329,7 @@ describe('the activity log logic', () => { it('can handle filter change with cohort', async () => { const logic = await featureFlagsTestSetup('with cohort', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', after: { @@ -370,7 +371,7 @@ describe('the activity log logic', () => { it('can describe a simple rollout percentage change', async () => { const logic = await featureFlagsTestSetup('with simple rollout change', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -403,7 +404,7 @@ describe('the activity log logic', () => { it('describes a null rollout percentage as 100%', async () => { const logic = await featureFlagsTestSetup('with null rollout change', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { @@ -461,7 +462,7 @@ describe('the activity log logic', () => { it('can describe two property changes', async () => { const logic = await featureFlagsTestSetup('with two changes', 'updated', [ { - type: 'FeatureFlag', + type: ActivityScope.FEATURE_FLAG, action: 'changed', field: 'filters', before: { diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.insight.test.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.insight.test.tsx index 12d7e59adc172..34d1f6c3c9c20 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.insight.test.tsx +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.insight.test.tsx @@ -3,7 +3,8 @@ import '@testing-library/jest-dom' import { render } from '@testing-library/react' import { MOCK_TEAM_ID } from 'lib/api.mock' import { makeTestSetup } from 'lib/components/ActivityLog/activityLogLogic.test.setup' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' + +import { ActivityScope } from '~/types' jest.mock('lib/colors') @@ -17,7 +18,7 @@ describe('the activity log logic', () => { it('can handle change of name', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'name', before: 'start', @@ -34,7 +35,7 @@ describe('the activity log logic', () => { it('can handle change of filters', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'filters', after: { @@ -85,7 +86,7 @@ describe('the activity log logic', () => { it('can handle change of insight query', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'query', after: { @@ -163,7 +164,7 @@ describe('the activity log logic', () => { it('can handle change of filters on a retention graph', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'filters', after: { @@ -193,7 +194,7 @@ describe('the activity log logic', () => { it('can handle soft delete', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'deleted', after: 'true', @@ -207,7 +208,7 @@ describe('the activity log logic', () => { it('can handle change of short id', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'short_id', after: 'changed', @@ -223,7 +224,7 @@ describe('the activity log logic', () => { it('can handle change of derived name', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'derived_name', before: 'original', @@ -240,7 +241,7 @@ describe('the activity log logic', () => { it('can handle change of description', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'description', after: 'changed', @@ -256,7 +257,7 @@ describe('the activity log logic', () => { it('can handle change of favorited', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'favorited', after: true, @@ -270,7 +271,7 @@ describe('the activity log logic', () => { it('can handle removal of favorited', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'favorited', after: false, @@ -284,7 +285,7 @@ describe('the activity log logic', () => { it('can handle addition of tags', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'tags', before: ['1', '2'], @@ -301,7 +302,7 @@ describe('the activity log logic', () => { it('can handle removal of tags', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'tags', before: ['1', '2', '3'], @@ -318,7 +319,7 @@ describe('the activity log logic', () => { it('can handle addition and removal of tags', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'tags', before: ['1', '2', '3'], @@ -335,7 +336,7 @@ describe('the activity log logic', () => { it('can handle addition of dashboards link', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'dashboards', before: [ @@ -359,7 +360,7 @@ describe('the activity log logic', () => { it('can handle addition of tile style dashboards link', async () => { const logic = await insightTestSetup('test insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'dashboards', before: [ @@ -383,7 +384,7 @@ describe('the activity log logic', () => { it('can handle removal of dashboards link', async () => { const logic = await insightTestSetup('test-insight', 'updated', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'changed', field: 'dashboards', before: [ @@ -408,7 +409,7 @@ describe('the activity log logic', () => { it(`can handle export of insight to ${format}`, async () => { const logic = await insightTestSetup('test insight', 'exported', [ { - type: 'Insight', + type: ActivityScope.INSIGHT, action: 'exported', field: 'export_format', before: undefined, diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.notebook.test.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.notebook.test.tsx index e0f884a96c2d4..e969764ed7ab7 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.notebook.test.tsx +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.notebook.test.tsx @@ -2,9 +2,9 @@ import '@testing-library/jest-dom' import { render } from '@testing-library/react' import { describerFor } from 'lib/components/ActivityLog/activityLogLogic' -import { ActivityLogItem, ActivityScope, humanize } from 'lib/components/ActivityLog/humanizeActivity' +import { ActivityLogItem, humanize } from 'lib/components/ActivityLog/humanizeActivity' -import { InsightShortId } from '~/types' +import { ActivityScope, InsightShortId } from '~/types' describe('the activity log logic', () => { describe('humanizing notebooks', () => { @@ -25,7 +25,7 @@ describe('the activity log logic', () => { type: undefined, changes: [ { - type: 'Notebook', + type: ActivityScope.NOTEBOOK, after: { type: 'doc', content: [ @@ -82,7 +82,7 @@ describe('the activity log logic', () => { }, }, { - type: 'Notebook', + type: ActivityScope.NOTEBOOK, after: 12, field: 'version', action: 'changed', diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.person.test.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.person.test.tsx index 62d58e6a90624..8a34afd1c00a6 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.person.test.tsx +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.person.test.tsx @@ -3,7 +3,8 @@ import '@testing-library/jest-dom' import { render } from '@testing-library/react' import { MOCK_TEAM_ID } from 'lib/api.mock' import { makeTestSetup } from 'lib/components/ActivityLog/activityLogLogic.test.setup' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' + +import { ActivityScope } from '~/types' describe('the activity log logic', () => { describe('humanizing persons', () => { @@ -11,7 +12,7 @@ describe('the activity log logic', () => { it('can handle addition of a property', async () => { const logic = await personTestSetup('test person', 'updated', [ { - type: 'Person', + type: ActivityScope.PERSON, action: 'changed', field: 'properties', }, @@ -23,7 +24,7 @@ describe('the activity log logic', () => { it('can handle merging people', async () => { const logic = await personTestSetup('test person', 'people_merged_into', null, { - type: 'Person', + type: ActivityScope.PERSON, source: [ { distinct_ids: ['a'], properties: {} }, { distinct_ids: ['c'], properties: {} }, @@ -40,7 +41,7 @@ describe('the activity log logic', () => { it('can handle splitting people', async () => { const logic = await personTestSetup('test_person', 'split_person', [ { - type: 'Person', + type: ActivityScope.PERSON, action: 'changed', field: undefined, before: {}, diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.plugin.test.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.plugin.test.tsx index 5533218dfd758..ddc1a720adbb2 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.plugin.test.tsx +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.plugin.test.tsx @@ -2,7 +2,8 @@ import '@testing-library/jest-dom' import { render } from '@testing-library/react' import { makeTestSetup } from 'lib/components/ActivityLog/activityLogLogic.test.setup' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' + +import { ActivityScope } from '~/types' describe('the activity log logic', () => { describe('humanizing plugins', () => { @@ -28,7 +29,7 @@ describe('the activity log logic', () => { it('can handle enabling a plugin', async () => { const logic = await pluginTestSetup('the removed plugin', 'enabled', [ { - type: 'Plugin', + type: ActivityScope.PLUGIN, action: 'created', field: 'name', after: 'world', @@ -44,13 +45,13 @@ describe('the activity log logic', () => { it('can handle enabling a plugin with a secret value', async () => { const logic = await pluginTestSetup('the removed plugin', 'enabled', [ { - type: 'Plugin', + type: ActivityScope.PLUGIN, action: 'created', field: 'name', after: 'world', }, { - type: 'Plugin', + type: ActivityScope.PLUGIN, action: 'created', field: 'super secret password', after: '**************** POSTHOG SECRET FIELD ****************', @@ -66,7 +67,7 @@ describe('the activity log logic', () => { it('can handle disabling a plugin', async () => { const logic = await pluginTestSetup('the removed plugin', 'disabled', [ { - type: 'Plugin', + type: ActivityScope.PLUGIN, action: 'created', field: 'name', after: 'world', @@ -82,21 +83,21 @@ describe('the activity log logic', () => { it('can handle config_update ', async () => { const logic = await pluginTestSetup('the changed plugin', 'config_updated', [ { - type: 'Plugin', + type: ActivityScope.PLUGIN, action: 'created', field: 'first example', after: 'added this config', }, { - type: 'Plugin', + type: ActivityScope.PLUGIN, action: 'deleted', field: 'second example', before: 'removed this config', }, { - type: 'Plugin', + type: ActivityScope.PLUGIN, action: 'changed', field: 'third example', before: 'changed from this config', @@ -182,7 +183,7 @@ describe('the activity log logic', () => { it('can handle new plugin attachments', async () => { const logic = await pluginTestSetup('the changed plugin', 'attachment_created', [ { - type: 'PluginConfig', + type: ActivityScope.PLUGIN_CONFIG, action: 'created', field: undefined, before: undefined, @@ -199,7 +200,7 @@ describe('the activity log logic', () => { it('can handle updated plugin attachments', async () => { const logic = await pluginTestSetup('the changed plugin', 'attachment_updated', [ { - type: 'PluginConfig', + type: ActivityScope.PLUGIN_CONFIG, action: 'changed', field: undefined, before: 'attachment.txt', @@ -216,7 +217,7 @@ describe('the activity log logic', () => { it('can handle renamed plugin attachments', async () => { const logic = await pluginTestSetup('the changed plugin', 'attachment_updated', [ { - type: 'PluginConfig', + type: ActivityScope.PLUGIN_CONFIG, action: 'changed', field: undefined, before: 'attachment1.txt', @@ -233,7 +234,7 @@ describe('the activity log logic', () => { it('can handle deleted plugin attachments', async () => { const logic = await pluginTestSetup('the changed plugin', 'attachment_deleted', [ { - type: 'PluginConfig', + type: ActivityScope.PLUGIN_CONFIG, action: 'deleted', field: undefined, before: 'attachment.txt', diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.test.setup.ts b/frontend/src/lib/components/ActivityLog/activityLogLogic.test.setup.ts index e947489ac1369..7e9f9c8c8a528 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.test.setup.ts +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.test.setup.ts @@ -1,15 +1,10 @@ import { expectLogic } from 'kea-test-utils' import { activityLogLogic } from 'lib/components/ActivityLog/activityLogLogic' -import { - ActivityChange, - ActivityLogItem, - ActivityScope, - PersonMerge, - Trigger, -} from 'lib/components/ActivityLog/humanizeActivity' +import { ActivityChange, ActivityLogItem, PersonMerge, Trigger } from 'lib/components/ActivityLog/humanizeActivity' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' +import { ActivityScope } from '~/types' interface APIMockSetup { name: string diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.test.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.test.tsx index 762412c406e48..e59627330e0cc 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.test.tsx +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.test.tsx @@ -4,11 +4,12 @@ import { expectLogic } from 'kea-test-utils' import { MOCK_TEAM_ID } from 'lib/api.mock' import { featureFlagsActivityResponseJson } from 'lib/components/ActivityLog/__mocks__/activityLogMocks' import { activityLogLogic, describerFor } from 'lib/components/ActivityLog/activityLogLogic' -import { ActivityLogItem, ActivityScope, humanize } from 'lib/components/ActivityLog/humanizeActivity' +import { ActivityLogItem, humanize } from 'lib/components/ActivityLog/humanizeActivity' import { flagActivityDescriber } from 'scenes/feature-flags/activityDescriptions' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' +import { ActivityScope } from '~/types' describe('the activity log logic', () => { let logic: ReturnType diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx index e971c14f916e4..aa4bdd1cdaaf8 100644 --- a/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx @@ -4,7 +4,7 @@ import { router, urlToAction } from 'kea-router' import api, { ActivityLogPaginatedResponse } from 'lib/api' import { ActivityLogItem, - ActivityScope, + defaultDescriber, Describer, humanize, HumanizedActivityLogItem, @@ -19,6 +19,8 @@ import { pluginActivityDescriber } from 'scenes/plugins/pluginActivityDescriptio import { insightActivityDescriber } from 'scenes/saved-insights/activityDescriptions' import { urls } from 'scenes/urls' +import { ActivityScope } from '~/types' + import type { activityLogLogicType } from './activityLogLogicType' /** @@ -43,7 +45,7 @@ export const describerFor = (logItem?: ActivityLogItem): Describer | undefined = case ActivityScope.NOTEBOOK: return notebookActivityDescriber default: - return undefined + return (logActivity, asNotification) => defaultDescriber(logActivity, asNotification) } } @@ -64,7 +66,7 @@ export const activityLogLogic = kea([ activity: [ { results: [], total_count: 0 } as ActivityLogPaginatedResponse, { - fetchActivity: async () => await api.activity.list(props, values.page), + fetchActivity: async () => await api.activity.listLegacy(props, values.page), }, ], })), diff --git a/frontend/src/lib/components/ActivityLog/humanizeActivity.tsx b/frontend/src/lib/components/ActivityLog/humanizeActivity.tsx index 28bdb6b4aa784..e5a6d241847c7 100644 --- a/frontend/src/lib/components/ActivityLog/humanizeActivity.tsx +++ b/frontend/src/lib/components/ActivityLog/humanizeActivity.tsx @@ -1,10 +1,11 @@ import { dayjs } from 'lib/dayjs' +import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' import { fullName } from 'lib/utils' -import { InsightShortId, PersonType } from '~/types' +import { ActivityScope, InsightShortId, PersonType } from '~/types' export interface ActivityChange { - type: 'FeatureFlag' | 'Person' | 'Insight' | 'Plugin' | 'PluginConfig' | 'Notebook' + type: ActivityScope action: 'changed' | 'created' | 'deleted' | 'exported' | 'split' field?: string before?: string | number | Record | boolean | null @@ -39,18 +40,6 @@ export interface ActivityUser { is_system?: boolean } -export enum ActivityScope { - FEATURE_FLAG = 'FeatureFlag', - PERSON = 'Person', - INSIGHT = 'Insight', - PLUGIN = 'Plugin', - PLUGIN_CONFIG = 'PluginConfig', - DATA_MANAGEMENT = 'DataManagement', - EVENT_DEFINITION = 'EventDefinition', - PROPERTY_DEFINITION = 'PropertyDefinition', - NOTEBOOK = 'Notebook', -} - export type ActivityLogItem = { user?: ActivityUser activity: string @@ -101,6 +90,10 @@ export function humanize( const logLines: HumanizedActivityLogItem[] = [] for (const logItem of results) { + if (!logItem.detail || !logItem.scope) { + // Sometimes we can end up with bad payloads from the backend so we check for some required fields here + continue + } const describer = describerFor?.(logItem) if (!describer) { @@ -129,3 +122,68 @@ export function userNameForLogItem(logItem: ActivityLogItem): string { } return logItem.user ? fullName(logItem.user) : 'A user' } + +const NO_PLURAL_SCOPES: ActivityScope[] = [ + ActivityScope.DATA_MANAGEMENT, + ActivityScope.EVENT_DEFINITION, + ActivityScope.PROPERTY_DEFINITION, +] + +export function humanizeScope(scope: ActivityScope, singular = false): string { + let output = scope.split(/(?=[A-Z])/).join(' ') + + if (!singular && !NO_PLURAL_SCOPES.includes(scope)) { + output += 's' + } + + return output +} + +export function defaultDescriber( + logItem: ActivityLogItem, + asNotification = false, + resource?: string | JSX.Element +): HumanizedChange { + resource = resource || logItem.detail.name || `a ${humanizeScope(logItem.scope, true)}` + + if (logItem.activity == 'deleted') { + return { + description: ( + <> + {userNameForLogItem(logItem)} deleted {resource} + + ), + } + } + + if (logItem.activity == 'commented') { + let description: JSX.Element | string + + if (logItem.scope === 'Comment') { + description = ( + <> + {userNameForLogItem(logItem)} replied to a {humanizeScope(logItem.scope, true)} + + ) + } else { + description = ( + <> + {userNameForLogItem(logItem)} commented + {asNotification ? <> on a {humanizeScope(logItem.scope, true)} : null} + + ) + } + const commentContent = logItem.detail.changes?.[0].after as string | undefined + + return { + description, + extendedDescription: commentContent ? ( +
+ {commentContent} +
+ ) : undefined, + } + } + + return { description: null } +} diff --git a/frontend/src/lib/components/FlagSelector.tsx b/frontend/src/lib/components/FlagSelector.tsx index 0ab4ed6e124d1..c3bf29d527a0f 100644 --- a/frontend/src/lib/components/FlagSelector.tsx +++ b/frontend/src/lib/components/FlagSelector.tsx @@ -10,9 +10,10 @@ interface FlagSelectorProps { value: number | undefined onChange: (id: number, key: string) => void readOnly?: boolean + disabledReason?: string } -export function FlagSelector({ value, onChange, readOnly }: FlagSelectorProps): JSX.Element { +export function FlagSelector({ value, onChange, readOnly, disabledReason }: FlagSelectorProps): JSX.Element { const [visible, setVisible] = useState(false) const { featureFlag } = useValues(featureFlagLogic({ id: value || 'link' })) @@ -39,17 +40,13 @@ export function FlagSelector({ value, onChange, readOnly }: FlagSelectorProps): fallbackPlacements={['left-end', 'bottom']} onClickOutside={() => setVisible(false)} > - {readOnly ? ( -
{featureFlag.key}
- ) : ( - setVisible(!visible)} - > - {featureFlag.key ? featureFlag.key : 'Select flag'} - - )} + setVisible(!visible)} + disabledReason={readOnly && (disabledReason || "I'm read-only")} + > + {featureFlag.key ? featureFlag.key : 'Select flag'} + ) } diff --git a/frontend/src/lib/components/MemberSelect.tsx b/frontend/src/lib/components/MemberSelect.tsx index faae91ebe57da..36910bfc59f0a 100644 --- a/frontend/src/lib/components/MemberSelect.tsx +++ b/frontend/src/lib/components/MemberSelect.tsx @@ -13,7 +13,7 @@ export type MemberSelectProps = { onChange: (value: UserBasicType | null) => void } -export function MemberSelect({ defaultLabel = 'All users', value, onChange }: MemberSelectProps): JSX.Element { +export function MemberSelect({ defaultLabel = 'Any user', value, onChange }: MemberSelectProps): JSX.Element { const { meFirstMembers, membersFuse } = useValues(membersLogic) const [showPopover, setShowPopover] = useState(false) const [searchTerm, setSearchTerm] = useState('') diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 3895367239b28..03db8561af550 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -156,7 +156,7 @@ export const FEATURE_FLAGS = { EXCEPTION_AUTOCAPTURE: 'exception-autocapture', DATA_WAREHOUSE: 'data-warehouse', // owner: @EDsCODE DATA_WAREHOUSE_VIEWS: 'data-warehouse-views', // owner: @EDsCODE - DATA_WAREHOUSE_EXTERNAL_LINK: 'data-warehouse-external-link', // owner: @EDsCODE + DATA_WAREHOUSE_HUBSPOT_IMPORT: 'data-warehouse-hubspot-import', // owner: @EDsCODE FF_DASHBOARD_TEMPLATES: 'ff-dashboard-templates', // owner: @EDsCODE SHOW_PRODUCT_INTRO_EXISTING_PRODUCTS: 'show-product-intro-existing-products', // owner: @raquelmsmith ARTIFICIAL_HOG: 'artificial-hog', // owner: @Twixes @@ -190,6 +190,7 @@ export const FEATURE_FLAGS = { SESSION_REPLAY_IOS: 'session-replay-ios', // owner: #team-replay YEAR_IN_HOG: 'year-in-hog', // owner: #team-replay SESSION_REPLAY_EXPORT_MOBILE_DATA: 'session-replay-export-mobile-data', // owner: #team-replay + DISCUSSIONS: 'discussions', // owner: #team-replay REDIRECT_INGESTION_PRODUCT_ANALYTICS_ONBOARDING: 'redirect-ingestion-product-analytics-onboarding', // owner: @biancayang } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx b/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx index f12cc67d0eb1f..99ee06902f0f2 100644 --- a/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx +++ b/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx @@ -9,7 +9,9 @@ import { ChangeEvent, createRef, RefObject, useEffect, useState } from 'react' export interface LemonFileInputProps extends Pick { value?: File[] onChange?: (newValue: File[]) => void - // are the files currently being uploaded? + /** + * are the files currently being uploaded? + */ loading?: boolean /** if this is not provided then this component is the drop target * and is styled when a file is dragged over it @@ -18,6 +20,10 @@ export interface LemonFileInputProps extends Pick + /** + * the text to display to the user, a sensible default is used if not provided + */ + callToAction?: string | JSX.Element } export const LemonFileInput = ({ @@ -28,6 +34,7 @@ export const LemonFileInput = ({ // e.g. '.json' or 'image/*' accept, alternativeDropTargetRef, + callToAction, }: LemonFileInputProps): JSX.Element => { const [files, setFiles] = useState(value || value || ([] as File[])) @@ -134,8 +141,12 @@ export const LemonFileInput = ({ accept={accept} onChange={onInputChange} /> - Click or drag and drop to upload - {accept ? ` ${acceptToDisplayName(accept)}` : ''} + {callToAction || ( + <> + Click or drag and drop to upload + {accept ? ` ${acceptToDisplayName(accept)}` : ''} + + )} {files.length > 0 && (
diff --git a/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.scss b/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.scss index f8ae353f277ac..047187139f526 100644 --- a/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.scss +++ b/frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.scss @@ -34,4 +34,8 @@ margin-bottom: 0.25em; font-weight: 600; } + + img { + max-width: 100%; + } } diff --git a/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.scss b/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.scss index 3696f6b12c088..d6fa775878404 100644 --- a/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.scss +++ b/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.scss @@ -1,6 +1,6 @@ .LemonTabs { --lemon-tabs-margin-bottom: 1rem; - --lemon-tabs-margin-right: 2rem; + --lemon-tabs-gap: 2rem; --lemon-tabs-content-padding: 0.75rem 0; position: relative; @@ -15,14 +15,6 @@ &.LemonTabs--inline { --lemon-tabs-margin-bottom: 0; - --lemon-tabs-margin-right: 1rem; - --lemon-tabs-content-padding: 0.25rem 0rem; - } - - &.LemonTabs--borderless { - .LemonTabs__bar::before { - content: none; - } } .LemonTabs__bar { @@ -30,6 +22,7 @@ display: flex; flex-direction: row; flex-shrink: 0; + gap: var(--lemon-tabs-gap); align-items: stretch; margin-bottom: var(--lemon-tabs-margin-bottom); overflow-x: auto; @@ -69,10 +62,6 @@ transition: color 200ms ease; } - &:not(:last-child) { - margin-right: var(--lemon-tabs-margin-right); - } - &:hover { color: var(--link); } diff --git a/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.tsx b/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.tsx index 44820a6523698..860693efe6eb6 100644 --- a/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.tsx +++ b/frontend/src/lib/lemon-ui/LemonTabs/LemonTabs.tsx @@ -30,7 +30,6 @@ export interface LemonTabsProps { /** List of tabs. Falsy entries are ignored - they're there to make conditional tabs convenient. */ tabs: (LemonTab | null | false)[] inline?: boolean - borderless?: boolean 'data-attr'?: string } @@ -56,7 +55,6 @@ export function LemonTabs({ onChange, tabs, inline = false, - borderless = false, 'data-attr': dataAttr, }: LemonTabsProps): JSX.Element { const { containerRef, selectionRef, sliderWidth, sliderOffset, transitioning } = useSliderPositioning< @@ -70,12 +68,7 @@ export function LemonTabs({ return (
void - placeholder?: string - 'data-attr'?: string -} - -export function LemonTextAreaMarkdown({ value, onChange, ...editAreaProps }: LemonTextAreaMarkdownProps): JSX.Element { - const { objectStorageAvailable } = useValues(preflightLogic) +export const LemonTextAreaMarkdown = React.forwardRef( + function _LemonTextAreaMarkdown({ value, onChange, ...editAreaProps }, ref): JSX.Element { + const { objectStorageAvailable } = useValues(preflightLogic) - const [isPreviewShown, setIsPreviewShown] = useState(false) - const dropRef = createRef() - const textAreaRef = useRef(null) + const [isPreviewShown, setIsPreviewShown] = useState(false) + const dropRef = useRef(null) - const { setFilesToUpload, filesToUpload, uploading } = useUploadFiles({ - onUpload: (url, fileName) => { - onChange?.(value + `\n\n![${fileName}](${url})`) - posthog.capture('markdown image uploaded', { name: fileName }) - }, - onError: (detail) => { - posthog.capture('markdown image upload failed', { error: detail }) - lemonToast.error(`Error uploading image: ${detail}`) - }, - }) + const { setFilesToUpload, filesToUpload, uploading } = useUploadFiles({ + onUpload: (url, fileName) => { + onChange?.(value + `\n\n![${fileName}](${url})`) + posthog.capture('markdown image uploaded', { name: fileName }) + }, + onError: (detail) => { + posthog.capture('markdown image upload failed', { error: detail }) + lemonToast.error(`Error uploading image: ${detail}`) + }, + }) - return ( - setIsPreviewShown(key === 'preview')} - tabs={[ - { - key: 'write', - label: 'Write', - content: ( -
- -
- - Markdown formatting support -
- {objectStorageAvailable ? ( - setIsPreviewShown(key === 'preview')} + tabs={[ + { + key: 'write', + label: 'Write', + content: ( +
+ - ) : (
- - - - - Add external images using{' '} - - {' '} - Markdown image links - - . - + + Markdown formatting support
- )} -
- ), - }, - { - key: 'preview', - label: 'Preview', - content: value ? ( - - ) : ( - Nothing to preview - ), - }, - ]} - /> - ) -} + {objectStorageAvailable ? ( + + ) : ( +
+ + + + + Add external images using{' '} + + {' '} + Markdown image links + + . + +
+ )} +
+ ), + }, + { + key: 'preview', + label: 'Preview', + content: value ? ( + + ) : ( + Nothing to preview + ), + }, + ]} + /> + ) + } +) diff --git a/frontend/src/mocks/fixtures/api/organizations/@current/@current.json b/frontend/src/mocks/fixtures/api/organizations/@current/@current.json new file mode 100644 index 0000000000000..ed8ca1eac9e38 --- /dev/null +++ b/frontend/src/mocks/fixtures/api/organizations/@current/@current.json @@ -0,0 +1,22 @@ +{ + "id": "0178a3ab-d163-0000-4b55-bceadebb03fa", + "name": "Hogflix Movies", + "created_at": "2021-04-05T20:14:09.763753Z", + "updated_at": "2021-04-05T20:14:25.443181Z", + "membership_level": 15, + "plugins_access_level": 9, + "teams": [ + { + "id": 2, + "uuid": "0178a3ab-d1e5-0000-c5ca-da746c68f506", + "organization": "0178a3ab-d163-0000-4b55-bceadebb03fa", + "api_token": "tJy-b6mTLwvNP_ZJHrfgn99pQCYOGFE3-nwpb8utFa8", + "name": "Hogflix Demo App", + "completed_snippet_onboarding": true, + "ingested_event": true, + "is_demo": true, + "timezone": "Europe/Kiev" + } + ], + "available_features": [] +} diff --git a/frontend/src/mocks/fixtures/api/organizations/@current/batchExports.json b/frontend/src/mocks/fixtures/api/organizations/@current/batchExports.json new file mode 100644 index 0000000000000..1926188189785 --- /dev/null +++ b/frontend/src/mocks/fixtures/api/organizations/@current/batchExports.json @@ -0,0 +1,34 @@ +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": "018c8dcd-1598-0001-a082-fa2d1e2dbb74", + "team_id": 2, + "name": "my export", + "destination": { + "type": "Postgres", + "config": { + "host": "sdsdd.domc.com", + "port": 5432, + "user": "sdssd", + "schema": "sddd", + "database": "sdsd", + "password": "sdsdsd", + "table_name": "ssss", + "exclude_events": ["sdd"], + "include_events": ["sdddddd"] + } + }, + "interval": "day", + "paused": false, + "created_at": "2023-12-21T19:14:37.135878Z", + "last_updated_at": "2023-12-22T18:42:43.863292Z", + "last_paused_at": "2023-12-22T18:41:50.122946Z", + "start_at": null, + "end_at": "2023-12-30T08:00:00Z", + "latest_runs": [] + } + ] +} diff --git a/frontend/src/mocks/fixtures/api/organizations/@current/plugins/exportsUnsubscribeConfigs.json b/frontend/src/mocks/fixtures/api/organizations/@current/plugins/exportsUnsubscribeConfigs.json new file mode 100644 index 0000000000000..e6cfa6a1bdfcc --- /dev/null +++ b/frontend/src/mocks/fixtures/api/organizations/@current/plugins/exportsUnsubscribeConfigs.json @@ -0,0 +1,24 @@ +[ + { + "id": 2, + "plugin": 4, + "enabled": true, + "order": 2, + "config": { + "host": "eu.posthog.com", + "replication": "1", + "disable_geoip": "No", + "project_api_key": "sdsdd", + "events_to_ignore": "" + }, + "error": null, + "team_id": 2, + "plugin_info": null, + "delivery_rate_24h": null, + "created_at": "2023-12-22T18:23:03.907137Z", + "updated_at": "2023-12-22T18:42:54.074571Z", + "name": "Replicator", + "description": "Replicate PostHog event stream in another PostHog instance", + "deleted": false + } +] diff --git a/frontend/src/mocks/fixtures/api/organizations/@current/plugins/plugins.json b/frontend/src/mocks/fixtures/api/organizations/@current/plugins/plugins.json new file mode 100644 index 0000000000000..cade709d2180f --- /dev/null +++ b/frontend/src/mocks/fixtures/api/organizations/@current/plugins/plugins.json @@ -0,0 +1,219 @@ +{ + "count": 4, + "next": null, + "previous": null, + "results": [ + { + "id": 4, + "plugin_type": "custom", + "name": "Replicator", + "description": "Replicate PostHog event stream in another PostHog instance", + "url": "https://github.com/PostHog/posthog-plugin-replicator", + "icon": null, + "config_schema": [ + { + "key": "host", + "hint": "E.g. posthog.yourcompany.com", + "name": "Host", + "type": "string", + "required": true + }, + { + "key": "project_api_key", + "hint": "Grab it from e.g. https://posthog.yourcompany.com/project/settings", + "name": "Project API Key", + "type": "string", + "required": true + }, + { + "key": "replication", + "hint": "How many times should each event be sent", + "name": "Replication", + "type": "string", + "default": "1", + "required": false + }, + { + "key": "events_to_ignore", + "hint": "Comma-separated list of events to ignore, e.g. $pageleave, purchase", + "name": "Events to ignore", + "type": "string", + "default": "", + "required": false + }, + { + "key": "disable_geoip", + "hint": "Add $disable_geoip so that the receiving PostHog instance doesn't try to resolve the IP address.", + "name": "Disable Geo IP?", + "type": "choice", + "choices": ["Yes", "No"], + "default": "No", + "required": false + } + ], + "tag": "cec29cd0dea20465839dd301894e4798d6dd6356", + "latest_tag": "cec29cd0dea20465839dd301894e4798d6dd6356", + "is_global": false, + "organization_id": "018aaa96-00d3-0000-b845-8eb60884ff76", + "organization_name": "test 123", + "capabilities": {}, + "metrics": {}, + "public_jobs": {} + }, + { + "id": 3, + "plugin_type": "custom", + "name": "GeoIP", + "description": "Enrich PostHog events and persons with IP location data", + "url": "https://github.com/PostHog/posthog-plugin-geoip", + "icon": null, + "config_schema": [], + "tag": "2dd67e1dec9c8b5febd7a6d9235c51072950cd37", + "latest_tag": "2dd67e1dec9c8b5febd7a6d9235c51072950cd37", + "is_global": false, + "organization_id": "018aaa96-00d3-0000-b845-8eb60884ff76", + "organization_name": "test 123", + "capabilities": {}, + "metrics": {}, + "public_jobs": {} + }, + { + "id": 2, + "plugin_type": "custom", + "name": "Customer.io", + "description": "Send event data and emails into Customer.io.", + "url": "https://github.com/PostHog/customerio-plugin", + "icon": null, + "config_schema": [ + { + "key": "customerioSiteId", + "hint": "Provided during Customer.io setup.", + "name": "Customer.io Site ID", + "type": "string", + "secret": true, + "default": "", + "required": true + }, + { + "key": "customerioToken", + "hint": "Provided during Customer.io setup.", + "name": "Customer.io API Key", + "type": "string", + "secret": true, + "default": "", + "required": true + }, + { + "key": "host", + "hint": "Use the EU variant if your Customer.io account is based in the EU region.", + "name": "Tracking Endpoint", + "type": "choice", + "choices": ["track.customer.io", "track-eu.customer.io"], + "default": "track.customer.io" + }, + { + "key": "identifyByEmail", + "hint": "If enabled, the plugin will identify users by email instead of ID, whenever an email is available.", + "name": "Identify by email", + "type": "choice", + "choices": ["Yes", "No"], + "default": "No" + }, + { + "key": "sendEventsFromAnonymousUsers", + "hint": "Customer.io pricing is based on the number of customers. This is an option to only send events from users that have been identified. Take into consideration that merging after identification won't work (as those previously anonymous events won't be there).", + "name": "Filtering of Anonymous Users", + "type": "choice", + "choices": [ + "Send all events", + "Only send events from users that have been identified", + "Only send events from users with emails" + ], + "default": "Send all events" + }, + { + "key": "eventsToSend", + "hint": "If this is set, only the specified events (comma-separated) will be sent to Customer.io.", + "name": "PostHog Event Allowlist", + "type": "string" + } + ], + "tag": "0b86074d53aa11617290f12501b56cfc27c7abde", + "latest_tag": "0b86074d53aa11617290f12501b56cfc27c7abde", + "is_global": false, + "organization_id": "018aaa96-00d3-0000-b845-8eb60884ff76", + "organization_name": "test 123", + "capabilities": {}, + "metrics": {}, + "public_jobs": {} + }, + { + "id": 1, + "plugin_type": "custom", + "name": "BigQuery Export", + "description": "Sends events to a BigQuery database on ingestion.", + "url": "https://github.com/PostHog/bigquery-plugin", + "icon": null, + "config_schema": [ + { + "key": "googleCloudKeyJson", + "name": "JSON file with your google cloud key", + "type": "attachment", + "secret": true, + "required": true + }, + { + "key": "datasetId", + "hint": "In case Google Cloud tells you \"my-project-123245:Something\", use \"Something\" as the ID.", + "name": "Dataset ID", + "type": "string", + "required": true + }, + { + "key": "tableId", + "hint": "A table will be created if it does not exist.", + "name": "Table ID", + "type": "string", + "required": true + }, + { + "key": "exportEventsToIgnore", + "hint": "Comma separated list of events to ignore", + "name": "Events to ignore", + "type": "string", + "default": "$feature_flag_called,$autocapture" + }, + { + "key": "exportEventsBufferBytes", + "hint": "Default 1MB. Upload events after buffering this many of them. The value must be between 1 MB and 10 MB.", + "name": "Maximum upload size in bytes", + "type": "string", + "default": "1048576" + }, + { + "key": "exportEventsBufferSeconds", + "hint": "Default 30 seconds. If there are events to upload and this many seconds has passed since the last upload, then upload the queued events. The value must be between 1 and 600 seconds.", + "name": "Export events at least every X seconds", + "type": "string", + "default": "30" + }, + { + "key": "exportElementsOnAnyEvent", + "hint": "Advanced", + "name": "Export the property $elements on events that aren't called `$autocapture`?", + "type": "choice", + "choices": ["Yes", "No"], + "default": "No" + } + ], + "tag": "5f5dcbc2f6a36ea7e9700ab36cc9397d92742ca3", + "latest_tag": "5f5dcbc2f6a36ea7e9700ab36cc9397d92742ca3", + "is_global": false, + "organization_id": "018aaa96-00d3-0000-b845-8eb60884ff76", + "organization_name": "test 123", + "capabilities": {}, + "metrics": {}, + "public_jobs": {} + } + ] +} diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 5768a859e1478..d039c449c402c 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -87,6 +87,8 @@ export const defaultMocks: Mocks = { ], '/api/projects/@current/': MOCK_DEFAULT_TEAM, '/api/billing-v2/': (): MockSignature => [200, {}], + '/api/projects/:team_id/comments/count': { count: 0 }, + '/api/projects/:team_id/comments': { results: [] }, '/_preflight': require('./fixtures/_preflight.json'), '/_system_status': require('./fixtures/_system_status.json'), '/api/instance_status': require('./fixtures/_instance_status.json'), diff --git a/frontend/src/models/notebooksModel.ts b/frontend/src/models/notebooksModel.ts index 17104131ca3e0..2f23fdbb58071 100644 --- a/frontend/src/models/notebooksModel.ts +++ b/frontend/src/models/notebooksModel.ts @@ -19,6 +19,7 @@ import { DashboardType, NotebookListItemType, NotebookNodeType, NotebookTarget } import type { notebooksModelType } from './notebooksModelType' export const SCRATCHPAD_NOTEBOOK: NotebookListItemType = { + id: 'scratchpad', short_id: 'scratchpad', title: 'My scratchpad', created_at: '', diff --git a/frontend/src/queries/nodes/DataVisualization/Components/SideBar.tsx b/frontend/src/queries/nodes/DataVisualization/Components/SideBar.tsx index 170b70ccdb878..e98fc743e8765 100644 --- a/frontend/src/queries/nodes/DataVisualization/Components/SideBar.tsx +++ b/frontend/src/queries/nodes/DataVisualization/Components/SideBar.tsx @@ -30,7 +30,6 @@ export const SideBar = (): JSX.Element => { setSideBarTab(tab as SideBarTab)} - borderless tabs={Object.values(TABS_TO_CONTENT).map((tab, index) => ({ label: tab.label, key: Object.keys(TABS_TO_CONTENT)[index], diff --git a/frontend/src/scenes/actions/actionLogic.ts b/frontend/src/scenes/actions/actionLogic.ts index 2b19965848ab4..6304bc459700d 100644 --- a/frontend/src/scenes/actions/actionLogic.ts +++ b/frontend/src/scenes/actions/actionLogic.ts @@ -66,7 +66,7 @@ export const actionLogic = kea([ path: urls.actions(), }, { - key: action?.id || 'new', + key: [Scene.Action, action?.id || 'new'], name: inProgressName ?? (action?.name || ''), onRename: async (name: string) => { const id = action?.id diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index 8c7a8c5ab8c09..021d4b883ae61 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -41,6 +41,7 @@ export const appScenes: Record any> = { [Scene.DataWarehouseExternal]: () => import('./data-warehouse/external/DataWarehouseExternalScene'), [Scene.DataWarehouseSavedQueries]: () => import('./data-warehouse/saved_queries/DataWarehouseSavedQueriesScene'), [Scene.DataWarehouseSettings]: () => import('./data-warehouse/settings/DataWarehouseSettingsScene'), + [Scene.DataWarehouseRedirect]: () => import('./data-warehouse/redirect/DataWarehouseRedirectScene'), [Scene.OrganizationCreateFirst]: () => import('./organization/Create'), [Scene.OrganizationCreationConfirm]: () => import('./organization/ConfirmOrganization/ConfirmOrganization'), [Scene.ProjectHomepage]: () => import('./project-homepage/ProjectHomepage'), diff --git a/frontend/src/scenes/apps/AppMetricsScene.tsx b/frontend/src/scenes/apps/AppMetricsScene.tsx index e129ff60be3b0..fd04767e63146 100644 --- a/frontend/src/scenes/apps/AppMetricsScene.tsx +++ b/frontend/src/scenes/apps/AppMetricsScene.tsx @@ -1,7 +1,6 @@ import { LemonButton } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { PageHeader } from 'lib/components/PageHeader' import { IconSettings } from 'lib/lemon-ui/icons' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' @@ -12,7 +11,7 @@ import { pluginsLogic } from 'scenes/plugins/pluginsLogic' import { PluginTags } from 'scenes/plugins/tabs/apps/components' import { SceneExport } from 'scenes/sceneTypes' -import { AppMetricsTab } from '~/types' +import { ActivityScope, AppMetricsTab } from '~/types' import { AppLogsTab } from './AppLogsTab' import { ErrorDetailsModal } from './ErrorDetailsModal' diff --git a/frontend/src/scenes/apps/appMetricsSceneLogic.ts b/frontend/src/scenes/apps/appMetricsSceneLogic.ts index 1c51aa7e45cb0..afcfec42c346e 100644 --- a/frontend/src/scenes/apps/appMetricsSceneLogic.ts +++ b/frontend/src/scenes/apps/appMetricsSceneLogic.ts @@ -188,7 +188,7 @@ export const appMetricsSceneLogic = kea([ path: urls.projectApps(), }, { - key: pluginConfigId, + key: [Scene.AppMetrics, pluginConfigId], name: pluginConfig?.plugin_info?.name, path: urls.appMetrics(pluginConfigId), }, diff --git a/frontend/src/scenes/batch_exports/BatchExportEditForm.tsx b/frontend/src/scenes/batch_exports/BatchExportEditForm.tsx index e2774c7b38a98..f40f0299cb460 100644 --- a/frontend/src/scenes/batch_exports/BatchExportEditForm.tsx +++ b/frontend/src/scenes/batch_exports/BatchExportEditForm.tsx @@ -457,6 +457,27 @@ export function BatchExportsEditForm(props: BatchExportsEditLogicProps): JSX.Ele + {isNew ? ( + + + Export 'properties', 'set', and 'set_once' fields as BigQuery + JSON type + + + + + } + /> + + ) : null} + ([ config: config, } as unknown as BatchExportDestinationSnowflake) - const data: Omit = { + const data: Omit = { paused, name, interval, diff --git a/frontend/src/scenes/batch_exports/batchExportLogic.ts b/frontend/src/scenes/batch_exports/batchExportLogic.ts index 37cc5fc86e649..12fc9bc677d43 100644 --- a/frontend/src/scenes/batch_exports/batchExportLogic.ts +++ b/frontend/src/scenes/batch_exports/batchExportLogic.ts @@ -233,7 +233,7 @@ export const batchExportLogic = kea([ path: urls.batchExports(), }, { - key: config?.id || 'loading', + key: [Scene.BatchExport, config?.id || 'loading'], name: config?.name, }, ], diff --git a/frontend/src/scenes/billing/Billing.stories.tsx b/frontend/src/scenes/billing/Billing.stories.tsx index ab6cbae8ea895..86634b18679c4 100644 --- a/frontend/src/scenes/billing/Billing.stories.tsx +++ b/frontend/src/scenes/billing/Billing.stories.tsx @@ -4,8 +4,14 @@ import { mswDecorator, useStorybookMocks } from '~/mocks/browser' import billingJson from '~/mocks/fixtures/_billing_v2.json' import billingJsonWithDiscount from '~/mocks/fixtures/_billing_v2_with_discount.json' import preflightJson from '~/mocks/fixtures/_preflight.json' +import organizationCurrent from '~/mocks/fixtures/api/organizations/@current/@current.json' +import batchExports from '~/mocks/fixtures/api/organizations/@current/batchExports.json' +import exportsUnsubscribeConfigs from '~/mocks/fixtures/api/organizations/@current/plugins/exportsUnsubscribeConfigs.json' +import organizationPlugins from '~/mocks/fixtures/api/organizations/@current/plugins/plugins.json' +import { BillingProductV2Type } from '~/types' import { Billing } from './Billing' +import { UnsubscribeSurveyModal } from './UnsubscribeSurveyModal' const meta: Meta = { title: 'Scenes-Other/Billing v2', @@ -50,3 +56,59 @@ export const BillingV2WithDiscount = (): JSX.Element => { return } + +export const BillingUnsubscribeModal = (): JSX.Element => { + useStorybookMocks({ + get: { + '/api/billing-v2/': { + ...billingJson, + }, + }, + }) + + return +} +export const BillingUnsubscribeModal_DataPipelines = (): JSX.Element => { + useStorybookMocks({ + get: { + '/api/billing-v2/': { + ...billingJson, + }, + '/api/organizations/@current/plugins/exports_unsubscribe_configs/': exportsUnsubscribeConfigs, + '/api/organizations/@current/batch_exports': batchExports, + '/api/organizations/@current/plugins': { + ...organizationPlugins, + }, + '/api/organizations/@current/': { + ...organizationCurrent, + }, + }, + }) + const product = billingJson.products[0] as BillingProductV2Type + product.addons = [ + { + type: 'data_pipelines', + subscribed: true, + name: 'Data Pipelines', + description: 'Add-on description', + price_description: 'Add-on price description', + image_url: 'Add-on image URL', + docs_url: 'Add-on documentation URL', + tiers: [], + tiered: false, + unit: '', + unit_amount_usd: '0', + current_amount_usd: '0', + current_usage: 0, + projected_usage: 0, + projected_amount_usd: '0', + plans: [], + usage_key: '', + }, + ] + + return +} +BillingUnsubscribeModal_DataPipelines.parameters = { + testOptions: { waitForSelector: '.LemonTable__content' }, +} diff --git a/frontend/src/scenes/billing/BillingProduct.tsx b/frontend/src/scenes/billing/BillingProduct.tsx index dd3b16d019190..6906124bbfa82 100644 --- a/frontend/src/scenes/billing/BillingProduct.tsx +++ b/frontend/src/scenes/billing/BillingProduct.tsx @@ -46,9 +46,10 @@ export const getTierDescription = ( export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonType }): JSX.Element => { const { billing, redirectPath } = useValues(billingLogic) - const { deactivateProduct } = useActions(billingLogic) - const { isPricingModalOpen, currentAndUpgradePlans } = useValues(billingProductLogic({ product: addon })) - const { toggleIsPricingModalOpen } = useActions(billingProductLogic({ product: addon })) + const { isPricingModalOpen, currentAndUpgradePlans, surveyID } = useValues(billingProductLogic({ product: addon })) + const { toggleIsPricingModalOpen, reportSurveyShown, setSurveyResponse } = useActions( + billingProductLogic({ product: addon }) + ) const productType = { plural: `${addon.unit}s`, singular: addon.unit } const tierDisplayOptions: LemonSelectOptions = [ @@ -89,7 +90,13 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp - deactivateProduct(addon.type)}> + { + setSurveyResponse(addon.type, '$survey_response_1') + reportSurveyShown(UNSUBSCRIBE_SURVEY_ID, addon.type) + }} + > Remove addon @@ -136,6 +143,7 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp : currentAndUpgradePlans?.upgradePlan?.plan_key } /> + {surveyID && }
) } diff --git a/frontend/src/scenes/billing/ExportsUnsubscribeTable/ExportsUnsubscribeTable.tsx b/frontend/src/scenes/billing/ExportsUnsubscribeTable/ExportsUnsubscribeTable.tsx new file mode 100644 index 0000000000000..dde100e766313 --- /dev/null +++ b/frontend/src/scenes/billing/ExportsUnsubscribeTable/ExportsUnsubscribeTable.tsx @@ -0,0 +1,75 @@ +import { IconCheckCircle } from '@posthog/icons' +import { useActions, useValues } from 'kea' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' +import { LemonTable } from 'lib/lemon-ui/LemonTable' +import { organizationLogic } from 'scenes/organizationLogic' + +import { exportsUnsubscribeTableLogic } from './exportsUnsubscribeTableLogic' + +export function ExportsUnsubscribeTable(): JSX.Element { + const { loading, itemsToDisable } = useValues(exportsUnsubscribeTableLogic) + const { disablePlugin, pauseBatchExport } = useActions(exportsUnsubscribeTableLogic) + const { currentOrganization } = useValues(organizationLogic) + + if (!currentOrganization) { + return <> + } + + return ( + + {item.name} + {item.description && ( + + {item.description} + + )} + + ) + }, + }, + { + title: 'Project', + render: function RenderTeam(_, item) { + return currentOrganization.teams.find((team) => team.id === item.team_id)?.name + }, + }, + { + title: '', + render: function RenderPluginDisable(_, item) { + return ( + { + if (item.plugin_config_id !== undefined) { + disablePlugin(item.plugin_config_id) + } else if (item.batch_export_id !== undefined) { + pauseBatchExport(item.batch_export_id) + } + }} + disabledReason={item.disabled ? 'Already disabled' : null} + icon={item.disabled ? : undefined} + > + {item.disabled ? 'Disabled' : 'Disable'} + + ) + }, + }, + ]} + /> + ) +} diff --git a/frontend/src/scenes/billing/ExportsUnsubscribeTable/exportsUnsubscribeTableLogic.tsx b/frontend/src/scenes/billing/ExportsUnsubscribeTable/exportsUnsubscribeTableLogic.tsx new file mode 100644 index 0000000000000..300680e9ac8d1 --- /dev/null +++ b/frontend/src/scenes/billing/ExportsUnsubscribeTable/exportsUnsubscribeTableLogic.tsx @@ -0,0 +1,126 @@ +import { actions, afterMount, connect, kea, path, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' +import { IconDatabase } from 'lib/lemon-ui/icons' +import { pluginsLogic } from 'scenes/plugins/pluginsLogic' +import { userLogic } from 'scenes/userLogic' + +import { BatchExportConfiguration, PluginConfigTypeNew } from '~/types' + +import { pipelineTransformationsLogic } from '../../pipeline/transformationsLogic' +import { RenderApp } from '../../pipeline/utils' +import type { exportsUnsubscribeTableLogicType } from './exportsUnsubscribeTableLogicType' + +export interface ItemToDisable { + plugin_config_id: number | undefined // exactly one of plugin_config_id or batch_export_id is set + batch_export_id: string | undefined + team_id: number + name: string + description: string | undefined + icon: JSX.Element + disabled: boolean +} + +export const exportsUnsubscribeTableLogic = kea([ + path(['scenes', 'pipeline', 'ExportsUnsubscribeTableLogic']), + connect({ + values: [pluginsLogic, ['plugins'], pipelineTransformationsLogic, ['canConfigurePlugins'], userLogic, ['user']], + }), + + actions({ + disablePlugin: (id: number) => ({ id }), + pauseBatchExport: (id: string) => ({ id }), + }), + loaders(({ values }) => ({ + pluginConfigsToDisable: [ + {} as Record, + { + loadPluginConfigs: async () => { + const res = await api.get( + `api/organizations/@current/plugins/exports_unsubscribe_configs` + ) + return Object.fromEntries(res.map((pluginConfig) => [pluginConfig.id, pluginConfig])) + }, + disablePlugin: async ({ id }) => { + if (!values.canConfigurePlugins) { + return values.pluginConfigsToDisable + } + const response = await api.update(`api/plugin_config/${id}`, { enabled: false }) + return { ...values.pluginConfigsToDisable, [id]: response } + }, + }, + ], + batchExportConfigs: [ + {} as Record, + { + loadBatchExportConfigs: async () => { + const res = await api.loadPaginatedResults(`api/organizations/@current/batch_exports`) + return Object.fromEntries( + res + .filter((batchExportConfig) => !batchExportConfig.paused) + .map((batchExportConfig) => [batchExportConfig.id, batchExportConfig]) + ) + }, + pauseBatchExport: async ({ id }) => { + await api.create(`api/organizations/@current/batch_exports/${id}/pause`) + return { ...values.batchExportConfigs, [id]: { ...values.batchExportConfigs[id], paused: true } } + }, + }, + ], + })), + selectors({ + loading: [ + (s) => [s.batchExportConfigsLoading, s.pluginConfigsToDisableLoading], + (batchExportsLoading, pluginConfigsLoading) => batchExportsLoading || pluginConfigsLoading, + ], + unsubscribeDisabledReason: [ + (s) => [s.loading, s.pluginConfigsToDisable, s.batchExportConfigs], + (loading, pluginConfigsToDisable, batchExportConfigs) => { + // TODO: check for permissions first - that the user has access to all the projects for this org + return loading + ? 'Loading...' + : Object.values(pluginConfigsToDisable).some((pluginConfig) => pluginConfig.enabled) + ? 'All apps above must be disabled first' + : Object.values(batchExportConfigs).some((batchExportConfig) => !batchExportConfig.paused) + ? 'All batch exports must be disabled first' + : null + }, + ], + itemsToDisable: [ + (s) => [s.pluginConfigsToDisable, s.batchExportConfigs, s.plugins], + (pluginConfigsToDisable, batchExportConfigs, plugins) => { + const pluginConfigs = Object.values(pluginConfigsToDisable).map((pluginConfig) => { + return { + plugin_config_id: pluginConfig.id, + team_id: pluginConfig.team_id, + name: pluginConfig.name, + description: pluginConfig.description, + icon: , + disabled: !pluginConfig.enabled, + } as ItemToDisable + }) + const batchExports = Object.values(batchExportConfigs).map((batchExportConfig) => { + return { + batch_export_id: batchExportConfig.id, + team_id: batchExportConfig.team_id, + name: batchExportConfig.name, + description: batchExportConfig.destination.type, + icon: ( + + ), + disabled: batchExportConfig.paused, + } as ItemToDisable + }) + return [...pluginConfigs, ...batchExports] + }, + ], + }), + afterMount(({ actions }) => { + actions.loadPluginConfigs() + actions.loadBatchExportConfigs() + }), +]) diff --git a/frontend/src/scenes/billing/ExportsUnsubscribeTable/index.ts b/frontend/src/scenes/billing/ExportsUnsubscribeTable/index.ts new file mode 100644 index 0000000000000..bd188e4fae179 --- /dev/null +++ b/frontend/src/scenes/billing/ExportsUnsubscribeTable/index.ts @@ -0,0 +1,2 @@ +export { ExportsUnsubscribeTable } from './ExportsUnsubscribeTable' +export { exportsUnsubscribeTableLogic } from './exportsUnsubscribeTableLogic' diff --git a/frontend/src/scenes/billing/UnsubscribeSurveyModal.tsx b/frontend/src/scenes/billing/UnsubscribeSurveyModal.tsx index e13f5143a830e..5ac16053aed56 100644 --- a/frontend/src/scenes/billing/UnsubscribeSurveyModal.tsx +++ b/frontend/src/scenes/billing/UnsubscribeSurveyModal.tsx @@ -1,17 +1,29 @@ import { LemonBanner, LemonButton, LemonModal, LemonTextArea, Link } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' -import { BillingProductV2Type } from '~/types' +import { BillingProductV2AddonType, BillingProductV2Type } from '~/types' import { billingLogic } from './billingLogic' import { billingProductLogic } from './billingProductLogic' +import { ExportsUnsubscribeTable, exportsUnsubscribeTableLogic } from './ExportsUnsubscribeTable' -export const UnsubscribeSurveyModal = ({ product }: { product: BillingProductV2Type }): JSX.Element | null => { +export const UnsubscribeSurveyModal = ({ + product, +}: { + product: BillingProductV2Type | BillingProductV2AddonType +}): JSX.Element | null => { const { surveyID, surveyResponse } = useValues(billingProductLogic({ product })) const { setSurveyResponse, reportSurveySent, reportSurveyDismissed } = useActions(billingProductLogic({ product })) const { deactivateProduct } = useActions(billingLogic) + const { unsubscribeDisabledReason, itemsToDisable } = useValues(exportsUnsubscribeTableLogic) const textAreaNotEmpty = surveyResponse['$survey_response']?.length > 0 + const includesPipelinesAddon = + product.type == 'data_pipelines' || + (product.type == 'product_analytics' && + (product as BillingProductV2Type)?.addons?.filter((addon) => addon.type === 'data_pipelines')[0] + ?.subscribed) + return ( { @@ -20,7 +32,21 @@ export const UnsubscribeSurveyModal = ({ product }: { product: BillingProductV2T width={'max(40vw)'} >
-

{`Why are you unsubscribing from ${product.name}?`}

+ {includesPipelinesAddon && itemsToDisable.length > 0 ? ( +
+
+

{`Important: Disable remaining export apps`}

+

+ To avoid unexpected impact on your data, you must explicitly disable the following apps + and exports before unsubscribing: +

+
+ +
+ ) : ( + <> + )} +

{`Why are you unsubscribing from ${product.name}?`}

{ textAreaNotEmpty ? reportSurveySent(surveyID, surveyResponse) diff --git a/frontend/src/scenes/cohorts/cohortSceneLogic.ts b/frontend/src/scenes/cohorts/cohortSceneLogic.ts index a1b604ffb484e..77a50251e9fc0 100644 --- a/frontend/src/scenes/cohorts/cohortSceneLogic.ts +++ b/frontend/src/scenes/cohorts/cohortSceneLogic.ts @@ -29,7 +29,7 @@ export const cohortSceneLogic = kea([ path: urls.cohorts(), }, { - key: cohortId || 'loading', + key: [Scene.Cohort, cohortId || 'loading'], name: cohortId && cohortId !== 'new' ? cohortsById[cohortId]?.name || 'Untitled' : 'Untitled', }, ] diff --git a/frontend/src/scenes/comments/Comment.tsx b/frontend/src/scenes/comments/Comment.tsx new file mode 100644 index 0000000000000..26a38d0fff4ce --- /dev/null +++ b/frontend/src/scenes/comments/Comment.tsx @@ -0,0 +1,145 @@ +import { TZLabel } from '@posthog/apps-common' +import { IconCheck, IconEllipsis, IconPencil, IconShare } from '@posthog/icons' +import { LemonButton, LemonMenu, LemonTextAreaMarkdown, ProfilePicture } from '@posthog/lemon-ui' +import clsx from 'clsx' +import { useActions, useValues } from 'kea' +import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' +import { useEffect, useRef } from 'react' + +import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut' +import { CommentType } from '~/types' + +import { commentsLogic, CommentWithRepliesType } from './commentsLogic' + +export type CommentProps = { + commentWithReplies: CommentWithRepliesType +} + +const Comment = ({ comment }: { comment: CommentType }): JSX.Element => { + const { editingComment, commentsLoading, replyingCommentId } = useValues(commentsLogic) + const { deleteComment, setEditingComment, persistEditedComment, setReplyingComment } = useActions(commentsLogic) + + const ref = useRef(null) + + const isHighlighted = replyingCommentId === comment.id || editingComment?.id === comment.id + + useEffect(() => { + if (isHighlighted) { + ref.current?.scrollIntoView() + } + }, [isHighlighted]) + + return ( +
+
+ + +
+
+ + {comment.created_by?.first_name ?? 'Unknown user'} + + {comment.created_at ? ( + + + + ) : null} + + , + label: 'Reply', + onClick: () => setReplyingComment(comment.source_comment ?? comment.id), + }, + { + icon: , + label: 'Edit', + onClick: () => setEditingComment(comment), + }, + { + icon: , + label: 'Delete', + onClick: () => deleteComment(comment), + // disabledReason: "Only admins can archive other peoples comments" + }, + ]} + > + } size="xsmall" /> + +
+ {comment.content} + {comment.version ? (edited) : null} +
+
+ + {editingComment?.id === comment.id ? ( +
+ setEditingComment({ ...editingComment, content: value })} + disabled={commentsLoading} + onPressCmdEnter={persistEditedComment} + /> +
+
+ setEditingComment(null)} + disabled={commentsLoading} + > + Cancel + + } + > + Save changes + +
+
+ ) : null} +
+ ) +} + +export const CommentWithReplies = ({ commentWithReplies }: CommentProps): JSX.Element => { + const { comment, replies } = commentWithReplies + + // TODO: Permissions + + return ( +
+ {comment ? ( + + ) : ( +
+ Deleted comment +
+ )} + +
+ {replies?.map((x) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/scenes/comments/CommentComposer.tsx b/frontend/src/scenes/comments/CommentComposer.tsx new file mode 100644 index 0000000000000..cd1adda528487 --- /dev/null +++ b/frontend/src/scenes/comments/CommentComposer.tsx @@ -0,0 +1,58 @@ +import { LemonButton, LemonTextAreaMarkdown } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { humanizeScope } from 'lib/components/ActivityLog/humanizeActivity' +import { useEffect } from 'react' + +import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut' + +import { commentsLogic, CommentsLogicProps } from './commentsLogic' + +export const CommentComposer = (props: CommentsLogicProps): JSX.Element => { + const { key, composedComment, commentsLoading, replyingCommentId, itemContext } = useValues(commentsLogic(props)) + const { setComposedComment, sendComposedContent, setReplyingComment, setComposerRef, clearItemContext } = + useActions(commentsLogic(props)) + + const placeholder = replyingCommentId + ? 'Reply...' + : `Comment on ${props.item_id ? 'this ' : ''}${humanizeScope(props.scope, !!props.item_id)}` + + useEffect(() => { + // Whenever the discussion context changes or we fully unmount we clear the item context + return () => clearItemContext() + }, [key]) + + return ( +
+ +
+
+ {replyingCommentId ? ( + setReplyingComment(null)}> + Cancel reply + + ) : null} + {itemContext ? ( + clearItemContext()}> + Cancel + + ) : null} + } + > + Add {replyingCommentId ? 'reply' : 'comment'} + +
+
+ ) +} diff --git a/frontend/src/scenes/comments/CommentsList.tsx b/frontend/src/scenes/comments/CommentsList.tsx new file mode 100644 index 0000000000000..63a8fb7e5186b --- /dev/null +++ b/frontend/src/scenes/comments/CommentsList.tsx @@ -0,0 +1,46 @@ +import { LemonSkeleton } from '@posthog/lemon-ui' +import { BindLogic, useActions, useValues } from 'kea' +import { PhonePairHogs } from 'lib/components/hedgehogs' +import { useEffect } from 'react' + +import { CommentWithReplies } from './Comment' +import { commentsLogic, CommentsLogicProps } from './commentsLogic' + +export const CommentsList = (props: CommentsLogicProps): JSX.Element => { + const { key, commentsWithReplies, commentsLoading } = useValues(commentsLogic(props)) + const { loadComments } = useActions(commentsLogic(props)) + + useEffect(() => { + // If the comment list focus changes we should load the comments + loadComments() + }, [key]) + + return ( + +
+ {!commentsWithReplies?.length && commentsLoading ? ( +
+ +
+ ) : !commentsWithReplies?.length ? ( +
+
+ +
+

Start the discussion!

+

+ You can add comments about this page for your team members to see. Great for sharing context + or ideas without getting in the way of the thing you are commenting on +

+
+ ) : null} + +
+ {commentsWithReplies?.map((x) => ( + + ))} +
+
+
+ ) +} diff --git a/frontend/src/scenes/comments/commentsLogic.ts b/frontend/src/scenes/comments/commentsLogic.ts new file mode 100644 index 0000000000000..518c9ec2692b6 --- /dev/null +++ b/frontend/src/scenes/comments/commentsLogic.ts @@ -0,0 +1,218 @@ +import { actions, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import { subscriptions } from 'kea-subscriptions' +import api from 'lib/api' +import { deleteWithUndo } from 'lib/utils/deleteWithUndo' + +import { CommentType } from '~/types' + +import type { commentsLogicType } from './commentsLogicType' + +export type CommentsLogicProps = { + scope: CommentType['scope'] + item_id?: CommentType['item_id'] +} + +export type CommentWithRepliesType = { + id: CommentType['id'] + comment?: CommentType // It may have been deleted + replies: CommentType[] +} + +export type CommentContext = { + context: Record | null + callback?: (event: { sent: boolean }) => void +} + +export const commentsLogic = kea([ + path(() => ['scenes', 'notebooks', 'Notebook', 'commentsLogic']), + props({} as CommentsLogicProps), + key((props) => `${props.scope}-${props.item_id || ''}`), + + // TODO: Connect to sidePanelDiscussionLogic and update the commentCount + actions({ + loadComments: true, + maybeLoadComments: true, + setComposedComment: (content: string) => ({ content }), + sendComposedContent: true, + deleteComment: (comment: CommentType) => ({ comment }), + setEditingComment: (comment: CommentType | null) => ({ comment }), + setReplyingComment: (commentId: string | null) => ({ commentId }), + setItemContext: (context: Record | null, callback?: (event: { sent: boolean }) => void) => ({ + context, + callback, + }), + clearItemContext: true, + persistEditedComment: true, + setComposerRef: (ref: HTMLTextAreaElement | null) => ({ ref }), + focusComposer: true, + }), + reducers({ + replyingCommentId: [ + null as string | null, + { + setReplyingComment: (_, { commentId }) => commentId, + sendComposedContentSuccess: () => null, + }, + ], + itemContext: [ + null as CommentContext | null, + { + setItemContext: (_, itemContext) => (itemContext.context ? itemContext : null), + sendComposedContentSuccess: () => null, + }, + ], + editingComment: [ + null as CommentType | null, + { + setEditingComment: (_, { comment }) => comment, + persistEditedCommentSuccess: () => null, + }, + ], + composedComment: [ + '', + { persist: true }, + { + setComposedComment: (_, { content }) => content, + sendComposedContentSuccess: () => '', + }, + ], + composerRef: [ + null as HTMLTextAreaElement | null, + { + setComposerRef: (_, { ref }) => ref, + }, + ], + }), + loaders(({ props, values, actions }) => ({ + comments: [ + null as CommentType[] | null, + { + loadComments: async () => { + const response = await api.comments.list({ + scope: props.scope, + item_id: props.item_id, + }) + + return response.results + }, + sendComposedContent: async () => { + const existingComments = values.comments ?? [] + + const newComment = await api.comments.create({ + content: values.composedComment, + scope: props.scope, + item_id: props.item_id, + item_context: values.itemContext?.context, + source_comment: values.replyingCommentId ?? undefined, + }) + + values.itemContext?.callback?.({ sent: true }) + return [...existingComments, newComment] + }, + + persistEditedComment: async () => { + const editedComment = values.editingComment + if (!editedComment) { + return values.comments + } + + const existingComments = values.comments ?? [] + const updatedComment = await api.comments.update(editedComment.id, { + content: editedComment.content, + }) + return [...existingComments.filter((c) => c.id !== editedComment.id), updatedComment] + }, + + deleteComment: async ({ comment }) => { + await deleteWithUndo({ + endpoint: `projects/@current/comments`, + object: { name: 'Comment', id: comment.id }, + callback: (isUndo) => { + if (isUndo) { + actions.loadCommentsSuccess([ + ...(values.comments?.filter((c) => c.id !== comment.id) ?? []), + comment, + ]) + } + }, + }) + + return values.comments?.filter((c) => c.id !== comment.id) ?? null + }, + }, + ], + })), + + listeners(({ values, actions }) => ({ + setReplyingComment: () => { + actions.clearItemContext() + }, + clearItemContext: () => { + values.itemContext?.callback?.({ sent: false }) + actions.setItemContext(null) + }, + setItemContext: ({ context }) => { + if (context) { + values.composerRef?.focus() + } + }, + focusComposer: () => { + values.composerRef?.focus() + }, + maybeLoadComments: () => { + if (!values.comments && !values.commentsLoading) { + actions.loadComments() + } + }, + })), + + selectors({ + key: [() => [(_, props) => props], (props): string => `${props.scope}-${props.item_id || ''}`], + sortedComments: [ + (s) => [s.comments], + (comments) => { + return comments?.sort((a, b) => (a.created_at > b.created_at ? 1 : -1)) ?? [] + }, + ], + + commentsWithReplies: [ + (s) => [s.sortedComments], + (sortedComments) => { + // NOTE: We build a tree of comments and replies here. + // Comments may have been deleted so if we have a reply to a comment that no longer exists, + // we still create the CommentWithRepliesType but with a null comment. + + const commentsById: Record = {} + + for (const comment of sortedComments ?? []) { + let commentsWithReplies = commentsById[comment.source_comment ?? comment.id] + + if (!commentsWithReplies) { + commentsById[comment.source_comment ?? comment.id] = commentsWithReplies = { + id: comment.source_comment ?? comment.id, + comment: undefined, + replies: [], + } + } + + if (commentsWithReplies.id === comment.id) { + commentsWithReplies.comment = comment + } else { + commentsWithReplies.replies.push(comment) + } + } + + return Object.values(commentsById) + }, + ], + }), + + subscriptions(({ actions }) => ({ + replyingCommentId: (value: string): void => { + if (value) { + actions.focusComposer() + } + }, + })), +]) diff --git a/frontend/src/scenes/dashboard/dashboardLogic.tsx b/frontend/src/scenes/dashboard/dashboardLogic.tsx index 9ccb774ac02f7..37172de1221a0 100644 --- a/frontend/src/scenes/dashboard/dashboardLogic.tsx +++ b/frontend/src/scenes/dashboard/dashboardLogic.tsx @@ -742,7 +742,7 @@ export const dashboardLogic = kea([ path: urls.dashboards(), }, { - key: dashboard?.id || 'new', + key: [Scene.Dashboard, dashboard?.id || 'new'], name: dashboard?.id ? dashboard.name || 'Unnamed' : null, onRename: async (name) => { if (dashboard) { diff --git a/frontend/src/scenes/data-management/DataManagementScene.tsx b/frontend/src/scenes/data-management/DataManagementScene.tsx index 7fec05a325f81..cc322bdd74ac3 100644 --- a/frontend/src/scenes/data-management/DataManagementScene.tsx +++ b/frontend/src/scenes/data-management/DataManagementScene.tsx @@ -1,7 +1,6 @@ import { actions, connect, kea, path, reducers, selectors, useActions, useValues } from 'kea' import { actionToUrl, urlToAction } from 'kea-router' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { PageHeader } from 'lib/components/PageHeader' import { TitleWithIcon } from 'lib/components/TitleWithIcon' import { FEATURE_FLAGS } from 'lib/constants' @@ -18,7 +17,7 @@ import { NewAnnotationButton } from 'scenes/annotations/AnnotationModal' import { Scene, SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' -import { Breadcrumb } from '~/types' +import { ActivityScope, Breadcrumb } from '~/types' import { ActionsTable } from './actions/ActionsTable' import { DatabaseTableList } from './database/DatabaseTableList' diff --git a/frontend/src/scenes/data-management/dataManagementDescribers.tsx b/frontend/src/scenes/data-management/dataManagementDescribers.tsx index ddc80d6ca4be9..b53f9911b693b 100644 --- a/frontend/src/scenes/data-management/dataManagementDescribers.tsx +++ b/frontend/src/scenes/data-management/dataManagementDescribers.tsx @@ -1,8 +1,8 @@ import { ActivityChange, ActivityLogItem, - ActivityScope, ChangeMapping, + defaultDescriber, Description, detectBoolean, HumanizedChange, @@ -15,6 +15,8 @@ import { Link } from 'lib/lemon-ui/Link' import { pluralize } from 'lib/utils' import { urls } from 'scenes/urls' +import { ActivityScope } from '~/types' + const dataManagementActionsMapping: Record< string, (change?: ActivityChange, logItem?: ActivityLogItem) => ChangeMapping | null @@ -90,7 +92,7 @@ function DescribeType({ logItem }: { logItem: ActivityLogItem }): JSX.Element { return <>{typeDescription} definition } -export function dataManagementActivityDescriber(logItem: ActivityLogItem): HumanizedChange { +export function dataManagementActivityDescriber(logItem: ActivityLogItem, asNotification?: boolean): HumanizedChange { if (logItem.scope !== ActivityScope.EVENT_DEFINITION && logItem.scope !== ActivityScope.PROPERTY_DEFINITION) { console.error('data management describer received a non-data-management activity') return { description: null } @@ -149,5 +151,5 @@ export function dataManagementActivityDescriber(logItem: ActivityLogItem): Human } } - return { description: null } + return defaultDescriber(logItem, asNotification, nameAndLink(logItem)) } diff --git a/frontend/src/scenes/data-management/definition/definitionLogic.ts b/frontend/src/scenes/data-management/definition/definitionLogic.ts index 0c9a97067499c..85486f4b44f90 100644 --- a/frontend/src/scenes/data-management/definition/definitionLogic.ts +++ b/frontend/src/scenes/data-management/definition/definitionLogic.ts @@ -133,7 +133,7 @@ export const definitionLogic = kea([ path: isEvent ? urls.eventDefinitions() : urls.propertyDefinitions(), }, { - key: definition?.id || 'new', + key: [isEvent ? Scene.EventDefinition : Scene.PropertyDefinition, definition?.id || 'new'], name: definition?.id !== 'new' ? getPropertyLabel(definition?.name) || 'Untitled' : 'Untitled', }, ] diff --git a/frontend/src/scenes/data-warehouse/external/DataWarehouseExternalScene.tsx b/frontend/src/scenes/data-warehouse/external/DataWarehouseExternalScene.tsx index 695f45b796f23..65328815f4103 100644 --- a/frontend/src/scenes/data-warehouse/external/DataWarehouseExternalScene.tsx +++ b/frontend/src/scenes/data-warehouse/external/DataWarehouseExternalScene.tsx @@ -3,9 +3,7 @@ import { useActions, useValues } from 'kea' import { router } from 'kea-router' import { PageHeader } from 'lib/components/PageHeader' import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' -import { FEATURE_FLAGS } from 'lib/constants' import { IconSettings } from 'lib/lemon-ui/icons' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -25,7 +23,6 @@ export function DataWarehouseExternalScene(): JSX.Element { const { shouldShowEmptyState, shouldShowProductIntroduction, isSourceModalOpen } = useValues(dataWarehouseSceneLogic) const { toggleSourceModal } = useActions(dataWarehouseSceneLogic) - const { featureFlags } = useValues(featureFlagLogic) return (
@@ -39,25 +36,19 @@ export function DataWarehouseExternalScene(): JSX.Element {
} buttons={ - featureFlags[FEATURE_FLAGS.DATA_WAREHOUSE_EXTERNAL_LINK] ? ( - , - onClick: () => router.actions.push(urls.dataWarehouseSettings()), - 'data-attr': 'saved-insights-new-insight-dropdown', - }} - data-attr="new-data-warehouse-easy-link" - key={'new-data-warehouse-easy-link'} - onClick={() => toggleSourceModal()} - > - Link Source - - ) : !(shouldShowProductIntroduction || shouldShowEmptyState) ? ( - - New table - - ) : undefined + , + onClick: () => router.actions.push(urls.dataWarehouseSettings()), + 'data-attr': 'saved-insights-new-insight-dropdown', + }} + data-attr="new-data-warehouse-easy-link" + key={'new-data-warehouse-easy-link'} + onClick={() => toggleSourceModal()} + > + Link Source + } caption={
@@ -78,11 +69,7 @@ export function DataWarehouseExternalScene(): JSX.Element { description={ 'Bring your production database, revenue data, CRM contacts or any other data into PostHog.' } - action={() => - featureFlags[FEATURE_FLAGS.DATA_WAREHOUSE_EXTERNAL_LINK] - ? toggleSourceModal() - : router.actions.push(urls.dataWarehouseTable()) - } + action={toggleSourceModal} isEmpty={shouldShowEmptyState} docsURL="https://posthog.com/docs/data/data-warehouse" productKey={ProductKey.DATA_WAREHOUSE} diff --git a/frontend/src/scenes/data-warehouse/external/SourceModal.tsx b/frontend/src/scenes/data-warehouse/external/SourceModal.tsx index e09050ca89d18..19d4023daaa0d 100644 --- a/frontend/src/scenes/data-warehouse/external/SourceModal.tsx +++ b/frontend/src/scenes/data-warehouse/external/SourceModal.tsx @@ -1,37 +1,49 @@ -import { LemonButton, LemonDivider, LemonInput, LemonModal, LemonModalProps } from '@posthog/lemon-ui' +import { LemonButton, LemonDivider, LemonInput, LemonModal, LemonModalProps, Link } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { Form } from 'kea-forms' +import { FEATURE_FLAGS } from 'lib/constants' import { Field } from 'lib/forms/Field' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import hubspotLogo from 'public/hubspot-logo.svg' import stripeLogo from 'public/stripe-logo.svg' +import { ExternalDataSourceType } from '~/types' + import { DatawarehouseTableForm } from '../new_table/DataWarehouseTableForm' +import { SOURCE_DETAILS, sourceFormLogic } from './sourceFormLogic' import { ConnectorConfigType, sourceModalLogic } from './sourceModalLogic' interface SourceModalProps extends LemonModalProps {} export default function SourceModal(props: SourceModalProps): JSX.Element { - const { tableLoading, isExternalDataSourceSubmitting, selectedConnector, isManualLinkFormVisible, connectors } = + const { tableLoading, selectedConnector, isManualLinkFormVisible, connectors, addToHubspotButtonUrl } = useValues(sourceModalLogic) - const { selectConnector, toggleManualLinkFormVisible, resetExternalDataSource, resetTable } = - useActions(sourceModalLogic) + const { selectConnector, toggleManualLinkFormVisible, onClear } = useActions(sourceModalLogic) + const { featureFlags } = useValues(featureFlagLogic) const MenuButton = (config: ConnectorConfigType): JSX.Element => { const onClick = (): void => { selectConnector(config) } - return ( - - {`stripe - - ) - } + if (config.name === 'Stripe') { + return ( + + {`stripe + + ) + } + if (config.name === 'Hubspot' && featureFlags[FEATURE_FLAGS.DATA_WAREHOUSE_HUBSPOT_IMPORT]) { + return ( + + + {`hubspot + + + ) + } - const onClear = (): void => { - selectConnector(null) - toggleManualLinkFormVisible(false) - resetExternalDataSource() - resetTable() + return <> } const onManualLinkClick = (): void => { @@ -40,39 +52,7 @@ export default function SourceModal(props: SourceModalProps): JSX.Element { const formToShow = (): JSX.Element => { if (selectedConnector) { - return ( -
- - - - - - - - - - -
- - Back - - - Link - -
- - ) + return } if (isManualLinkFormVisible) { @@ -131,3 +111,47 @@ export default function SourceModal(props: SourceModalProps): JSX.Element { ) } + +interface SourceFormProps { + sourceType: ExternalDataSourceType +} + +function SourceForm({ sourceType }: SourceFormProps): JSX.Element { + const logic = sourceFormLogic({ sourceType }) + const { isExternalDataSourceSubmitting } = useValues(logic) + const { onBack } = useActions(logic) + + return ( +
+ + + + {SOURCE_DETAILS[sourceType].fields.map((field) => ( + + + + ))} + +
+ + Back + + + Link + +
+ + ) +} diff --git a/frontend/src/scenes/data-warehouse/external/sourceFormLogic.ts b/frontend/src/scenes/data-warehouse/external/sourceFormLogic.ts new file mode 100644 index 0000000000000..67877f9a32205 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/external/sourceFormLogic.ts @@ -0,0 +1,135 @@ +import { lemonToast } from '@posthog/lemon-ui' +import { actions, connect, kea, listeners, path, props } from 'kea' +import { forms } from 'kea-forms' +import { router, urlToAction } from 'kea-router' +import api from 'lib/api' +import { urls } from 'scenes/urls' + +import { ExternalDataSourceCreatePayload, ExternalDataSourceType } from '~/types' + +import type { sourceFormLogicType } from './sourceFormLogicType' +import { getHubspotRedirectUri, sourceModalLogic } from './sourceModalLogic' + +export interface SourceFormProps { + sourceType: ExternalDataSourceType +} + +interface SourceConfig { + name: string + caption: string + fields: FieldConfig[] +} +interface FieldConfig { + name: string + label: string + type: string + required: boolean +} + +export const SOURCE_DETAILS: Record = { + Stripe: { + name: 'Stripe', + caption: 'Enter your Stripe credentials to link your Stripe to PostHog', + fields: [ + { + name: 'account_id', + label: 'Account ID', + type: 'text', + required: true, + }, + { + name: 'client_secret', + label: 'Client Secret', + type: 'text', + required: true, + }, + ], + }, +} + +const getPayloadDefaults = (sourceType: string): Record => { + switch (sourceType) { + case 'Stripe': + return { + account_id: '', + client_secret: '', + } + default: + return {} + } +} + +const getErrorsDefaults = (sourceType: string): ((args: Record) => Record) => { + switch (sourceType) { + case 'Stripe': + return ({ payload }) => ({ + payload: { + account_id: !payload.account_id && 'Please enter an account id.', + client_secret: !payload.client_secret && 'Please enter a client secret.', + }, + }) + default: + return () => ({}) + } +} + +export const sourceFormLogic = kea([ + path(['scenes', 'data-warehouse', 'external', 'sourceFormLogic']), + props({} as SourceFormProps), + connect({ + actions: [sourceModalLogic, ['onClear', 'toggleSourceModal', 'loadSources']], + }), + actions({ + onBack: true, + handleRedirect: (kind: string, searchParams: any) => ({ kind, searchParams }), + }), + listeners(({ actions }) => ({ + onBack: () => { + actions.resetExternalDataSource() + actions.onClear() + }, + submitExternalDataSourceSuccess: () => { + lemonToast.success('New Data Resource Created') + actions.toggleSourceModal(false) + actions.resetExternalDataSource() + actions.loadSources() + router.actions.push(urls.dataWarehouseSettings()) + }, + submitExternalDataSourceFailure: ({ error }) => { + lemonToast.error(error?.message || 'Something went wrong') + }, + handleRedirect: async ({ kind, searchParams }) => { + switch (kind) { + case 'hubspot': { + actions.setExternalDataSourceValue('payload', { + code: searchParams.code, + redirect_uri: getHubspotRedirectUri(), + }) + actions.setExternalDataSourceValue('source_type', 'Hubspot') + return + } + default: + lemonToast.error(`Something went wrong.`) + } + }, + })), + urlToAction(({ actions }) => ({ + '/data-warehouse/:kind/redirect': ({ kind = '' }, searchParams) => { + actions.handleRedirect(kind, searchParams) + }, + })), + forms(({ props }) => ({ + externalDataSource: { + defaults: { + prefix: '', + source_type: props.sourceType, + payload: getPayloadDefaults(props.sourceType), + } as ExternalDataSourceCreatePayload, + errors: getErrorsDefaults(props.sourceType), + submit: async (payload: ExternalDataSourceCreatePayload) => { + const newResource = await api.externalDataSources.create(payload) + return newResource + }, + }, + })), +]) diff --git a/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts b/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts index 13e6976b2f988..32ae739a5c4d3 100644 --- a/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts +++ b/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts @@ -1,19 +1,16 @@ -import { lemonToast } from '@posthog/lemon-ui' import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea' -import { forms } from 'kea-forms' -import { router } from 'kea-router' -import api from 'lib/api' -import { urls } from 'scenes/urls' +import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' -import { ExternalDataStripeSourceCreatePayload } from '~/types' +import { ExternalDataSourceType } from '~/types' import { dataWarehouseTableLogic } from '../new_table/dataWarehouseTableLogic' import { dataWarehouseSettingsLogic } from '../settings/dataWarehouseSettingsLogic' import { dataWarehouseSceneLogic } from './dataWarehouseSceneLogic' import type { sourceModalLogicType } from './sourceModalLogicType' +export const getHubspotRedirectUri = (): string => `${window.location.origin}/data-warehouse/hubspot/redirect` export interface ConnectorConfigType { - name: string + name: ExternalDataSourceType fields: string[] caption: string disabledReason: string | null @@ -23,10 +20,16 @@ export interface ConnectorConfigType { export const CONNECTORS: ConnectorConfigType[] = [ { name: 'Stripe', - fields: ['accound_id', 'client_secret'], + fields: ['account_id', 'client_secret'], caption: 'Enter your Stripe credentials to link your Stripe to PostHog', disabledReason: null, }, + { + name: 'Hubspot', + fields: [], + caption: '', + disabledReason: null, + }, ] export const sourceModalLogic = kea([ @@ -34,9 +37,18 @@ export const sourceModalLogic = kea([ actions({ selectConnector: (connector: ConnectorConfigType | null) => ({ connector }), toggleManualLinkFormVisible: (visible: boolean) => ({ visible }), + handleRedirect: (kind: string, searchParams: any) => ({ kind, searchParams }), + onClear: true, }), connect({ - values: [dataWarehouseTableLogic, ['tableLoading'], dataWarehouseSettingsLogic, ['dataWarehouseSources']], + values: [ + dataWarehouseTableLogic, + ['tableLoading'], + dataWarehouseSettingsLogic, + ['dataWarehouseSources'], + preflightLogic, + ['preflight'], + ], actions: [ dataWarehouseSceneLogic, ['toggleSourceModal'], @@ -77,37 +89,39 @@ export const sourceModalLogic = kea([ })) }, ], - }), - forms(() => ({ - externalDataSource: { - defaults: { - account_id: '', - client_secret: '', - prefix: '', - source_type: 'Stripe', - } as ExternalDataStripeSourceCreatePayload, - errors: ({ account_id, client_secret }) => { - return { - account_id: !account_id && 'Please enter an account id.', - client_secret: !client_secret && 'Please enter a client secret.', + addToHubspotButtonUrl: [ + (s) => [s.preflight], + (preflight) => { + return () => { + const clientId = preflight?.data_warehouse_integrations?.hubspot.client_id + + if (!clientId) { + return null + } + + const scopes = [ + 'crm.objects.contacts.read', + 'crm.objects.companies.read', + 'crm.objects.deals.read', + 'tickets', + 'crm.objects.quotes.read', + ] + + const params = new URLSearchParams() + params.set('client_id', clientId) + params.set('redirect_uri', getHubspotRedirectUri()) + params.set('scope', scopes.join(' ')) + + return `https://app.hubspot.com/oauth/authorize?${params.toString()}` } }, - submit: async (payload: ExternalDataStripeSourceCreatePayload) => { - const newResource = await api.externalDataSources.create(payload) - return newResource - }, - }, - })), + ], + }), listeners(({ actions }) => ({ - submitExternalDataSourceSuccess: () => { - lemonToast.success('New Data Resource Created') - actions.toggleSourceModal() - actions.resetExternalDataSource() - actions.loadSources() - router.actions.push(urls.dataWarehouseSettings()) - }, - submitExternalDataSourceFailure: ({ error }) => { - lemonToast.error(error?.message || 'Something went wrong') + onClear: () => { + actions.selectConnector(null) + actions.toggleManualLinkFormVisible(false) + actions.resetTable() }, })), ]) diff --git a/frontend/src/scenes/data-warehouse/new_table/dataWarehouseTableLogic.tsx b/frontend/src/scenes/data-warehouse/new_table/dataWarehouseTableLogic.tsx index 88ab7dfa92962..e37702ca55b30 100644 --- a/frontend/src/scenes/data-warehouse/new_table/dataWarehouseTableLogic.tsx +++ b/frontend/src/scenes/data-warehouse/new_table/dataWarehouseTableLogic.tsx @@ -108,7 +108,7 @@ export const dataWarehouseTableLogic = kea([ path: urls.dataWarehouseExternal(), }, { - key: 'new', + key: [Scene.DataWarehouseTable, 'new'], name: 'New', }, ], diff --git a/frontend/src/scenes/data-warehouse/redirect/DataWarehouseRedirectScene.tsx b/frontend/src/scenes/data-warehouse/redirect/DataWarehouseRedirectScene.tsx new file mode 100644 index 0000000000000..736132b3b747f --- /dev/null +++ b/frontend/src/scenes/data-warehouse/redirect/DataWarehouseRedirectScene.tsx @@ -0,0 +1,34 @@ +import { LemonButton, LemonInput } from '@posthog/lemon-ui' +import { Form } from 'kea-forms' +import { Field } from 'lib/forms/Field' +import { sourceFormLogic } from 'scenes/data-warehouse/external/sourceFormLogic' +import { SceneExport } from 'scenes/sceneTypes' + +export const scene: SceneExport = { + component: DataWarehouseRedirectScene, + logic: sourceFormLogic, +} + +export function DataWarehouseRedirectScene(): JSX.Element { + return ( +
+

Configure

+

Add a prefix to your tables to avoid conflicts with other data sources

+
+ + + + + Submit + +
+
+ ) +} + +export default DataWarehouseRedirectScene diff --git a/frontend/src/scenes/data-warehouse/settings/DataWarehouseSettingsScene.tsx b/frontend/src/scenes/data-warehouse/settings/DataWarehouseSettingsScene.tsx index c0b13232a6cdd..791d6bb2f0ed4 100644 --- a/frontend/src/scenes/data-warehouse/settings/DataWarehouseSettingsScene.tsx +++ b/frontend/src/scenes/data-warehouse/settings/DataWarehouseSettingsScene.tsx @@ -2,9 +2,7 @@ import { TZLabel } from '@posthog/apps-common' import { LemonButton, LemonDialog, LemonSwitch, LemonTable, LemonTag, Link, Spinner } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { PageHeader } from 'lib/components/PageHeader' -import { FEATURE_FLAGS } from 'lib/constants' import { More } from 'lib/lemon-ui/LemonButton/More' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -33,7 +31,6 @@ export function DataWarehouseSettingsScene(): JSX.Element { const { deleteSource, reloadSource } = useActions(dataWarehouseSettingsLogic) const { toggleSourceModal } = useActions(dataWarehouseSceneLogic) const { isSourceModalOpen } = useValues(dataWarehouseSceneLogic) - const { featureFlags } = useValues(featureFlagLogic) const renderExpandable = (source: ExternalDataStripeSource): JSX.Element => { return ( @@ -59,16 +56,14 @@ export function DataWarehouseSettingsScene(): JSX.Element {
} buttons={ - featureFlags[FEATURE_FLAGS.DATA_WAREHOUSE_EXTERNAL_LINK] ? ( - toggleSourceModal()} - > - Link Source - - ) : undefined + toggleSourceModal()} + > + Link Source + } caption={
@@ -106,7 +101,7 @@ export function DataWarehouseSettingsScene(): JSX.Element { }, { title: 'Sync Frequency', - key: 'prefix', + key: 'frequency', render: function RenderFrequency() { return 'Every 24 hours' }, diff --git a/frontend/src/scenes/early-access-features/earlyAccessFeatureLogic.ts b/frontend/src/scenes/early-access-features/earlyAccessFeatureLogic.ts index cfc21f71a21fe..3604d86bcb4f9 100644 --- a/frontend/src/scenes/early-access-features/earlyAccessFeatureLogic.ts +++ b/frontend/src/scenes/early-access-features/earlyAccessFeatureLogic.ts @@ -129,7 +129,7 @@ export const earlyAccessFeatureLogic = kea([ path: urls.earlyAccessFeatures(), }, { - key: earlyAccessFeature.id || 'new', + key: [Scene.EarlyAccessFeature, earlyAccessFeature.id || 'new'], name: earlyAccessFeature.name, }, ], diff --git a/frontend/src/scenes/experiments/ExperimentPreview.tsx b/frontend/src/scenes/experiments/ExperimentPreview.tsx index 66aa3298e1cc3..60f11d15ae769 100644 --- a/frontend/src/scenes/experiments/ExperimentPreview.tsx +++ b/frontend/src/scenes/experiments/ExperimentPreview.tsx @@ -1,5 +1,5 @@ -import { LemonButton, LemonDivider, LemonModal, Tooltip } from '@posthog/lemon-ui' -import { InputNumber, Slider } from 'antd' +import { LemonButton, LemonDivider, LemonInput, LemonModal, Tooltip } from '@posthog/lemon-ui' +import { Slider } from 'antd' import { useActions, useValues } from 'kea' import { Field, Form } from 'kea-forms' import { InsightLabel } from 'lib/components/InsightLabel' @@ -136,12 +136,12 @@ export function ExperimentPreview({ tipFormatter={(value) => `${value}%`} />
- `${value}%`} - style={{ margin: '0 16px' }} + suffix={%} value={minimumDetectableChange} onChange={(value) => { setExperiment({ diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index f5ed0668764d0..97ea3b7257abd 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -650,7 +650,7 @@ export const experimentLogic = kea([ path: urls.experiments(), }, { - key: experimentId, + key: [Scene.Experiment, experimentId], name: experiment?.name || 'New', path: urls.experiment(experimentId || 'new'), }, diff --git a/frontend/src/scenes/feature-flags/FeatureFlag.tsx b/frontend/src/scenes/feature-flags/FeatureFlag.tsx index 5fbc1adeb567c..1ec7f9cf540f2 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlag.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlag.tsx @@ -6,7 +6,6 @@ import { useActions, useValues } from 'kea' import { Form, Group } from 'kea-forms' import { router } from 'kea-router' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' import { NotFound } from 'lib/components/NotFound' import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags' @@ -49,6 +48,7 @@ import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' import { Query } from '~/queries/Query/Query' import { NodeKind } from '~/queries/schema' import { + ActivityScope, AnyPropertyFilter, AvailableFeature, DashboardPlacement, diff --git a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx index 16abb06bb344d..4723c4345db8e 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx @@ -1,7 +1,7 @@ import './FeatureFlag.scss' -import { LemonSelect, Link } from '@posthog/lemon-ui' -import { InputNumber, Select } from 'antd' +import { LemonInput, LemonSelect, Link } from '@posthog/lemon-ui' +import { Select } from 'antd' import { useActions, useValues } from 'kea' import { router } from 'kea-router' import { allOperatorsToHumanName } from 'lib/components/DefinitionPopover/utils' @@ -242,15 +242,17 @@ export function FeatureFlagReleaseConditions({
Roll out to{' '} - { - updateConditionSet(index, value) + updateConditionSet(index, value || 100) }} value={group.rollout_percentage != null ? group.rollout_percentage : 100} min={0} max={100} - addonAfter="%" + suffix={%} />{' '} of {aggregationTargetName} in this set.{' '}
diff --git a/frontend/src/scenes/feature-flags/FeatureFlags.tsx b/frontend/src/scenes/feature-flags/FeatureFlags.tsx index afc8b34775d3f..58b263817f544 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlags.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlags.tsx @@ -2,7 +2,6 @@ import { LemonInput, LemonSelect, LemonTag } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { router } from 'kea-router' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { FeatureFlagHog } from 'lib/components/hedgehogs' import { MemberSelect } from 'lib/components/MemberSelect' import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags' @@ -30,7 +29,14 @@ import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' import { groupsModel, Noun } from '~/models/groupsModel' -import { AnyPropertyFilter, AvailableFeature, FeatureFlagFilters, FeatureFlagType, ProductKey } from '~/types' +import { + ActivityScope, + AnyPropertyFilter, + AvailableFeature, + FeatureFlagFilters, + FeatureFlagType, + ProductKey, +} from '~/types' import { teamLogic } from '../teamLogic' import { featureFlagsLogic, FeatureFlagsTab } from './featureFlagsLogic' diff --git a/frontend/src/scenes/feature-flags/activityDescriptions.tsx b/frontend/src/scenes/feature-flags/activityDescriptions.tsx index 0c3361d5d154c..c1604013f8f8b 100644 --- a/frontend/src/scenes/feature-flags/activityDescriptions.tsx +++ b/frontend/src/scenes/feature-flags/activityDescriptions.tsx @@ -2,6 +2,7 @@ import { ActivityChange, ActivityLogItem, ChangeMapping, + defaultDescriber, Description, detectBoolean, HumanizedChange, @@ -261,21 +262,6 @@ export function flagActivityDescriber(logItem: ActivityLogItem, asNotification?: return { description: null } } - if (logItem.activity == 'created') { - return { - description: <> created {nameOrLinkToFlag(logItem?.item_id, logItem?.detail.name)}, - } - } - if (logItem.activity == 'deleted') { - return { - description: ( - <> - deleted {asNotification && ' the flag '} - {logItem.detail.name} - - ), - } - } if (logItem.activity == 'updated') { let changes: Description[] = [] let changeSuffix: Description = ( @@ -319,5 +305,5 @@ export function flagActivityDescriber(logItem: ActivityLogItem, asNotification?: } } - return { description: null } + return defaultDescriber(logItem, asNotification, nameOrLinkToFlag(logItem?.item_id, logItem?.detail.name)) } diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts index c18d323f1fc10..6b39cd119dae8 100644 --- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts +++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts @@ -944,7 +944,7 @@ export const featureFlagLogic = kea([ name: 'Feature Flags', path: urls.featureFlags(), }, - { key: featureFlag.id || 'unknown', name: featureFlag.key || 'Unnamed' }, + { key: [Scene.FeatureFlag, featureFlag.id || 'unknown'], name: featureFlag.key || 'Unnamed' }, ], ], propertySelectErrors: [ diff --git a/frontend/src/scenes/groups/groupLogic.ts b/frontend/src/scenes/groups/groupLogic.ts index 5fa0ab912d69f..a5d97ef290cc1 100644 --- a/frontend/src/scenes/groups/groupLogic.ts +++ b/frontend/src/scenes/groups/groupLogic.ts @@ -117,7 +117,7 @@ export const groupLogic = kea([ path: urls.groups(String(groupTypeIndex)), }, { - key: `${groupTypeIndex}-${groupKey}`, + key: [Scene.Group, `${groupTypeIndex}-${groupKey}`], name: groupDisplayId(groupKey, groupData?.group_properties || {}), path: urls.group(String(groupTypeIndex), groupKey), }, diff --git a/frontend/src/scenes/insights/EditorFilters/PathsAdvanced.tsx b/frontend/src/scenes/insights/EditorFilters/PathsAdvanced.tsx index 624045ea8c957..62bdb68617160 100644 --- a/frontend/src/scenes/insights/EditorFilters/PathsAdvanced.tsx +++ b/frontend/src/scenes/insights/EditorFilters/PathsAdvanced.tsx @@ -1,5 +1,4 @@ -import { LemonDivider } from '@posthog/lemon-ui' -import { InputNumber } from 'antd' +import { LemonDivider, LemonInput } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' import { IconSettings } from 'lib/lemon-ui/icons' @@ -42,7 +41,8 @@ export function PathsAdvanced({ insightProps, ...rest }: EditorFilterProps): JSX Maximum number of paths -
Between - @@ -76,7 +77,8 @@ export function PathsAdvanced({ insightProps, ...rest }: EditorFilterProps): JSX onPressEnter={updateEdgeParameters} /> and - setLocalEdgeParameters((state) => ({ ...state, diff --git a/frontend/src/scenes/insights/insightSceneLogic.tsx b/frontend/src/scenes/insights/insightSceneLogic.tsx index 456203c08e521..d39b5575de0f9 100644 --- a/frontend/src/scenes/insights/insightSceneLogic.tsx +++ b/frontend/src/scenes/insights/insightSceneLogic.tsx @@ -12,9 +12,10 @@ import { teamLogic } from 'scenes/teamLogic' import { mathsLogic } from 'scenes/trends/mathsLogic' import { urls } from 'scenes/urls' +import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' import { cohortsModel } from '~/models/cohortsModel' import { groupsModel } from '~/models/groupsModel' -import { Breadcrumb, FilterType, InsightShortId, InsightType, ItemMode } from '~/types' +import { ActivityScope, Breadcrumb, FilterType, InsightShortId, InsightType, ItemMode } from '~/types' import { insightDataLogic } from './insightDataLogic' import { insightDataLogicType } from './insightDataLogicType' @@ -108,7 +109,7 @@ export const insightSceneLogic = kea([ path: urls.savedInsights(), }, { - key: insight?.short_id || 'new', + key: [Scene.Insight, insight?.short_id || 'new'], name: insight?.name || summarizeInsight(insight?.query, filters, { @@ -122,6 +123,17 @@ export const insightSceneLogic = kea([ }, ], ], + activityFilters: [ + (s) => [s.insight], + (insight): ActivityFilters | null => { + return insight + ? { + scope: ActivityScope.INSIGHT, + item_id: `${insight.id}`, + } + : null + }, + ], })), sharedListeners(({ actions, values }) => ({ reloadInsightLogic: () => { diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelBinsPicker.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelBinsPicker.tsx index b10dd04e1e45a..cfa19f26624dd 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelBinsPicker.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelBinsPicker.tsx @@ -1,4 +1,5 @@ -import { InputNumber, Select } from 'antd' +import { LemonInput } from '@posthog/lemon-ui' +import { Select } from 'antd' import clsx from 'clsx' import { useActions, useValues } from 'kea' import { BIN_COUNT_AUTO } from 'lib/constants' @@ -63,9 +64,9 @@ export function FunnelBinsPicker({ disabled }: FunnelBinsPickerProps): JSX.Eleme <> {menu}
- (el as HTMLSpanElement).dataset.id, + renderHTML: (attrs) => ({ 'data-id': attrs.id }), + }, + } + }, + + parseHTML() { + return [ + { + tag: 'span[data-id]', + getAttrs: (el) => !!(el as HTMLSpanElement).dataset.id?.trim() && null, + }, + ] + }, + + onSelectionUpdate() { + if (this.editor.isActive('comment')) { + const notebookLogic = this.editor.extensionStorage._notebookLogic as BuiltLogic + notebookLogic.actions.selectComment(this.editor.getAttributes('comment').id) + } + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes(HTMLAttributes, { + class: clsx('NotebookComment'), + }), + 0, + ] + }, +}) diff --git a/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx b/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx index 652c7d3c73425..6c3cfc539b9d4 100644 --- a/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx @@ -64,7 +64,7 @@ function NodeWrapper(props: NodeWrapperP const mountedNotebookLogic = useMountedLogic(notebookLogic) const { isEditable, editingNodeId, containerSize } = useValues(mountedNotebookLogic) - const { unregisterNodeLogic } = useActions(notebookLogic) + const { unregisterNodeLogic, insertComment, selectComment } = useActions(notebookLogic) const [slashCommandsPopoverVisible, setSlashCommandsPopoverVisible] = useState(false) const logicProps: NotebookNodeLogicProps = { @@ -74,7 +74,7 @@ function NodeWrapper(props: NodeWrapperP // nodeId can start null, but should then immediately be generated const nodeLogic = useMountedLogic(notebookNodeLogic(logicProps)) - const { resizeable, expanded, actions, nodeId } = useValues(nodeLogic) + const { resizeable, expanded, actions, nodeId, sourceComment } = useValues(nodeLogic) const { setRef, setExpanded, @@ -174,6 +174,11 @@ function NodeWrapper(props: NodeWrapperP : null, isEditable ? { label: 'Edit title', onClick: () => toggleEditingTitle(true) } : null, + isEditable + ? sourceComment + ? { label: 'Show comment', onClick: () => selectComment(nodeId) } + : { label: 'Comment', onClick: () => insertComment({ type: 'node', id: nodeId }) } + : null, isEditable ? { label: 'Remove', onClick: () => deleteNode(), sideIcon: , status: 'danger' } : null, ] diff --git a/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts b/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts index 4f2de7954b1f4..b67cd60e025d8 100644 --- a/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts +++ b/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts @@ -71,7 +71,7 @@ export const notebookNodeLogic = kea([ connect((props: NotebookNodeLogicProps) => ({ actions: [props.notebookLogic, ['onUpdateEditor', 'setTextSelection']], - values: [props.notebookLogic, ['editor', 'isEditable']], + values: [props.notebookLogic, ['editor', 'isEditable', 'comments']], })), reducers(({ props }) => ({ @@ -161,6 +161,15 @@ export const notebookNodeLogic = kea([ } }, ], + + sourceComment: [ + (s) => [s.comments, s.nodeId], + (comments, nodeId) => + comments && + comments.find( + (comment) => comment.item_context?.type === 'node' && comment.item_context?.id === nodeId + ), + ], }), listeners(({ actions, values, props }) => ({ diff --git a/frontend/src/scenes/notebooks/Notebook/Editor.tsx b/frontend/src/scenes/notebooks/Notebook/Editor.tsx index af543133528a8..d0042df38956a 100644 --- a/frontend/src/scenes/notebooks/Notebook/Editor.tsx +++ b/frontend/src/scenes/notebooks/Notebook/Editor.tsx @@ -7,13 +7,14 @@ import TaskItem from '@tiptap/extension-task-item' import TaskList from '@tiptap/extension-task-list' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' -import { useActions, useValues } from 'kea' +import { useActions, useMountedLogic, useValues } from 'kea' import { sampleOne } from 'lib/utils' import posthog from 'posthog-js' import { useCallback, useMemo, useRef } from 'react' import { NotebookNodeType } from '~/types' +import { NotebookMarkComment } from '../Marks/NotebookMarkComment' import { NotebookMarkLink } from '../Marks/NotebookMarkLink' import { NotebookNodeBacklink } from '../Nodes/NotebookNodeBacklink' import { NotebookNodeCohort } from '../Nodes/NotebookNodeCohort' @@ -51,6 +52,8 @@ const PLACEHOLDER_TITLES = ['Release notes', 'Product roadmap', 'Meeting notes', export function Editor(): JSX.Element { const editorRef = useRef() + const mountedNotebookLogic = useMountedLogic(notebookLogic) + const { shortId, mode } = useValues(notebookLogic) const { setEditor, onEditorUpdate, onEditorSelectionUpdate } = useActions(notebookLogic) @@ -99,6 +102,7 @@ export function Editor(): JSX.Element { nested: true, }), NotebookMarkLink, + NotebookMarkComment, NotebookNodeBacklink, NotebookNodeQuery, NotebookNodeRecording, @@ -229,6 +233,9 @@ export function Editor(): JSX.Element { onCreate: ({ editor }) => { editorRef.current = editor + // NOTE: This could be the wrong way of passing state to extensions but this is what we are using for now! + editor.extensionStorage._notebookLogic = mountedNotebookLogic + setEditor({ getJSON: () => editor.getJSON(), getText: () => textContent(editor.state.doc), @@ -243,6 +250,9 @@ export function Editor(): JSX.Element { focus: (position?: EditorFocusPosition) => queueMicrotask(() => editor.commands.focus(position)), chain: () => editor.chain().focus(), destroy: () => editor.destroy(), + getMarks: (type: string) => getMarks(editor, type), + findCommentPosition: (markId: string) => findCommentPosition(editor, markId), + removeComment: (pos: number) => removeCommentMark(editor, pos), deleteRange: (range: EditorRange) => editor.chain().focus().deleteRange(range), insertContent: (content: JSONContent) => editor.chain().insertContent(content).focus().run(), insertContentAfterNode: (position: number, content: JSONContent) => { @@ -381,3 +391,37 @@ export function hasMatchingNode( ) ) } + +export function findCommentPosition(editor: TTEditor, markId: string): number | null { + let result = null + const doc = editor.state.doc + doc.descendants((node, pos) => { + const mark = node.marks.find((mark) => mark.type.name === 'comment' && mark.attrs.id === markId) + if (mark) { + result = pos + return + } + }) + return result +} + +export function getMarks(editor: TTEditor, type: string): { id: string; pos: number }[] { + const results: { id: string; pos: number }[] = [] + const doc = editor.state.doc + + doc.descendants((node, pos) => { + const marks = node.marks.filter((mark) => mark.type.name === type) + marks.forEach((mark) => results.push({ id: mark.attrs.id, pos })) + }) + + return results +} + +export function removeCommentMark(editor: TTEditor, pos: number): void { + editor + .chain() + .setNodeSelection(pos) + .unsetMark('comment', { extendEmptyMarkRange: true }) + .setNodeSelection(0) // need to reset the selection so that the editor does not complain after mark is removed + .run() +} diff --git a/frontend/src/scenes/notebooks/Notebook/InlineMenu.tsx b/frontend/src/scenes/notebooks/Notebook/InlineMenu.tsx index 9186b6ca5cfe4..36efbc8d1b8df 100644 --- a/frontend/src/scenes/notebooks/Notebook/InlineMenu.tsx +++ b/frontend/src/scenes/notebooks/Notebook/InlineMenu.tsx @@ -1,15 +1,21 @@ import { LemonButton, LemonDivider, LemonInput } from '@posthog/lemon-ui' import { Editor, isTextSelection } from '@tiptap/core' import { BubbleMenu } from '@tiptap/react' -import { IconBold, IconDelete, IconItalic, IconLink, IconOpenInNew } from 'lib/lemon-ui/icons' -import { isURL } from 'lib/utils' +import { useActions } from 'kea' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' +import { IconBold, IconComment, IconDelete, IconItalic, IconLink, IconOpenInNew } from 'lib/lemon-ui/icons' +import { isURL, uuid } from 'lib/utils' import { useRef } from 'react' import NotebookIconHeading from './NotebookIconHeading' +import { notebookLogic } from './notebookLogic' export const InlineMenu = ({ editor }: { editor: Editor }): JSX.Element => { + const { insertComment } = useActions(notebookLogic) const { href, target } = editor.getAttributes('link') const menuRef = useRef(null) + const hasDiscussions = useFeatureFlag('DISCUSSIONS') + const commentSelected = editor.isActive('comment') const setLink = (href: string): void => { editor.commands.setMark('link', { href: href }) @@ -101,6 +107,20 @@ export const InlineMenu = ({ editor }: { editor: Editor }): JSX.Element => { /> )} + {hasDiscussions && !commentSelected && ( + <> + + { + const markId = uuid() + editor.commands.setMark('comment', { id: markId }) + insertComment({ type: 'mark', id: markId }) + }} + icon={} + size="small" + /> + + )}
) diff --git a/frontend/src/scenes/notebooks/Notebook/Notebook.scss b/frontend/src/scenes/notebooks/Notebook/Notebook.scss index f40140cb209c9..6e8449bf0ef88 100644 --- a/frontend/src/scenes/notebooks/Notebook/Notebook.scss +++ b/frontend/src/scenes/notebooks/Notebook/Notebook.scss @@ -286,6 +286,26 @@ max-height: 22px; } + .NotebookComment { + --notebook-comment-background-opacity: 0.25; + + position: relative; + transform-style: preserve-3d; + + &:hover { + --notebook-comment-background-opacity: 0.5; + } + + &::after { + position: absolute; + inset: 0; + z-index: -1; + content: ''; + background: var(--primary-3000); + opacity: var(--notebook-comment-background-opacity); + } + } + // Overrides for insight controls .InsightVizDisplay { diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx index 3f72641d328e0..7ffd9e158e242 100644 --- a/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx +++ b/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx @@ -12,9 +12,11 @@ import { import { JSONContent } from '@tiptap/core' import { useActions, useValues } from 'kea' import { activityLogLogic } from 'lib/components/ActivityLog/activityLogLogic' -import { ActivityLogItem, ActivityScope, userNameForLogItem } from 'lib/components/ActivityLog/humanizeActivity' +import { ActivityLogItem, userNameForLogItem } from 'lib/components/ActivityLog/humanizeActivity' import { useMemo } from 'react' +import { ActivityScope } from '~/types' + import { notebookLogic } from './notebookLogic' const getFieldChange = (logItem: ActivityLogItem, field: string): any => { diff --git a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts index 56c0cbe4586af..175647c05a7a6 100644 --- a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts +++ b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts @@ -7,6 +7,7 @@ export const notebookTestTemplate = ( title: string = 'Notebook for snapshots', notebookJson: JSONContent[] ): NotebookType => ({ + id: 'template-introduction', short_id: 'template-introduction', title: title, created_at: '2023-06-02T00:00:00Z', diff --git a/frontend/src/scenes/notebooks/Notebook/notebookActivityDescriber.tsx b/frontend/src/scenes/notebooks/Notebook/notebookActivityDescriber.tsx index a1eeb50ace70f..1756eb91c275d 100644 --- a/frontend/src/scenes/notebooks/Notebook/notebookActivityDescriber.tsx +++ b/frontend/src/scenes/notebooks/Notebook/notebookActivityDescriber.tsx @@ -1,8 +1,8 @@ import { ActivityChange, ActivityLogItem, - ActivityScope, ChangeMapping, + defaultDescriber, Description, HumanizedChange, userNameForLogItem, @@ -11,6 +11,8 @@ import { SentenceList } from 'lib/components/ActivityLog/SentenceList' import { Link } from 'lib/lemon-ui/Link' import { urls } from 'scenes/urls' +import { ActivityScope } from '~/types' + const notebookActionsMapping: Record< string, (change?: ActivityChange, logItem?: ActivityLogItem) => ChangeMapping | null @@ -33,7 +35,7 @@ function nameAndLink(logItem?: ActivityLogItem): JSX.Element { ) } -export function notebookActivityDescriber(logItem: ActivityLogItem): HumanizedChange { +export function notebookActivityDescriber(logItem: ActivityLogItem, asNotification?: boolean): HumanizedChange { if (logItem.scope !== ActivityScope.NOTEBOOK) { console.error('notebook describer received a non-Notebook activity') return { description: null } @@ -77,25 +79,5 @@ export function notebookActivityDescriber(logItem: ActivityLogItem): HumanizedCh } } - if (logItem.activity == 'created') { - return { - description: ( - <> - {userNameForLogItem(logItem)} created {nameAndLink(logItem)} - - ), - } - } - - if (logItem.activity == 'deleted') { - return { - description: ( - <> - {userNameForLogItem(logItem)} deleted {nameAndLink(logItem)} - - ), - } - } - - return { description: null } + return defaultDescriber(logItem, asNotification, nameAndLink(logItem)) } diff --git a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts index 8bff3a9590f0a..492b00fc3cdbb 100644 --- a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts +++ b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts @@ -6,13 +6,24 @@ import { subscriptions } from 'kea-subscriptions' import api from 'lib/api' import { base64Decode, base64Encode, downloadFile, slugify } from 'lib/utils' import posthog from 'posthog-js' +import { commentsLogic } from 'scenes/comments/commentsLogic' import { buildTimestampCommentContent, NotebookNodeReplayTimestampAttrs, } from 'scenes/notebooks/Nodes/NotebookNodeReplayTimestamp' +import { urls } from 'scenes/urls' +import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic' import { notebooksModel, openNotebook, SCRATCHPAD_NOTEBOOK } from '~/models/notebooksModel' -import { NotebookNodeType, NotebookSyncStatus, NotebookTarget, NotebookType } from '~/types' +import { + ActivityScope, + CommentType, + NotebookNodeType, + NotebookSyncStatus, + NotebookTarget, + NotebookType, + SidePanelTab, +} from '~/types' import { notebookNodeLogicType } from '../Nodes/notebookNodeLogicType' import { migrate, NOTEBOOKS_VERSION } from './migrations/migrate' @@ -56,9 +67,27 @@ export const notebookLogic = kea([ props({} as NotebookLogicProps), path((key) => ['scenes', 'notebooks', 'Notebook', 'notebookLogic', key]), key(({ shortId, mode }) => `${shortId}-${mode}`), - connect(() => ({ - values: [notebooksModel, ['scratchpadNotebook', 'notebookTemplates']], - actions: [notebooksModel, ['receiveNotebookUpdate']], + connect((props: NotebookLogicProps) => ({ + values: [ + notebooksModel, + ['scratchpadNotebook', 'notebookTemplates'], + commentsLogic({ + scope: ActivityScope.NOTEBOOK, + item_id: props.shortId, + }), + ['comments', 'itemContext'], + ], + actions: [ + notebooksModel, + ['receiveNotebookUpdate'], + sidePanelStateLogic, + ['openSidePanel'], + commentsLogic({ + scope: ActivityScope.NOTEBOOK, + item_id: props.shortId, + }), + ['setItemContext', 'maybeLoadComments'], + ], })), actions({ setEditor: (editor: NotebookEditor) => ({ editor }), @@ -102,6 +131,8 @@ export const notebookLogic = kea([ setShowHistory: (showHistory: boolean) => ({ showHistory }), setTextSelection: (selection: number | EditorRange) => ({ selection }), setContainerSize: (containerSize: 'small' | 'medium') => ({ containerSize }), + insertComment: (context: Record) => ({ context }), + selectComment: (itemContextId: string) => ({ itemContextId }), }), reducers(({ props }) => ({ localContent: [ @@ -549,7 +580,10 @@ export const notebookLogic = kea([ }, saveNotebookSuccess: actions.scheduleNotebookRefresh, - loadNotebookSuccess: actions.scheduleNotebookRefresh, + loadNotebookSuccess: () => { + actions.scheduleNotebookRefresh() + actions.maybeLoadComments() + }, exportJSON: () => { const file = new File( @@ -590,9 +624,35 @@ export const notebookLogic = kea([ actions.loadNotebook() }, NOTEBOOK_REFRESH_MS) }, + + // Comments + insertComment: ({ context }) => { + actions.openSidePanel(SidePanelTab.Discussion) + + actions.setItemContext(context, (result) => { + if (!result.sent && values.editor) { + const pos = values.editor.findCommentPosition(context.id) + if (pos) { + values.editor.removeComment(pos) + } + } + }) + if (router.values.currentLocation.pathname !== urls.notebook(values.shortId)) { + router.actions.push(urls.notebook(values.shortId)) + } + }, + selectComment: ({ itemContextId }) => { + const commentId = values.comments?.find((x) => x.item_context?.id === itemContextId)?.id + + actions.openSidePanel(SidePanelTab.Discussion, commentId) + + if (router.values.currentLocation.pathname !== urls.notebook(values.shortId)) { + router.actions.push(urls.notebook(values.shortId)) + } + }, })), - subscriptions(({ actions }) => ({ + subscriptions(({ values, actions }) => ({ notebook: (notebook?: NotebookType) => { // Keep the list logic up to date with any changes if (notebook && notebook.short_id !== SCRATCHPAD_NOTEBOOK.short_id) { @@ -601,6 +661,20 @@ export const notebookLogic = kea([ // If the notebook ever changes, we want to reset the scheduled refresh actions.scheduleNotebookRefresh() }, + comments: (comments: CommentType[] | undefined | null) => { + if (comments && values.editor) { + const { editor } = values + const commentMarkIds = comments + .filter((comment) => comment.item_context?.type === 'mark') + .map((comment) => comment.item_context?.id) + + editor.getMarks('comment').forEach((mark) => { + if (!commentMarkIds.includes(mark.id) && values.itemContext?.context?.id !== mark.id) { + editor.removeComment(mark.pos) + } + }) + } + }, })), urlToAction(({ values, actions, cache }) => ({ diff --git a/frontend/src/scenes/notebooks/Notebook/utils.ts b/frontend/src/scenes/notebooks/Notebook/utils.ts index 7a8e74902a99b..3cfc09b1f99b2 100644 --- a/frontend/src/scenes/notebooks/Notebook/utils.ts +++ b/frontend/src/scenes/notebooks/Notebook/utils.ts @@ -106,6 +106,9 @@ export interface NotebookEditor { focus: (position?: EditorFocusPosition) => void chain: () => EditorCommands destroy: () => void + findCommentPosition: (markId: string) => number | null + getMarks: (type: string) => { id: string; pos: number }[] + removeComment: (pos: number) => void deleteRange: (range: EditorRange) => EditorCommands insertContent: (content: JSONContent) => void insertContentAfterNode: (position: number, content: JSONContent) => void diff --git a/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts b/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts index f2fe3b0d454ae..3ff279c9b060f 100644 --- a/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts +++ b/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts @@ -12,6 +12,7 @@ const TEMPLATE_USERS: Record = { export const LOCAL_NOTEBOOK_TEMPLATES: NotebookType[] = [ { + id: 'template-introduction', short_id: 'template-introduction', title: 'Introducing Notebooks! 🥳', created_at: '2023-06-02T00:00:00Z', diff --git a/frontend/src/scenes/notebooks/NotebooksTable/NotebooksTable.tsx b/frontend/src/scenes/notebooks/NotebooksTable/NotebooksTable.tsx index f9de0260d58bd..66b72b784618a 100644 --- a/frontend/src/scenes/notebooks/NotebooksTable/NotebooksTable.tsx +++ b/frontend/src/scenes/notebooks/NotebooksTable/NotebooksTable.tsx @@ -9,7 +9,7 @@ import { atColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils' import { Link } from 'lib/lemon-ui/Link' import { useEffect } from 'react' import { ContainsTypeFilters } from 'scenes/notebooks/NotebooksTable/ContainsTypeFilter' -import { DEFAULT_FILTERS, notebooksTableLogic } from 'scenes/notebooks/NotebooksTable/notebooksTableLogic' +import { notebooksTableLogic } from 'scenes/notebooks/NotebooksTable/notebooksTableLogic' import { urls } from 'scenes/urls' import { notebooksModel } from '~/models/notebooksModel' @@ -122,7 +122,7 @@ export function NotebooksTable(): JSX.Element { Created by: setFilters({ createdBy: user?.uuid || DEFAULT_FILTERS.createdBy })} + onChange={(user) => setFilters({ createdBy: user?.uuid || null })} />
diff --git a/frontend/src/scenes/notebooks/NotebooksTable/notebooksTableLogic.ts b/frontend/src/scenes/notebooks/NotebooksTable/notebooksTableLogic.ts index b7c21c7abcff9..5a3f4bbd350f1 100644 --- a/frontend/src/scenes/notebooks/NotebooksTable/notebooksTableLogic.ts +++ b/frontend/src/scenes/notebooks/NotebooksTable/notebooksTableLogic.ts @@ -12,13 +12,13 @@ import type { notebooksTableLogicType } from './notebooksTableLogicType' export interface NotebooksListFilters { search: string // UUID of the user that created the notebook - createdBy: string + createdBy: string | null contains: NotebookNodeType[] } export const DEFAULT_FILTERS: NotebooksListFilters = { search: '', - createdBy: 'All users', + createdBy: null, contains: [], } @@ -77,7 +77,7 @@ export const notebooksTableLogic = kea([ const res = await api.notebooks.list({ contains, - created_by: createdByForQuery, + created_by: createdByForQuery ?? undefined, search: values.filters?.search || undefined, order: values.sortValue ?? '-last_modified_at', limit: RESULTS_PER_PAGE, diff --git a/frontend/src/scenes/notebooks/notebookSceneLogic.ts b/frontend/src/scenes/notebooks/notebookSceneLogic.ts index 19061b7208bea..592a1b39e09ed 100644 --- a/frontend/src/scenes/notebooks/notebookSceneLogic.ts +++ b/frontend/src/scenes/notebooks/notebookSceneLogic.ts @@ -36,7 +36,7 @@ export const notebookSceneLogic = kea([ path: urls.notebooks(), }, { - key: notebook?.short_id || 'new', + key: [Scene.Notebook, notebook?.short_id || 'new'], name: notebook ? notebook?.title || 'Unnamed' : loading ? null : 'Notebook not found', }, ], diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ios.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ios.tsx index ce68fc3a5225a..ab001a149c8a4 100644 --- a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ios.tsx +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ios.tsx @@ -5,7 +5,7 @@ import { teamLogic } from 'scenes/teamLogic' function IOSInstallSnippet(): JSX.Element { return ( - {'pod "PostHog", "~> 1.1" # Cocoapods \n# OR \ngithub "posthog/posthog-ios" # Carthage'} + {'pod "PostHog", "~> 2.0.0" # Cocoapods \n# OR \ngithub "posthog/posthog-ios" # Carthage'} ) } diff --git a/frontend/src/scenes/persons/PersonScene.tsx b/frontend/src/scenes/persons/PersonScene.tsx index b570b1d9d7bac..2c8d60b8e8b8b 100644 --- a/frontend/src/scenes/persons/PersonScene.tsx +++ b/frontend/src/scenes/persons/PersonScene.tsx @@ -6,7 +6,6 @@ import { LemonButton, LemonDivider, LemonSelect, LemonTag, Link } from '@posthog import { Dropdown, Menu } from 'antd' import { useActions, useValues } from 'kea' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' import { NotFound } from 'lib/components/NotFound' import { PageHeader } from 'lib/components/PageHeader' @@ -30,7 +29,7 @@ import { urls } from 'scenes/urls' import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' import { Query } from '~/queries/Query/Query' import { NodeKind } from '~/queries/schema' -import { NotebookNodeType, PersonsTabType, PersonType, PropertyDefinitionType } from '~/types' +import { ActivityScope, NotebookNodeType, PersonsTabType, PersonType, PropertyDefinitionType } from '~/types' import { MergeSplitPerson } from './MergeSplitPerson' import { PersonCohorts } from './PersonCohorts' diff --git a/frontend/src/scenes/persons/activityDescriptions.tsx b/frontend/src/scenes/persons/activityDescriptions.tsx index 743f290a4afa3..ae904bd5a88d0 100644 --- a/frontend/src/scenes/persons/activityDescriptions.tsx +++ b/frontend/src/scenes/persons/activityDescriptions.tsx @@ -1,10 +1,15 @@ -import { ActivityLogItem, HumanizedChange, userNameForLogItem } from 'lib/components/ActivityLog/humanizeActivity' +import { + ActivityLogItem, + defaultDescriber, + HumanizedChange, + userNameForLogItem, +} from 'lib/components/ActivityLog/humanizeActivity' import { SentenceList } from 'lib/components/ActivityLog/SentenceList' import { Link } from 'lib/lemon-ui/Link' import { PersonDisplay } from 'scenes/persons/PersonDisplay' import { urls } from 'scenes/urls' -export function personActivityDescriber(logItem: ActivityLogItem): HumanizedChange { +export function personActivityDescriber(logItem: ActivityLogItem, asNotification?: boolean): HumanizedChange { if (logItem.scope != 'Person') { console.error('person describer received a non-person activity') return { description: null } @@ -77,5 +82,5 @@ export function personActivityDescriber(logItem: ActivityLogItem): HumanizedChan } } - return { description: null } + return defaultDescriber(logItem, asNotification) } diff --git a/frontend/src/scenes/persons/personsLogic.tsx b/frontend/src/scenes/persons/personsLogic.tsx index e197b4003b5c8..e76628aade6eb 100644 --- a/frontend/src/scenes/persons/personsLogic.tsx +++ b/frontend/src/scenes/persons/personsLogic.tsx @@ -13,8 +13,10 @@ import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' +import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' import { hogqlQuery } from '~/queries/query' import { + ActivityScope, AnyPropertyFilter, Breadcrumb, CohortType, @@ -254,7 +256,7 @@ export const personsLogic = kea([ ] if (showPerson) { breadcrumbs.push({ - key: person.id || 'unknown', + key: [Scene.Person, person.id || 'unknown'], name: asDisplay(person), }) } @@ -262,6 +264,17 @@ export const personsLogic = kea([ }, ], + activityFilters: [ + (s) => [s.person], + (person): ActivityFilters => { + return { + scope: ActivityScope.PERSON, + // TODO: Is this correct? It doesn't seem to work... + item_id: person?.id ? `${person?.id}` : undefined, + } + }, + ], + exporterProps: [ (s) => [s.listFilters, (_, { cohort }) => cohort], (listFilters, cohort: number | 'new' | undefined): TriggerExportProps[] => [ diff --git a/frontend/src/scenes/pipeline/transformationsLogic.tsx b/frontend/src/scenes/pipeline/transformationsLogic.tsx index 1e3f0ee304b45..f05655b139f4f 100644 --- a/frontend/src/scenes/pipeline/transformationsLogic.tsx +++ b/frontend/src/scenes/pipeline/transformationsLogic.tsx @@ -52,21 +52,11 @@ export const pipelineTransformationsLogic = kea, { loadPluginConfigs: async () => { - const pluginConfigs: Record = {} - const results = await api.loadPaginatedResults( + const res: PluginConfigTypeNew[] = await api.loadPaginatedResults( `api/projects/${values.currentTeamId}/pipeline_transformations_configs` ) - for (const pluginConfig of results) { - pluginConfigs[pluginConfig.id] = { - ...pluginConfig, - // If this pluginConfig doesn't have a name of desciption, use the plugin's - // note that this will get saved to the db on certain actions and that's fine - name: pluginConfig.name || values.plugins[pluginConfig.plugin]?.name || 'Unknown app', - description: pluginConfig.description || values.plugins[pluginConfig.plugin]?.description, - } - } - return pluginConfigs + return Object.fromEntries(res.map((pluginConfig) => [pluginConfig.id, pluginConfig])) }, savePluginConfigsOrder: async ({ newOrders }) => { if (!values.canConfigurePlugins) { diff --git a/frontend/src/scenes/pipeline/utils.tsx b/frontend/src/scenes/pipeline/utils.tsx index 2e71867f523b0..19908b8b735b3 100644 --- a/frontend/src/scenes/pipeline/utils.tsx +++ b/frontend/src/scenes/pipeline/utils.tsx @@ -2,7 +2,7 @@ import api from 'lib/api' import { Link } from 'lib/lemon-ui/Link' import { Tooltip } from 'lib/lemon-ui/Tooltip' import posthog from 'posthog-js' -import { PluginImage } from 'scenes/plugins/plugin/PluginImage' +import { PluginImage, PluginImageSize } from 'scenes/plugins/plugin/PluginImage' import { PluginConfigTypeNew, PluginType } from '~/types' @@ -34,9 +34,10 @@ export async function loadPaginatedResults( type RenderAppProps = { plugin: PluginType + imageSize?: PluginImageSize } -export function RenderApp({ plugin }: RenderAppProps): JSX.Element { +export function RenderApp({ plugin, imageSize }: RenderAppProps): JSX.Element { return (
{plugin.url ? ( - + ) : ( - // TODO: tooltip doesn't work on this + // TODO: tooltip doesn't work on this )}
diff --git a/frontend/src/scenes/plugins/AppsScene.tsx b/frontend/src/scenes/plugins/AppsScene.tsx index 4bd07fb5dc10f..0df0f23bc0e79 100644 --- a/frontend/src/scenes/plugins/AppsScene.tsx +++ b/frontend/src/scenes/plugins/AppsScene.tsx @@ -3,7 +3,6 @@ import './Plugins.scss' import { LemonButton } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { PageHeader } from 'lib/components/PageHeader' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { useEffect } from 'react' @@ -11,6 +10,8 @@ import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' +import { ActivityScope } from '~/types' + import { canGloballyManagePlugins, canViewPlugins } from './access' import { pluginsLogic } from './pluginsLogic' import { AppsManagementTab } from './tabs/apps/AppsManagementTab' diff --git a/frontend/src/scenes/plugins/plugin/PluginImage.tsx b/frontend/src/scenes/plugins/plugin/PluginImage.tsx index b7401e2e6561d..7086ed76bb050 100644 --- a/frontend/src/scenes/plugins/plugin/PluginImage.tsx +++ b/frontend/src/scenes/plugins/plugin/PluginImage.tsx @@ -5,18 +5,20 @@ import { useEffect, useState } from 'react' import { PluginType } from '~/types' +export type PluginImageSize = 'small' | 'medium' | 'large' + export function PluginImage({ plugin, size = 'medium', }: { plugin: Partial> - size?: 'medium' | 'large' | 'small' + size?: PluginImageSize }): JSX.Element { const { plugin_type: pluginType, url, icon } = plugin const [state, setState] = useState({ image: imgPluginDefault }) const pixelSize = { - medium: 60, large: 100, + medium: 60, small: 30, }[size] diff --git a/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx b/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx index da79b95a14129..a4889604735c1 100644 --- a/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx +++ b/frontend/src/scenes/plugins/pluginActivityDescriptions.tsx @@ -1,15 +1,17 @@ import { ActivityLogItem, - ActivityScope, + defaultDescriber, HumanizedChange, userNameForLogItem, } from 'lib/components/ActivityLog/humanizeActivity' import { SentenceList } from 'lib/components/ActivityLog/SentenceList' import { dayjs } from 'lib/dayjs' +import { ActivityScope } from '~/types' + import { SECRET_FIELD_VALUE } from './utils' -export function pluginActivityDescriber(logItem: ActivityLogItem): HumanizedChange { +export function pluginActivityDescriber(logItem: ActivityLogItem, asNotification?: boolean): HumanizedChange { if (logItem.scope !== ActivityScope.PLUGIN && logItem.scope !== ActivityScope.PLUGIN_CONFIG) { console.error('plugin describer received a non-plugin activity') return { description: null } @@ -218,5 +220,5 @@ export function pluginActivityDescriber(logItem: ActivityLogItem): HumanizedChan } } - return { description: null } + return defaultDescriber(logItem, asNotification) } diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index ca283ff751ccd..8bd4e6bdea818 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -15,7 +15,6 @@ import { import { LemonSelectOptions } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' import { InsightCard } from 'lib/components/Cards/InsightCard' import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags' import { PageHeader } from 'lib/components/PageHeader' @@ -57,7 +56,7 @@ import { cohortsModel } from '~/models/cohortsModel' import { groupsModel } from '~/models/groupsModel' import { NodeKind } from '~/queries/schema' import { isInsightVizNode } from '~/queries/utils' -import { InsightModel, InsightType, LayoutView, SavedInsightsTabs } from '~/types' +import { ActivityScope, InsightModel, InsightType, LayoutView, SavedInsightsTabs } from '~/types' import { teamLogic } from '../teamLogic' import { INSIGHTS_PER_PAGE, savedInsightsLogic } from './savedInsightsLogic' diff --git a/frontend/src/scenes/saved-insights/activityDescriptions.tsx b/frontend/src/scenes/saved-insights/activityDescriptions.tsx index 8699a76078348..bca2dacda8cbe 100644 --- a/frontend/src/scenes/saved-insights/activityDescriptions.tsx +++ b/frontend/src/scenes/saved-insights/activityDescriptions.tsx @@ -5,6 +5,7 @@ import { ActivityChange, ActivityLogItem, ChangeMapping, + defaultDescriber, Description, detectBoolean, HumanizedChange, @@ -372,5 +373,5 @@ export function insightActivityDescriber(logItem: ActivityLogItem, asNotificatio } } - return { description: null } + return defaultDescriber(logItem, asNotification, nameOrLinkToInsight(logItem?.detail.short_id)) } diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 4604a64a697da..a71b2bec85dae 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -1,5 +1,7 @@ import { LogicWrapper } from 'kea' +import { ActivityScope } from '~/types' + // 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. @@ -43,6 +45,7 @@ export enum Scene { DataWarehouseSavedQueries = 'DataWarehouseSavedQueries', DataWarehouseTable = 'DataWarehouseTable', DataWarehouseSettings = 'DataWarehouseSettings', + DataWarehouseRedirect = 'DataWarehouseRedirect', OrganizationCreateFirst = 'OrganizationCreate', ProjectHomepage = 'ProjectHomepage', ProjectCreateFirst = 'ProjectCreate', @@ -133,7 +136,8 @@ export interface SceneConfig { organizationBased?: boolean /** Route requires project access (used e.g. by breadcrumbs). `true` implies also `organizationBased` */ projectBased?: boolean - + /** Set the scope of the activity (affects activity and discussion panel) */ + activityScope?: ActivityScope /** Default docs path - what the docs side panel will open by default if this scene is active */ defaultDocsPath?: string } diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 4ebd425f741f0..66ddafec1f17f 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -9,7 +9,7 @@ import { Error404 as Error404Component } from '~/layout/Error404' import { ErrorNetwork as ErrorNetworkComponent } from '~/layout/ErrorNetwork' import { ErrorProjectUnavailable as ErrorProjectUnavailableComponent } from '~/layout/ErrorProjectUnavailable' import { EventsQuery } from '~/queries/schema' -import { InsightShortId, PipelineAppTabs, PipelineTabs, PropertyFilterType, ReplayTabs } from '~/types' +import { ActivityScope, InsightShortId, PipelineAppTabs, PipelineTabs, PropertyFilterType, ReplayTabs } from '~/types' export const emptySceneParams = { params: {}, searchParams: {}, hashParams: {} } @@ -46,16 +46,17 @@ export const sceneConfigurations: Record = { [Scene.Dashboards]: { projectBased: true, name: 'Dashboards', - defaultDocsPath: '/docs/product-analytics/dashboards', + activityScope: ActivityScope.DASHBOARD, }, [Scene.Dashboard]: { projectBased: true, + activityScope: ActivityScope.DASHBOARD, defaultDocsPath: '/docs/product-analytics/dashboards', }, - [Scene.Insight]: { projectBased: true, name: 'Insights', + activityScope: ActivityScope.INSIGHT, defaultDocsPath: '/docs/product-analytics/insights', }, [Scene.WebAnalytics]: { @@ -92,39 +93,47 @@ export const sceneConfigurations: Record = { [Scene.DataManagement]: { projectBased: true, name: 'Data management', + activityScope: ActivityScope.DATA_MANAGEMENT, }, [Scene.EventDefinition]: { projectBased: true, name: 'Data management', + activityScope: ActivityScope.EVENT_DEFINITION, defaultDocsPath: '/docs/data/events', }, [Scene.PropertyDefinition]: { projectBased: true, name: 'Data management', + activityScope: ActivityScope.PROPERTY_DEFINITION, }, [Scene.Replay]: { projectBased: true, name: 'Session replay', + activityScope: ActivityScope.REPLAY, defaultDocsPath: '/docs/session-replay', }, [Scene.ReplaySingle]: { projectBased: true, name: 'Replay recording', + activityScope: ActivityScope.REPLAY, defaultDocsPath: '/docs/session-replay', }, [Scene.ReplayPlaylist]: { projectBased: true, name: 'Replay playlist', + activityScope: ActivityScope.REPLAY, defaultDocsPath: '/docs/session-replay', }, [Scene.Person]: { projectBased: true, name: 'Person', + activityScope: ActivityScope.PERSON, defaultDocsPath: '/docs/session-replay', }, [Scene.PersonsManagement]: { projectBased: true, name: 'People & groups', + activityScope: ActivityScope.PERSON, defaultDocsPath: '/docs/data/persons', }, [Scene.Action]: { @@ -140,41 +149,48 @@ export const sceneConfigurations: Record = { [Scene.Pipeline]: { projectBased: true, name: 'Pipeline', + activityScope: ActivityScope.PLUGIN, defaultDocsPath: '/docs/cdp', }, [Scene.PipelineApp]: { projectBased: true, name: 'Pipeline app', + activityScope: ActivityScope.PLUGIN, defaultDocsPath: '/docs/cdp', }, [Scene.Experiments]: { projectBased: true, name: 'A/B testing', defaultDocsPath: '/docs/experiments', + activityScope: ActivityScope.EXPERIMENT, }, [Scene.Experiment]: { projectBased: true, name: 'Experiment', defaultDocsPath: '/docs/experiments/creating-an-experiment', + activityScope: ActivityScope.EXPERIMENT, }, [Scene.FeatureFlags]: { projectBased: true, name: 'Feature flags', - defaultDocsPath: '/docs/feature-flags', + activityScope: ActivityScope.FEATURE_FLAG, }, [Scene.FeatureFlag]: { projectBased: true, + activityScope: ActivityScope.FEATURE_FLAG, defaultDocsPath: '/docs/feature-flags/creating-feature-flags', }, [Scene.Surveys]: { projectBased: true, name: 'Surveys', defaultDocsPath: '/docs/feature-flags/creating-feature-flags', + activityScope: ActivityScope.SURVEY, }, [Scene.Survey]: { projectBased: true, name: 'Survey', defaultDocsPath: '/docs/surveys', + activityScope: ActivityScope.SURVEY, }, [Scene.SurveyTemplates]: { projectBased: true, @@ -206,6 +222,9 @@ export const sceneConfigurations: Record = { name: 'Data warehouse settings', defaultDocsPath: '/docs/data-warehouse', }, + [Scene.DataWarehouseRedirect]: { + name: 'Data warehouse redirect', + }, [Scene.DataWarehouseTable]: { projectBased: true, name: 'Data warehouse table', @@ -213,30 +232,36 @@ export const sceneConfigurations: Record = { }, [Scene.EarlyAccessFeatures]: { projectBased: true, - defaultDocsPath: '/docs/data-warehouse', + defaultDocsPath: '/docs/feature-flags/early-access-feature-management', + activityScope: ActivityScope.EARLY_ACCESS_FEATURE, }, [Scene.EarlyAccessFeature]: { projectBased: true, defaultDocsPath: '/docs/feature-flags/early-access-feature-management', + activityScope: ActivityScope.EARLY_ACCESS_FEATURE, }, [Scene.Apps]: { projectBased: true, name: 'Apps', + activityScope: ActivityScope.PLUGIN, defaultDocsPath: '/docs/cdp', }, [Scene.FrontendAppScene]: { projectBased: true, name: 'App', + activityScope: ActivityScope.PLUGIN, defaultDocsPath: '/docs/cdp', }, [Scene.AppMetrics]: { projectBased: true, name: 'Apps', + activityScope: ActivityScope.PLUGIN, defaultDocsPath: '/docs/cdp', }, [Scene.SavedInsights]: { projectBased: true, name: 'Product analytics', + activityScope: ActivityScope.INSIGHT, defaultDocsPath: '/docs/product-analytics', }, [Scene.ProjectHomepage]: { @@ -339,11 +364,13 @@ export const sceneConfigurations: Record = { hideProjectNotice: true, // Currently doesn't render well... name: 'Notebook', layout: 'app-raw', + activityScope: ActivityScope.NOTEBOOK, defaultDocsPath: '/blog/introducing-notebooks', }, [Scene.Notebooks]: { projectBased: true, name: 'Notebooks', + activityScope: ActivityScope.NOTEBOOK, defaultDocsPath: '/blog/introducing-notebooks', }, [Scene.Canvas]: { @@ -490,6 +517,7 @@ export const routes: Record = { [urls.dataWarehouseExternal()]: Scene.DataWarehouseExternal, [urls.dataWarehouseSavedQueries()]: Scene.DataWarehouseSavedQueries, [urls.dataWarehouseSettings()]: Scene.DataWarehouseSettings, + [urls.dataWarehouseRedirect(':kind')]: Scene.DataWarehouseRedirect, [urls.featureFlags()]: Scene.FeatureFlags, [urls.featureFlag(':id')]: Scene.FeatureFlag, [urls.annotations()]: Scene.DataManagement, diff --git a/frontend/src/scenes/session-recordings/detail/sessionRecordingDetailLogic.ts b/frontend/src/scenes/session-recordings/detail/sessionRecordingDetailLogic.ts index aad8e724401e5..d41dc0a28c774 100644 --- a/frontend/src/scenes/session-recordings/detail/sessionRecordingDetailLogic.ts +++ b/frontend/src/scenes/session-recordings/detail/sessionRecordingDetailLogic.ts @@ -23,7 +23,7 @@ export const sessionRecordingDetailLogic = kea( path: urls.replay(), }, { - key: sessionRecordingId, + key: [Scene.ReplaySingle, sessionRecordingId], name: sessionRecordingId ?? 'Not Found', path: sessionRecordingId ? urls.replaySingle(sessionRecordingId) : undefined, }, diff --git a/frontend/src/scenes/session-recordings/file-playback/SessionRecordingFilePlayback.tsx b/frontend/src/scenes/session-recordings/file-playback/SessionRecordingFilePlayback.tsx index 0d0f0bd3a5346..9026fc3e843c4 100644 --- a/frontend/src/scenes/session-recordings/file-playback/SessionRecordingFilePlayback.tsx +++ b/frontend/src/scenes/session-recordings/file-playback/SessionRecordingFilePlayback.tsx @@ -1,9 +1,10 @@ -import Dragger from 'antd/lib/upload/Dragger' import { useActions, useValues } from 'kea' import { PayGatePage } from 'lib/components/PayGatePage/PayGatePage' import { IconUploadFile } from 'lib/lemon-ui/icons' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { LemonFileInput } from 'lib/lemon-ui/LemonFileInput' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' +import { useRef } from 'react' import { userLogic } from 'scenes/userLogic' import { AvailableFeature } from '~/types' @@ -17,6 +18,8 @@ export function SessionRecordingFilePlayback(): JSX.Element { const { hasAvailableFeature } = useValues(userLogic) const filePlaybackEnabled = hasAvailableFeature(AvailableFeature.RECORDINGS_FILE_EXPORT) + const dropRef = useRef(null) + if (!filePlaybackEnabled) { return (
) : ( - { - loadFromFile(file) - return false - }} +
-
-

- - Load recording -

-

- Drag and drop your exported recording here or click to open the file browser. -

-
- + loadFromFile(files[0])} + alternativeDropTargetRef={dropRef} + callToAction={ +
+ + Load recording + +
Drag and drop your exported recording here or click to open the file browser.
+
+ } + /> +
)}
) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts index 6d7c55e88acc1..1338ae281e46c 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts @@ -152,7 +152,7 @@ export const sessionRecordingsPlaylistSceneLogic = kea { if (!playlist) { diff --git a/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts b/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts index c321f32b89b22..2a03fd471af36 100644 --- a/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts +++ b/frontend/src/scenes/session-recordings/sessionRecordingsLogic.ts @@ -5,7 +5,8 @@ import { capitalizeFirstLetter } from 'lib/utils' import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' -import { Breadcrumb, ReplayTabs } from '~/types' +import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic' +import { ActivityScope, Breadcrumb, ReplayTabs } from '~/types' import type { sessionRecordingsLogicType } from './sessionRecordingsLogicType' @@ -64,6 +65,17 @@ export const sessionRecordingsLogic = kea([ return breadcrumbs }, ], + activityFilters: [ + () => [router.selectors.searchParams], + (searchParams): ActivityFilters | null => { + return searchParams.sessionRecordingId + ? { + scope: ActivityScope.REPLAY, + item_id: searchParams.sessionRecordingId, + } + : null + }, + ], })), urlToAction(({ actions, values }) => { diff --git a/frontend/src/scenes/settings/SettingsMap.tsx b/frontend/src/scenes/settings/SettingsMap.tsx index 8ed352a5bf88d..f362bffe16268 100644 --- a/frontend/src/scenes/settings/SettingsMap.tsx +++ b/frontend/src/scenes/settings/SettingsMap.tsx @@ -1,5 +1,3 @@ -import { AvailableFeature } from '~/types' - import { Invites } from './organization/Invites' import { Members } from './organization/Members' import { OrganizationDangerZone } from './organization/OrganizationDangerZone' @@ -159,12 +157,6 @@ export const SettingsMap: SettingSection[] = [ id: 'replay-ingestion', title: 'Ingestion controls', component: , - flag: 'SESSION_RECORDING_SAMPLING', - features: [ - AvailableFeature.SESSION_REPLAY_SAMPLING, - AvailableFeature.RECORDING_DURATION_MINIMUM, - AvailableFeature.FEATURE_FLAG_BASED_RECORDING, - ], }, ], }, diff --git a/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx b/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx index 230704ea193c8..4f0af1012a76d 100644 --- a/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx +++ b/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx @@ -4,6 +4,7 @@ import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUr import { AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic' import { FlaggedFeature } from 'lib/components/FlaggedFeature' import { FlagSelector } from 'lib/components/FlagSelector' +import { supportLogic } from 'lib/components/Support/supportLogic' import { FEATURE_FLAGS, SESSION_REPLAY_MINIMUM_DURATION_OPTIONS } from 'lib/constants' import { IconCancel } from 'lib/lemon-ui/icons' import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel' @@ -182,6 +183,7 @@ export function ReplayCostControl(): JSX.Element { const { currentTeam } = useValues(teamLogic) const { hasAvailableFeature } = useValues(userLogic) const { featureFlags } = useValues(featureFlagLogic) + const { openSupportForm } = useActions(supportLogic) const samplingControlFeatureEnabled = hasAvailableFeature(AvailableFeature.SESSION_REPLAY_SAMPLING) const recordingDurationMinimumFeatureEnabled = hasAvailableFeature(AvailableFeature.RECORDING_DURATION_MINIMUM) const featureFlagRecordingFeatureEnabled = hasAvailableFeature(AvailableFeature.FEATURE_FLAG_BASED_RECORDING) @@ -191,8 +193,16 @@ export function ReplayCostControl(): JSX.Element { recordingDurationMinimumFeatureEnabled || featureFlagRecordingFeatureEnabled - return costControlFeaturesEnabled ? ( + return ( <> + {!costControlFeaturesEnabled && ( + + openSupportForm({ kind: 'support', target_area: 'session_replay' })}> + Contact support + {' '} + to enable these features. + + )}

PostHog offers several tools to let you control the number of recordings you collect and which users you collect recordings for.{' '} @@ -200,174 +210,165 @@ export function ReplayCostControl(): JSX.Element { to={'https://posthog.com/docs/session-replay/how-to-control-which-sessions-you-record'} target={'blank'} > - Learn more in our docs + Learn more in our docs.

- - Requires posthog-js version 1.88.2 or greater - - {(featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] || samplingControlFeatureEnabled) && ( - <> -
- 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. -

- - )} - {(featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] || recordingDurationMinimumFeatureEnabled) && ( - <> -
- Minimum session duration (seconds) - { - updateCurrentTeam({ session_recording_minimum_duration_milliseconds: v }) + + <> +
+ 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={SESSION_REPLAY_MINIMUM_DURATION_OPTIONS} + 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 } }) }} - options={SESSION_REPLAY_MINIMUM_DURATION_OPTIONS} - value={currentTeam?.session_recording_minimum_duration_milliseconds} + readOnly={!costControlFeaturesEnabled} + disabledReason="Please contact support to enable this feature." /> -
-

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

- - )} - {(featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] || featureFlagRecordingFeatureEnabled) && ( - <> -
- Enable recordings using feature flag -
- { - updateCurrentTeam({ session_recording_linked_flag: { id, key } }) - }} + {currentTeam?.session_recording_linked_flag && ( + } + size="small" + type="secondary" + onClick={() => updateCurrentTeam({ session_recording_linked_flag: null })} + title="Clear selected flag" /> - {currentTeam?.session_recording_linked_flag && ( - } - size="small" - type="secondary" - onClick={() => updateCurrentTeam({ session_recording_linked_flag: null })} - title="Clear selected flag" - data-attr={'session-recording-config-clear-linked-flag'} - /> - )} -
+ )}
-

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

- - )} +
+

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

+ - ) : ( - <> ) } diff --git a/frontend/src/scenes/settings/settingsSceneLogic.ts b/frontend/src/scenes/settings/settingsSceneLogic.ts index 9339ee8b1274e..996da3c22c32c 100644 --- a/frontend/src/scenes/settings/settingsSceneLogic.ts +++ b/frontend/src/scenes/settings/settingsSceneLogic.ts @@ -34,7 +34,7 @@ export const settingsSceneLogic = kea([ path: urls.settings('project'), }, { - key: selectedSectionId || selectedLevel, + key: [Scene.Settings, selectedSectionId || selectedLevel], name: selectedSectionId ? SettingsMap.find((x) => x.id === selectedSectionId)?.title : capitalizeFirstLetter(selectedLevel), diff --git a/frontend/src/scenes/sites/siteLogic.ts b/frontend/src/scenes/sites/siteLogic.ts index 48a318edde6b1..a59dcf71af98e 100644 --- a/frontend/src/scenes/sites/siteLogic.ts +++ b/frontend/src/scenes/sites/siteLogic.ts @@ -21,7 +21,7 @@ export const siteLogic = kea([ name: `Site`, }, { - key: url, + key: [Scene.Site, url], name: url, }, ], diff --git a/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx b/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx index 7e4c5709626f4..d3420e1a81749 100644 --- a/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx +++ b/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx @@ -84,7 +84,7 @@ export function SurveyEditQuestionHeader({ export function SurveyEditQuestionGroup({ index, question }: { index: number; question: any }): JSX.Element { const { survey, writingHTMLDescription } = useValues(surveyLogic) - const { setDefaultForQuestionType, setWritingHTMLDescription } = useActions(surveyLogic) + const { setDefaultForQuestionType, setWritingHTMLDescription, setSurveyValue } = useActions(surveyLogic) return (
@@ -249,6 +249,12 @@ export function SurveyEditQuestionGroup({ index, question }: { index: number; qu { label: 'Number', value: 'number' }, { label: 'Emoji', value: 'emoji' }, ]} + onChange={(val) => { + const newQuestion = { ...survey.questions[index], display: val, scale: 5 } + const newQuestions = [...survey.questions] + newQuestions[index] = newQuestion + setSurveyValue('questions', newQuestions) + }} /> diff --git a/frontend/src/scenes/surveys/surveyLogic.tsx b/frontend/src/scenes/surveys/surveyLogic.tsx index 2bc25c6ce1bca..b24784eb35044 100644 --- a/frontend/src/scenes/surveys/surveyLogic.tsx +++ b/frontend/src/scenes/surveys/surveyLogic.tsx @@ -581,7 +581,7 @@ export const surveyLogic = kea([ name: 'Surveys', path: urls.surveys(), }, - { key: survey?.id || 'new', name: survey.name }, + { key: [Scene.Survey, survey?.id || 'new'], name: survey.name }, ], ], dataTableQuery: [ diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index bd5cbe5939aec..53c1250229d13 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -127,6 +127,7 @@ export const urls = { dataWarehouseExternal: (): string => '/data-warehouse/external', dataWarehouseSavedQueries: (): string => '/data-warehouse/views', dataWarehouseSettings: (): string => '/data-warehouse/settings', + dataWarehouseRedirect: (kind: string): string => `/data-warehouse/${kind}/redirect`, annotations: (): string => '/data-management/annotations', annotation: (id: AnnotationType['id'] | ':id'): string => `/data-management/annotations/${id}`, projectApps: (tab?: PluginTab): string => `/project/apps${tab ? `?tab=${tab}` : ''}`, diff --git a/frontend/src/toolbar/actions/ActionsListView.tsx b/frontend/src/toolbar/actions/ActionsListView.tsx index efc62b176ff00..45c49d3cb7df4 100644 --- a/frontend/src/toolbar/actions/ActionsListView.tsx +++ b/frontend/src/toolbar/actions/ActionsListView.tsx @@ -14,7 +14,7 @@ export function ActionsListView({ actions }: ActionsListViewProps): JSX.Element const { allActionsLoading, searchTerm } = useValues(actionsLogic) const { selectAction } = useActions(actionsTabLogic) return ( -
+
{allActionsLoading ? (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 71137a245b67e..e2a4ef187a92b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -27,6 +27,7 @@ import { LogLevel } from 'rrweb' import { BehavioralFilterKey, BehavioralFilterType } from 'scenes/cohorts/CohortFilters/types' import { AggregationAxisFormat } from 'scenes/insights/aggregationAxisFormat' import { JSONContent } from 'scenes/notebooks/Notebook/utils' +import { Scene } from 'scenes/sceneTypes' import { QueryContext } from '~/queries/types' @@ -230,7 +231,7 @@ export interface OrganizationType extends OrganizationBasicType { created_at: string updated_at: string plugins_access_level: PluginsAccessLevel - teams: TeamBasicType[] | null + teams: TeamBasicType[] available_features: AvailableFeature[] available_product_features: BillingV2FeatureType[] is_member_join_email_enabled: boolean @@ -2512,6 +2513,11 @@ export interface PreflightStatus { available: boolean client_id?: string } + data_warehouse_integrations: { + hubspot: { + client_id?: string + } + } /** Whether PostHog is running in DEBUG mode. */ is_debug?: boolean licensed_users_available?: number | null @@ -2859,8 +2865,8 @@ export interface DateMappingOption { } interface BreadcrumbBase { - /** E.g. scene identifier or item ID. Particularly important if `onRename` is used. */ - key: string | number + /** E.g. scene, tab, or scene with item ID. Particularly important for `onRename`. */ + key: string | number | [scene: Scene, key: string | number] /** Name to display. */ name: string | null | undefined /** Symbol, e.g. a lettermark or a profile picture. */ @@ -2881,9 +2887,6 @@ interface RenamableBreadcrumb extends BreadcrumbBase { forceEditMode?: boolean } export type Breadcrumb = LinkBreadcrumb | RenamableBreadcrumb -export type FinalizedBreadcrumb = - | (LinkBreadcrumb & { globalKey: string }) - | (RenamableBreadcrumb & { globalKey: string }) export enum GraphType { Bar = 'bar', @@ -3251,8 +3254,39 @@ export type PromptFlag = { tooltipCSS?: Partial } +// Should be kept in sync with "posthog/models/activity_logging/activity_log.py" +export enum ActivityScope { + FEATURE_FLAG = 'FeatureFlag', + PERSON = 'Person', + INSIGHT = 'Insight', + PLUGIN = 'Plugin', + PLUGIN_CONFIG = 'PluginConfig', + DATA_MANAGEMENT = 'DataManagement', + EVENT_DEFINITION = 'EventDefinition', + PROPERTY_DEFINITION = 'PropertyDefinition', + NOTEBOOK = 'Notebook', + DASHBOARD = 'Dashboard', + REPLAY = 'Replay', + EXPERIMENT = 'Experiment', + SURVEY = 'Survey', + EARLY_ACCESS_FEATURE = 'EarlyAccessFeature', + COMMENT = 'Comment', +} + +export type CommentType = { + id: string + content: string + version: number + created_at: string + created_by: UserBasicType | null + source_comment?: string | null + scope: ActivityScope + item_id?: string + item_context: Record | null +} + export type NotebookListItemType = { - // id: string + id: string short_id: string title?: string is_template?: boolean @@ -3340,13 +3374,13 @@ export interface DataWarehouseViewLink { from_join_key?: string } -export interface ExternalDataStripeSourceCreatePayload { - account_id: string - client_secret: string +export type ExternalDataSourceType = 'Stripe' | 'Hubspot' + +export interface ExternalDataSourceCreatePayload { + source_type: ExternalDataSourceType prefix: string - source_type: string + payload: Record } - export interface ExternalDataStripeSource { id: string source_id: string @@ -3434,6 +3468,7 @@ export type BatchExportDestinationBigQuery = { table_id: string exclude_events: string[] include_events: string[] + use_json_type: boolean } } @@ -3464,6 +3499,7 @@ export type BatchExportConfiguration = { // User provided data for the export. This is the data that the user // provides when creating the export. id: string + team_id: number name: string destination: BatchExportDestination interval: 'hour' | 'day' | 'every 5 minutes' @@ -3572,4 +3608,5 @@ export enum SidePanelTab { Welcome = 'welcome', FeaturePreviews = 'feature-previews', Activity = 'activity', + Discussion = 'discussion', } diff --git a/hogql_parser/CONTRIBUTING.md b/hogql_parser/CONTRIBUTING.md index c03720aa3aabe..0d11fd4151506 100644 --- a/hogql_parser/CONTRIBUTING.md +++ b/hogql_parser/CONTRIBUTING.md @@ -38,11 +38,11 @@ The three pages below are must-reads though. They're key to writing production-r ## Conventions 1. Use `snake_case`. ANTLR is `camelCase`-heavy because of its Java heritage, but both the C++ stdlib and CPython are snaky. -2. Use the `auto` type for ANTLR and ANTLR-derived types, since they can be pretty verbose. Otherwise specify the type explictly. +2. Use the `auto` type for ANTLR and ANTLR-derived types, since they can be pretty verbose. Otherwise, specify the type explicitly. 3. Stay out of Python land as long as possible. E.g. avoid using `PyObject*`s` for bools or strings. Do use Python for parsing numbers though - that way we don't need to consider integer overflow. 4. If any child rule results in an AST node, so must the parent rule - once in Python land, always in Python land. - E.g. it doesn't make sense to create a `vector`, that should just be a `PyObject*` of Python type `list`. + E.g. it doesn't make sense to create a `vector`, that should just be a `PyObject*` of Python type `list`. ## How to develop locally on macOS diff --git a/hogql_parser/pyproject.toml b/hogql_parser/pyproject.toml index 6fde5a0352ccf..9e0c63839f5a4 100644 --- a/hogql_parser/pyproject.toml +++ b/hogql_parser/pyproject.toml @@ -9,7 +9,7 @@ build = [ # Build CPython wheels on Linux and macOS, for x86 as well as ARM "cp3*-manylinux_x86_64", "cp3*-manylinux_aarch64", ] -build-frontend = "build" # This is successor to building with pip +build-frontend = "build" # This is the successor to building with pip [tool.cibuildwheel.macos] archs = [ # We could also build a universal wheel, but separate ones are lighter individually @@ -28,9 +28,9 @@ before-all = [ "dnf install -y boost-devel 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_known_md5sum=\"c875c148991aacd043f733827644a76f\"", "antlr_found_ms5sum=\"$(md5sum antlr4-source.zip | cut -d' ' -f1)\"", - 'if [[ "$anltr_known_md5sum" != "$antlr_found_ms5sum" ]]; then exit 64; fi', + 'if [[ "$antlr_known_md5sum" != "$antlr_found_ms5sum" ]]; then exit 64; fi', "unzip antlr4-source.zip -d antlr4-source && cd antlr4-source", "cmake .", "DESTDIR=out make install", diff --git a/latest_migrations.manifest b/latest_migrations.manifest index 9deccea94b408..988a599c74b76 100644 --- a/latest_migrations.manifest +++ b/latest_migrations.manifest @@ -5,7 +5,7 @@ contenttypes: 0002_remove_content_type_name ee: 0015_add_verified_properties otp_static: 0002_throttling otp_totp: 0002_auto_20190420_0723 -posthog: 0379_alter_scheduledchange +posthog: 0381_alter_externaldatasource_source_type sessions: 0001_initial social_django: 0010_uid_db_index two_factor: 0007_auto_20201201_1019 diff --git a/plugin-server/src/capabilities.ts b/plugin-server/src/capabilities.ts index d5cbe7f05cf5a..c9b444067eeda 100644 --- a/plugin-server/src/capabilities.ts +++ b/plugin-server/src/capabilities.ts @@ -19,7 +19,7 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin processAsyncOnEventHandlers: true, processAsyncWebhooksHandlers: true, sessionRecordingBlobIngestion: true, - personOverrides: config.POE_DEFERRED_WRITES_ENABLED, + personOverrides: true, appManagementSingleton: true, preflightSchedules: true, ...sharedCapabilities, diff --git a/plugin-server/src/config/config.ts b/plugin-server/src/config/config.ts index 973b9c99eba46..fa1b290c0793a 100644 --- a/plugin-server/src/config/config.ts +++ b/plugin-server/src/config/config.ts @@ -128,9 +128,9 @@ export function getDefaultConfig(): PluginsServerConfig { EXTERNAL_REQUEST_TIMEOUT_MS: 10 * 1000, // 10 seconds DROP_EVENTS_BY_TOKEN_DISTINCT_ID: '', DROP_EVENTS_BY_TOKEN: '', - POE_DEFERRED_WRITES_ENABLED: false, - POE_DEFERRED_WRITES_USE_FLAT_OVERRIDES: false, POE_EMBRACE_JOIN_FOR_TEAMS: '', + POE_WRITES_ENABLED_MAX_TEAM_ID: 0, + POE_WRITES_EXCLUDE_TEAMS: '', RELOAD_PLUGIN_JITTER_MAX_MS: 60000, RUSTY_HOOK_FOR_TEAMS: '', RUSTY_HOOK_URL: '', diff --git a/plugin-server/src/main/pluginsServer.ts b/plugin-server/src/main/pluginsServer.ts index edf8a2f787833..f44567183e144 100644 --- a/plugin-server/src/main/pluginsServer.ts +++ b/plugin-server/src/main/pluginsServer.ts @@ -21,11 +21,7 @@ import { status } from '../utils/status' import { delay } from '../utils/utils' import { AppMetrics } from '../worker/ingestion/app-metrics' import { OrganizationManager } from '../worker/ingestion/organization-manager' -import { - DeferredPersonOverrideWorker, - FlatPersonOverrideWriter, - PersonOverrideWriter, -} from '../worker/ingestion/person-state' +import { DeferredPersonOverrideWorker, FlatPersonOverrideWriter } from '../worker/ingestion/person-state' import { TeamManager } from '../worker/ingestion/team-manager' import Piscina, { makePiscina as defaultMakePiscina } from '../worker/piscina' import { GraphileWorker } from './graphile-worker/graphile-worker' @@ -450,9 +446,7 @@ export async function startPluginsServer( personOverridesPeriodicTask = new DeferredPersonOverrideWorker( postgres, kafkaProducer, - serverConfig.POE_DEFERRED_WRITES_USE_FLAT_OVERRIDES - ? new FlatPersonOverrideWriter(postgres) - : new PersonOverrideWriter(postgres) + new FlatPersonOverrideWriter(postgres) ).runTask(5000) personOverridesPeriodicTask.promise.catch(async () => { status.error('⚠️', 'Person override worker task crashed! Requesting shutdown...') diff --git a/plugin-server/src/types.ts b/plugin-server/src/types.ts index a7f45b18aeb21..208e8cb48b43f 100644 --- a/plugin-server/src/types.ts +++ b/plugin-server/src/types.ts @@ -199,8 +199,8 @@ export interface PluginsServerConfig { DROP_EVENTS_BY_TOKEN_DISTINCT_ID: string DROP_EVENTS_BY_TOKEN: string POE_EMBRACE_JOIN_FOR_TEAMS: string - POE_DEFERRED_WRITES_ENABLED: boolean - POE_DEFERRED_WRITES_USE_FLAT_OVERRIDES: boolean + POE_WRITES_ENABLED_MAX_TEAM_ID: number + POE_WRITES_EXCLUDE_TEAMS: string RELOAD_PLUGIN_JITTER_MAX_MS: number RUSTY_HOOK_FOR_TEAMS: string RUSTY_HOOK_URL: string @@ -279,6 +279,7 @@ export interface Hub extends PluginsServerConfig { // ValueMatchers used for various opt-in/out features pluginConfigsToSkipElementsParsing: ValueMatcher poeEmbraceJoinForTeams: ValueMatcher + poeWritesExcludeTeams: ValueMatcher rustyHookForTeams: ValueMatcher // lookups eventsToDropByToken: Map diff --git a/plugin-server/src/utils/db/hub.ts b/plugin-server/src/utils/db/hub.ts index 75f15be3897b7..9a7af116308a3 100644 --- a/plugin-server/src/utils/db/hub.ts +++ b/plugin-server/src/utils/db/hub.ts @@ -188,6 +188,7 @@ export async function createHub( conversionBufferEnabledTeams, pluginConfigsToSkipElementsParsing: buildIntegerMatcher(process.env.SKIP_ELEMENTS_PARSING_PLUGINS, true), poeEmbraceJoinForTeams: buildIntegerMatcher(process.env.POE_EMBRACE_JOIN_FOR_TEAMS, true), + poeWritesExcludeTeams: buildIntegerMatcher(process.env.POE_WRITES_EXCLUDE_TEAMS, false), rustyHookForTeams: buildIntegerMatcher(process.env.RUSTY_HOOK_FOR_TEAMS, true), eventsToDropByToken: createEventsToDropByToken(process.env.DROP_EVENTS_BY_TOKEN_DISTINCT_ID), } diff --git a/plugin-server/src/worker/ingestion/event-pipeline/processPersonsStep.ts b/plugin-server/src/worker/ingestion/event-pipeline/processPersonsStep.ts index e01133f8c615f..91bf6f10f27bd 100644 --- a/plugin-server/src/worker/ingestion/event-pipeline/processPersonsStep.ts +++ b/plugin-server/src/worker/ingestion/event-pipeline/processPersonsStep.ts @@ -4,7 +4,7 @@ import { Person } from 'types' import { normalizeEvent } from '../../../utils/event' import { status } from '../../../utils/status' -import { DeferredPersonOverrideWriter, PersonOverrideWriter, PersonState } from '../person-state' +import { DeferredPersonOverrideWriter, PersonState } from '../person-state' import { parseEventTimestamp } from '../timestamps' import { EventPipelineRunner } from './runner' @@ -22,13 +22,9 @@ export async function processPersonsStep( throw error } - let overridesWriter: PersonOverrideWriter | DeferredPersonOverrideWriter | undefined = undefined + let overridesWriter: DeferredPersonOverrideWriter | undefined = undefined if (runner.poEEmbraceJoin) { - if (runner.hub.POE_DEFERRED_WRITES_ENABLED) { - overridesWriter = new DeferredPersonOverrideWriter(runner.hub.db.postgres) - } else { - overridesWriter = new PersonOverrideWriter(runner.hub.db.postgres) - } + overridesWriter = new DeferredPersonOverrideWriter(runner.hub.db.postgres) } const person = await new PersonState( diff --git a/plugin-server/src/worker/ingestion/event-pipeline/runner.ts b/plugin-server/src/worker/ingestion/event-pipeline/runner.ts index 451a033440aa2..e23e640461bb1 100644 --- a/plugin-server/src/worker/ingestion/event-pipeline/runner.ts +++ b/plugin-server/src/worker/ingestion/event-pipeline/runner.ts @@ -111,7 +111,10 @@ export class EventPipelineRunner { } async runEventPipelineSteps(event: PluginEvent): Promise { - if (this.hub.poeEmbraceJoinForTeams?.(event.team_id)) { + if ( + this.hub.poeEmbraceJoinForTeams?.(event.team_id) || + (event.team_id <= this.hub.POE_WRITES_ENABLED_MAX_TEAM_ID && !this.hub.poeWritesExcludeTeams(event.team_id)) + ) { // https://docs.google.com/document/d/12Q1KcJ41TicIwySCfNJV5ZPKXWVtxT7pzpB3r9ivz_0 // We're not using the buffer anymore // instead we'll (if within timeframe) merge into the newer personId diff --git a/plugin-server/src/worker/ingestion/person-state.ts b/plugin-server/src/worker/ingestion/person-state.ts index f49ee1ad334e9..958cc6f09f598 100644 --- a/plugin-server/src/worker/ingestion/person-state.ts +++ b/plugin-server/src/worker/ingestion/person-state.ts @@ -99,7 +99,7 @@ export class PersonState { distinctId: string, timestamp: DateTime, db: DB, - private personOverrideWriter?: PersonOverrideWriter | DeferredPersonOverrideWriter, + private personOverrideWriter?: DeferredPersonOverrideWriter, uuid: UUIDT | undefined = undefined, maxMergeAttempts: number = MAX_FAILED_PERSON_MERGE_ATTEMPTS ) { @@ -496,23 +496,14 @@ export class PersonState { const deletePersonMessages = await this.db.deletePerson(otherPerson, tx) - let personOverrideMessages: ProducerRecord[] = [] if (this.personOverrideWriter) { - personOverrideMessages = await this.personOverrideWriter.addPersonOverride( + await this.personOverrideWriter.addPersonOverride( tx, getPersonOverrideDetails(this.teamId, otherPerson, mergeInto) ) } - return [ - [ - ...personOverrideMessages, - ...updatePersonMessages, - ...distinctIdMessages, - ...deletePersonMessages, - ], - person, - ] + return [[...updatePersonMessages, ...distinctIdMessages, ...deletePersonMessages], person] } ) @@ -554,185 +545,6 @@ function getPersonOverrideDetails(teamId: number, oldPerson: Person, overridePer } } -export class PersonOverrideWriter { - constructor(private postgres: PostgresRouter) {} - - public async addPersonOverride( - tx: TransactionClient, - overrideDetails: PersonOverrideDetails - ): Promise { - const mergedAt = DateTime.now() - /** - We'll need to do 4 updates: - - 1. Add the persons involved to the helper table (2 of them) - 2. Add an override from oldPerson to override person - 3. Update any entries that have oldPerson as the override person to now also point to the new override person. Note that we don't update `oldest_event`, because it's a heuristic (used to optimise squashing) tied to the old_person and nothing changed about the old_person who's events need to get squashed. - */ - const oldPersonMappingId = await this.addPersonOverrideMapping( - tx, - overrideDetails.team_id, - overrideDetails.old_person_id - ) - const overridePersonMappingId = await this.addPersonOverrideMapping( - tx, - overrideDetails.team_id, - overrideDetails.override_person_id - ) - - await this.postgres.query( - tx, - SQL` - INSERT INTO posthog_personoverride ( - team_id, - old_person_id, - override_person_id, - oldest_event, - version - ) VALUES ( - ${overrideDetails.team_id}, - ${oldPersonMappingId}, - ${overridePersonMappingId}, - ${overrideDetails.oldest_event}, - 0 - ) - `, - undefined, - 'personOverride' - ) - - // The follow-up JOIN is required as ClickHouse requires UUIDs, so we need to fetch the UUIDs - // of the IDs we updated from the mapping table. - const { rows: transitiveUpdates } = await this.postgres.query( - tx, - SQL` - WITH updated_ids AS ( - UPDATE - posthog_personoverride - SET - override_person_id = ${overridePersonMappingId}, version = COALESCE(version, 0)::numeric + 1 - WHERE - team_id = ${overrideDetails.team_id} AND override_person_id = ${oldPersonMappingId} - RETURNING - old_person_id, - version, - oldest_event - ) - SELECT - helper.uuid as old_person_id, - updated_ids.version, - updated_ids.oldest_event - FROM - updated_ids - JOIN - posthog_personoverridemapping helper - ON - helper.id = updated_ids.old_person_id; - `, - undefined, - 'transitivePersonOverrides' - ) - - status.debug('🔁', 'person_overrides_updated', { transitiveUpdates }) - - const personOverrideMessages: ProducerRecord[] = [ - { - topic: KAFKA_PERSON_OVERRIDE, - messages: [ - { - value: JSON.stringify({ - team_id: overrideDetails.team_id, - old_person_id: overrideDetails.old_person_id, - override_person_id: overrideDetails.override_person_id, - oldest_event: castTimestampOrNow(overrideDetails.oldest_event, TimestampFormat.ClickHouse), - merged_at: castTimestampOrNow(mergedAt, TimestampFormat.ClickHouse), - version: 0, - }), - }, - ...transitiveUpdates.map(({ old_person_id, version, oldest_event }) => ({ - value: JSON.stringify({ - team_id: overrideDetails.team_id, - old_person_id: old_person_id, - override_person_id: overrideDetails.override_person_id, - oldest_event: castTimestampOrNow(oldest_event, TimestampFormat.ClickHouse), - merged_at: castTimestampOrNow(mergedAt, TimestampFormat.ClickHouse), - version: version, - }), - })), - ], - }, - ] - - return personOverrideMessages - } - - private async addPersonOverrideMapping(tx: TransactionClient, teamId: number, personId: string): Promise { - /** - Update the helper table that serves as a mapping between a serial ID and a Person UUID. - - This mapping is used to enable an exclusion constraint in the personoverrides table, which - requires int[], while avoiding any constraints on "hotter" tables, like person. - **/ - - // ON CONFLICT nothing is returned, so we get the id in the second SELECT statement below. - // Fear not, the constraints on personoverride will handle any inconsistencies. - // This mapping table is really nothing more than a mapping to support exclusion constraints - // as we map int ids to UUIDs (the latter not supported in exclusion contraints). - const { - rows: [{ id }], - } = await this.postgres.query( - tx, - `WITH insert_id AS ( - INSERT INTO posthog_personoverridemapping( - team_id, - uuid - ) - VALUES ( - ${teamId}, - '${personId}' - ) - ON CONFLICT("team_id", "uuid") DO NOTHING - RETURNING id - ) - SELECT * FROM insert_id - UNION ALL - SELECT id - FROM posthog_personoverridemapping - WHERE team_id = ${teamId} AND uuid = '${personId}' - `, - undefined, - 'personOverrideMapping' - ) - - return id - } - - public async getPersonOverrides(teamId: number): Promise { - const { rows } = await this.postgres.query( - PostgresUse.COMMON_WRITE, - SQL` - SELECT - override.team_id, - old_person.uuid as old_person_id, - override_person.uuid as override_person_id, - oldest_event - FROM posthog_personoverride override - LEFT OUTER JOIN posthog_personoverridemapping old_person - ON override.team_id = old_person.team_id AND override.old_person_id = old_person.id - LEFT OUTER JOIN posthog_personoverridemapping override_person - ON override.team_id = override_person.team_id AND override.override_person_id = override_person.id - WHERE override.team_id = ${teamId} - `, - undefined, - 'getPersonOverrides' - ) - return rows.map((row) => ({ - ...row, - oldest_event: DateTime.fromISO(row.oldest_event), - })) - } -} - export class FlatPersonOverrideWriter { constructor(private postgres: PostgresRouter) {} @@ -848,10 +660,7 @@ export class DeferredPersonOverrideWriter { /** * Enqueue an override for deferred processing. */ - public async addPersonOverride( - tx: TransactionClient, - overrideDetails: PersonOverrideDetails - ): Promise { + public async addPersonOverride(tx: TransactionClient, overrideDetails: PersonOverrideDetails): Promise { await this.postgres.query( tx, SQL` @@ -870,7 +679,6 @@ export class DeferredPersonOverrideWriter { 'pendingPersonOverride' ) deferredPersonOverridesWrittenCounter.inc() - return [] } } @@ -891,7 +699,7 @@ export class DeferredPersonOverrideWorker { constructor( private postgres: PostgresRouter, private kafkaProducer: KafkaProducerWrapper, - private writer: PersonOverrideWriter | FlatPersonOverrideWriter + private writer: FlatPersonOverrideWriter ) {} /** diff --git a/plugin-server/src/worker/plugins/run.ts b/plugin-server/src/worker/plugins/run.ts index 13df2873fae43..97341742e2711 100644 --- a/plugin-server/src/worker/plugins/run.ts +++ b/plugin-server/src/worker/plugins/run.ts @@ -76,10 +76,13 @@ export async function runOnEvent(hub: Hub, event: ProcessedPluginEvent): Promise const RUSTY_HOOK_BASE_DELAY_MS = 100 const MAX_RUSTY_HOOK_DELAY_MS = 30_000 -interface RustyWebhookPayload extends Webhook { - team_id: number - plugin_id: number - plugin_config_id: number +interface RustyWebhookPayload { + parameters: Webhook + metadata: { + team_id: number + plugin_id: number + plugin_config_id: number + } } async function enqueueInRustyHook(hub: Hub, webhook: Webhook, pluginConfig: PluginConfig) { @@ -87,10 +90,12 @@ async function enqueueInRustyHook(hub: Hub, webhook: Webhook, pluginConfig: Plug webhook.headers ??= {} const rustyWebhookPayload: RustyWebhookPayload = { - ...webhook, - team_id: pluginConfig.team_id, - plugin_id: pluginConfig.plugin_id, - plugin_config_id: pluginConfig.id, + parameters: webhook, + metadata: { + team_id: pluginConfig.team_id, + plugin_id: pluginConfig.plugin_id, + plugin_config_id: pluginConfig.id, + }, } const body = JSON.stringify(rustyWebhookPayload, undefined, 4) @@ -129,7 +134,10 @@ async function enqueueInRustyHook(hub: Hub, webhook: Webhook, pluginConfig: Plug .labels(pluginConfig.plugin_id.toString(), 'enqueueRustyHook', 'error') .observe(new Date().getTime() - timer.getTime()) - const redactedWebhook = { ...rustyWebhookPayload, body: '' } + const redactedWebhook = { + parameters: { ...rustyWebhookPayload.parameters, body: '' }, + metadata: rustyWebhookPayload.metadata, + } status.error('🔴', 'Webhook enqueue to rusty-hook failed', { error, redactedWebhook, attempt }) Sentry.captureException(error, { extra: { redactedWebhook } }) } diff --git a/plugin-server/tests/worker/ingestion/person-state.test.ts b/plugin-server/tests/worker/ingestion/person-state.test.ts index 318c3504d3e98..df847a5112f73 100644 --- a/plugin-server/tests/worker/ingestion/person-state.test.ts +++ b/plugin-server/tests/worker/ingestion/person-state.test.ts @@ -12,7 +12,6 @@ import { DeferredPersonOverrideWorker, DeferredPersonOverrideWriter, FlatPersonOverrideWriter, - PersonOverrideWriter, PersonState, } from '../../../src/worker/ingestion/person-state' import { delayUntilEventIngested } from '../../helpers/clickhouse' @@ -27,7 +26,7 @@ const timestampch = '2020-01-01 12:00:05.000' interface PersonOverridesMode { supportsSyncTransaction: boolean - getWriter(hub: Hub): PersonOverrideWriter | DeferredPersonOverrideWriter + getWriter(hub: Hub): DeferredPersonOverrideWriter fetchPostgresPersonIdOverrides( hub: Hub, teamId: number @@ -36,37 +35,6 @@ interface PersonOverridesMode { const PersonOverridesModes: Record = { disabled: undefined, - 'immediate, with mappings': { - supportsSyncTransaction: true, - getWriter: (hub) => new PersonOverrideWriter(hub.db.postgres), - fetchPostgresPersonIdOverrides: async (hub, teamId) => { - const writer = new PersonOverrideWriter(hub.db.postgres) // XXX: ideally would reference ``this``, not new instance - return new Set( - (await writer.getPersonOverrides(teamId)).map(({ old_person_id, override_person_id }) => ({ - old_person_id, - override_person_id, - })) - ) - }, - }, - 'deferred, with mappings': { - supportsSyncTransaction: false, - getWriter: (hub) => new DeferredPersonOverrideWriter(hub.db.postgres), - fetchPostgresPersonIdOverrides: async (hub, teamId) => { - const syncWriter = new PersonOverrideWriter(hub.db.postgres) - await new DeferredPersonOverrideWorker( - hub.db.postgres, - hub.db.kafkaProducer, - syncWriter - ).processPendingOverrides() - return new Set( - (await syncWriter.getPersonOverrides(teamId)).map(({ old_person_id, override_person_id }) => ({ - old_person_id, - override_person_id, - })) - ) - }, - }, 'deferred, without mappings (flat)': { supportsSyncTransaction: false, getWriter: (hub) => new DeferredPersonOverrideWriter(hub.db.postgres), @@ -2107,23 +2075,18 @@ describe('PersonState.update()', () => { }) }) -const PersonOverridesWriterMode = { - mapping: (hub: Hub) => new PersonOverrideWriter(hub.db.postgres), - flat: (hub: Hub) => new FlatPersonOverrideWriter(hub.db.postgres), -} - -describe.each(Object.keys(PersonOverridesWriterMode))('person overrides writer: %s', (mode) => { +describe('flat person overrides writer', () => { let hub: Hub let closeHub: () => Promise let organizationId: string let teamId: number - let writer: PersonOverrideWriter | FlatPersonOverrideWriter + let writer: FlatPersonOverrideWriter beforeAll(async () => { ;[hub, closeHub] = await createHub({}) organizationId = await createOrganization(hub.db.postgres) - writer = PersonOverridesWriterMode[mode](hub) + writer = new FlatPersonOverrideWriter(hub.db.postgres) }) beforeEach(async () => { @@ -2201,14 +2164,14 @@ describe('deferred person overrides', () => { let teamId: number let writer: DeferredPersonOverrideWriter - let syncWriter: PersonOverrideWriter + let syncWriter: FlatPersonOverrideWriter let worker: DeferredPersonOverrideWorker beforeAll(async () => { ;[hub, closeHub] = await createHub({}) organizationId = await createOrganization(hub.db.postgres) writer = new DeferredPersonOverrideWriter(hub.db.postgres) - syncWriter = new PersonOverrideWriter(hub.db.postgres) + syncWriter = new FlatPersonOverrideWriter(hub.db.postgres) worker = new DeferredPersonOverrideWorker(hub.db.postgres, hub.db.kafkaProducer, syncWriter) }) diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py index 6eb06a1649514..63283a026942f 100644 --- a/posthog/api/__init__.py +++ b/posthog/api/__init__.py @@ -11,6 +11,7 @@ app_metrics, async_migration, authentication, + comments, dead_letter_queue, early_access_feature, event_definition, @@ -189,6 +190,9 @@ def api_not_found(request): # Organizations nested endpoints organizations_router = router.register(r"organizations", organization.OrganizationViewSet, "organizations") +organizations_router.register( + r"batch_exports", batch_exports.BatchExportOrganizationViewSet, "batch_exports", ["organization_id"] +) organization_plugins_router = organizations_router.register( r"plugins", plugin.PluginViewSet, "organization_plugins", ["organization_id"] ) @@ -359,4 +363,11 @@ def api_not_found(request): ["team_id"], ) +projects_router.register( + r"comments", + comments.CommentViewSet, + "project_comments", + ["team_id"], +) + projects_router.register(r"search", search.SearchViewSet, "project_search", ["team_id"]) diff --git a/posthog/api/activity_log.py b/posthog/api/activity_log.py index 09d3bec7a3de9..0cf972935ad44 100644 --- a/posthog/api/activity_log.py +++ b/posthog/api/activity_log.py @@ -1,4 +1,5 @@ -from typing import Any, Optional +import time +from typing import Any, Optional, Dict from django.db.models import Q, QuerySet @@ -11,6 +12,7 @@ from posthog.api.routing import StructuredViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.models import ActivityLog, FeatureFlag, Insight, NotificationViewed, User +from posthog.models.comment import Comment from posthog.models.notebook.notebook import Notebook @@ -44,6 +46,31 @@ class ActivityLogPagination(pagination.CursorPagination): page_size = 100 +# context manager for gathering a sequence of server timings +class ServerTimingsGathered: + # Class level dictionary to store timings + timings_dict: Dict[str, float] = {} + + def __call__(self, name): + self.name = name + return self + + def __enter__(self): + # timings are assumed to be in milliseconds when reported + # but are gathered by time.perf_counter which is fractional seconds 🫠 + # so each value is multiplied by 1000 at collection + self.start_time = time.perf_counter() * 1000 + + def __exit__(self, exc_type, exc_val, exc_tb): + end_time = time.perf_counter() * 1000 + elapsed_time = end_time - self.start_time + ServerTimingsGathered.timings_dict[self.name] = elapsed_time + + @classmethod + def get_all_timings(cls): + return cls.timings_dict + + class ActivityLogViewSet(StructuredViewSetMixin, viewsets.GenericViewSet, mixins.ListModelMixin): queryset = ActivityLog.objects.all() serializer_class = ActivityLogSerializer @@ -59,6 +86,10 @@ def get_queryset(self) -> QuerySet: if params.get("user"): queryset = queryset.filter(user=params.get("user")) + if params.get("scope"): + queryset = queryset.filter(scope=params.get("scope")) + if params.get("item_id"): + queryset = queryset.filter(item_id=params.get("item_id")) return queryset @@ -71,116 +102,146 @@ def important_changes(self, request: Request, *args: Any, **kwargs: Any) -> Resp # this is for mypy return Response(status=status.HTTP_401_UNAUTHORIZED) - # first things this user created - my_insights = list(Insight.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True)) - my_feature_flags = list( - FeatureFlag.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True) - ) - my_notebooks = list(Notebook.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True)) - - # then things they edited - interesting_changes = [ - "updated", - "exported", - "sharing enabled", - "sharing disabled", - "deleted", - ] - my_changed_insights = list( - ActivityLog.objects.filter( - team_id=self.team.id, - activity__in=interesting_changes, - user_id=user.pk, - scope="Insight", + timer = ServerTimingsGathered() + + with timer("gather_query_parts"): + # first things this user created + my_insights = list( + Insight.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True) + ) + my_feature_flags = list( + FeatureFlag.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True) + ) + my_notebooks = list( + Notebook.objects.filter(created_by=user, team_id=self.team.pk).values_list("short_id", flat=True) + ) + my_comments = list( + Comment.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True) ) - .exclude(item_id__in=my_insights) - .values_list("item_id", flat=True) - ) - my_changed_notebooks = list( - ActivityLog.objects.filter( - team_id=self.team.id, - activity__in=interesting_changes, - user_id=user.pk, - scope="Notebook", + # then things they edited + interesting_changes = [ + "updated", + "exported", + "sharing enabled", + "sharing disabled", + "deleted", + "commented", + ] + my_changed_insights = list( + ActivityLog.objects.filter( + team_id=self.team.id, + activity__in=interesting_changes, + user_id=user.pk, + scope="Insight", + ) + .exclude(item_id__in=my_insights) + .values_list("item_id", flat=True) ) - .exclude(item_id__in=my_notebooks) - .values_list("item_id", flat=True) - ) - my_changed_feature_flags = list( - ActivityLog.objects.filter( - team_id=self.team.id, - activity__in=interesting_changes, - user_id=user.pk, - scope="FeatureFlag", + my_changed_notebooks = list( + ActivityLog.objects.filter( + team_id=self.team.id, + activity__in=interesting_changes, + user_id=user.pk, + scope="Notebook", + ) + .exclude(item_id__in=my_notebooks) + .values_list("item_id", flat=True) ) - .exclude(item_id__in=my_feature_flags) - .values_list("item_id", flat=True) - ) - last_read_date = NotificationViewed.objects.filter(user=user).first() - last_read_filter = "" - - if last_read_date and params.get("unread") == "true": - last_read_filter = f"AND created_at > '{last_read_date.last_viewed_activity_date.isoformat()}'" - - # before we filter to include only the important changes, we need to deduplicate too frequent changes - candidate_ids = ActivityLog.objects.raw( - f""" - SELECT id - FROM (SELECT - Row_number() over ( - PARTITION BY five_minute_window, activity, item_id, scope ORDER BY created_at DESC - ) AS row_number, - * - FROM ( - -- copied from https://stackoverflow.com/a/43028800 - SELECT to_timestamp(floor(Extract(epoch FROM created_at) / extract(epoch FROM interval '5 min')) * - extract(epoch FROM interval '5 min')) AS five_minute_window, - activity, item_id, scope, id, created_at - FROM posthog_activitylog - WHERE team_id = {self.team_id} - AND NOT (user_id = {user.pk} AND user_id IS NOT NULL) - {last_read_filter} - ORDER BY created_at DESC) AS inner_q) AS counted_q - WHERE row_number = 1 - """ - ) + my_changed_feature_flags = list( + ActivityLog.objects.filter( + team_id=self.team.id, + activity__in=interesting_changes, + user_id=user.pk, + scope="FeatureFlag", + ) + .exclude(item_id__in=my_feature_flags) + .values_list("item_id", flat=True) + ) - other_peoples_changes = ( - self.queryset.exclude(user=user) - .filter(team_id=self.team.id) - .filter( - Q( - Q(Q(scope="FeatureFlag") & Q(item_id__in=my_feature_flags)) - | Q(Q(scope="Insight") & Q(item_id__in=my_insights)) - | Q(Q(scope="Notebook") & Q(item_id__in=my_notebooks)) + my_changed_comments = list( + ActivityLog.objects.filter( + team_id=self.team.id, + activity__in=interesting_changes, + user_id=user.pk, + scope="Comment", ) - | Q( - # don't want to see creation of these things since that was before the user edited these things - Q(activity__in=interesting_changes) - & Q( - Q(Q(scope="FeatureFlag") & Q(item_id__in=my_changed_feature_flags)) - | Q(Q(scope="Insight") & Q(item_id__in=my_changed_insights)) - | Q(Q(scope="Notebook") & Q(item_id__in=my_changed_notebooks)) + .exclude(item_id__in=my_comments) + .values_list("item_id", flat=True) + ) + + last_read_date = NotificationViewed.objects.filter(user=user).first() + last_read_filter = "" + + if last_read_date and params.get("unread") == "true": + last_read_filter = f"AND created_at > '{last_read_date.last_viewed_activity_date.isoformat()}'" + + with timer("query_for_candidate_ids"): + # before we filter to include only the important changes, we need to deduplicate too frequent changes + candidate_ids = ActivityLog.objects.raw( + f""" + SELECT id + FROM (SELECT + Row_number() over ( + PARTITION BY five_minute_window, activity, item_id, scope ORDER BY created_at DESC + ) AS row_number, + * + FROM ( + -- copied from https://stackoverflow.com/a/43028800 + SELECT to_timestamp(floor(Extract(epoch FROM created_at) / extract(epoch FROM interval '5 min')) * + extract(epoch FROM interval '5 min')) AS five_minute_window, + activity, item_id, scope, id, created_at + FROM posthog_activitylog + WHERE team_id = {self.team_id} + AND NOT (user_id = {user.pk} AND user_id IS NOT NULL) + {last_read_filter} + ORDER BY created_at DESC) AS inner_q) AS counted_q + WHERE row_number = 1 + """ + ) + + with timer("construct_query"): + other_peoples_changes = ( + self.queryset.exclude(user=user) + .filter(team_id=self.team.id) + .filter( + Q( + Q(Q(scope="FeatureFlag") & Q(item_id__in=my_feature_flags)) + | Q(Q(scope="Insight") & Q(item_id__in=my_insights)) + | Q(Q(scope="Notebook") & Q(item_id__in=my_notebooks)) + | Q(Q(scope="Comment") & Q(item_id__in=my_comments)) + ) + | Q( + # don't want to see creation of these things since that was before the user edited these things + Q(activity__in=interesting_changes) + & Q( + Q(Q(scope="FeatureFlag") & Q(item_id__in=my_changed_feature_flags)) + | Q(Q(scope="Insight") & Q(item_id__in=my_changed_insights)) + | Q(Q(scope="Notebook") & Q(item_id__in=my_changed_notebooks)) + | Q(Q(scope="Comment") & Q(item_id__in=my_changed_comments)) + ) ) ) + .filter(id__in=[c.id for c in candidate_ids]) + .order_by("-created_at") ) - .filter(id__in=[c.id for c in candidate_ids]) - .order_by("-created_at") - ) - if last_read_date and params.get("unread") == "true": - other_peoples_changes = other_peoples_changes.filter( - created_at__gt=last_read_date.last_viewed_activity_date - ) + if last_read_date and params.get("unread") == "true": + other_peoples_changes = other_peoples_changes.filter( + created_at__gt=last_read_date.last_viewed_activity_date + ) + + with timer("query_for_data"): + page_of_data = other_peoples_changes[:10] - serialized_data = ActivityLogSerializer( - instance=other_peoples_changes[:10], many=True, context={"user": user} - ).data + with timer("serialize"): + serialized_data = ActivityLogSerializer(instance=page_of_data, many=True, context={"user": user}).data - return Response( + timings = timer.get_all_timings() + + response = Response( status=status.HTTP_200_OK, data={ "results": serialized_data, @@ -188,6 +249,12 @@ def important_changes(self, request: Request, *args: Any, **kwargs: Any) -> Resp }, ) + response.headers["Server-Timing"] = ", ".join( + f"{key};dur={round(duration, ndigits=2)}" for key, duration in timings.items() + ) + + return response + @action(methods=["POST"], detail=False) def bookmark_activity_notification(self, request: Request, *args: Any, **kwargs: Any) -> Response: user = request.user diff --git a/posthog/api/comments.py b/posthog/api/comments.py new file mode 100644 index 0000000000000..9b8a4f280142c --- /dev/null +++ b/posthog/api/comments.py @@ -0,0 +1,108 @@ +from typing import Any, Dict, cast +from django.db import transaction +from django.db.models import QuerySet + +from rest_framework import exceptions, serializers, viewsets, pagination +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from posthog.api.forbid_destroy_model import ForbidDestroyModel + +from posthog.api.routing import StructuredViewSetMixin +from posthog.api.shared import UserBasicSerializer +from posthog.models.comment import Comment + + +class CommentSerializer(serializers.ModelSerializer): + created_by = UserBasicSerializer(read_only=True) + + class Meta: + model = Comment + exclude = ["team"] + read_only_fields = ["id", "created_by", "version"] + + def validate(self, data): + request = self.context["request"] + instance = cast(Comment, self.instance) + + if instance: + if instance.created_by != request.user: + raise exceptions.PermissionDenied("You can only modify your own comments") + # TODO: Ensure created_by is set + # And only allow updates to own comment + + data["created_by"] = request.user + + return data + + def create(self, validated_data: Any) -> Any: + validated_data["team_id"] = self.context["team_id"] + return super().create(validated_data) + + def update(self, instance: Comment, validated_data: Dict, **kwargs) -> Comment: + request = self.context["request"] + + with transaction.atomic(): + # select_for_update locks the database row so we ensure version updates are atomic + locked_instance = Comment.objects.select_for_update().get(pk=instance.pk) + + if locked_instance.created_by != request.user: + raise + + if validated_data.keys(): + if validated_data.get("content"): + validated_data["version"] = locked_instance.version + 1 + + updated_instance = super().update(locked_instance, validated_data) + + return updated_instance + + +class CommentPagination(pagination.CursorPagination): + ordering = "-created_at" + page_size = 100 + + +class CommentViewSet(StructuredViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet): + queryset = Comment.objects.all() + serializer_class = CommentSerializer + pagination_class = CommentPagination + + def get_queryset(self) -> QuerySet: + queryset = super().get_queryset() + params = self.request.GET.dict() + + if params.get("user"): + queryset = queryset.filter(user=params.get("user")) + + if self.action != "partial_update" and params.get("deleted", "false") == "false": + queryset = queryset.filter(deleted=False) + + if params.get("scope"): + queryset = queryset.filter(scope=params.get("scope")) + + if params.get("item_id"): + queryset = queryset.filter(item_id=params.get("item_id")) + + source_comment = params.get("source_comment") + if self.action == "thread": + # Filter based on the source_comment + source_comment = self.kwargs.get("pk") + + if source_comment: + # NOTE: Should we also return the source_comment ? + queryset = queryset.filter(source_comment_id=source_comment) + + return queryset + + @action(methods=["GET"], detail=True) + def thread(self, request: Request, *args: Any, **kwargs: Any) -> Response: + return self.list(request, *args, **kwargs) + + @action(methods=["GET"], detail=False) + def count(self, request: Request, **kwargs) -> Response: + queryset = self.get_queryset() + count = queryset.count() + + return Response({"count": count}) diff --git a/posthog/api/feature_flag.py b/posthog/api/feature_flag.py index 5022fd21676ea..dcc040606cadc 100644 --- a/posthog/api/feature_flag.py +++ b/posthog/api/feature_flag.py @@ -671,7 +671,7 @@ def activity(self, request: request.Request, **kwargs): activity_page = load_activity( scope="FeatureFlag", team_id=self.team_id, - item_id=item_id, + item_ids=[str(item_id)], limit=limit, page=page, ) diff --git a/posthog/api/insight.py b/posthog/api/insight.py index e42a99caf36d5..9990038255635 100644 --- a/posthog/api/insight.py +++ b/posthog/api/insight.py @@ -1029,7 +1029,7 @@ def activity(self, request: request.Request, **kwargs): activity_page = load_activity( scope="Insight", team_id=self.team_id, - item_id=item_id, + item_ids=[str(item_id)], limit=limit, page=page, ) diff --git a/posthog/api/notebook.py b/posthog/api/notebook.py index 12faffa975ba4..d0f63e08220fa 100644 --- a/posthog/api/notebook.py +++ b/posthog/api/notebook.py @@ -58,22 +58,22 @@ def depluralize(string: str | None) -> str | None: def log_notebook_activity( activity: str, - notebook_id: str, - notebook_short_id: str, - notebook_name: str, + notebook: Notebook, organization_id: UUIDT, team_id: int, user: User, changes: Optional[List[Change]] = None, ) -> None: + short_id = str(notebook.short_id) + log_activity( organization_id=organization_id, team_id=team_id, user=user, - item_id=notebook_id, + item_id=notebook.short_id, scope="Notebook", activity=activity, - detail=Detail(changes=changes, short_id=notebook_short_id, name=notebook_name), + detail=Detail(changes=changes, short_id=short_id, name=notebook.title), ) @@ -135,9 +135,7 @@ def create(self, validated_data: Dict, *args, **kwargs) -> Notebook: log_notebook_activity( activity="created", - notebook_id=notebook.id, - notebook_short_id=str(notebook.short_id), - notebook_name=notebook.title, + notebook=notebook, organization_id=self.context["request"].user.current_organization_id, team_id=team.id, user=self.context["request"].user, @@ -171,9 +169,7 @@ def update(self, instance: Notebook, validated_data: Dict, **kwargs) -> Notebook log_notebook_activity( activity="updated", - notebook_id=str(updated_notebook.id), - notebook_short_id=str(updated_notebook.short_id), - notebook_name=updated_notebook.title, + notebook=updated_notebook, organization_id=self.context["request"].user.current_organization_id, team_id=self.context["team_id"], user=self.context["request"].user, @@ -375,7 +371,7 @@ def activity(self, request: Request, **kwargs): activity_page = load_activity( scope="Notebook", team_id=self.team_id, - item_id=notebook.id, + item_ids=[notebook.id, notebook.short_id], limit=limit, page=page, ) diff --git a/posthog/api/person.py b/posthog/api/person.py index 287f4c256825c..390f4f82a4c98 100644 --- a/posthog/api/person.py +++ b/posthog/api/person.py @@ -622,7 +622,7 @@ def activity(self, request: request.Request, pk=None, **kwargs): activity_page = load_activity( scope="Person", team_id=self.team_id, - item_id=item_id, + item_ids=[item_id] if item_id else None, limit=limit, page=page, ) diff --git a/posthog/api/plugin.py b/posthog/api/plugin.py index c6cf5e28a5775..b5a8bfc130218 100644 --- a/posthog/api/plugin.py +++ b/posthog/api/plugin.py @@ -349,6 +349,17 @@ def unused(self, request: request.Request, **kwargs): ).values_list("id", flat=True) return Response(ids) + @action(methods=["GET"], detail=False) + def exports_unsubscribe_configs(self, request: request.Request, **kwargs): + # return all the plugin_configs for the org that are not global transformation/filter plugins + allowed_plugins_q = Q(plugin__is_global=True) & ( + Q(plugin__capabilities__methods__contains=["processEvent"]) | Q(plugin__capabilities={}) + ) + plugin_configs = PluginConfig.objects.filter( + Q(team__organization_id=self.organization_id, enabled=True) & ~allowed_plugins_q + ) + return Response(PluginConfigSerializer(plugin_configs, many=True).data) + @action(methods=["GET"], detail=True) def check_for_updates(self, request: request.Request, **kwargs): plugin = self.get_plugin_with_permissions(reason="installation") @@ -600,6 +611,12 @@ def get_config(self, plugin_config: PluginConfig): return new_plugin_config + def to_representation(self, instance: Any) -> Any: + representation = super().to_representation(instance) + representation["name"] = representation["name"] or instance.plugin.name + representation["description"] = representation["description"] or instance.plugin.description + return representation + def get_plugin_info(self, plugin_config: PluginConfig): if "view" in self.context and self.context["view"].action == "retrieve": return PluginSerializer(instance=plugin_config.plugin).data diff --git a/posthog/api/test/__snapshots__/test_feature_flag.ambr b/posthog/api/test/__snapshots__/test_feature_flag.ambr index 8aa343d472877..822f395d4a690 100644 --- a/posthog/api/test/__snapshots__/test_feature_flag.ambr +++ b/posthog/api/test/__snapshots__/test_feature_flag.ambr @@ -1678,7 +1678,7 @@ --- # name: TestFeatureFlag.test_creating_static_cohort.14 ' - /* user_id:191 celery:posthog.tasks.calculate_cohort.insert_cohort_from_feature_flag */ + /* user_id:192 celery:posthog.tasks.calculate_cohort.insert_cohort_from_feature_flag */ SELECT count(DISTINCT person_id) FROM person_static_cohort WHERE team_id = 2 diff --git a/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr b/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr index 9cfd43775ba67..ea83d38195a8f 100644 --- a/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr +++ b/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr @@ -120,122 +120,6 @@ ' --- # name: TestNotebooks.test_updates_notebook.12 - ' - SELECT "posthog_team"."id", - "posthog_team"."uuid", - "posthog_team"."organization_id", - "posthog_team"."api_token", - "posthog_team"."app_urls", - "posthog_team"."name", - "posthog_team"."slack_incoming_webhook", - "posthog_team"."created_at", - "posthog_team"."updated_at", - "posthog_team"."anonymize_ips", - "posthog_team"."completed_snippet_onboarding", - "posthog_team"."has_completed_onboarding_for", - "posthog_team"."ingested_event", - "posthog_team"."autocapture_opt_out", - "posthog_team"."autocapture_exceptions_opt_in", - "posthog_team"."autocapture_exceptions_errors_to_ignore", - "posthog_team"."session_recording_opt_in", - "posthog_team"."session_recording_sample_rate", - "posthog_team"."session_recording_minimum_duration_milliseconds", - "posthog_team"."session_recording_linked_flag", - "posthog_team"."session_recording_network_payload_capture_config", - "posthog_team"."capture_console_log_opt_in", - "posthog_team"."capture_performance_opt_in", - "posthog_team"."surveys_opt_in", - "posthog_team"."session_recording_version", - "posthog_team"."signup_token", - "posthog_team"."is_demo", - "posthog_team"."access_control", - "posthog_team"."week_start_day", - "posthog_team"."inject_web_apps", - "posthog_team"."test_account_filters", - "posthog_team"."test_account_filters_default_checked", - "posthog_team"."path_cleaning_filters", - "posthog_team"."timezone", - "posthog_team"."data_attributes", - "posthog_team"."person_display_name_properties", - "posthog_team"."live_events_columns", - "posthog_team"."recording_domains", - "posthog_team"."primary_dashboard_id", - "posthog_team"."extra_settings", - "posthog_team"."correlation_config", - "posthog_team"."session_recording_retention_period_days", - "posthog_team"."plugins_opt_in", - "posthog_team"."opt_out_capture", - "posthog_team"."event_names", - "posthog_team"."event_names_with_usage", - "posthog_team"."event_properties", - "posthog_team"."event_properties_with_usage", - "posthog_team"."event_properties_numerical", - "posthog_team"."external_data_workspace_id", - "posthog_team"."external_data_workspace_last_synced_at" - FROM "posthog_team" - WHERE "posthog_team"."id" = 2 - LIMIT 21 /*controller='project_notebooks-detail',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/%28%3FP%3Cshort_id%3E%5B%5E/.%5D%2B%29/%3F%24'*/ - ' ---- -# name: TestNotebooks.test_updates_notebook.13 - ' - SELECT "posthog_team"."id", - "posthog_team"."uuid", - "posthog_team"."organization_id", - "posthog_team"."api_token", - "posthog_team"."app_urls", - "posthog_team"."name", - "posthog_team"."slack_incoming_webhook", - "posthog_team"."created_at", - "posthog_team"."updated_at", - "posthog_team"."anonymize_ips", - "posthog_team"."completed_snippet_onboarding", - "posthog_team"."has_completed_onboarding_for", - "posthog_team"."ingested_event", - "posthog_team"."autocapture_opt_out", - "posthog_team"."autocapture_exceptions_opt_in", - "posthog_team"."autocapture_exceptions_errors_to_ignore", - "posthog_team"."session_recording_opt_in", - "posthog_team"."session_recording_sample_rate", - "posthog_team"."session_recording_minimum_duration_milliseconds", - "posthog_team"."session_recording_linked_flag", - "posthog_team"."session_recording_network_payload_capture_config", - "posthog_team"."capture_console_log_opt_in", - "posthog_team"."capture_performance_opt_in", - "posthog_team"."surveys_opt_in", - "posthog_team"."session_recording_version", - "posthog_team"."signup_token", - "posthog_team"."is_demo", - "posthog_team"."access_control", - "posthog_team"."week_start_day", - "posthog_team"."inject_web_apps", - "posthog_team"."test_account_filters", - "posthog_team"."test_account_filters_default_checked", - "posthog_team"."path_cleaning_filters", - "posthog_team"."timezone", - "posthog_team"."data_attributes", - "posthog_team"."person_display_name_properties", - "posthog_team"."live_events_columns", - "posthog_team"."recording_domains", - "posthog_team"."primary_dashboard_id", - "posthog_team"."extra_settings", - "posthog_team"."correlation_config", - "posthog_team"."session_recording_retention_period_days", - "posthog_team"."plugins_opt_in", - "posthog_team"."opt_out_capture", - "posthog_team"."event_names", - "posthog_team"."event_names_with_usage", - "posthog_team"."event_properties", - "posthog_team"."event_properties_with_usage", - "posthog_team"."event_properties_numerical", - "posthog_team"."external_data_workspace_id", - "posthog_team"."external_data_workspace_last_synced_at" - FROM "posthog_team" - WHERE "posthog_team"."id" = 2 - LIMIT 21 /*controller='project_notebooks-detail',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/%28%3FP%3Cshort_id%3E%5B%5E/.%5D%2B%29/%3F%24'*/ - ' ---- -# name: TestNotebooks.test_updates_notebook.14 ' SELECT "posthog_user"."id", "posthog_user"."password", @@ -266,7 +150,7 @@ LIMIT 21 /*controller='project_notebooks-detail',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/%28%3FP%3Cshort_id%3E%5B%5E/.%5D%2B%29/%3F%24'*/ ' --- -# name: TestNotebooks.test_updates_notebook.15 +# name: TestNotebooks.test_updates_notebook.13 ' SELECT "posthog_user"."id", "posthog_user"."password", @@ -296,7 +180,7 @@ LIMIT 21 /**/ ' --- -# name: TestNotebooks.test_updates_notebook.16 +# name: TestNotebooks.test_updates_notebook.14 ' SELECT "posthog_team"."id", "posthog_team"."uuid", @@ -347,7 +231,7 @@ LIMIT 21 /*controller='project_notebooks-all-activity',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/activity/%3F%24'*/ ' --- -# name: TestNotebooks.test_updates_notebook.17 +# name: TestNotebooks.test_updates_notebook.15 ' SELECT "posthog_organizationmembership"."id", "posthog_organizationmembership"."organization_id", @@ -377,7 +261,7 @@ WHERE "posthog_organizationmembership"."user_id" = 2 /*controller='project_notebooks-all-activity',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/activity/%3F%24'*/ ' --- -# name: TestNotebooks.test_updates_notebook.18 +# name: TestNotebooks.test_updates_notebook.16 ' SELECT "posthog_instancesetting"."id", "posthog_instancesetting"."key", @@ -388,6 +272,58 @@ LIMIT 1 /*controller='project_notebooks-all-activity',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/activity/%3F%24'*/ ' --- +# name: TestNotebooks.test_updates_notebook.17 + ' + SELECT COUNT(*) AS "__count" + FROM "posthog_activitylog" + WHERE ("posthog_activitylog"."scope" = 'Notebook' + AND "posthog_activitylog"."team_id" = 2) /*controller='project_notebooks-all-activity',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/activity/%3F%24'*/ + ' +--- +# name: TestNotebooks.test_updates_notebook.18 + ' + SELECT "posthog_activitylog"."id", + "posthog_activitylog"."team_id", + "posthog_activitylog"."organization_id", + "posthog_activitylog"."user_id", + "posthog_activitylog"."is_system", + "posthog_activitylog"."activity", + "posthog_activitylog"."item_id", + "posthog_activitylog"."scope", + "posthog_activitylog"."detail", + "posthog_activitylog"."created_at", + "posthog_user"."id", + "posthog_user"."password", + "posthog_user"."last_login", + "posthog_user"."first_name", + "posthog_user"."last_name", + "posthog_user"."is_staff", + "posthog_user"."is_active", + "posthog_user"."date_joined", + "posthog_user"."uuid", + "posthog_user"."current_organization_id", + "posthog_user"."current_team_id", + "posthog_user"."email", + "posthog_user"."pending_email", + "posthog_user"."temporary_token", + "posthog_user"."distinct_id", + "posthog_user"."is_email_verified", + "posthog_user"."requested_password_reset_at", + "posthog_user"."has_seen_product_intro_for", + "posthog_user"."email_opt_in", + "posthog_user"."theme_mode", + "posthog_user"."partial_notification_settings", + "posthog_user"."anonymize_data", + "posthog_user"."toolbar_mode", + "posthog_user"."events_column_config" + FROM "posthog_activitylog" + LEFT OUTER JOIN "posthog_user" ON ("posthog_activitylog"."user_id" = "posthog_user"."id") + WHERE ("posthog_activitylog"."scope" = 'Notebook' + AND "posthog_activitylog"."team_id" = 2) + ORDER BY "posthog_activitylog"."created_at" DESC + LIMIT 2 /*controller='project_notebooks-all-activity',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/activity/%3F%24'*/ + ' +--- # name: TestNotebooks.test_updates_notebook.19 ' SELECT COUNT(*) AS "__count" diff --git a/posthog/api/test/notebooks/test_notebook.py b/posthog/api/test/notebooks/test_notebook.py index 0325765001be0..2779f1a226c78 100644 --- a/posthog/api/test/notebooks/test_notebook.py +++ b/posthog/api/test/notebooks/test_notebook.py @@ -100,7 +100,7 @@ def test_create_a_notebook(self, _, content: Dict | None, text_content: str | No self.assert_notebook_activity( [ - self.created_activity(item_id=response.json()["id"], short_id=response.json()["short_id"]), + self.created_activity(item_id=response.json()["short_id"], short_id=response.json()["short_id"]), ], ) @@ -134,7 +134,7 @@ def test_updates_notebook(self) -> None: self.assert_notebook_activity( [ - self.created_activity(item_id=response.json()["id"], short_id=response.json()["short_id"]), + self.created_activity(item_id=response.json()["short_id"], short_id=response.json()["short_id"]), { "activity": "updated", "created_at": mock.ANY, @@ -167,7 +167,7 @@ def test_updates_notebook(self) -> None: "trigger": None, "type": None, }, - "item_id": response.json()["id"], + "item_id": response.json()["short_id"], "scope": "Notebook", "user": { "email": self.user.email, diff --git a/posthog/api/test/test_comments.py b/posthog/api/test/test_comments.py new file mode 100644 index 0000000000000..42ede7a56587b --- /dev/null +++ b/posthog/api/test/test_comments.py @@ -0,0 +1,136 @@ +from typing import Any +from unittest import mock + +from rest_framework import status + +from posthog.test.base import APIBaseTest, QueryMatchingTest + + +class TestComments(APIBaseTest, QueryMatchingTest): + def _create_comment(self, data={}) -> Any: + payload = { + "content": "my content", + "scope": "Notebook", + } + + payload.update(data) + + return self.client.post( + f"/api/projects/{self.team.id}/comments", + payload, + ).json() + + def test_creates_comment_with_validation_errors(self) -> None: + response = self.client.post( + f"/api/projects/{self.team.id}/comments", + { + "content": "This is a comment", + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "type": "validation_error", + "code": "required", + "detail": "This field is required.", + "attr": "scope", + } + + def test_creates_comment_successfully(self) -> None: + response = self.client.post( + f"/api/projects/{self.team.id}/comments", + { + "content": "This is a comment", + "scope": "Notebook", + }, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["created_by"]["id"] == self.user.id + assert response.json() == { + "id": mock.ANY, + "created_by": response.json()["created_by"], + "content": "This is a comment", + "deleted": False, + "version": 0, + "created_at": mock.ANY, + "item_id": None, + "item_context": None, + "scope": "Notebook", + "source_comment": None, + } + + def test_updates_content_and_increments_version(self) -> None: + existing = self.client.post( + f"/api/projects/{self.team.id}/comments", + { + "content": "This is a comment", + "scope": "Notebook", + }, + ) + + response = self.client.patch( + f"/api/projects/{self.team.id}/comments/{existing.json()['id']}", + { + "content": "This is an edited comment", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "id": mock.ANY, + "created_by": response.json()["created_by"], + "content": "This is an edited comment", + "deleted": False, + "version": 1, + "created_at": mock.ANY, + "item_id": None, + "item_context": None, + "scope": "Notebook", + "source_comment": None, + } + + def test_empty_comments_list(self) -> None: + response = self.client.get(f"/api/projects/{self.team.id}/comments") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "next": None, + "previous": None, + "results": [], + } + + def test_lists_comments(self) -> None: + self._create_comment({"content": "comment 1"}) + self._create_comment({"content": "comment 2"}) + response = self.client.get(f"/api/projects/{self.team.id}/comments") + assert len(response.json()["results"]) == 2 + + assert response.json()["results"][0]["content"] == "comment 2" + assert response.json()["results"][1]["content"] == "comment 1" + + def test_lists_comments_filtering(self) -> None: + self._create_comment({"content": "comment notebook-1", "scope": "Notebook", "item_id": "1"}) + self._create_comment({"content": "comment notebook-2", "scope": "Notebook", "item_id": "2"}) + self._create_comment({"content": "comment dashboard-1", "scope": "Dashboard", "item_id": "1"}) + + response = self.client.get(f"/api/projects/{self.team.id}/comments?scope=Notebook") + assert len(response.json()["results"]) == 2 + assert response.json()["results"][0]["content"] == "comment notebook-2" + assert response.json()["results"][1]["content"] == "comment notebook-1" + + response = self.client.get(f"/api/projects/{self.team.id}/comments?scope=Notebook&item_id=2") + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["content"] == "comment notebook-2" + + def test_lists_comments_thread(self) -> None: + initial_comment = self._create_comment({"content": "comment notebook-1", "scope": "Notebook", "item_id": "1"}) + self._create_comment({"content": "comment reply", "source_comment": initial_comment["id"]}) + self._create_comment({"content": "comment other reply", "source_comment": initial_comment["id"]}) + self._create_comment({"content": "comment elsewhere"}) + + for url in [ + f"/api/projects/{self.team.id}/comments/{initial_comment['id']}/thread", + f"/api/projects/{self.team.id}/comments/?source_comment={initial_comment['id']}", + ]: + response = self.client.get(url) + assert len(response.json()["results"]) == 2 + assert response.json()["results"][0]["content"] == "comment other reply" + assert response.json()["results"][1]["content"] == "comment reply" diff --git a/posthog/api/test/test_plugin.py b/posthog/api/test/test_plugin.py index 12ce1bd16e079..a2eb76174fb48 100644 --- a/posthog/api/test/test_plugin.py +++ b/posthog/api/test/test_plugin.py @@ -1356,8 +1356,8 @@ def test_create_plugin_config_with_secrets(self, mock_get, mock_reload): "delivery_rate_24h": None, "created_at": mock.ANY, "updated_at": mock.ANY, - "name": None, - "description": None, + "name": "Hello World", + "description": "Greet the World and Foo a Bar, JS edition!", "deleted": False, }, ) @@ -1385,8 +1385,8 @@ def test_create_plugin_config_with_secrets(self, mock_get, mock_reload): "delivery_rate_24h": None, "created_at": mock.ANY, "updated_at": mock.ANY, - "name": None, - "description": None, + "name": "Hello World", + "description": "Greet the World and Foo a Bar, JS edition!", "deleted": False, }, ) @@ -1416,8 +1416,8 @@ def test_create_plugin_config_with_secrets(self, mock_get, mock_reload): "delivery_rate_24h": None, "created_at": mock.ANY, "updated_at": mock.ANY, - "name": None, - "description": None, + "name": "Hello World", + "description": "Greet the World and Foo a Bar, JS edition!", "deleted": False, }, ) diff --git a/posthog/api/test/test_preflight.py b/posthog/api/test/test_preflight.py index 74d5a8b2e490b..6b63ad28f7542 100644 --- a/posthog/api/test/test_preflight.py +++ b/posthog/api/test/test_preflight.py @@ -29,6 +29,7 @@ def preflight_dict(self, options={}): "db": True, "initiated": True, "cloud": False, + "data_warehouse_integrations": {"hubspot": {"client_id": None}}, "demo": False, "clickhouse": True, "kafka": True, diff --git a/posthog/async_migrations/test/test_0010_move_old_partitions.py b/posthog/async_migrations/test/test_0010_move_old_partitions.py index 5c50cedc55e00..527f751e6aec5 100644 --- a/posthog/async_migrations/test/test_0010_move_old_partitions.py +++ b/posthog/async_migrations/test/test_0010_move_old_partitions.py @@ -69,18 +69,5 @@ def tearDown(self): def test_completes_successfully(self): self.assertTrue(run_migration()) + # this test is not very helpful, but we will at least catch if this changes self.assertEqual(len(MIGRATION_DEFINITION.operations), 5) - - self.assertIn( - "ALTER TABLE sharded_events MOVE PARTITION '190001' TO TABLE events_backup", - MIGRATION_DEFINITION.operations[1].sql, # type: ignore - ) - # skip over idx 2 because it's inconsistent and not important - self.assertIn( - "ALTER TABLE sharded_events MOVE PARTITION '202202' TO TABLE events_backup", - MIGRATION_DEFINITION.operations[3].sql, # type: ignore - ) - self.assertIn( - "ALTER TABLE sharded_events MOVE PARTITION '204502' TO TABLE events_backup", - MIGRATION_DEFINITION.operations[4].sql, # type: ignore - ) diff --git a/posthog/batch_exports/http.py b/posthog/batch_exports/http.py index e896ac70e0be1..38c9354df09e8 100644 --- a/posthog/batch_exports/http.py +++ b/posthog/batch_exports/http.py @@ -45,6 +45,7 @@ User, ) from posthog.permissions import ( + OrganizationMemberPermissions, ProjectMembershipNecessaryPermissions, TeamMemberAccessPermission, ) @@ -163,6 +164,7 @@ class Meta: model = BatchExport fields = [ "id", + "team_id", "name", "destination", "interval", @@ -174,7 +176,7 @@ class Meta: "end_at", "latest_runs", ] - read_only_fields = ["id", "created_at", "last_updated_at", "latest_runs"] + read_only_fields = ["id", "team_id", "created_at", "last_updated_at", "latest_runs"] def create(self, validated_data: dict) -> BatchExport: """Create a BatchExport.""" @@ -238,15 +240,10 @@ class BatchExportViewSet(StructuredViewSetMixin, viewsets.ModelViewSet): serializer_class = BatchExportSerializer def get_queryset(self): - if not isinstance(self.request.user, User) or self.request.user.current_team is None: + if not isinstance(self.request.user, User): raise NotAuthenticated() - return ( - self.queryset.filter(team_id=self.team_id) - .exclude(deleted=True) - .order_by("-created_at") - .prefetch_related("destination") - ) + return super().get_queryset().exclude(deleted=True).order_by("-created_at").prefetch_related("destination") @action(methods=["POST"], detail=True) def backfill(self, request: request.Request, *args, **kwargs) -> response.Response: @@ -277,14 +274,14 @@ def backfill(self, request: request.Request, *args, **kwargs) -> response.Respon @action(methods=["POST"], detail=True) def pause(self, request: request.Request, *args, **kwargs) -> response.Response: """Pause a BatchExport.""" - if not isinstance(request.user, User) or request.user.current_team is None: + if not isinstance(request.user, User): raise NotAuthenticated() + batch_export = self.get_object() user_id = request.user.distinct_id - team_id = request.user.current_team.id + team_id = batch_export.team_id note = f"Pause requested by user {user_id} from team {team_id}" - batch_export = self.get_object() temporal = sync_connect() try: @@ -347,6 +344,11 @@ def perform_destroy(self, instance: BatchExport): cancel_running_batch_export_backfill(temporal, backfill.workflow_id) +class BatchExportOrganizationViewSet(BatchExportViewSet): + permission_classes = [IsAuthenticated, OrganizationMemberPermissions] + filter_rewrite_rules = {"organization_id": "team__organization_id"} + + class BatchExportLogEntrySerializer(DataclassSerializer): class Meta: dataclass = BatchExportLogEntry diff --git a/posthog/batch_exports/service.py b/posthog/batch_exports/service.py index b7e69153f0fbb..0d0384fa95687 100644 --- a/posthog/batch_exports/service.py +++ b/posthog/batch_exports/service.py @@ -21,15 +21,15 @@ BatchExportBackfill, BatchExportRun, ) +from posthog.constants import BATCH_EXPORTS_TASK_QUEUE from posthog.temporal.common.client import sync_connect from posthog.temporal.common.schedule import ( create_schedule, - update_schedule, - unpause_schedule, - pause_schedule, delete_schedule, + pause_schedule, + unpause_schedule, + update_schedule, ) -from posthog.constants import BATCH_EXPORTS_TASK_QUEUE class BatchExportsInputsProtocol(typing.Protocol): @@ -132,6 +132,7 @@ class BigQueryBatchExportInputs: data_interval_end: str | None = None exclude_events: list[str] | None = None include_events: list[str] | None = None + use_json_type: bool = False @dataclass diff --git a/posthog/hogql_queries/insights/trends/breakdown.py b/posthog/hogql_queries/insights/trends/breakdown.py index 523d634462c59..a2a004166533c 100644 --- a/posthog/hogql_queries/insights/trends/breakdown.py +++ b/posthog/hogql_queries/insights/trends/breakdown.py @@ -2,7 +2,13 @@ from posthog.hogql import ast from posthog.hogql.parser import parse_expr from posthog.hogql.timings import HogQLTimings -from posthog.hogql_queries.insights.trends.breakdown_values import BreakdownValues +from posthog.hogql_queries.insights.trends.breakdown_values import ( + BREAKDOWN_NULL_NUMERIC_LABEL, + BREAKDOWN_NULL_STRING_LABEL, + BREAKDOWN_OTHER_NUMERIC_LABEL, + BREAKDOWN_OTHER_STRING_LABEL, + BreakdownValues, +) from posthog.hogql_queries.insights.trends.display import TrendsDisplay from posthog.hogql_queries.insights.trends.utils import ( get_properties_chain, @@ -76,10 +82,11 @@ def column_expr(self) -> ast.Expr: expr=parse_expr(self.query.breakdown.breakdown), ) - return ast.Alias( - alias="breakdown_value", - expr=ast.Field(chain=self._properties_chain), - ) + # If there's no breakdown values + if len(self._get_breakdown_values) == 1 and self._get_breakdown_values[0] is None: + return ast.Alias(alias="breakdown_value", expr=ast.Field(chain=self._properties_chain)) + + return ast.Alias(alias="breakdown_value", expr=self._get_breakdown_transform_func) def events_where_filter(self) -> ast.Expr | None: if self.query.breakdown.breakdown_type == "cohort": @@ -97,10 +104,22 @@ def events_where_filter(self) -> ast.Expr | None: else: left = ast.Field(chain=self._properties_chain) - compare_ops = [ - ast.CompareOperation(left=left, op=ast.CompareOperationOp.Eq, right=ast.Constant(value=v)) - for v in self._get_breakdown_values - ] + compare_ops = [] + for v in self._get_breakdown_values: + # If the value is one of the "other" values, then use the `transform()` func + if ( + v == BREAKDOWN_OTHER_STRING_LABEL + or v == BREAKDOWN_OTHER_NUMERIC_LABEL + or v == float(BREAKDOWN_OTHER_NUMERIC_LABEL) + ): + transform_func = self._get_breakdown_transform_func + compare_ops.append( + ast.CompareOperation(left=transform_func, op=ast.CompareOperationOp.Eq, right=ast.Constant(value=v)) + ) + else: + compare_ops.append( + ast.CompareOperation(left=left, op=ast.CompareOperationOp.Eq, right=ast.Constant(value=v)) + ) if len(compare_ops) == 1: return compare_ops[0] @@ -109,6 +128,35 @@ def events_where_filter(self) -> ast.Expr | None: return ast.Or(exprs=compare_ops) + @cached_property + def _get_breakdown_transform_func(self) -> ast.Call: + values = self._get_breakdown_values + all_values_are_ints_or_none = all(isinstance(value, int) or value is None for value in values) + all_values_are_floats_or_none = all(isinstance(value, float) or value is None for value in values) + + if all_values_are_ints_or_none: + breakdown_other_value = BREAKDOWN_OTHER_NUMERIC_LABEL + breakdown_null_value = BREAKDOWN_NULL_NUMERIC_LABEL + elif all_values_are_floats_or_none: + breakdown_other_value = float(BREAKDOWN_OTHER_NUMERIC_LABEL) + breakdown_null_value = float(BREAKDOWN_NULL_NUMERIC_LABEL) + else: + breakdown_other_value = BREAKDOWN_OTHER_STRING_LABEL + breakdown_null_value = BREAKDOWN_NULL_STRING_LABEL + + return ast.Call( + name="transform", + args=[ + ast.Call( + name="ifNull", + args=[ast.Field(chain=self._properties_chain), ast.Constant(value=breakdown_null_value)], + ), + self._breakdown_values_ast, + self._breakdown_values_ast, + ast.Constant(value=breakdown_other_value), + ], + ) + @cached_property def _breakdown_buckets_ast(self) -> ast.Array: buckets = self._get_breakdown_histogram_buckets() @@ -134,6 +182,8 @@ def _get_breakdown_values(self) -> List[str | int]: chart_display_type=self._trends_display().display_type, histogram_bin_count=self.query.breakdown.breakdown_histogram_bin_count, group_type_index=self.query.breakdown.breakdown_group_type_index, + hide_other_aggregation=self.query.breakdown.breakdown_hide_other_aggregation, + breakdown_limit=self.query.breakdown.breakdown_limit, ) return breakdown.get_breakdown_values() diff --git a/posthog/hogql_queries/insights/trends/breakdown_values.py b/posthog/hogql_queries/insights/trends/breakdown_values.py index e05566386d551..870d1d3fb44dd 100644 --- a/posthog/hogql_queries/insights/trends/breakdown_values.py +++ b/posthog/hogql_queries/insights/trends/breakdown_values.py @@ -8,6 +8,11 @@ from posthog.models.team.team import Team from posthog.schema import ChartDisplayType +BREAKDOWN_OTHER_STRING_LABEL = "$$_posthog_breakdown_other_$$" +BREAKDOWN_OTHER_NUMERIC_LABEL = 9007199254740991 # pow(2, 53) - 1, for JS compatibility +BREAKDOWN_NULL_STRING_LABEL = "$$_posthog_breakdown_null_$$" +BREAKDOWN_NULL_NUMERIC_LABEL = 9007199254740990 # pow(2, 53) - 2, for JS compatibility + class BreakdownValues: team: Team @@ -19,6 +24,8 @@ class BreakdownValues: chart_display_type: ChartDisplayType histogram_bin_count: Optional[int] group_type_index: Optional[int] + hide_other_aggregation: Optional[bool] + breakdown_limit: Optional[float] def __init__( self, @@ -31,6 +38,8 @@ def __init__( chart_display_type: ChartDisplayType, histogram_bin_count: Optional[float] = None, group_type_index: Optional[float] = None, + hide_other_aggregation: Optional[bool] = False, + breakdown_limit: Optional[float] = None, ): self.team = team self.event_name = event_name @@ -41,6 +50,8 @@ def __init__( self.chart_display_type = chart_display_type self.histogram_bin_count = int(histogram_bin_count) if histogram_bin_count is not None else None self.group_type_index = int(group_type_index) if group_type_index is not None else None + self.hide_other_aggregation = hide_other_aggregation + self.breakdown_limit = breakdown_limit def get_breakdown_values(self) -> List[str | int]: if self.breakdown_type == "cohort": @@ -66,6 +77,11 @@ def get_breakdown_values(self) -> List[str | int]: ), ) + if self.chart_display_type == ChartDisplayType.WorldMap: + breakdown_limit = BREAKDOWN_VALUES_LIMIT_FOR_COUNTRIES + else: + breakdown_limit = self.breakdown_limit or BREAKDOWN_VALUES_LIMIT + inner_events_query = parse_select( """ SELECT @@ -85,11 +101,7 @@ def get_breakdown_values(self) -> List[str | int]: placeholders={ "events_where": self.events_filter, "select_field": select_field, - "breakdown_limit": ast.Constant( - value=BREAKDOWN_VALUES_LIMIT_FOR_COUNTRIES - if self.chart_display_type == ChartDisplayType.WorldMap - else BREAKDOWN_VALUES_LIMIT - ), + "breakdown_limit": ast.Constant(value=breakdown_limit), }, ) @@ -113,10 +125,28 @@ def get_breakdown_values(self) -> List[str | int]: values: List[Any] = response.results[0][0] - if self.histogram_bin_count is None: + if len(values) == 0: values.insert(0, None) - - return values + return values + + # Add "other" value if "other" is not hidden and we're not bucketing numeric values + if self.hide_other_aggregation is not True and self.histogram_bin_count is None: + all_values_are_ints_or_none = all(isinstance(value, int) or value is None for value in values) + all_values_are_floats_or_none = all(isinstance(value, float) or value is None for value in values) + all_values_are_string_or_none = all(isinstance(value, str) or value is None for value in values) + + if all_values_are_ints_or_none or all_values_are_floats_or_none: + if all_values_are_ints_or_none: + values = [BREAKDOWN_NULL_NUMERIC_LABEL if value is None else value for value in values] + values.insert(0, BREAKDOWN_OTHER_NUMERIC_LABEL) + else: + values = [float(BREAKDOWN_NULL_NUMERIC_LABEL) if value is None else value for value in values] + values.insert(0, float(BREAKDOWN_OTHER_NUMERIC_LABEL)) + elif all_values_are_string_or_none: + values = [BREAKDOWN_NULL_STRING_LABEL if value in (None, "") else value for value in values] + values.insert(0, BREAKDOWN_OTHER_STRING_LABEL) + + return values[:breakdown_limit] def _to_bucketing_expression(self) -> ast.Expr: assert isinstance(self.histogram_bin_count, int) diff --git a/posthog/hogql_queries/insights/trends/query_builder.py b/posthog/hogql_queries/insights/trends/query_builder.py index 26c287600e455..7733bdfd41dcb 100644 --- a/posthog/hogql_queries/insights/trends/query_builder.py +++ b/posthog/hogql_queries/insights/trends/query_builder.py @@ -300,6 +300,34 @@ def _inner_select_query(self, inner_query: ast.SelectQuery | ast.SelectUnionQuer ), ) + if ( + self.query.trendsFilter is not None + and self.query.trendsFilter.smoothing_intervals is not None + and self.query.trendsFilter.smoothing_intervals > 1 + ): + rolling_average = ast.Alias( + alias="count", + expr=ast.Call( + name="floor", + args=[ + ast.WindowFunction( + name="avg", + args=[ast.Call(name="sum", args=[ast.Field(chain=["total"])])], + over_expr=ast.WindowExpr( + order_by=[ast.OrderExpr(expr=ast.Field(chain=["day_start"]), order="ASC")], + frame_method="ROWS", + frame_start=ast.WindowFrameExpr( + frame_type="PRECEDING", + frame_value=int(self.query.trendsFilter.smoothing_intervals - 1), + ), + frame_end=ast.WindowFrameExpr(frame_type="CURRENT ROW"), + ), + ) + ], + ), + ) + query.select = [rolling_average] + query.group_by = [] query.order_by = [] diff --git a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr index ca37e4696dd4d..f8145d156fe2d 100644 --- a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr +++ b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr @@ -227,12 +227,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'finance', 'technology'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'finance', 'technology'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - e__group_0.properties___industry AS breakdown_value + transform(ifNull(e__group_0.properties___industry, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'finance', 'technology'], ['$$_posthog_breakdown_other_$$', 'finance', 'technology'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 LEFT JOIN (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(groups.group_properties, 'industry'), ''), 'null'), '^"|"$', ''), groups._timestamp) AS properties___industry, @@ -242,7 +242,7 @@ WHERE and(equals(groups.team_id, 2), ifNull(equals(index, 0), 0)) GROUP BY groups.group_type_index, groups.group_key) AS e__group_0 ON equals(e.`$group_0`, e__group_0.key) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(e__group_0.properties___industry), ifNull(equals(e__group_0.properties___industry, 'finance'), 0), ifNull(equals(e__group_0.properties___industry, 'technology'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(e__group_0.properties___industry, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'finance', 'technology'], ['$$_posthog_breakdown_other_$$', 'finance', 'technology'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(e__group_0.properties___industry, 'finance'), 0), ifNull(equals(e__group_0.properties___industry, 'technology'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -330,12 +330,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'finance'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'finance'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - e__group_0.properties___industry AS breakdown_value + transform(ifNull(e__group_0.properties___industry, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'finance'], ['$$_posthog_breakdown_other_$$', 'finance'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 LEFT JOIN (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(groups.group_properties, 'industry'), ''), 'null'), '^"|"$', ''), groups._timestamp) AS properties___industry, @@ -345,7 +345,7 @@ WHERE and(equals(groups.team_id, 2), ifNull(equals(index, 0), 0)) GROUP BY groups.group_type_index, groups.group_key) AS e__group_0 ON equals(e.`$group_0`, e__group_0.key) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, 'key'), ''), 'null'), '^"|"$', ''), 'value'), 0), or(isNull(e__group_0.properties___industry), ifNull(equals(e__group_0.properties___industry, 'finance'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, 'key'), ''), 'null'), '^"|"$', ''), 'value'), 0), or(ifNull(equals(transform(ifNull(e__group_0.properties___industry, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'finance'], ['$$_posthog_breakdown_other_$$', 'finance'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(e__group_0.properties___industry, 'finance'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -394,14 +394,14 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'second url'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'second url'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$current_url'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$current_url'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'second url'], ['$$_posthog_breakdown_other_$$', 'second url'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-22 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$browser'), ''), 'null'), '^"|"$', ''), 'Firefox'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Windows'), 0)), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$current_url'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$current_url'), ''), 'null'), '^"|"$', ''), 'second url'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-22 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$browser'), ''), 'null'), '^"|"$', ''), 'Firefox'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Windows'), 0)), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$current_url'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'second url'], ['$$_posthog_breakdown_other_$$', 'second url'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$current_url'), ''), 'null'), '^"|"$', ''), 'second url'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -521,7 +521,7 @@ CROSS JOIN (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, e__pdi__person.id AS actor_id, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val', 'bor'], ['$$_posthog_breakdown_other_$$', 'val', 'bor'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -536,7 +536,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), and(equals(e.event, '$pageview'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'bor'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 23:59:59', 6, 'UTC'))), 0)) + WHERE and(equals(e.team_id, 2), and(equals(e.event, '$pageview'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val', 'bor'], ['$$_posthog_breakdown_other_$$', 'val', 'bor'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'bor'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 23:59:59', 6, 'UTC'))), 0)) GROUP BY timestamp, actor_id, breakdown_value) AS e WHERE and(ifNull(lessOrEquals(e.timestamp, plus(d.timestamp, toIntervalDay(1))), 0), ifNull(greater(e.timestamp, minus(d.timestamp, toIntervalDay(6))), 0)) @@ -606,7 +606,7 @@ CROSS JOIN (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, e__pdi__person.id AS actor_id, - nullIf(nullIf(e.mat_key, ''), 'null') AS breakdown_value + transform(ifNull(nullIf(nullIf(e.mat_key, ''), 'null'), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val', 'bor'], ['$$_posthog_breakdown_other_$$', 'val', 'bor'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -621,7 +621,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), and(equals(e.event, '$pageview'), or(isNull(nullIf(nullIf(e.mat_key, ''), 'null')), ifNull(equals(nullIf(nullIf(e.mat_key, ''), 'null'), 'val'), 0), ifNull(equals(nullIf(nullIf(e.mat_key, ''), 'null'), 'bor'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 23:59:59', 6, 'UTC'))), 0)) + WHERE and(equals(e.team_id, 2), and(equals(e.event, '$pageview'), or(ifNull(equals(transform(ifNull(nullIf(nullIf(e.mat_key, ''), 'null'), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val', 'bor'], ['$$_posthog_breakdown_other_$$', 'val', 'bor'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(nullIf(nullIf(e.mat_key, ''), 'null'), 'val'), 0), ifNull(equals(nullIf(nullIf(e.mat_key, ''), 'null'), 'bor'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-11 23:59:59', 6, 'UTC'))), 0)) GROUP BY timestamp, actor_id, breakdown_value) AS e WHERE and(ifNull(lessOrEquals(e.timestamp, plus(d.timestamp, toIntervalDay(1))), 0), ifNull(greater(e.timestamp, minus(d.timestamp, toIntervalDay(6))), 0)) @@ -718,7 +718,7 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'val'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'val'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT counts AS total, @@ -734,7 +734,7 @@ CROSS JOIN (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, e__pdi__person.id AS actor_id, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val'], ['$$_posthog_breakdown_other_$$', 'val'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -758,7 +758,7 @@ FROM cohortpeople WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 25)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version - HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), 0)) + HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val'], ['$$_posthog_breakdown_other_$$', 'val'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), 0)) GROUP BY timestamp, actor_id, breakdown_value) AS e WHERE and(ifNull(lessOrEquals(e.timestamp, plus(d.timestamp, toIntervalDay(1))), 0), ifNull(greater(e.timestamp, minus(d.timestamp, toIntervalDay(6))), 0)) @@ -820,12 +820,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'uh', 'oh'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'uh', 'oh'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 LEFT JOIN (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(groups.group_properties, 'industry'), ''), 'null'), '^"|"$', ''), groups._timestamp) AS properties___industry, @@ -835,7 +835,7 @@ WHERE and(equals(groups.team_id, 2), ifNull(equals(index, 0), 0)) GROUP BY groups.group_type_index, groups.group_key) AS e__group_0 ON equals(e.`$group_0`, e__group_0.key) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), ifNull(equals(e__group_0.properties___industry, 'finance'), 0), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'uh'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'oh'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), ifNull(equals(e__group_0.properties___industry, 'finance'), 0), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'uh'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'oh'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -906,12 +906,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'uh', 'oh'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'uh', 'oh'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id)) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 LEFT OUTER JOIN (SELECT argMax(person_overrides.override_person_id, person_overrides.version) AS override_person_id, @@ -927,7 +927,7 @@ WHERE and(equals(groups.team_id, 2), ifNull(equals(index, 0), 0)) GROUP BY groups.group_type_index, groups.group_key) AS e__group_0 ON equals(e.`$group_0`, e__group_0.key) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), ifNull(equals(e__group_0.properties___industry, 'finance'), 0), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'uh'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'oh'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), ifNull(equals(e__group_0.properties___industry, 'finance'), 0), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], ['$$_posthog_breakdown_other_$$', 'uh', 'oh'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'uh'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'oh'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -976,12 +976,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'other_value', 'value'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'other_value', 'value'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT e__pdi__person.id) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1.0 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -996,7 +996,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -1045,12 +1045,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'other_value', 'value'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'other_value', 'value'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT e__pdi__person.id) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1.0 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -1065,7 +1065,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], ['$$_posthog_breakdown_other_$$', 'other_value', 'value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -1396,7 +1396,7 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'some_val2', 'some_val'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT counts AS total, @@ -1412,7 +1412,7 @@ CROSS JOIN (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, e__pdi__person.id AS actor_id, - e__pdi__person.`properties___$some_prop` AS breakdown_value + transform(ifNull(e__pdi__person.`properties___$some_prop`, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -1432,7 +1432,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0))), 0)) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), and(equals(e.event, 'sign up'), ifNull(equals(e__pdi__person.properties___filter_prop, 'filter_val'), 0), or(isNull(e__pdi__person.`properties___$some_prop`), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val2'), 0), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')), toIntervalDay(30))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), 0)) + WHERE and(equals(e.team_id, 2), and(equals(e.event, 'sign up'), ifNull(equals(e__pdi__person.properties___filter_prop, 'filter_val'), 0), or(ifNull(equals(transform(ifNull(e__pdi__person.`properties___$some_prop`, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val2'), 0), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')), toIntervalDay(30))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), 0)) GROUP BY timestamp, actor_id, breakdown_value) AS e WHERE and(ifNull(lessOrEquals(e.timestamp, plus(d.timestamp, toIntervalDay(1))), 0), ifNull(greater(e.timestamp, minus(d.timestamp, toIntervalDay(29))), 0)) @@ -1486,7 +1486,7 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'some_val2', 'some_val'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT counts AS total, @@ -1502,7 +1502,7 @@ CROSS JOIN (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id) AS actor_id, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 LEFT OUTER JOIN (SELECT argMax(person_overrides.override_person_id, person_overrides.version) AS override_person_id, @@ -1510,7 +1510,7 @@ FROM person_overrides WHERE equals(person_overrides.team_id, 2) GROUP BY person_overrides.old_person_id) AS e__override ON equals(e.person_id, e__override.old_person_id) - WHERE and(equals(e.team_id, 2), and(equals(e.event, 'sign up'), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, 'filter_prop'), ''), 'null'), '^"|"$', ''), 'filter_val'), 0), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', ''), 'some_val2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', ''), 'some_val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')), toIntervalDay(30))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), 0)) + WHERE and(equals(e.team_id, 2), and(equals(e.event, 'sign up'), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, 'filter_prop'), ''), 'null'), '^"|"$', ''), 'filter_val'), 0), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], ['$$_posthog_breakdown_other_$$', 'some_val2', 'some_val'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', ''), 'some_val2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$some_prop'), ''), 'null'), '^"|"$', ''), 'some_val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:01:01', 6, 'UTC')), toIntervalDay(30))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), 0)) GROUP BY timestamp, actor_id, breakdown_value) AS e WHERE and(ifNull(lessOrEquals(e.timestamp, plus(d.timestamp, toIntervalDay(1))), 0), ifNull(greater(e.timestamp, minus(d.timestamp, toIntervalDay(29))), 0)) @@ -1622,12 +1622,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'value', 'other_value'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'value', 'other_value'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -1641,7 +1641,7 @@ FROM cohortpeople WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 38)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version - HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) + HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -1721,12 +1721,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'value', 'other_value'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'value', 'other_value'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 LEFT OUTER JOIN (SELECT argMax(person_overrides.override_person_id, person_overrides.version) AS override_person_id, @@ -1739,7 +1739,7 @@ FROM cohortpeople WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 39)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version - HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) + HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -2227,12 +2227,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'Mac'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'Mac'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT e__pdi__person.id) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'Mac'], ['$$_posthog_breakdown_other_$$', 'Mac'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -2247,7 +2247,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-29 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-29 13:01:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'Mac'], ['$$_posthog_breakdown_other_$$', 'Mac'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -2439,12 +2439,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'Mac'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'Mac'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT e__pdi__person.id) AS total, toStartOfDay(toTimeZone(e.timestamp, 'America/Phoenix')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'Mac'], ['$$_posthog_breakdown_other_$$', 'Mac'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -2459,7 +2459,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'America/Phoenix'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-29 06:01:01', 6, 'America/Phoenix')))), lessOrEquals(toTimeZone(e.timestamp, 'America/Phoenix'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'America/Phoenix'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'America/Phoenix'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-29 06:01:01', 6, 'America/Phoenix')))), lessOrEquals(toTimeZone(e.timestamp, 'America/Phoenix'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'America/Phoenix'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'Mac'], ['$$_posthog_breakdown_other_$$', 'Mac'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -2651,12 +2651,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'Mac'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'Mac'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT e__pdi__person.id) AS total, toStartOfDay(toTimeZone(e.timestamp, 'Asia/Tokyo')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'Mac'], ['$$_posthog_breakdown_other_$$', 'Mac'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -2671,7 +2671,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'Asia/Tokyo'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-29 22:01:01', 6, 'Asia/Tokyo')))), lessOrEquals(toTimeZone(e.timestamp, 'Asia/Tokyo'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'Asia/Tokyo'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'Asia/Tokyo'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-29 22:01:01', 6, 'Asia/Tokyo')))), lessOrEquals(toTimeZone(e.timestamp, 'Asia/Tokyo'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-05 23:59:59', 6, 'Asia/Tokyo'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'Mac'], ['$$_posthog_breakdown_other_$$', 'Mac'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$os'), ''), 'null'), '^"|"$', ''), 'Mac'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -3090,12 +3090,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'test2@posthog.com', 'test@gmail.com', 'test5@posthog.com', 'test4@posthog.com', 'test3@posthog.com'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'test2@posthog.com', 'test@gmail.com', 'test5@posthog.com', 'test4@posthog.com', 'test3@posthog.com'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - e__pdi__person.properties___email AS breakdown_value + transform(ifNull(e__pdi__person.properties___email, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'test2@posthog.com', 'test@gmail.com', 'test5@posthog.com', 'test4@posthog.com', 'test3@posthog.com'], ['$$_posthog_breakdown_other_$$', 'test2@posthog.com', 'test@gmail.com', 'test5@posthog.com', 'test4@posthog.com', 'test3@posthog.com'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -3116,7 +3116,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0))), 0)) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-07-01 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), and(or(ifNull(notILike(e__pdi__person.properties___email, '%@posthog.com%'), 1), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0)), or(ifNull(equals(e__pdi__person.`properties___$os`, 'android'), 0), ifNull(equals(e__pdi__person.`properties___$browser`, 'safari'), 0))), or(isNull(e__pdi__person.properties___email), ifNull(equals(e__pdi__person.properties___email, 'test2@posthog.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test@gmail.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test5@posthog.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test4@posthog.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test3@posthog.com'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-07-01 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), and(or(ifNull(notILike(e__pdi__person.properties___email, '%@posthog.com%'), 1), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0)), or(ifNull(equals(e__pdi__person.`properties___$os`, 'android'), 0), ifNull(equals(e__pdi__person.`properties___$browser`, 'safari'), 0))), or(ifNull(equals(transform(ifNull(e__pdi__person.properties___email, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'test2@posthog.com', 'test@gmail.com', 'test5@posthog.com', 'test4@posthog.com', 'test3@posthog.com'], ['$$_posthog_breakdown_other_$$', 'test2@posthog.com', 'test@gmail.com', 'test5@posthog.com', 'test4@posthog.com', 'test3@posthog.com'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(e__pdi__person.properties___email, 'test2@posthog.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test@gmail.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test5@posthog.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test4@posthog.com'), 0), ifNull(equals(e__pdi__person.properties___email, 'test3@posthog.com'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -3184,12 +3184,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'test2@posthog.com'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'test2@posthog.com'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(e.uuid) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - e__pdi__person.properties___email AS breakdown_value + transform(ifNull(e__pdi__person.properties___email, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'test2@posthog.com'], ['$$_posthog_breakdown_other_$$', 'test2@posthog.com'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -3210,7 +3210,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0))), 0)) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-07-01 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), and(ifNull(equals(e__pdi__person.`properties___$os`, 'android'), 0), ifNull(equals(e__pdi__person.`properties___$browser`, 'chrome'), 0)), and(ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0), ifNull(ilike(e__pdi__person.properties___email, '%@posthog.com%'), 0)), or(isNull(e__pdi__person.properties___email), ifNull(equals(e__pdi__person.properties___email, 'test2@posthog.com'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-07-01 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), and(ifNull(equals(e__pdi__person.`properties___$os`, 'android'), 0), ifNull(equals(e__pdi__person.`properties___$browser`, 'chrome'), 0)), and(ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0), ifNull(ilike(e__pdi__person.properties___email, '%@posthog.com%'), 0)), or(ifNull(equals(transform(ifNull(e__pdi__person.properties___email, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'test2@posthog.com'], ['$$_posthog_breakdown_other_$$', 'test2@posthog.com'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(e__pdi__person.properties___email, 'test2@posthog.com'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -3345,12 +3345,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'some_val'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'some_val'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT e.distinct_id) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - e__pdi__person.`properties___$some_prop` AS breakdown_value + transform(ifNull(e__pdi__person.`properties___$some_prop`, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val'], ['$$_posthog_breakdown_other_$$', 'some_val'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -3369,7 +3369,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0))), 0)) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-24 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-31 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(e__pdi__person.`properties___$some_prop`), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-24 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-31 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(e__pdi__person.`properties___$some_prop`, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val'], ['$$_posthog_breakdown_other_$$', 'some_val'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -3609,12 +3609,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'value', 'other_value'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'value', 'other_value'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT e__pdi__person.id) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -3629,7 +3629,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -3683,12 +3683,12 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'value', 'other_value'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'value', 'other_value'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT count(DISTINCT ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id)) AS total, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 LEFT OUTER JOIN (SELECT argMax(person_overrides.override_person_id, person_overrides.version) AS override_person_id, @@ -3696,7 +3696,7 @@ FROM person_overrides WHERE equals(person_overrides.team_id, 2) GROUP BY person_overrides.old_person_id) AS e__override ON equals(e.person_id, e__override.old_person_id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start, breakdown_value) GROUP BY day_start, @@ -3752,7 +3752,7 @@ breakdown_value AS breakdown_value FROM (SELECT any(e__session.duration) AS session_duration, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT events.`$session_id` AS id, @@ -3760,7 +3760,7 @@ FROM events WHERE and(equals(events.team_id, 2), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:33', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), ifNull(notEquals(id, ''), 1)) GROUP BY id) AS e__session ON equals(e.`$session_id`, e__session.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:33', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:33', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) GROUP BY e__session.id, breakdown_value) GROUP BY breakdown_value) @@ -3816,7 +3816,7 @@ breakdown_value AS breakdown_value FROM (SELECT any(e__session.duration) AS session_duration, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT events.`$session_id` AS id, @@ -3824,7 +3824,7 @@ FROM events WHERE and(equals(events.team_id, 2), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), ifNull(notEquals(id, ''), 1)) GROUP BY id) AS e__session ON equals(e.`$session_id`, e__session.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) GROUP BY e__session.id, breakdown_value) GROUP BY breakdown_value) @@ -4050,7 +4050,7 @@ breakdown_value AS breakdown_value FROM (SELECT count(e.uuid) AS total, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', '') AS breakdown_value + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'red', 'blue'], ['$$_posthog_breakdown_other_$$', 'red', 'blue'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1.0 INNER JOIN (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, @@ -4065,7 +4065,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), and(equals(e.event, 'viewed video'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', ''), 'red'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', ''), 'blue'), 0))), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), toIntervalDay(0))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-07 23:59:59', 6, 'UTC')))) + WHERE and(equals(e.team_id, 2), and(equals(e.event, 'viewed video'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'red', 'blue'], ['$$_posthog_breakdown_other_$$', 'red', 'blue'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', ''), 'red'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'color'), ''), 'null'), '^"|"$', ''), 'blue'), 0))), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), toIntervalDay(0))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-07 23:59:59', 6, 'UTC')))) GROUP BY e__pdi__person.id, breakdown_value) GROUP BY breakdown_value)) @@ -4255,7 +4255,7 @@ breakdown_value AS breakdown_value FROM (SELECT any(e__session.duration) AS session_duration, - e__pdi__person.`properties___$some_prop` AS breakdown_value + transform(ifNull(e__pdi__person.`properties___$some_prop`, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val', 'another_val'], ['$$_posthog_breakdown_other_$$', 'some_val', 'another_val'], '$$_posthog_breakdown_other_$$') AS breakdown_value FROM events AS e SAMPLE 1 INNER JOIN (SELECT events.`$session_id` AS id, @@ -4280,7 +4280,7 @@ WHERE equals(person.team_id, 2) GROUP BY person.id HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0))), 0)) SETTINGS optimize_aggregation_in_order=1) AS e__pdi__person ON equals(e__pdi.person_id, e__pdi__person.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(e__pdi__person.`properties___$some_prop`), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val'), 0), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'another_val'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(e__pdi__person.`properties___$some_prop`, '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'some_val', 'another_val'], ['$$_posthog_breakdown_other_$$', 'some_val', 'another_val'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'some_val'), 0), ifNull(equals(e__pdi__person.`properties___$some_prop`, 'another_val'), 0))) GROUP BY e__session.id, breakdown_value) GROUP BY breakdown_value) @@ -4509,7 +4509,7 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'value2', 'value1'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'value2', 'value1'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT quantile(0.5)(session_duration) AS total, @@ -4517,7 +4517,7 @@ breakdown_value AS breakdown_value FROM (SELECT any(e__session.duration) AS session_duration, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value, + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$') AS breakdown_value, toStartOfWeek(toTimeZone(e.timestamp, 'UTC'), 0) AS day_start FROM events AS e SAMPLE 1 INNER JOIN @@ -4526,7 +4526,7 @@ FROM events WHERE and(equals(events.team_id, 2), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), ifNull(notEquals(id, ''), 1)) GROUP BY id) AS e__session ON equals(e.`$session_id`, e__session.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:01', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) GROUP BY day_start, e__session.id, breakdown_value, @@ -4579,7 +4579,7 @@ CROSS JOIN (SELECT breakdown_value FROM - (SELECT [NULL, 'value2', 'value1'] AS breakdown_value) ARRAY + (SELECT ['$$_posthog_breakdown_other_$$', 'value2', 'value1'] AS breakdown_value) ARRAY JOIN breakdown_value AS breakdown_value) AS sec ORDER BY sec.breakdown_value ASC, day_start ASC UNION ALL SELECT quantile(0.5)(session_duration) AS total, @@ -4587,7 +4587,7 @@ breakdown_value AS breakdown_value FROM (SELECT any(e__session.duration) AS session_duration, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '') AS breakdown_value, + transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$') AS breakdown_value, toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start FROM events AS e SAMPLE 1 INNER JOIN @@ -4596,7 +4596,7 @@ FROM events WHERE and(equals(events.team_id, 2), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:05', 6, 'UTC')))), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), ifNull(notEquals(id, ''), 1)) GROUP BY id) AS e__session ON equals(e.`$session_id`, e__session.id) - WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:05', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(isNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 13:00:05', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up'), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], ['$$_posthog_breakdown_other_$$', 'value2', 'value1'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value2'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value1'), 0))) GROUP BY day_start, e__session.id, breakdown_value, diff --git a/posthog/hogql_queries/insights/trends/test/test_trends.py b/posthog/hogql_queries/insights/trends/test/test_trends.py index 5c396f3543183..318969cddee54 100644 --- a/posthog/hogql_queries/insights/trends/test/test_trends.py +++ b/posthog/hogql_queries/insights/trends/test/test_trends.py @@ -600,7 +600,7 @@ def test_trends_breakdown_cumulative(self): self.team, ) - self.assertEqual(response[0]["label"], "none") + self.assertEqual(response[0]["label"], "$$_posthog_breakdown_other_$$") self.assertEqual(response[0]["labels"][4], "1-Jan-2020") self.assertEqual(response[0]["data"], [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]) @@ -961,6 +961,7 @@ def test_unique_session_with_session_breakdown(self): ("[4.95,10.05]", 2.0, [2.0, 0.0, 0.0, 0.0]), ("[0.0,4.95]", 1.0, [1.0, 0.0, 0.0, 0.0]), ("[10.05,15.01]", 1.0, [0.0, 1.0, 0.0, 0.0]), + ('["",""]', 0.0, [0.0, 0.0, 0.0, 0.0]), ], ) @@ -1448,7 +1449,7 @@ def test_trends_breakdown_with_session_property_single_aggregate_math_and_breakd # empty has: 1 seconds self.assertEqual( [resp["breakdown_value"] for resp in daily_response], - ["value2", "value1", "none"], + ["value2", "value1", "$$_posthog_breakdown_other_$$"], ) self.assertEqual([resp["aggregated_value"] for resp in daily_response], [12.5, 10, 1]) @@ -2968,12 +2969,17 @@ def test_trends_with_session_property_total_volume_math_with_breakdowns(self): # value1 has 0,5,10 seconds (in second interval) # value2 has 5,10,15 seconds (in second interval) - self.assertEqual([resp["breakdown_value"] for resp in daily_response], ["value2", "value1"]) + self.assertEqual( + [resp["breakdown_value"] for resp in daily_response], ["value2", "value1", "$$_posthog_breakdown_other_$$"] + ) self.assertCountEqual(daily_response[0]["labels"], ["22-Dec-2019", "29-Dec-2019"]) self.assertCountEqual(daily_response[0]["data"], [0, 10]) self.assertCountEqual(daily_response[1]["data"], [0, 5]) + self.assertCountEqual(daily_response[2]["data"], [0, 0]) - self.assertEqual([resp["breakdown_value"] for resp in weekly_response], ["value2", "value1"]) + self.assertEqual( + [resp["breakdown_value"] for resp in weekly_response], ["value2", "value1", "$$_posthog_breakdown_other_$$"] + ) self.assertCountEqual( weekly_response[0]["labels"], [ @@ -2989,6 +2995,7 @@ def test_trends_with_session_property_total_volume_math_with_breakdowns(self): ) self.assertCountEqual(weekly_response[0]["data"], [0, 0, 0, 0, 7.5, 15, 0, 0]) self.assertCountEqual(weekly_response[1]["data"], [0, 0, 0, 0, 5, 5, 0, 0]) + self.assertCountEqual(weekly_response[2]["data"], [0, 0, 0, 0, 0, 0, 0, 0]) def test_trends_with_session_property_total_volume_math_with_sessions_spanning_multiple_intervals(self): self._create_person( @@ -3350,8 +3357,9 @@ def test_breakdown_with_filter(self): ), self.team, ) - self.assertEqual(len(response), 1) + self.assertEqual(len(response), 2) self.assertEqual(response[0]["breakdown_value"], "val") + self.assertEqual(response[1]["breakdown_value"], "$$_posthog_breakdown_other_$$") def test_action_filtering(self): sign_up_action, person = self._create_events() @@ -4093,7 +4101,7 @@ def test_breakdown_by_person_property(self): self.assertListEqual( sorted(res["breakdown_value"] for res in event_response), - ["person1", "person2", "person3"], + ["$$_posthog_breakdown_other_$$", "person1", "person2", "person3"], ) for response in event_response: @@ -4134,7 +4142,7 @@ def test_breakdown_by_person_property_for_person_on_events(self): self.assertListEqual( sorted(res["breakdown_value"] for res in event_response), - ["person1", "person2", "person3"], + ["$$_posthog_breakdown_other_$$", "person1", "person2", "person3"], ) for response in event_response: @@ -4599,7 +4607,7 @@ def test_trends_aggregate_by_distinct_id(self): self.assertEqual(daily_response[0]["data"][0], 2) self.assertEqual(daily_response[0]["label"], "some_val") self.assertEqual(daily_response[1]["data"][0], 1) - self.assertEqual(daily_response[1]["label"], "none") + self.assertEqual(daily_response[1]["label"], "$$_posthog_breakdown_other_$$") # MAU with freeze_time("2019-12-31T13:00:03Z"): @@ -4745,7 +4753,7 @@ def test_breakdown_filtering(self): self.team, ) - self.assertEqual(response[0]["label"], "sign up - none") + self.assertEqual(response[0]["label"], "sign up - $$_posthog_breakdown_other_$$") self.assertEqual(response[1]["label"], "sign up - value") self.assertEqual(response[2]["label"], "sign up - other_value") self.assertEqual(response[3]["label"], "no events - none") @@ -4806,7 +4814,7 @@ def test_breakdown_filtering_persons(self): ), self.team, ) - self.assertEqual(response[0]["label"], "none") + self.assertEqual(response[0]["label"], "$$_posthog_breakdown_other_$$") self.assertEqual(response[1]["label"], "test@gmail.com") self.assertEqual(response[2]["label"], "test@posthog.com") @@ -4864,7 +4872,7 @@ def test_breakdown_filtering_persons_with_action_props(self): ), self.team, ) - self.assertEqual(response[0]["label"], "none") + self.assertEqual(response[0]["label"], "$$_posthog_breakdown_other_$$") self.assertEqual(response[1]["label"], "test@gmail.com") self.assertEqual(response[2]["label"], "test@posthog.com") @@ -4940,14 +4948,18 @@ def test_breakdown_filtering_with_properties(self): ) response = sorted(response, key=lambda x: x["label"]) - self.assertEqual(response[0]["label"], "first url") - self.assertEqual(response[1]["label"], "second url") + self.assertEqual(response[0]["label"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[1]["label"], "first url") + self.assertEqual(response[2]["label"], "second url") - self.assertEqual(sum(response[0]["data"]), 1) - self.assertEqual(response[0]["breakdown_value"], "first url") + self.assertEqual(sum(response[0]["data"]), 0) + self.assertEqual(response[0]["breakdown_value"], "$$_posthog_breakdown_other_$$") self.assertEqual(sum(response[1]["data"]), 1) - self.assertEqual(response[1]["breakdown_value"], "second url") + self.assertEqual(response[1]["breakdown_value"], "first url") + + self.assertEqual(sum(response[2]["data"]), 1) + self.assertEqual(response[2]["breakdown_value"], "second url") @snapshot_clickhouse_queries def test_breakdown_filtering_with_properties_in_new_format(self): @@ -5023,10 +5035,13 @@ def test_breakdown_filtering_with_properties_in_new_format(self): ) response = sorted(response, key=lambda x: x["label"]) - self.assertEqual(response[0]["label"], "second url") + self.assertEqual(response[0]["label"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[1]["label"], "second url") - self.assertEqual(sum(response[0]["data"]), 1) - self.assertEqual(response[0]["breakdown_value"], "second url") + self.assertEqual(sum(response[0]["data"]), 0) + self.assertEqual(response[0]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(sum(response[1]["data"]), 1) + self.assertEqual(response[1]["breakdown_value"], "second url") # AND filter properties with disjoint set means results should be empty with freeze_time("2020-01-05T13:01:01Z"): @@ -5634,13 +5649,14 @@ def test_trend_breakdown_user_props_with_filter_with_partial_property_pushdowns( self.team, ) response = sorted(response, key=lambda item: item["breakdown_value"]) - self.assertEqual(len(response), 5) + self.assertEqual(len(response), 6) # person1 shouldn't be selected because it doesn't match the filter - self.assertEqual(response[0]["breakdown_value"], "test2@posthog.com") - self.assertEqual(response[1]["breakdown_value"], "test3@posthog.com") - self.assertEqual(response[2]["breakdown_value"], "test4@posthog.com") - self.assertEqual(response[3]["breakdown_value"], "test5@posthog.com") - self.assertEqual(response[4]["breakdown_value"], "test@gmail.com") + self.assertEqual(response[0]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[1]["breakdown_value"], "test2@posthog.com") + self.assertEqual(response[2]["breakdown_value"], "test3@posthog.com") + self.assertEqual(response[3]["breakdown_value"], "test4@posthog.com") + self.assertEqual(response[4]["breakdown_value"], "test5@posthog.com") + self.assertEqual(response[5]["breakdown_value"], "test@gmail.com") # now have more strict filters with entity props response = self._run( @@ -5697,8 +5713,9 @@ def test_trend_breakdown_user_props_with_filter_with_partial_property_pushdowns( ), self.team, ) - self.assertEqual(len(response), 1) + self.assertEqual(len(response), 2) self.assertEqual(response[0]["breakdown_value"], "test2@posthog.com") + self.assertEqual(response[1]["breakdown_value"], "$$_posthog_breakdown_other_$$") def _create_active_users_events(self): self._create_person(team_id=self.team.pk, distinct_ids=["p0"], properties={"name": "p1"}) @@ -7485,7 +7502,7 @@ def test_trends_count_per_user_average_with_event_property_breakdown(self): assert len(daily_response) == 3 assert daily_response[0]["breakdown_value"] == "red" assert daily_response[1]["breakdown_value"] == "blue" - assert daily_response[2]["breakdown_value"] == "none" + assert daily_response[2]["breakdown_value"] == "$$_posthog_breakdown_other_$$" assert daily_response[0]["days"] == [ "2020-01-01", "2020-01-02", @@ -7499,7 +7516,7 @@ def test_trends_count_per_user_average_with_event_property_breakdown(self): assert daily_response[2]["days"] == daily_response[0]["days"] assert daily_response[0]["data"] == [1.0, 0.0, 0.0, 1.0, 2.0, 0.0, 0.0] # red assert daily_response[1]["data"] == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0] # blue - assert daily_response[2]["data"] == [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] # none + assert daily_response[2]["data"] == [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] # $$_posthog_breakdown_other_$$ def test_trends_count_per_user_average_with_person_property_breakdown(self): self._create_event_count_per_actor_events() @@ -7519,9 +7536,10 @@ def test_trends_count_per_user_average_with_person_property_breakdown(self): self.team, ) - assert len(daily_response) == 2 + assert len(daily_response) == 3 assert daily_response[0]["breakdown_value"] == "mango" assert daily_response[1]["breakdown_value"] == "tomato" + assert daily_response[2]["breakdown_value"] == "$$_posthog_breakdown_other_$$" assert daily_response[0]["days"] == [ "2020-01-01", "2020-01-02", @@ -7532,8 +7550,10 @@ def test_trends_count_per_user_average_with_person_property_breakdown(self): "2020-01-07", ] assert daily_response[1]["days"] == daily_response[0]["days"] + assert daily_response[2]["days"] == daily_response[0]["days"] assert daily_response[0]["data"] == [2.0, 0.0, 0.0, 1.0, 2.0, 0.0, 0.0] # red assert daily_response[1]["data"] == [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] # blue + assert daily_response[2]["data"] == [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] # $$_posthog_breakdown_other_$$ def test_trends_count_per_user_average_aggregated_with_event_property_breakdown(self): self._create_event_count_per_actor_events() @@ -7554,10 +7574,10 @@ def test_trends_count_per_user_average_aggregated_with_event_property_breakdown( assert len(daily_response) == 3 assert daily_response[0]["breakdown_value"] == "red" - assert daily_response[1]["breakdown_value"] == "none" + assert daily_response[1]["breakdown_value"] == "$$_posthog_breakdown_other_$$" assert daily_response[2]["breakdown_value"] == "blue" assert daily_response[0]["aggregated_value"] == 2.0 # red - assert daily_response[1]["aggregated_value"] == 1.0 # none + assert daily_response[1]["aggregated_value"] == 1.0 # $$_posthog_breakdown_other_$$ assert daily_response[2]["aggregated_value"] == 1.0 # blue @snapshot_clickhouse_queries @@ -7581,10 +7601,10 @@ def test_trends_count_per_user_average_aggregated_with_event_property_breakdown_ assert len(daily_response) == 3 assert daily_response[0]["breakdown_value"] == "red" - assert daily_response[1]["breakdown_value"] == "none" + assert daily_response[1]["breakdown_value"] == "$$_posthog_breakdown_other_$$" assert daily_response[2]["breakdown_value"] == "blue" assert daily_response[0]["aggregated_value"] == 2.0 # red - assert daily_response[1]["aggregated_value"] == 1.0 # none + assert daily_response[1]["aggregated_value"] == 1.0 # $$_posthog_breakdown_other_$$ assert daily_response[2]["aggregated_value"] == 1.0 # blue # TODO: Add support for avg_count by group indexes (see this Slack thread for more context: https://posthog.slack.com/archives/C0368RPHLQH/p1700484174374229) @@ -7784,11 +7804,13 @@ def test_breakdown_with_filter_groups(self): self.team, ) - self.assertEqual(len(response), 2) + self.assertEqual(len(response), 3) self.assertEqual(response[0]["breakdown_value"], "oh") self.assertEqual(response[0]["count"], 1) self.assertEqual(response[1]["breakdown_value"], "uh") self.assertEqual(response[1]["count"], 1) + self.assertEqual(response[2]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[2]["count"], 0) @also_test_with_materialized_columns( event_properties=["key"], @@ -7849,11 +7871,13 @@ def test_breakdown_with_filter_groups_person_on_events(self): self.team, ) - self.assertEqual(len(response), 2) + self.assertEqual(len(response), 3) self.assertEqual(response[0]["breakdown_value"], "oh") self.assertEqual(response[0]["count"], 1) self.assertEqual(response[1]["breakdown_value"], "uh") self.assertEqual(response[1]["count"], 1) + self.assertEqual(response[2]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[2]["count"], 0) @override_settings(PERSON_ON_EVENTS_V2_OVERRIDE=True) @snapshot_clickhouse_queries @@ -7925,11 +7949,13 @@ def test_breakdown_with_filter_groups_person_on_events_v2(self): self.team, ) - self.assertEqual(len(response), 2) + self.assertEqual(len(response), 3) self.assertEqual(response[0]["breakdown_value"], "oh") self.assertEqual(response[0]["count"], 1) self.assertEqual(response[1]["breakdown_value"], "uh") self.assertEqual(response[1]["count"], 1) + self.assertEqual(response[2]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[2]["count"], 0) # TODO: Delete this test when moved to person-on-events def test_breakdown_by_group_props(self): @@ -7974,11 +8000,13 @@ def test_breakdown_by_group_props(self): ) response = self._run(filter, self.team) - self.assertEqual(len(response), 2) + self.assertEqual(len(response), 3) self.assertEqual(response[0]["breakdown_value"], "finance") self.assertEqual(response[0]["count"], 2) self.assertEqual(response[1]["breakdown_value"], "technology") self.assertEqual(response[1]["count"], 1) + self.assertEqual(response[2]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[2]["count"], 0) filter = filter.shallow_clone( { @@ -8040,11 +8068,13 @@ def test_breakdown_by_group_props_person_on_events(self): with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): response = self._run(filter, self.team) - self.assertEqual(len(response), 2) + self.assertEqual(len(response), 3) self.assertEqual(response[0]["breakdown_value"], "finance") self.assertEqual(response[0]["count"], 2) self.assertEqual(response[1]["breakdown_value"], "technology") self.assertEqual(response[1]["count"], 1) + self.assertEqual(response[2]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[2]["count"], 0) filter = filter.shallow_clone( { @@ -8098,9 +8128,11 @@ def test_breakdown_by_group_props_with_person_filter(self): response = self._run(filter, self.team) - self.assertEqual(len(response), 1) + self.assertEqual(len(response), 2) self.assertEqual(response[0]["breakdown_value"], "finance") self.assertEqual(response[0]["count"], 1) + self.assertEqual(response[1]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[1]["count"], 0) # TODO: Delete this test when moved to person-on-events def test_filtering_with_group_props(self): @@ -8257,9 +8289,11 @@ def test_breakdown_by_group_props_with_person_filter_person_on_events(self): with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): response = self._run(filter, self.team) - self.assertEqual(len(response), 1) + self.assertEqual(len(response), 2) self.assertEqual(response[0]["breakdown_value"], "finance") self.assertEqual(response[0]["count"], 1) + self.assertEqual(response[1]["breakdown_value"], "$$_posthog_breakdown_other_$$") + self.assertEqual(response[1]["count"], 0) @also_test_with_materialized_columns( person_properties=["key"], diff --git a/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py b/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py index ec01ad63e7776..b54d4ca147015 100644 --- a/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py +++ b/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py @@ -424,16 +424,18 @@ def test_trends_breakdowns(self): breakdown_labels = [result["breakdown_value"] for result in response.results] - assert len(response.results) == 4 - assert breakdown_labels == ["Chrome", "Firefox", "Edge", "Safari"] - assert response.results[0]["label"] == f"Chrome" - assert response.results[1]["label"] == f"Firefox" - assert response.results[2]["label"] == f"Edge" - assert response.results[3]["label"] == f"Safari" + assert len(response.results) == 5 + assert breakdown_labels == ["Chrome", "Firefox", "Edge", "Safari", "$$_posthog_breakdown_other_$$"] + assert response.results[0]["label"] == "Chrome" + assert response.results[1]["label"] == "Firefox" + assert response.results[2]["label"] == "Edge" + assert response.results[3]["label"] == "Safari" + assert response.results[4]["label"] == "$$_posthog_breakdown_other_$$" assert response.results[0]["count"] == 6 assert response.results[1]["count"] == 2 assert response.results[2]["count"] == 1 assert response.results[3]["count"] == 1 + assert response.results[4]["count"] == 0 def test_trends_breakdowns_boolean(self): self._create_test_events() @@ -449,14 +451,16 @@ def test_trends_breakdowns_boolean(self): breakdown_labels = [result["breakdown_value"] for result in response.results] - assert len(response.results) == 2 - assert breakdown_labels == ["true", "false"] + assert len(response.results) == 3 + assert breakdown_labels == ["true", "false", "$$_posthog_breakdown_other_$$"] assert response.results[0]["label"] == f"$pageview - true" assert response.results[1]["label"] == f"$pageview - false" + assert response.results[2]["label"] == f"$pageview - Other" assert response.results[0]["count"] == 7 assert response.results[1]["count"] == 3 + assert response.results[2]["count"] == 0 def test_trends_breakdowns_histogram(self): self._create_test_events() @@ -476,23 +480,20 @@ def test_trends_breakdowns_histogram(self): breakdown_labels = [result["breakdown_value"] for result in response.results] - assert len(response.results) == 4 - assert breakdown_labels == [ - "[10.0,17.5]", - "[17.5,25.0]", - "[25.0,32.5]", - "[32.5,40.01]", - ] + assert len(response.results) == 5 + assert breakdown_labels == ["[10.0,17.5]", "[17.5,25.0]", "[25.0,32.5]", "[32.5,40.01]", '["",""]'] assert response.results[0]["label"] == "[10.0,17.5]" assert response.results[1]["label"] == "[17.5,25.0]" assert response.results[2]["label"] == "[25.0,32.5]" assert response.results[3]["label"] == "[32.5,40.01]" + assert response.results[4]["label"] == '["",""]' assert response.results[0]["data"] == [0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0] assert response.results[1]["data"] == [1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0] assert response.results[2]["data"] == [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0] assert response.results[3]["data"] == [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0] + assert response.results[4]["data"] == [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] def test_trends_breakdowns_cohort(self): self._create_test_events() @@ -555,16 +556,18 @@ def test_trends_breakdowns_hogql(self): breakdown_labels = [result["breakdown_value"] for result in response.results] - assert len(response.results) == 4 - assert breakdown_labels == ["Chrome", "Firefox", "Edge", "Safari"] - assert response.results[0]["label"] == f"Chrome" - assert response.results[1]["label"] == f"Firefox" - assert response.results[2]["label"] == f"Edge" - assert response.results[3]["label"] == f"Safari" + assert len(response.results) == 5 + assert breakdown_labels == ["Chrome", "Firefox", "Edge", "Safari", "$$_posthog_breakdown_other_$$"] + assert response.results[0]["label"] == "Chrome" + assert response.results[1]["label"] == "Firefox" + assert response.results[2]["label"] == "Edge" + assert response.results[3]["label"] == "Safari" + assert response.results[4]["label"] == "$$_posthog_breakdown_other_$$" assert response.results[0]["count"] == 6 assert response.results[1]["count"] == 2 assert response.results[2]["count"] == 1 assert response.results[3]["count"] == 1 + assert response.results[4]["count"] == 0 def test_trends_breakdowns_multiple_hogql(self): self._create_test_events() @@ -580,24 +583,39 @@ def test_trends_breakdowns_multiple_hogql(self): breakdown_labels = [result["breakdown_value"] for result in response.results] - assert len(response.results) == 8 - assert breakdown_labels == ["Chrome", "Firefox", "Edge", "Safari", "Chrome", "Edge", "Firefox", "Safari"] + assert len(response.results) == 10 + assert breakdown_labels == [ + "Chrome", + "Firefox", + "Edge", + "Safari", + "$$_posthog_breakdown_other_$$", + "Chrome", + "Edge", + "Firefox", + "Safari", + "$$_posthog_breakdown_other_$$", + ] assert response.results[0]["label"] == f"$pageview - Chrome" assert response.results[1]["label"] == f"$pageview - Firefox" assert response.results[2]["label"] == f"$pageview - Edge" assert response.results[3]["label"] == f"$pageview - Safari" - assert response.results[4]["label"] == f"$pageleave - Chrome" - assert response.results[5]["label"] == f"$pageleave - Edge" - assert response.results[6]["label"] == f"$pageleave - Firefox" - assert response.results[7]["label"] == f"$pageleave - Safari" + assert response.results[4]["label"] == f"$pageview - $$_posthog_breakdown_other_$$" + assert response.results[5]["label"] == f"$pageleave - Chrome" + assert response.results[6]["label"] == f"$pageleave - Edge" + assert response.results[7]["label"] == f"$pageleave - Firefox" + assert response.results[8]["label"] == f"$pageleave - Safari" + assert response.results[9]["label"] == f"$pageleave - $$_posthog_breakdown_other_$$" assert response.results[0]["count"] == 6 assert response.results[1]["count"] == 2 assert response.results[2]["count"] == 1 assert response.results[3]["count"] == 1 - assert response.results[4]["count"] == 3 - assert response.results[5]["count"] == 1 + assert response.results[4]["count"] == 0 + assert response.results[5]["count"] == 3 assert response.results[6]["count"] == 1 assert response.results[7]["count"] == 1 + assert response.results[8]["count"] == 1 + assert response.results[9]["count"] == 0 def test_trends_breakdowns_and_compare(self): self._create_test_events() @@ -613,38 +631,48 @@ def test_trends_breakdowns_and_compare(self): breakdown_labels = [result["breakdown_value"] for result in response.results] - assert len(response.results) == 5 + assert len(response.results) == 7 assert breakdown_labels == [ "Chrome", "Safari", + "$$_posthog_breakdown_other_$$", "Chrome", "Firefox", "Edge", + "$$_posthog_breakdown_other_$$", ] assert response.results[0]["label"] == f"$pageview - Chrome" assert response.results[1]["label"] == f"$pageview - Safari" - assert response.results[2]["label"] == f"$pageview - Chrome" - assert response.results[3]["label"] == f"$pageview - Firefox" - assert response.results[4]["label"] == f"$pageview - Edge" + assert response.results[2]["label"] == f"$pageview - $$_posthog_breakdown_other_$$" + assert response.results[3]["label"] == f"$pageview - Chrome" + assert response.results[4]["label"] == f"$pageview - Firefox" + assert response.results[5]["label"] == f"$pageview - Edge" + assert response.results[6]["label"] == f"$pageview - $$_posthog_breakdown_other_$$" assert response.results[0]["count"] == 3 assert response.results[1]["count"] == 1 - assert response.results[2]["count"] == 3 - assert response.results[3]["count"] == 2 - assert response.results[4]["count"] == 1 + assert response.results[2]["count"] == 0 + assert response.results[3]["count"] == 3 + assert response.results[4]["count"] == 2 + assert response.results[5]["count"] == 1 + assert response.results[6]["count"] == 0 assert response.results[0]["compare_label"] == "current" assert response.results[1]["compare_label"] == "current" - assert response.results[2]["compare_label"] == "previous" + assert response.results[2]["compare_label"] == "current" assert response.results[3]["compare_label"] == "previous" assert response.results[4]["compare_label"] == "previous" + assert response.results[5]["compare_label"] == "previous" + assert response.results[6]["compare_label"] == "previous" assert response.results[0]["compare"] is True assert response.results[1]["compare"] is True assert response.results[2]["compare"] is True assert response.results[3]["compare"] is True assert response.results[4]["compare"] is True + assert response.results[5]["compare"] is True + assert response.results[6]["compare"] is True def test_trends_breakdown_and_aggregation_query_orchestration(self): self._create_test_events() @@ -660,12 +688,13 @@ def test_trends_breakdown_and_aggregation_query_orchestration(self): breakdown_labels = [result["breakdown_value"] for result in response.results] - assert len(response.results) == 4 - assert breakdown_labels == ["Chrome", "Firefox", "Safari", "Edge"] - assert response.results[0]["label"] == f"Chrome" - assert response.results[1]["label"] == f"Firefox" - assert response.results[2]["label"] == f"Safari" - assert response.results[3]["label"] == f"Edge" + assert len(response.results) == 5 + assert breakdown_labels == ["Chrome", "Firefox", "Safari", "Edge", "$$_posthog_breakdown_other_$$"] + assert response.results[0]["label"] == "Chrome" + assert response.results[1]["label"] == "Firefox" + assert response.results[2]["label"] == "Safari" + assert response.results[3]["label"] == "Edge" + assert response.results[4]["label"] == "$$_posthog_breakdown_other_$$" assert response.results[0]["data"] == [ 0, @@ -723,6 +752,20 @@ def test_trends_breakdown_and_aggregation_query_orchestration(self): 0, 0, ] + assert response.results[4]["data"] == [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] def test_trends_aggregation_hogql(self): self._create_test_events() @@ -955,6 +998,7 @@ def test_trends_display_cumulative(self): ) assert len(response.results) == 1 + assert response.results[0]["count"] == 10 assert response.results[0]["data"] == [ 1, 1, @@ -1100,3 +1144,17 @@ def test_properties_filtering_with_materialized_columns_and_empty_string_as_prop ) assert response.results[0]["data"] == [1] + + def test_smoothing(self): + self._create_test_events() + + response = self._run_trends_query( + "2020-01-09", + "2020-01-20", + IntervalType.day, + [EventsNode(event="$pageview")], + TrendsFilter(smoothing_intervals=7), + None, + ) + + assert response.results[0]["data"] == [1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0] diff --git a/posthog/hogql_queries/insights/trends/trends_query_runner.py b/posthog/hogql_queries/insights/trends/trends_query_runner.py index a2fd1b49bd0fc..944c838ce33db 100644 --- a/posthog/hogql_queries/insights/trends/trends_query_runner.py +++ b/posthog/hogql_queries/insights/trends/trends_query_runner.py @@ -15,6 +15,10 @@ from posthog.hogql import ast from posthog.hogql.query import execute_hogql_query from posthog.hogql.timings import HogQLTimings +from posthog.hogql_queries.insights.trends.breakdown_values import ( + BREAKDOWN_OTHER_NUMERIC_LABEL, + BREAKDOWN_OTHER_STRING_LABEL, +) from posthog.hogql_queries.insights.trends.display import TrendsDisplay from posthog.hogql_queries.insights.trends.query_builder import TrendsQueryBuilder from posthog.hogql_queries.insights.trends.series_with_extras import SeriesWithExtras @@ -196,6 +200,11 @@ def get_value(name: str, val: Any): }, } else: + if self._trends_display.display_type == ChartDisplayType.ActionsLineGraphCumulative: + count = get_value("total", val)[-1] + else: + count = float(sum(get_value("total", val))) + series_object = { "data": get_value("total", val), "labels": [ @@ -210,7 +219,7 @@ def get_value(name: str, val: Any): ) for item in get_value("date", val) ], - "count": float(sum(get_value("total", val))), + "count": count, "label": "All events" if series_label is None else series_label, "filter": self._query_to_filter(), "action": { # TODO: Populate missing props in `action` @@ -243,10 +252,12 @@ def get_value(name: str, val: Any): # Modifications for when breakdowns are active if self.query.breakdown is not None and self.query.breakdown.breakdown is not None: + remapped_label = None + if self._is_breakdown_field_boolean(): remapped_label = self._convert_boolean(get_value("breakdown_value", val)) - if remapped_label == "" or remapped_label == '["",""]' or remapped_label is None: + if remapped_label == "" or remapped_label is None: # Skip the "none" series if it doesn't have any data if series_object["count"] == 0 and series_object.get("aggregated_value", 0) == 0: continue @@ -262,7 +273,7 @@ def get_value(name: str, val: Any): series_object["breakdown_value"] = "all" if str(cohort_id) == "0" else int(cohort_id) else: remapped_label = get_value("breakdown_value", val) - if remapped_label == "" or remapped_label == '["",""]' or remapped_label is None: + if remapped_label == "" or remapped_label is None: # Skip the "none" series if it doesn't have any data if series_object["count"] == 0 and series_object.get("aggregated_value", 0) == 0: continue @@ -276,6 +287,18 @@ def get_value(name: str, val: Any): series_object["breakdown_value"] = remapped_label + # If the breakdown value is the numeric "other", then set it to the string version + if ( + remapped_label == BREAKDOWN_OTHER_NUMERIC_LABEL + or remapped_label == str(BREAKDOWN_OTHER_NUMERIC_LABEL) + or remapped_label == float(BREAKDOWN_OTHER_NUMERIC_LABEL) + ): + series_object["breakdown_value"] = BREAKDOWN_OTHER_STRING_LABEL + if series_count > 1 or self._is_breakdown_field_boolean(): + series_object["label"] = "{} - {}".format(series_label or "All events", "Other") + else: + series_object["label"] = "Other" + res.append(series_object) return res diff --git a/posthog/management/commands/change_team_ownership.py b/posthog/management/commands/change_team_ownership.py new file mode 100644 index 0000000000000..1dac8b957d191 --- /dev/null +++ b/posthog/management/commands/change_team_ownership.py @@ -0,0 +1,54 @@ +import logging +import uuid + +import structlog +from django.core.management import CommandError +from django.core.management.base import BaseCommand + +from posthog.models import Organization, Team + +logger = structlog.get_logger(__name__) +logger.setLevel(logging.INFO) + + +class Command(BaseCommand): + help = "Move a team into another organization." + + def add_arguments(self, parser): + parser.add_argument("--team-id", default=None, type=int, help="Specify a team to fix data for.") + parser.add_argument( + "--organization-id", default=None, type=uuid.UUID, help="Specify the destination organization by UUID." + ) + parser.add_argument("--live-run", action="store_true", help="Run changes, default is dry-run") + + def handle(self, *args, **options): + run(options) + + +def run(options): + live_run = options["live_run"] + + if options["team_id"] is None: + raise CommandError("You must specify --team-id to run this script") + + if options["organization_id"] is None: + raise CommandError("You must specify --organization-id to run this script") + + team_id = options["team_id"] + organization_id = options["organization_id"] + + team = Team.objects.get(pk=team_id) + logger.info(f"Team {team_id} is currently in organization {team.organization_id}, named {team.organization.name}") + + org = Organization.objects.get(pk=organization_id) + logger.info(f"Target organization {organization_id} is named {org.name}") + + if team.organization_id == organization_id: + raise CommandError("Team is already in the specified organization") + + if live_run: + team.organization_id = organization_id + team.save() + logger.info("Saved team change") + else: + logger.info("Skipping the team change, pass --live-run to run it") diff --git a/posthog/management/commands/test/test_change_team_ownership.py b/posthog/management/commands/test/test_change_team_ownership.py new file mode 100644 index 0000000000000..c8aecd89e2272 --- /dev/null +++ b/posthog/management/commands/test/test_change_team_ownership.py @@ -0,0 +1,79 @@ +import pytest +from django.core.management import CommandError, call_command + +from posthog.api.test.test_organization import create_organization +from posthog.api.test.test_team import create_team + + +@pytest.fixture +def organization(): + organization = create_organization("old") + yield organization + organization.delete() + + +@pytest.fixture +def new_organization(): + organization = create_organization("new") + yield organization + organization.delete() + + +@pytest.fixture +def team(organization): + team = create_team(organization=organization) + yield team + team.delete() + + +def test_change_team_ownership_required_parameters(): + with pytest.raises(CommandError): + call_command("change_team_ownership", "--team-id=123") + + with pytest.raises(CommandError): + call_command("change_team_ownership", "--organization-id=123") + + +@pytest.mark.django_db +def test_change_team_ownership_dry_run(organization, new_organization, team): + """Test organization doesn't change in a dry run""" + call_command( + "change_team_ownership", + f"--team-id={team.id}", + f"--organization-id={new_organization.id}", + ) + + team.refresh_from_db() + + assert team.organization_id == organization.id + + +@pytest.mark.django_db +def test_change_team_ownership_fails_with_same_organization(organization, team): + """Test command fails if trying to change to same organization.""" + with pytest.raises(CommandError): + call_command( + "change_team_ownership", + f"--team-id={team.id}", + f"--organization-id={organization.id}", + "--live-run", + ) + + team.refresh_from_db() + + assert team.organization_id == organization.id + + +@pytest.mark.django_db +def test_change_team_ownership(new_organization, team): + """Test the command works.""" + call_command( + "change_team_ownership", + f"--team-id={team.id}", + f"--organization-id={new_organization.id}", + "--live-run", + ) + + team.refresh_from_db() + + assert team.organization_id == new_organization.id diff --git a/posthog/migrations/0380_add_comments.py b/posthog/migrations/0380_add_comments.py new file mode 100644 index 0000000000000..a7fbb27b1715f --- /dev/null +++ b/posthog/migrations/0380_add_comments.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.19 on 2024-01-02 11:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import posthog.models.utils + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0379_alter_scheduledchange"), + ] + + operations = [ + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("content", models.TextField(blank=True, null=True)), + ("version", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("deleted", models.BooleanField(blank=True, default=False, null=True)), + ("item_id", models.CharField(max_length=72, null=True)), + ("item_context", models.JSONField(null=True)), + ("scope", models.CharField(max_length=79)), + ( + "created_by", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ( + "source_comment", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="posthog.comment" + ), + ), + ("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")), + ], + ), + migrations.AddIndex( + model_name="comment", + index=models.Index(fields=["team_id", "scope", "item_id"], name="posthog_com_team_id_be2206_idx"), + ), + ] diff --git a/posthog/migrations/0381_alter_externaldatasource_source_type.py b/posthog/migrations/0381_alter_externaldatasource_source_type.py new file mode 100644 index 0000000000000..973d2bd511cd8 --- /dev/null +++ b/posthog/migrations/0381_alter_externaldatasource_source_type.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.19 on 2023-12-29 18:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0380_add_comments"), + ] + + operations = [ + migrations.AlterField( + model_name="externaldatasource", + name="source_type", + field=models.CharField(choices=[("Stripe", "Stripe"), ("Hubspot", "Hubspot")], max_length=128), + ), + ] diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index d0fc43534b77f..001e98d076e1e 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -20,6 +20,7 @@ from .async_deletion import AsyncDeletion, DeletionType from .async_migration import AsyncMigration, AsyncMigrationError, MigrationStatus from .cohort import Cohort, CohortPeople +from .comment import Comment from .dashboard import Dashboard from .dashboard_tile import DashboardTile, Text from .early_access_feature import EarlyAccessFeature diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py index 94a9c0914faf6..81bab7f54dce2 100644 --- a/posthog/models/activity_logging/activity_log.py +++ b/posthog/models/activity_logging/activity_log.py @@ -21,10 +21,17 @@ "Insight", "Plugin", "PluginConfig", - "SessionRecordingPlaylist", + "DataManagement", "EventDefinition", "PropertyDefinition", "Notebook", + "Dashboard", + "Replay", + "Experiment", + "Survey", + "EarlyAccessFeature", + "SessionRecordingPlaylist", + "Comment", ] ChangeAction = Literal["changed", "created", "deleted", "merged", "split", "exported"] @@ -47,11 +54,13 @@ class Trigger: @dataclasses.dataclass(frozen=True) class Detail: - changes: Optional[List[Change]] = None - trigger: Optional[Trigger] = None + # The display name of the item in question name: Optional[str] = None + # The short_id if it has one short_id: Optional[str] = None type: Optional[str] = None + changes: Optional[List[Change]] = None + trigger: Optional[Trigger] = None class ActivityDetailEncoder(json.JSONEncoder): @@ -98,50 +107,48 @@ class Meta: created_at: models.DateTimeField = models.DateTimeField(default=timezone.now) +common_field_exclusions = [ + "id", + "uuid", + "short_id", + "created_at", + "created_by", + "last_modified_at", + "last_modified_by", + "updated_at", + "updated_by", + "team", + "team_id", +] + + field_exclusions: Dict[ActivityScope, List[str]] = { "Notebook": [ - "id", - "last_modified_at", - "last_modified_by", - "created_at", - "created_by", "text_content", ], "FeatureFlag": [ - "id", - "created_at", - "created_by", "is_simple_flag", "experiment", - "team", "featureflagoverride", ], "Person": [ - "id", - "uuid", "distinct_ids", "name", - "created_at", "is_identified", "persondistinctid", "cohort", "cohortpeople", "properties_last_updated_at", "properties_last_operation", - "team", "version", "is_user", ], "Insight": [ - "id", "filters_hash", - "created_at", "refreshing", "dive_dashboard", - "updated_at", "type", "funnel", - "last_modified_at", "layouts", "color", "order", @@ -151,50 +158,30 @@ class Meta: "saved", "is_sample", "refresh_attempt", - "last_modified_by", "short_id", - "created_by", "insightviewed", "dashboardtile", "caching_states", ], - "SessionRecordingPlaylist": [ - "id", - "short_id", - "created_at", - "created_by", - "last_modified_at", - "last_modified_by", - ], "EventDefinition": [ "eventdefinition_ptr_id", - "id", - "created_at", "_state", "deprecated_tags", - "team_id", - "updated_at", "owner_id", "query_usage_30_day", "verified_at", "verified_by", - "updated_by", "post_to_slack", ], "PropertyDefinition": [ "propertydefinition_ptr_id", - "id", - "created_at", "_state", "deprecated_tags", - "team_id", - "updated_at", "owner_id", "query_usage_30_day", "volume_30_day", "verified_at", "verified_by", - "updated_by", "post_to_slack", "property_type_format", ], @@ -226,7 +213,7 @@ def _read_through_relation(relation: models.Manager) -> List[Union[Dict, str]]: def changes_between( - model_type: Literal["FeatureFlag", "Person", "Insight", "SessionRecordingPlaylist", "Notebook"], + model_type: ActivityScope, previous: Optional[models.Model], current: Optional[models.Model], ) -> List[Change]: @@ -241,8 +228,9 @@ def changes_between( if previous is not None: fields = current._meta.get_fields() if current is not None else [] + excluded_fields = field_exclusions.get(model_type, []) + common_field_exclusions + filtered_fields = [f.name for f in fields if f.name not in excluded_fields] - filtered_fields = [f.name for f in fields if f.name not in field_exclusions[model_type]] for field in filtered_fields: left = getattr(previous, field, None) if isinstance(left, models.Manager): @@ -300,7 +288,7 @@ def dict_changes_between( fields = set(list(previous.keys()) + list(new.keys())) if use_field_exclusions: - fields = fields - set(field_exclusions.get(model_type, [])) + fields = fields - set(field_exclusions.get(model_type, [])) - set(common_field_exclusions) for field in fields: previous_value = previous.get(field, None) @@ -398,7 +386,7 @@ def get_activity_page(activity_query: models.QuerySet, limit: int = 10, page: in def load_activity( scope: ActivityScope, team_id: int, - item_id: Optional[int] = None, + item_ids: Optional[list[str]] = None, limit: int = 10, page: int = 1, ) -> ActivityPage: @@ -408,8 +396,8 @@ def load_activity( ActivityLog.objects.select_related("user").filter(team_id=team_id, scope=scope).order_by("-created_at") ) - if item_id is not None: - activity_query = activity_query.filter(item_id=item_id) + if item_ids is not None: + activity_query = activity_query.filter(item_id__in=item_ids) return get_activity_page(activity_query, limit, page) diff --git a/posthog/models/comment.py b/posthog/models/comment.py new file mode 100644 index 0000000000000..fdd3f63237883 --- /dev/null +++ b/posthog/models/comment.py @@ -0,0 +1,66 @@ +from typing import cast +from django.db import models +from posthog.models.activity_logging.activity_log import Change, Detail, log_activity +from posthog.models.signals import mutable_receiver + +from posthog.models.utils import UUIDModel + +# NOTE: This model is meant to be loosely related to the `activity_log` as they are similar in function and approach + + +class Comment(UUIDModel): + team: models.ForeignKey = models.ForeignKey("Team", on_delete=models.CASCADE) + content: models.TextField = models.TextField(blank=True, null=True) + version: models.IntegerField = models.IntegerField(default=0) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True, blank=True) + created_by: models.ForeignKey = models.ForeignKey("User", on_delete=models.SET_NULL, null=True, blank=True) + deleted: models.BooleanField = models.BooleanField(null=True, blank=True, default=False) + + # Loose relationship modelling to other PostHog resources + item_id = models.CharField(max_length=72, null=True) + item_context = models.JSONField(null=True) + scope = models.CharField(max_length=79, null=False) + + # Threads/replies are simply comments with a source_comment_id + source_comment: models.ForeignKey = models.ForeignKey("Comment", on_delete=models.CASCADE, null=True, blank=True) + + class Meta: + indexes = [models.Index(fields=["team_id", "scope", "item_id"])] + + +@mutable_receiver(models.signals.post_save, sender=Comment) +def log_comment_activity(sender, instance: Comment, created: bool, **kwargs): + if created: + # TRICKY: - Comments relate to a "thing" like a flag or insight. When we log the activity we need to know what the "thing" is + + # Rendering in the frontend we need + # 1. The comment content + # 2. The resource commented on (title, link) + + # For filtering important changes we need to know + # 1. The thing that was commented on (Ben commented on your insight/1234) - NOTE: We don't have the short_id here... + # 2. The reply thread (Paul replied to your comment on insight/1234) + # 3. Persons mentioned in the comment (@Ben mentioned you in insight/1234) + + # Options: + # 1. Pass the information when commenting needed for the activity log (title, link) + # 2. Lookup the information when loading the activity (could be pretty slow as well as needing custom logic for each type of thing) + # 3. Pass only the URL which allows us to say "X commented on insight/1234" + + # If it is a reply, the scope is the original comment + item_id = cast(str, instance.source_comment_id) or instance.item_id + scope = "Comment" if instance.source_comment_id else instance.scope + + log_activity( + organization_id=None, + team_id=instance.team_id, + user=instance.created_by, + item_id=item_id, + scope=scope, + activity="commented", + detail=Detail( + # name=TODO, + # short_id=TODO, + changes=[Change(type="Comment", field="content", action="created", after=instance.content)], + ), + ) diff --git a/posthog/models/property/util.py b/posthog/models/property/util.py index c67a3d8cf3154..cfefbb94dc1f7 100644 --- a/posthog/models/property/util.py +++ b/posthog/models/property/util.py @@ -15,7 +15,6 @@ from rest_framework import exceptions -from posthog.clickhouse.client.escape import escape_param_for_clickhouse from posthog.clickhouse.kafka_engine import trim_quotes_expr from posthog.clickhouse.materialized_columns import ( TableWithProperties, @@ -652,7 +651,7 @@ def get_single_or_multi_property_string_expr( allow_denormalized_props=True, materialised_table_column: str = "properties", normalize_url: bool = False, -): +) -> Tuple[str, Dict[str, Any]]: """ When querying for breakdown properties: * If the breakdown provided is a string, we extract the JSON from the properties object stored in the DB @@ -666,12 +665,16 @@ def get_single_or_multi_property_string_expr( no alias will be appended. """ - + breakdown_params: Dict[str, Any] = {} if isinstance(breakdown, str) or isinstance(breakdown, int): + breakdown_key = f"breakdown_param_{len(breakdown_params) + 1}" + breakdown_key = f"breakdown_param_{len(breakdown_params) + 1}" + breakdown_params[breakdown_key] = breakdown + expression, _ = get_property_string_expr( table, str(breakdown), - escape_param_for_clickhouse(breakdown), + f"%({breakdown_key})s", column, allow_denormalized_props, materialised_table_column=materialised_table_column, @@ -681,10 +684,12 @@ def get_single_or_multi_property_string_expr( else: expressions = [] for b in breakdown: + breakdown_key = f"breakdown_param_{len(breakdown_params) + 1}" + breakdown_params[breakdown_key] = b expr, _ = get_property_string_expr( table, b, - escape_param_for_clickhouse(b), + f"%({breakdown_key})s", column, allow_denormalized_props, materialised_table_column=materialised_table_column, @@ -694,9 +699,9 @@ def get_single_or_multi_property_string_expr( expression = f"array({','.join(expressions)})" if query_alias is None: - return expression + return expression, breakdown_params - return f"{expression} AS {query_alias}" + return f"{expression} AS {query_alias}", breakdown_params def normalize_url_breakdown(breakdown_value, breakdown_normalize_url: bool = True): diff --git a/posthog/queries/breakdown_props.py b/posthog/queries/breakdown_props.py index 059e1998f673e..64a550edf543d 100644 --- a/posthog/queries/breakdown_props.py +++ b/posthog/queries/breakdown_props.py @@ -164,7 +164,7 @@ def get_breakdown_prop_values( hogql_context=filter.hogql_context, ) - value_expression = _to_value_expression( + breakdown_expression, breakdown_params = _to_value_expression( filter.breakdown_type, filter.breakdown, filter.breakdown_group_type_index, @@ -185,7 +185,7 @@ def get_breakdown_prop_values( bucketing_expression = _to_bucketing_expression(cast(int, filter.breakdown_histogram_bin_count)) elements_query = HISTOGRAM_ELEMENTS_ARRAY_OF_KEY_SQL.format( bucketing_expression=bucketing_expression, - value_expression=value_expression, + breakdown_expression=breakdown_expression, parsed_date_from=parsed_date_from, parsed_date_to=parsed_date_to, prop_filters=prop_filters, @@ -199,7 +199,7 @@ def get_breakdown_prop_values( ) else: elements_query = TOP_ELEMENTS_ARRAY_OF_KEY_SQL.format( - value_expression=value_expression, + breakdown_expression=breakdown_expression, parsed_date_from=parsed_date_from, parsed_date_to=parsed_date_to, prop_filters=prop_filters, @@ -222,6 +222,7 @@ def get_breakdown_prop_values( "timezone": team.timezone, **prop_filter_params, **entity_params, + **breakdown_params, **person_join_params, **groups_join_params, **sessions_join_params, @@ -251,7 +252,8 @@ def _to_value_expression( breakdown_normalize_url: bool = False, direct_on_events: bool = False, cast_as_float: bool = False, -) -> str: +) -> Tuple[str, Dict]: + params: Dict[str, Any] = {} if breakdown_type == "session": if breakdown == "$session_duration": # Return the session duration expression right away because it's already an number, @@ -260,7 +262,7 @@ def _to_value_expression( else: raise ValidationError(f'Invalid breakdown "{breakdown}" for breakdown type "session"') elif breakdown_type == "person": - value_expression = get_single_or_multi_property_string_expr( + value_expression, params = get_single_or_multi_property_string_expr( breakdown, query_alias=None, table="events" if direct_on_events else "person", @@ -289,7 +291,7 @@ def _to_value_expression( else: value_expression = translate_hogql(cast(str, breakdown), hogql_context) else: - value_expression = get_single_or_multi_property_string_expr( + value_expression, params = get_single_or_multi_property_string_expr( breakdown, table="events", query_alias=None, @@ -300,7 +302,7 @@ def _to_value_expression( if cast_as_float: value_expression = f"toFloat64OrNull(toString({value_expression}))" - return f"{value_expression} AS value" + return f"{value_expression} AS value", params def _to_bucketing_expression(bin_count: int) -> str: diff --git a/posthog/queries/funnels/base.py b/posthog/queries/funnels/base.py index 482e821fd5d11..03702bf26328e 100644 --- a/posthog/queries/funnels/base.py +++ b/posthog/queries/funnels/base.py @@ -459,7 +459,8 @@ def _get_inner_event_query( # where i is the starting step for exclusion on that entity all_step_cols.extend(step_cols) - breakdown_select_prop = self._get_breakdown_select_prop() + breakdown_select_prop, breakdown_select_prop_params = self._get_breakdown_select_prop() + self.params.update(breakdown_select_prop_params) if breakdown_select_prop: all_step_cols.append(breakdown_select_prop) @@ -718,15 +719,16 @@ def get_step_counts_query(self) -> str: def get_step_counts_without_aggregation_query(self) -> str: raise NotImplementedError() - def _get_breakdown_select_prop(self) -> str: + def _get_breakdown_select_prop(self) -> Tuple[str, Dict[str, Any]]: basic_prop_selector = "" + basic_prop_params: Dict[str, Any] = {} if not self._filter.breakdown: - return basic_prop_selector + return basic_prop_selector, basic_prop_params self.params.update({"breakdown": self._filter.breakdown}) if self._filter.breakdown_type == "person": if self._team.person_on_events_mode != PersonOnEventsMode.DISABLED: - basic_prop_selector = get_single_or_multi_property_string_expr( + basic_prop_selector, basic_prop_params = get_single_or_multi_property_string_expr( self._filter.breakdown, table="events", query_alias="prop_basic", @@ -735,14 +737,14 @@ def _get_breakdown_select_prop(self) -> str: materialised_table_column="person_properties", ) else: - basic_prop_selector = get_single_or_multi_property_string_expr( + basic_prop_selector, basic_prop_params = get_single_or_multi_property_string_expr( self._filter.breakdown, table="person", query_alias="prop_basic", column="person_props", ) elif self._filter.breakdown_type == "event": - basic_prop_selector = get_single_or_multi_property_string_expr( + basic_prop_selector, basic_prop_params = get_single_or_multi_property_string_expr( self._filter.breakdown, table="events", query_alias="prop_basic", @@ -799,7 +801,7 @@ def _get_breakdown_select_prop(self) -> str: prop_window = "groupUniqArray(prop) over (PARTITION by aggregation_target) as prop_vals" - return ",".join([basic_prop_selector, *select_columns, final_select, prop_window]) + return ",".join([basic_prop_selector, *select_columns, final_select, prop_window]), basic_prop_params elif self._filter.breakdown_attribution_type in [ BreakdownAttributionType.FIRST_TOUCH, BreakdownAttributionType.LAST_TOUCH, @@ -818,10 +820,10 @@ def _get_breakdown_select_prop(self) -> str: breakdown_window_selector = f"{aggregate_operation}(prop, timestamp, {prop_conditional})" prop_window = f"{breakdown_window_selector} over (PARTITION by aggregation_target) as prop_vals" - return ",".join([basic_prop_selector, "prop_basic as prop", prop_window]) + return ",".join([basic_prop_selector, "prop_basic as prop", prop_window]), basic_prop_params else: # ALL_EVENTS - return ",".join([basic_prop_selector, "prop_basic as prop"]) + return ",".join([basic_prop_selector, "prop_basic as prop"]), basic_prop_params def _get_cohort_breakdown_join(self) -> str: cohort_queries, ids, cohort_params = format_breakdown_cohort_join_query(self._team, self._filter) diff --git a/posthog/queries/properties_timeline/properties_timeline.py b/posthog/queries/properties_timeline/properties_timeline.py index 578a9dee85620..fbf7cff240243 100644 --- a/posthog/queries/properties_timeline/properties_timeline.py +++ b/posthog/queries/properties_timeline/properties_timeline.py @@ -104,7 +104,7 @@ def run( event_query_sql, event_query_params = event_query.get_query() crucial_property_keys = self.extract_crucial_property_keys(filter) - crucial_property_columns = get_single_or_multi_property_string_expr( + crucial_property_columns, crucial_property_params = get_single_or_multi_property_string_expr( crucial_property_keys, query_alias=None, table="events", @@ -127,6 +127,7 @@ def run( params = { **event_query_params, + **crucial_property_params, "actor_id": actor.uuid if isinstance(actor, Person) else actor.group_key, } raw_query_result = insight_sync_execute( diff --git a/posthog/queries/retention/retention_events_query.py b/posthog/queries/retention/retention_events_query.py index f003e19495840..eef0d101452da 100644 --- a/posthog/queries/retention/retention_events_query.py +++ b/posthog/queries/retention/retention_events_query.py @@ -66,13 +66,14 @@ def get_query(self) -> Tuple[str, Dict[str, Any]]: "properties" if self._person_on_events_mode == PersonOnEventsMode.DISABLED else "person_properties" ) - breakdown_values_expression = get_single_or_multi_property_string_expr( + breakdown_values_expression, breakdown_values_params = get_single_or_multi_property_string_expr( breakdown=[breakdown["property"] for breakdown in self._filter.breakdowns], table=cast(Union[Literal["events"], Literal["person"]], table), query_alias=None, column=column, materialised_table_column=materalised_table_column, ) + self.params.update(breakdown_values_params) if self._event_query_type == RetentionQueryType.TARGET_FIRST_TIME: _fields += [f"argMin({breakdown_values_expression}, e.timestamp) AS breakdown_values"] diff --git a/posthog/queries/trends/sql.py b/posthog/queries/trends/sql.py index be4a56cc1debd..0f9d08040b34f 100644 --- a/posthog/queries/trends/sql.py +++ b/posthog/queries/trends/sql.py @@ -99,7 +99,7 @@ TOP_ELEMENTS_ARRAY_OF_KEY_SQL = """ SELECT - {value_expression}, + {breakdown_expression}, {aggregate_operation} as count FROM events e {sample_clause} @@ -116,7 +116,7 @@ HISTOGRAM_ELEMENTS_ARRAY_OF_KEY_SQL = """ SELECT {bucketing_expression} FROM ( SELECT - {value_expression}, + {breakdown_expression}, {aggregate_operation} as count FROM events e {sample_clause} diff --git a/posthog/settings/__init__.py b/posthog/settings/__init__.py index 7ee845e134694..3a3225e7deed5 100644 --- a/posthog/settings/__init__.py +++ b/posthog/settings/__init__.py @@ -39,7 +39,7 @@ from posthog.settings.object_storage import * from posthog.settings.temporal import * from posthog.settings.web import * -from posthog.settings.airbyte import * +from posthog.settings.data_warehouse import * from posthog.settings.utils import get_from_env, str_to_bool diff --git a/posthog/settings/airbyte.py b/posthog/settings/data_warehouse.py similarity index 75% rename from posthog/settings/airbyte.py rename to posthog/settings/data_warehouse.py index bcbcf2fefacb5..b53e16e570a13 100644 --- a/posthog/settings/airbyte.py +++ b/posthog/settings/data_warehouse.py @@ -8,3 +8,6 @@ # for DLT BUCKET_URL = os.getenv("BUCKET_URL", None) AIRBYTE_BUCKET_NAME = os.getenv("AIRBYTE_BUCKET_NAME", None) + +HUBSPOT_APP_CLIENT_ID = os.getenv("HUBSPOT_APP_CLIENT_ID", None) +HUBSPOT_APP_CLIENT_SECRET = os.getenv("HUBSPOT_APP_CLIENT_SECRET", None) diff --git a/posthog/temporal/batch_exports/bigquery_batch_export.py b/posthog/temporal/batch_exports/bigquery_batch_export.py index c802cc3192afe..ca11b60be5e25 100644 --- a/posthog/temporal/batch_exports/bigquery_batch_export.py +++ b/posthog/temporal/batch_exports/bigquery_batch_export.py @@ -23,11 +23,11 @@ get_rows_count, ) from posthog.temporal.batch_exports.clickhouse import get_client -from posthog.temporal.common.logger import bind_temporal_worker_logger from posthog.temporal.batch_exports.metrics import ( get_bytes_exported_metric, get_rows_exported_metric, ) +from posthog.temporal.common.logger import bind_temporal_worker_logger from posthog.temporal.common.utils import ( BatchExportHeartbeatDetails, should_resume_from_activity_heartbeat, @@ -85,6 +85,7 @@ class BigQueryInsertInputs: data_interval_end: str exclude_events: list[str] | None = None include_events: list[str] | None = None + use_json_type: bool = False @contextlib.contextmanager @@ -161,13 +162,21 @@ async def insert_into_bigquery_activity(inputs: BigQueryInsertInputs): exclude_events=inputs.exclude_events, include_events=inputs.include_events, ) + + if inputs.use_json_type is True: + json_type = "JSON" + json_string_columns = ["elements"] + else: + json_type = "STRING" + json_string_columns = ["properties", "elements", "set", "set_once"] + table_schema = [ bigquery.SchemaField("uuid", "STRING"), bigquery.SchemaField("event", "STRING"), - bigquery.SchemaField("properties", "STRING"), + bigquery.SchemaField("properties", json_type), bigquery.SchemaField("elements", "STRING"), - bigquery.SchemaField("set", "STRING"), - bigquery.SchemaField("set_once", "STRING"), + bigquery.SchemaField("set", json_type), + bigquery.SchemaField("set_once", json_type), bigquery.SchemaField("distinct_id", "STRING"), bigquery.SchemaField("team_id", "INT64"), bigquery.SchemaField("ip", "STRING"), @@ -175,7 +184,6 @@ async def insert_into_bigquery_activity(inputs: BigQueryInsertInputs): bigquery.SchemaField("timestamp", "TIMESTAMP"), bigquery.SchemaField("bq_ingested_timestamp", "TIMESTAMP"), ] - json_columns = ("properties", "elements", "set", "set_once") result = None @@ -219,7 +227,9 @@ async def flush_to_bigquery(): for result in results_iterator: row = { - field.name: json.dumps(result[field.name]) if field.name in json_columns else result[field.name] + field.name: json.dumps(result[field.name]) + if field.name in json_string_columns + else result[field.name] for field in table_schema if field.name != "bq_ingested_timestamp" } diff --git a/posthog/temporal/batch_exports/squash_person_overrides.py b/posthog/temporal/batch_exports/squash_person_overrides.py index 6843131c60333..446bcc0777a57 100644 --- a/posthog/temporal/batch_exports/squash_person_overrides.py +++ b/posthog/temporal/batch_exports/squash_person_overrides.py @@ -137,176 +137,6 @@ class PersonOverrideTuple(NamedTuple): override_person_id: UUID -class PostgresPersonOverridesManager: - def __init__(self, connection): - self.connection = connection - - def fetchall(self, team_id: int) -> Sequence[PersonOverrideTuple]: - with self.connection.cursor() as cursor: - cursor.execute( - """ - SELECT - old_person.uuid, - override_person.uuid - FROM posthog_personoverride override - LEFT OUTER JOIN - posthog_personoverridemapping old_person - ON override.team_id = old_person.team_id AND override.old_person_id = old_person.id - LEFT OUTER JOIN - posthog_personoverridemapping override_person - ON override.team_id = override_person.team_id AND override.override_person_id = override_person.id - WHERE override.team_id = %(team_id)s - """, - {"team_id": team_id}, - ) - return [PersonOverrideTuple(*row) for row in cursor.fetchall()] - - def insert(self, team_id: int, override: PersonOverrideTuple) -> None: - with self.connection.cursor() as cursor: - mapping_ids = [] - for person_uuid in (override.override_person_id, override.old_person_id): - cursor.execute( - """ - INSERT INTO posthog_personoverridemapping( - team_id, - uuid - ) - VALUES ( - %(team_id)s, - %(uuid)s - ) - ON CONFLICT("team_id", "uuid") DO NOTHING - RETURNING id - """, - {"team_id": team_id, "uuid": person_uuid}, - ) - mapping_ids.append(cursor.fetchone()) - - cursor.execute( - """ - INSERT INTO posthog_personoverride( - team_id, - old_person_id, - override_person_id, - oldest_event, - version - ) - VALUES ( - %(team_id)s, - %(old_person_id)s, - %(override_person_id)s, - NOW(), - 1 - ); - """, - { - "team_id": team_id, - "old_person_id": mapping_ids[1], - "override_person_id": mapping_ids[0], - }, - ) - - def delete(self, person_override: SerializablePersonOverrideToDelete, dry_run: bool = False) -> None: - with self.connection.cursor() as cursor: - SELECT_ID_FROM_OVERRIDE_UUID = """ - SELECT - id - FROM - posthog_personoverridemapping - WHERE - team_id = %(team_id)s - AND uuid = %(uuid)s; - """ - - cursor.execute( - SELECT_ID_FROM_OVERRIDE_UUID, - { - "team_id": person_override.team_id, - "uuid": person_override.old_person_id, - }, - ) - row = cursor.fetchone() - if not row: - return - - old_person_id = row[0] - - cursor.execute( - SELECT_ID_FROM_OVERRIDE_UUID, - { - "team_id": person_override.team_id, - "uuid": person_override.override_person_id, - }, - ) - row = cursor.fetchone() - if not row: - return - - override_person_id = row[0] - - DELETE_FROM_PERSON_OVERRIDES = """ - DELETE FROM - posthog_personoverride - WHERE - team_id = %(team_id)s - AND old_person_id = %(old_person_id)s - AND override_person_id = %(override_person_id)s - AND version = %(latest_version)s - RETURNING - old_person_id; - """ - - parameters = { - "team_id": person_override.team_id, - "old_person_id": old_person_id, - "override_person_id": override_person_id, - "latest_version": person_override.latest_version, - } - - if dry_run is True: - activity.logger.info("This is a DRY RUN so nothing will be deleted.") - activity.logger.info( - "Would have run query: %s with parameters %s", - DELETE_FROM_PERSON_OVERRIDES, - parameters, - ) - return - - cursor.execute(DELETE_FROM_PERSON_OVERRIDES, parameters) - row = cursor.fetchone() - if not row: - # There is no existing mapping for this (old_person_id, override_person_id) pair. - # It could be that a newer one was added (with a later version). - return - - deleted_id = row[0] - - DELETE_FROM_PERSON_OVERRIDE_MAPPINGS = """ - DELETE FROM - posthog_personoverridemapping - WHERE - id = %(deleted_id)s; - """ - - cursor.execute( - DELETE_FROM_PERSON_OVERRIDE_MAPPINGS, - { - "deleted_id": deleted_id, - }, - ) - - def clear(self, team_id: int) -> None: - with self.connection.cursor() as cursor: - cursor.execute( - "DELETE FROM posthog_personoverride WHERE team_id = %s", - [team_id], - ) - cursor.execute( - "DELETE FROM posthog_personoverridemapping WHERE team_id = %s", - [team_id], - ) - - class FlatPostgresPersonOverridesManager: def __init__(self, connection): self.connection = connection @@ -389,15 +219,6 @@ def clear(self, team_id: int) -> None: ) -POSTGRES_PERSON_OVERRIDES_MANAGERS = { - "mappings": PostgresPersonOverridesManager, - "flat": FlatPostgresPersonOverridesManager, -} - -DEFAULT_POSTGRES_PERSON_OVERRIDES_MANAGER = "flat" -assert DEFAULT_POSTGRES_PERSON_OVERRIDES_MANAGER in POSTGRES_PERSON_OVERRIDES_MANAGERS - - @dataclass class QueryInputs: """Inputs for activities that run queries in the SquashPersonOverrides workflow. @@ -421,7 +242,6 @@ class QueryInputs: dictionary_name: str = "person_overrides_join_dict" dry_run: bool = True _latest_created_at: str | datetime | None = None - postgres_person_overrides_manager: str = DEFAULT_POSTGRES_PERSON_OVERRIDES_MANAGER def __post_init__(self) -> None: if isinstance(self._latest_created_at, datetime): @@ -450,9 +270,6 @@ def iter_person_overides_to_delete(self) -> Iterable[SerializablePersonOverrideT for person_override_to_delete in self.person_overrides_to_delete: yield SerializablePersonOverrideToDelete(*person_override_to_delete) - def get_postgres_person_overrides_manager(self, connection): - return POSTGRES_PERSON_OVERRIDES_MANAGERS[self.postgres_person_overrides_manager](connection) - @activity.defn async def prepare_person_overrides(inputs: QueryInputs) -> None: @@ -695,7 +512,7 @@ async def delete_squashed_person_overrides_from_postgres(inputs: QueryInputs) -> port=settings.DATABASES["default"]["PORT"], **settings.DATABASES["default"].get("SSL_OPTIONS", {}), ) as connection: - overrides_manager = inputs.get_postgres_person_overrides_manager(connection) + overrides_manager = FlatPostgresPersonOverridesManager(connection) for person_override_to_delete in inputs.iter_person_overides_to_delete(): activity.logger.debug("%s", person_override_to_delete) overrides_manager.delete(person_override_to_delete, inputs.dry_run) @@ -762,7 +579,6 @@ class SquashPersonOverridesInputs: dictionary_name: str = "person_overrides_join_dict" last_n_months: int = 1 dry_run: bool = True - postgres_person_overrides_manager: str = DEFAULT_POSTGRES_PERSON_OVERRIDES_MANAGER def iter_partition_ids(self) -> Iterator[str]: """Iterate over configured partition ids. @@ -797,66 +613,21 @@ def iter_last_n_months(self) -> Iterator[datetime]: class SquashPersonOverridesWorkflow(PostHogWorkflow): """Workflow to squash outstanding person overrides into events. - Squashing refers to the process of updating the person_id associated with an event - to match the new id assigned via a person override. This process must be done - regularly to control the size of the person_overrides table. - - For example, let's imagine the initial state of tables as: - - posthog_personoverridesmapping - - | id | uuid | - | ------- + -------------------------------------- | - | 1 | '179bed4d-0cf9-49a5-8826-b4c36348fae4' | - | 2 | 'ced21432-7528-4045-bc22-855cbe69a6c1' | - - posthog_personoverride - - | old_person_id | override_person_id | - | ------------- + ------------------ | - | 1 | 2 | - - The activity select_persons_to_delete will select the uuid with id 1 as safe to delete - as its the only old_person_id at the time of starting. - - While executing this job, a new override (2->3) may be inserted, leaving both tables as: - - posthog_personoverridesmapping - - | id | uuid | - | ------- + -------------------------------------- | - | 1 | '179bed4d-0cf9-49a5-8826-b4c36348fae4' | - | 2 | 'ced21432-7528-4045-bc22-855cbe69a6c1' | - | 3 | 'b57de46b-55ad-4126-9a92-966fac570ec4' | - - posthog_personoverride - - | old_person_id | override_person_id | - | ------------- + ------------------ | - | 1 | 3 | - | 2 | 3 | - - Upon executing the squash_events_partition events with person_id 1 or 2 will be correctly - updated to reference person_id 3. - - At the end, we'll cleanup the tables by deleting the old_person_ids we deemed safe to do - so (1) from both tables: - - posthog_personoverridesmapping - - | id | uuid | - | ------- + -------------------------------------- | - | 2 | 'ced21432-7528-4045-bc22-855cbe69a6c1' | - | 3 | 'b57de46b-55ad-4126-9a92-966fac570ec4' | - - posthog_personoverride - - | old_person_id | override_person_id | - | ------------- + ------------------ | - | 2 | 3 | - - Any overrides that arrived during the job will be left there for the next job run to clean - up. These will be a no-op for the next job run as the override will already have been applied. + Squashing refers to the process of updating the person ID of existing + ClickHouse event records on disk to reflect their most up-to-date person ID. + + The persons associated with existing events can change as a result of + actions such as person merges. To account for this, we keep a record of what + new person ID should be used in place of (or "override") a previously used + person ID. The ``posthog_flatpersonoverride`` table is the primary + representation of this data in Postgres. The ``person_overrides`` table in + ClickHouse contains a replica of the data stored in Postgres, and can be + joined onto the events table to get the most up-to-date person for an event. + + This process must be done regularly to control the size of the person + overrides tables -- both to reduce the amount of storage required for these + tables, as well as ensuring that the join mentioned previously does not + become prohibitively large to evaluate. """ @staticmethod @@ -882,7 +653,6 @@ async def run(self, inputs: SquashPersonOverridesInputs): dictionary_name=inputs.dictionary_name, team_ids=inputs.team_ids, dry_run=inputs.dry_run, - postgres_person_overrides_manager=inputs.postgres_person_overrides_manager, ) async with person_overrides_dictionary( diff --git a/posthog/temporal/data_imports/external_data_job.py b/posthog/temporal/data_imports/external_data_job.py index 97c05ee2c4b16..6ef888e8b4817 100644 --- a/posthog/temporal/data_imports/external_data_job.py +++ b/posthog/temporal/data_imports/external_data_job.py @@ -155,6 +155,25 @@ async def run_external_data_job(inputs: ExternalDataJobInputs) -> None: source = stripe_source( api_key=stripe_secret_key, endpoints=tuple(inputs.schemas), job_id=str(model.id), team_id=inputs.team_id ) + elif model.pipeline.source_type == ExternalDataSource.Type.HUBSPOT: + from posthog.temporal.data_imports.pipelines.hubspot.auth import refresh_access_token + from posthog.temporal.data_imports.pipelines.hubspot import hubspot + + hubspot_access_code = model.pipeline.job_inputs.get("hubspot_secret_key", None) + refresh_token = model.pipeline.job_inputs.get("hubspot_refresh_token", None) + if not refresh_token: + raise ValueError(f"Hubspot refresh token not found for job {model.id}") + + if not hubspot_access_code: + hubspot_access_code = refresh_access_token(refresh_token) + + source = hubspot( + api_key=hubspot_access_code, + refresh_token=refresh_token, + job_id=str(model.id), + team_id=inputs.team_id, + endpoints=tuple(inputs.schemas), + ) else: raise ValueError(f"Source type {model.pipeline.source_type} not supported") diff --git a/posthog/temporal/data_imports/pipelines/hubspot/__init__.py b/posthog/temporal/data_imports/pipelines/hubspot/__init__.py new file mode 100644 index 0000000000000..3275071efe992 --- /dev/null +++ b/posthog/temporal/data_imports/pipelines/hubspot/__init__.py @@ -0,0 +1,146 @@ +""" +This is a module that provides a DLT source to retrieve data from multiple endpoints of the HubSpot API using a specified API key. The retrieved data is returned as a tuple of Dlt resources, one for each endpoint. + +The source retrieves data from the following endpoints: +- CRM Companies +- CRM Contacts +- CRM Deals +- CRM Tickets +- CRM Quotes +- Web Analytics Events + +For each endpoint, a resource and transformer function are defined to retrieve data and transform it to a common format. +The resource functions yield the raw data retrieved from the API, while the transformer functions are used to retrieve +additional information from the Web Analytics Events endpoint. + +The source also supports enabling Web Analytics Events for each endpoint by setting the corresponding enable flag to True. + +Example: +To retrieve data from all endpoints, use the following code: + +python + +>>> resources = hubspot(api_key="hubspot_access_code") +""" + +from typing import Literal, Sequence, Iterator, Iterable + +import dlt +from dlt.common.typing import TDataItems +from dlt.sources import DltResource +from posthog.temporal.data_imports.pipelines.helpers import limit_paginated_generator + +from .helpers import ( + fetch_data, + _get_property_names, + fetch_property_history, +) +from .settings import ( + ALL, + CRM_OBJECT_ENDPOINTS, + DEFAULT_PROPS, + OBJECT_TYPE_PLURAL, + OBJECT_TYPE_SINGULAR, +) + +THubspotObjectType = Literal["company", "contact", "deal", "ticket", "quote"] + + +@dlt.source(name="hubspot") +def hubspot( + api_key: str, + refresh_token: str, + job_id: str, + team_id: int, + endpoints: Sequence[str] = ("companies", "contacts", "deals", "tickets", "quotes"), + include_history: bool = False, +) -> Iterable[DltResource]: + """ + A DLT source that retrieves data from the HubSpot API using the + specified API key. + + This function retrieves data for several HubSpot API endpoints, + including companies, contacts, deals, tickets and web + analytics events. It returns a tuple of Dlt resources, one for + each endpoint. + + Args: + api_key (Optional[str]): + The API key used to authenticate with the HubSpot API. Defaults + to dlt.secrets.value. + include_history (Optional[bool]): + Whether to load history of property changes along with entities. + The history entries are loaded to separate tables. + + Returns: + Sequence[DltResource]: Dlt resources, one for each HubSpot API endpoint. + + Notes: + This function uses the `fetch_data` function to retrieve data from the + HubSpot CRM API. The API key is passed to `fetch_data` as the + `api_key` argument. + """ + + for endpoint in endpoints: + yield dlt.resource( + crm_objects, + name=endpoint, + write_disposition="append", + )( + object_type=OBJECT_TYPE_SINGULAR[endpoint], + api_key=api_key, + refresh_token=refresh_token, + include_history=include_history, + props=DEFAULT_PROPS[endpoint], + include_custom_props=True, + job_id=job_id, + team_id=team_id, + ) + + +@limit_paginated_generator +def crm_objects( + object_type: str, + api_key: str, + refresh_token: str, + include_history: bool, + props: Sequence[str], + include_custom_props: bool = True, +) -> Iterator[TDataItems]: + """Building blocks for CRM resources.""" + if props == ALL: + props = list(_get_property_names(api_key, refresh_token, object_type)) + + if include_custom_props: + all_props = _get_property_names(api_key, refresh_token, object_type) + custom_props = [prop for prop in all_props if not prop.startswith("hs_")] + props = props + custom_props # type: ignore + + props = ",".join(sorted(list(set(props)))) + + if len(props) > 2000: + raise ValueError( + ( + "Your request to Hubspot is too long to process. " + "Maximum allowed query length is 2000 symbols, while " + f"your list of properties `{props[:200]}`... is {len(props)} " + "symbols long. Use the `props` argument of the resource to " + "set the list of properties to extract from the endpoint." + ) + ) + + params = {"properties": props, "limit": 100} + + yield from fetch_data(CRM_OBJECT_ENDPOINTS[object_type], api_key, refresh_token, params=params) + if include_history: + # Get history separately, as requesting both all properties and history together + # is likely to hit hubspot's URL length limit + for history_entries in fetch_property_history( + CRM_OBJECT_ENDPOINTS[object_type], + api_key, + props, + ): + yield dlt.mark.with_table_name( + history_entries, + OBJECT_TYPE_PLURAL[object_type] + "_property_history", + ) diff --git a/posthog/temporal/data_imports/pipelines/hubspot/auth.py b/posthog/temporal/data_imports/pipelines/hubspot/auth.py new file mode 100644 index 0000000000000..490552cfe237d --- /dev/null +++ b/posthog/temporal/data_imports/pipelines/hubspot/auth.py @@ -0,0 +1,42 @@ +import requests +from django.conf import settings +from typing import Tuple + + +def refresh_access_token(refresh_token: str) -> str: + res = requests.post( + "https://api.hubapi.com/oauth/v1/token", + data={ + "grant_type": "refresh_token", + "client_id": settings.HUBSPOT_APP_CLIENT_ID, + "client_secret": settings.HUBSPOT_APP_CLIENT_SECRET, + "refresh_token": refresh_token, + }, + ) + + if res.status_code != 200: + err_message = res.json()["message"] + raise Exception(err_message) + + return res.json()["access_token"] + + +def get_access_token_from_code(code: str, redirect_uri: str) -> Tuple[str, str]: + res = requests.post( + "https://api.hubapi.com/oauth/v1/token", + data={ + "grant_type": "authorization_code", + "client_id": settings.HUBSPOT_APP_CLIENT_ID, + "client_secret": settings.HUBSPOT_APP_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "code": code, + }, + ) + + if res.status_code != 200: + err_message = res.json()["message"] + raise Exception(err_message) + + payload = res.json() + + return payload["access_token"], payload["refresh_token"] diff --git a/posthog/temporal/data_imports/pipelines/hubspot/helpers.py b/posthog/temporal/data_imports/pipelines/hubspot/helpers.py new file mode 100644 index 0000000000000..b724368fe7e40 --- /dev/null +++ b/posthog/temporal/data_imports/pipelines/hubspot/helpers.py @@ -0,0 +1,198 @@ +"""Hubspot source helpers""" + +import urllib.parse +from typing import Iterator, Dict, Any, List, Optional + +from dlt.sources.helpers import requests +import requests as http_requests +from .settings import OBJECT_TYPE_PLURAL +from .auth import refresh_access_token + +BASE_URL = "https://api.hubapi.com/" + + +def get_url(endpoint: str) -> str: + """Get absolute hubspot endpoint URL""" + return urllib.parse.urljoin(BASE_URL, endpoint) + + +def _get_headers(api_key: str) -> Dict[str, str]: + """ + Return a dictionary of HTTP headers to use for API requests, including the specified API key. + + Args: + api_key (str): The API key to use for authentication, as a string. + + Returns: + dict: A dictionary of HTTP headers to include in API requests, with the `Authorization` header + set to the specified API key in the format `Bearer {api_key}`. + + """ + # Construct the dictionary of HTTP headers to use for API requests + return dict(authorization=f"Bearer {api_key}") + + +def extract_property_history(objects: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]: + for item in objects: + history = item.get("propertiesWithHistory") + if not history: + return + # Yield a flat list of property history entries + for key, changes in history.items(): + if not changes: + continue + for entry in changes: + yield {"object_id": item["id"], "property_name": key, **entry} + + +def fetch_property_history( + endpoint: str, + api_key: str, + props: str, + params: Optional[Dict[str, Any]] = None, +) -> Iterator[List[Dict[str, Any]]]: + """Fetch property history from the given CRM endpoint. + + Args: + endpoint: The endpoint to fetch data from, as a string. + api_key: The API key to use for authentication, as a string. + props: A comma separated list of properties to retrieve the history for + params: Optional dict of query params to include in the request + + Yields: + List of property history entries (dicts) + """ + # Construct the URL and headers for the API request + url = get_url(endpoint) + headers = _get_headers(api_key) + + params = dict(params or {}) + params["propertiesWithHistory"] = props + params["limit"] = 50 + # Make the API request + r = requests.get(url, headers=headers, params=params) + # Parse the API response and yield the properties of each result + + # Parse the response JSON data + _data = r.json() + while _data is not None: + if "results" in _data: + yield list(extract_property_history(_data["results"])) + + # Follow pagination links if they exist + _next = _data.get("paging", {}).get("next", None) + if _next: + next_url = _next["link"] + # Get the next page response + r = requests.get(next_url, headers=headers) + _data = r.json() + else: + _data = None + + +def fetch_data( + endpoint: str, api_key: str, refresh_token: str, params: Optional[Dict[str, Any]] = None +) -> Iterator[List[Dict[str, Any]]]: + """ + Fetch data from HUBSPOT endpoint using a specified API key and yield the properties of each result. + For paginated endpoint this function yields item from all pages. + + Args: + endpoint (str): The endpoint to fetch data from, as a string. + api_key (str): The API key to use for authentication, as a string. + params: Optional dict of query params to include in the request + + Yields: + A List of CRM object dicts + + Raises: + requests.exceptions.HTTPError: If the API returns an HTTP error status code. + + Notes: + This function uses the `requests` library to make a GET request to the specified endpoint, with + the API key included in the headers. If the API returns a non-successful HTTP status code (e.g. + 404 Not Found), a `requests.exceptions.HTTPError` exception will be raised. + + The `endpoint` argument should be a relative URL, which will be appended to the base URL for the + API. The `params` argument is used to pass additional query parameters to the request + + This function also includes a retry decorator that will automatically retry the API call up to + 3 times with a 5-second delay between retries, using an exponential backoff strategy. + """ + # Construct the URL and headers for the API request + url = get_url(endpoint) + headers = _get_headers(api_key) + + # Make the API request + try: + r = requests.get(url, headers=headers, params=params) + except http_requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + # refresh token + api_key = refresh_access_token(refresh_token) + headers = _get_headers(api_key) + r = requests.get(url, headers=headers, params=params) + else: + raise e + # Parse the API response and yield the properties of each result + # Parse the response JSON data + + _data = r.json() + # Yield the properties of each result in the API response + while _data is not None: + if "results" in _data: + _objects: List[Dict[str, Any]] = [] + for _result in _data["results"]: + _obj = _result.get("properties", _result) + if "id" not in _obj and "id" in _result: + # Move id from properties to top level + _obj["id"] = _result["id"] + if "associations" in _result: + for association in _result["associations"]: + __values = [ + { + "value": _obj["hs_object_id"], + f"{association}_id": __r["id"], + } + for __r in _result["associations"][association]["results"] + ] + + # remove duplicates from list of dicts + __values = [dict(t) for t in {tuple(d.items()) for d in __values}] + + _obj[association] = __values + _objects.append(_obj) + + yield _objects + + # Follow pagination links if they exist + _next = _data.get("paging", {}).get("next", None) + if _next: + next_url = _next["link"] + # Get the next page response + r = requests.get(next_url, headers=headers) + _data = r.json() + else: + _data = None + + +def _get_property_names(api_key: str, refresh_token: str, object_type: str) -> List[str]: + """ + Retrieve property names for a given entity from the HubSpot API. + + Args: + entity: The entity name for which to retrieve property names. + + Returns: + A list of property names. + + Raises: + Exception: If an error occurs during the API request. + """ + properties = [] + endpoint = f"/crm/v3/properties/{OBJECT_TYPE_PLURAL[object_type]}" + + for page in fetch_data(endpoint, api_key, refresh_token): + properties.extend([prop["name"] for prop in page]) + + return properties diff --git a/posthog/temporal/data_imports/pipelines/hubspot/settings.py b/posthog/temporal/data_imports/pipelines/hubspot/settings.py new file mode 100644 index 0000000000000..10af4c47b5a31 --- /dev/null +++ b/posthog/temporal/data_imports/pipelines/hubspot/settings.py @@ -0,0 +1,106 @@ +"""Hubspot source settings and constants""" + +from dlt.common import pendulum + +STARTDATE = pendulum.datetime(year=2000, month=1, day=1) + +CONTACT = "contact" +COMPANY = "company" +DEAL = "deal" +TICKET = "ticket" +QUOTE = "quote" + +CRM_CONTACTS_ENDPOINT = "/crm/v3/objects/contacts?associations=deals,tickets,quotes" +CRM_COMPANIES_ENDPOINT = "/crm/v3/objects/companies?associations=contacts,deals,tickets,quotes" +CRM_DEALS_ENDPOINT = "/crm/v3/objects/deals" +CRM_TICKETS_ENDPOINT = "/crm/v3/objects/tickets" +CRM_QUOTES_ENDPOINT = "/crm/v3/objects/quotes" + +CRM_OBJECT_ENDPOINTS = { + CONTACT: CRM_CONTACTS_ENDPOINT, + COMPANY: CRM_COMPANIES_ENDPOINT, + DEAL: CRM_DEALS_ENDPOINT, + TICKET: CRM_TICKETS_ENDPOINT, + QUOTE: CRM_QUOTES_ENDPOINT, +} + +WEB_ANALYTICS_EVENTS_ENDPOINT = "/events/v3/events?objectType={objectType}&objectId={objectId}&occurredAfter={occurredAfter}&occurredBefore={occurredBefore}&sort=-occurredAt" + +OBJECT_TYPE_SINGULAR = { + "companies": COMPANY, + "contacts": CONTACT, + "deals": DEAL, + "tickets": TICKET, + "quotes": QUOTE, +} + +OBJECT_TYPE_PLURAL = {v: k for k, v in OBJECT_TYPE_SINGULAR.items()} + + +ENDPOINTS = ( + OBJECT_TYPE_PLURAL[CONTACT], + OBJECT_TYPE_PLURAL[DEAL], + OBJECT_TYPE_PLURAL[COMPANY], + OBJECT_TYPE_PLURAL[TICKET], + OBJECT_TYPE_PLURAL[QUOTE], +) + +DEFAULT_DEAL_PROPS = [ + "amount", + "closedate", + "createdate", + "dealname", + "dealstage", + "hs_lastmodifieddate", + "hs_object_id", + "pipeline", +] + +DEFAULT_COMPANY_PROPS = [ + "createdate", + "domain", + "hs_lastmodifieddate", + "hs_object_id", + "name", +] + +DEFAULT_CONTACT_PROPS = [ + "createdate", + "email", + "firstname", + "hs_object_id", + "lastmodifieddate", + "lastname", +] + +DEFAULT_TICKET_PROPS = [ + "createdate", + "content", + "hs_lastmodifieddate", + "hs_object_id", + "hs_pipeline", + "hs_pipeline_stage", + "hs_ticket_category", + "hs_ticket_priority", + "subject", +] + +DEFAULT_QUOTE_PROPS = [ + "hs_createdate", + "hs_expiration_date", + "hs_lastmodifieddate", + "hs_object_id", + "hs_public_url_key", + "hs_status", + "hs_title", +] + +DEFAULT_PROPS = { + OBJECT_TYPE_PLURAL[CONTACT]: DEFAULT_CONTACT_PROPS, + OBJECT_TYPE_PLURAL[COMPANY]: DEFAULT_COMPANY_PROPS, + OBJECT_TYPE_PLURAL[DEAL]: DEFAULT_DEAL_PROPS, + OBJECT_TYPE_PLURAL[TICKET]: DEFAULT_TICKET_PROPS, + OBJECT_TYPE_PLURAL[QUOTE]: DEFAULT_QUOTE_PROPS, +} + +ALL = ("ALL",) diff --git a/posthog/temporal/data_imports/pipelines/schemas.py b/posthog/temporal/data_imports/pipelines/schemas.py index a62db7d664e40..eaaa431d7aef9 100644 --- a/posthog/temporal/data_imports/pipelines/schemas.py +++ b/posthog/temporal/data_imports/pipelines/schemas.py @@ -1,4 +1,8 @@ from posthog.warehouse.models import ExternalDataSource -from posthog.temporal.data_imports.pipelines.stripe.settings import ENDPOINTS +from posthog.temporal.data_imports.pipelines.stripe.settings import ENDPOINTS as STRIPE_ENDPOINTS +from posthog.temporal.data_imports.pipelines.hubspot.settings import ENDPOINTS as HUBSPOT_ENDPOINTS -PIPELINE_TYPE_SCHEMA_DEFAULT_MAPPING = {ExternalDataSource.Type.STRIPE: ENDPOINTS} +PIPELINE_TYPE_SCHEMA_DEFAULT_MAPPING = { + ExternalDataSource.Type.STRIPE: STRIPE_ENDPOINTS, + ExternalDataSource.Type.HUBSPOT: HUBSPOT_ENDPOINTS, +} diff --git a/posthog/temporal/tests/batch_exports/test_bigquery_batch_export_workflow.py b/posthog/temporal/tests/batch_exports/test_bigquery_batch_export_workflow.py index b106b814ea3e2..719cd37e08e40 100644 --- a/posthog/temporal/tests/batch_exports/test_bigquery_batch_export_workflow.py +++ b/posthog/temporal/tests/batch_exports/test_bigquery_batch_export_workflow.py @@ -143,12 +143,19 @@ def bigquery_dataset(bigquery_config, bigquery_client) -> typing.Generator[bigqu yield dataset - bigquery_client.delete_dataset(dataset_id, delete_contents=True, not_found_ok=True) + # bigquery_client.delete_dataset(dataset_id, delete_contents=True, not_found_ok=True) @pytest.mark.parametrize("exclude_events", [None, ["test-exclude"]], indirect=True) +@pytest.mark.parametrize("use_json_type", [False, True]) async def test_insert_into_bigquery_activity_inserts_data_into_bigquery_table( - clickhouse_client, activity_environment, bigquery_client, bigquery_config, exclude_events, bigquery_dataset + clickhouse_client, + activity_environment, + bigquery_client, + bigquery_config, + exclude_events, + bigquery_dataset, + use_json_type, ): """Test that the insert_into_bigquery_activity function inserts data into a BigQuery table. @@ -215,6 +222,7 @@ async def test_insert_into_bigquery_activity_inserts_data_into_bigquery_table( data_interval_start=data_interval_start.isoformat(), data_interval_end=data_interval_end.isoformat(), exclude_events=exclude_events, + use_json_type=use_json_type, **bigquery_config, ) @@ -272,6 +280,7 @@ async def bigquery_batch_export( @pytest.mark.parametrize("interval", ["hour", "day"]) @pytest.mark.parametrize("exclude_events", [None, ["test-exclude"]], indirect=True) +@pytest.mark.parametrize("use_json_type", [False, True]) async def test_bigquery_export_workflow( clickhouse_client, bigquery_client, @@ -280,6 +289,7 @@ async def test_bigquery_export_workflow( exclude_events, ateam, table_id, + use_json_type, ): """Test BigQuery Export Workflow end-to-end. @@ -321,6 +331,7 @@ async def test_bigquery_export_workflow( batch_export_id=str(bigquery_batch_export.id), data_interval_end=data_interval_end.isoformat(), interval=interval, + use_json_type=use_json_type, **bigquery_batch_export.destination.config, ) diff --git a/posthog/temporal/tests/test_squash_person_overrides_workflow.py b/posthog/temporal/tests/test_squash_person_overrides_workflow.py index 4e90610914ef4..795e05879f99c 100644 --- a/posthog/temporal/tests/test_squash_person_overrides_workflow.py +++ b/posthog/temporal/tests/test_squash_person_overrides_workflow.py @@ -2,7 +2,7 @@ import random from collections import defaultdict from datetime import datetime, timedelta -from typing import Iterator, NamedTuple, TypedDict +from typing import Iterator, TypedDict from uuid import UUID, uuid4 import psycopg2 @@ -23,9 +23,8 @@ PERSON_OVERRIDES_CREATE_TABLE_SQL, ) from posthog.temporal.batch_exports.squash_person_overrides import ( - POSTGRES_PERSON_OVERRIDES_MANAGERS, + FlatPostgresPersonOverridesManager, PersonOverrideTuple, - PostgresPersonOverridesManager, QueryInputs, SerializablePersonOverrideToDelete, SquashPersonOverridesInputs, @@ -935,39 +934,23 @@ def team_id(query_inputs, organization_uuid, pg_connection): cursor.execute("DELETE FROM posthog_team WHERE id = %s", [team_id]) -class PostgresPersonOverrideFixtures(NamedTuple): - manager: str - override: PersonOverrideTuple - - -@pytest.fixture(params=POSTGRES_PERSON_OVERRIDES_MANAGERS.keys()) -def postgres_person_override_fixtures( - request, query_inputs: QueryInputs, team_id, pg_connection -) -> Iterator[PostgresPersonOverrideFixtures]: +@pytest.fixture +def postgres_person_override(team_id, pg_connection) -> Iterator[PersonOverrideTuple]: """Create a PersonOverrideMapping and a PersonOverride. We cannot use the Django ORM safely in an async context, so we INSERT INTO directly on the database. This means we need to clean up after ourselves, which we do after yielding. """ - # XXX: Several activity-based tests use this person overrides fixture and - # should vary their behavior to ensure that they work with both the old - # (mappings) and new (flat) approaches, but not all tests that use - # `query_inputs` need to be vary on the overrides manager type as many of - # them don't use Postgres overrides at all. To ensure that whenever Postgres - # overrides *are* used, we need to update the fixture here. This indirection - # isn't good, but this code should be short-lived, right? (... right???) - query_inputs.postgres_person_overrides_manager = request.param - override = PersonOverrideTuple(uuid4(), uuid4()) with pg_connection: - query_inputs.get_postgres_person_overrides_manager(pg_connection).insert(team_id, override) + FlatPostgresPersonOverridesManager(pg_connection).insert(team_id, override) - yield PostgresPersonOverrideFixtures(request.param, override) + yield override with pg_connection: - query_inputs.get_postgres_person_overrides_manager(pg_connection).clear(team_id) + FlatPostgresPersonOverridesManager(pg_connection).clear(team_id) @pytest.mark.django_db @@ -976,7 +959,7 @@ async def test_delete_squashed_person_overrides_from_postgres( query_inputs, activity_environment, team_id, - postgres_person_override_fixtures: PostgresPersonOverrideFixtures, + postgres_person_override: PersonOverrideTuple, pg_connection, ): """Test we can delete person overrides that have already been squashed. @@ -984,18 +967,16 @@ async def test_delete_squashed_person_overrides_from_postgres( For the purposes of this unit test, we take the person overrides as given. A comprehensive test will cover the entire worflow end-to-end. """ - override = postgres_person_override_fixtures.override - # These are sanity checks to ensure the fixtures are working properly. # If any assertions fail here, its likely a test setup issue. with pg_connection: - assert query_inputs.get_postgres_person_overrides_manager(pg_connection).fetchall(team_id) == [override] + assert FlatPostgresPersonOverridesManager(pg_connection).fetchall(team_id) == [postgres_person_override] person_overrides_to_delete = [ SerializablePersonOverrideToDelete( team_id, - override.old_person_id, - override.override_person_id, + postgres_person_override.old_person_id, + postgres_person_override.override_person_id, OVERRIDES_CREATED_AT.isoformat(), 1, OLDEST_EVENT_AT.isoformat(), @@ -1007,7 +988,7 @@ async def test_delete_squashed_person_overrides_from_postgres( await activity_environment.run(delete_squashed_person_overrides_from_postgres, query_inputs) with pg_connection: - assert query_inputs.get_postgres_person_overrides_manager(pg_connection).fetchall(team_id) == [] + assert FlatPostgresPersonOverridesManager(pg_connection).fetchall(team_id) == [] @pytest.mark.django_db @@ -1016,22 +997,20 @@ async def test_delete_squashed_person_overrides_from_postgres_dry_run( query_inputs, activity_environment, team_id, - postgres_person_override_fixtures: PostgresPersonOverrideFixtures, + postgres_person_override: PersonOverrideTuple, pg_connection, ): """Test we do not delete person overrides when dry_run=True.""" - override = postgres_person_override_fixtures.override - # These are sanity checks to ensure the fixtures are working properly. # If any assertions fail here, its likely a test setup issue. with pg_connection: - assert query_inputs.get_postgres_person_overrides_manager(pg_connection).fetchall(team_id) == [override] + assert FlatPostgresPersonOverridesManager(pg_connection).fetchall(team_id) == [postgres_person_override] person_overrides_to_delete = [ SerializablePersonOverrideToDelete( team_id, - override.old_person_id, - override.override_person_id, + postgres_person_override.old_person_id, + postgres_person_override.override_person_id, OVERRIDES_CREATED_AT.isoformat(), 1, OLDEST_EVENT_AT.isoformat(), @@ -1043,99 +1022,7 @@ async def test_delete_squashed_person_overrides_from_postgres_dry_run( await activity_environment.run(delete_squashed_person_overrides_from_postgres, query_inputs) with pg_connection: - assert query_inputs.get_postgres_person_overrides_manager(pg_connection).fetchall(team_id) == [override] - - -@pytest.mark.django_db -@pytest.mark.asyncio -async def test_delete_squashed_person_overrides_from_postgres_with_newer_override( - query_inputs, - activity_environment, - team_id, - postgres_person_override_fixtures: PostgresPersonOverrideFixtures, - pg_connection, -): - """Test we do not delete a newer mapping from Postgres. - - For the purposes of this unit test, we take the person overrides as given. A - comprehensive test will cover the entire worflow end-to-end. - """ - override = postgres_person_override_fixtures.override - - # These are sanity checks to ensure the fixtures are working properly. - # If any assertions fail here, its likely a test setup issue. - with pg_connection: - overrides_manager = query_inputs.get_postgres_person_overrides_manager(pg_connection) - if not isinstance(overrides_manager, PostgresPersonOverridesManager): - pytest.xfail(f"{overrides_manager!r} does not support mappings") - - with pg_connection.cursor() as cursor: - cursor.execute("SELECT id, team_id, uuid FROM posthog_personoverridemapping") - mappings = cursor.fetchall() - assert len(mappings) == 2 - - cursor.execute("SELECT team_id, old_person_id, override_person_id FROM posthog_personoverride") - overrides = cursor.fetchall() - assert len(overrides) == 1 - - with pg_connection: - with pg_connection.cursor() as cursor: - # Let's insert a newer mapping that arrives while we are running the squash job. - # Since only one mapping can exist per old_person_id, we'll bump the version number. - cursor.execute( - """ - UPDATE posthog_personoverride - SET version = version + 1 - WHERE - team_id = %(team_id)s - AND old_person_id = %(old_person_id)s - """, - { - "team_id": team_id, - "old_person_id": [mapping[0] for mapping in mappings if mapping[2] == override.old_person_id][0], - }, - ) - - person_overrides_to_delete = [ - # We are schedulling for deletion an override with lower version number, so nothing should happen. - SerializablePersonOverrideToDelete( - team_id, - override.old_person_id, - override.override_person_id, - OVERRIDES_CREATED_AT.isoformat(), - 1, - OLDEST_EVENT_AT.isoformat(), - ) - ] - query_inputs.person_overrides_to_delete = person_overrides_to_delete - query_inputs.dry_run = False - - await activity_environment.run(delete_squashed_person_overrides_from_postgres, query_inputs) - - with pg_connection: - with pg_connection.cursor() as cursor: - cursor.execute("SELECT id, team_id, uuid FROM posthog_personoverridemapping") - mappings = cursor.fetchall() - - # Nothing was deleted from mappings table - assert len(mappings) == 2 - assert override.override_person_id in [mapping[2] for mapping in mappings] - assert override.old_person_id in [mapping[2] for mapping in mappings] - - cursor.execute("SELECT team_id, old_person_id, override_person_id, version FROM posthog_personoverride") - overrides = cursor.fetchall() - - # And nothing was deleted from overrides table - assert len(overrides) == 1 - - team_id, old_person_id, override_person_id, version = overrides[0] - assert team_id == team_id - assert old_person_id == [mapping[0] for mapping in mappings if mapping[2] == override.old_person_id][0] - assert ( - override_person_id - == [mapping[0] for mapping in mappings if mapping[2] == override.override_person_id][0] - ) - assert version == 2 + assert FlatPostgresPersonOverridesManager(pg_connection).fetchall(team_id) == [postgres_person_override] @pytest.mark.django_db @@ -1143,7 +1030,7 @@ async def test_delete_squashed_person_overrides_from_postgres_with_newer_overrid async def test_squash_person_overrides_workflow( events_to_override, person_overrides_data, - postgres_person_override_fixtures: PostgresPersonOverrideFixtures, + postgres_person_override: PersonOverrideTuple, person_overrides_table, ): """Test the squash_person_overrides workflow end-to-end.""" @@ -1156,7 +1043,6 @@ async def test_squash_person_overrides_workflow( inputs = SquashPersonOverridesInputs( partition_ids=["202001"], dry_run=False, - postgres_person_overrides_manager=postgres_person_override_fixtures.manager, ) async with Worker( @@ -1192,7 +1078,7 @@ async def test_squash_person_overrides_workflow( async def test_squash_person_overrides_workflow_with_newer_overrides( events_to_override, person_overrides_data, - postgres_person_override_fixtures: PostgresPersonOverrideFixtures, + postgres_person_override: PersonOverrideTuple, newer_overrides, ): """Test the squash_person_overrides workflow end-to-end with newer overrides.""" @@ -1205,7 +1091,6 @@ async def test_squash_person_overrides_workflow_with_newer_overrides( inputs = SquashPersonOverridesInputs( partition_ids=["202001"], dry_run=False, - postgres_person_overrides_manager=postgres_person_override_fixtures.manager, ) async with Worker( @@ -1238,7 +1123,7 @@ async def test_squash_person_overrides_workflow_with_newer_overrides( async def test_squash_person_overrides_workflow_with_limited_team_ids( events_to_override, person_overrides_data, - postgres_person_override_fixtures: PostgresPersonOverrideFixtures, + postgres_person_override: PersonOverrideTuple, ): """Test the squash_person_overrides workflow end-to-end.""" client = await Client.connect( @@ -1251,7 +1136,6 @@ async def test_squash_person_overrides_workflow_with_limited_team_ids( inputs = SquashPersonOverridesInputs( partition_ids=["202001"], team_ids=[random_team], - postgres_person_overrides_manager=postgres_person_override_fixtures.manager, dry_run=False, ) diff --git a/posthog/views.py b/posthog/views.py index 4750cf170cc27..1f757f9833734 100644 --- a/posthog/views.py +++ b/posthog/views.py @@ -92,6 +92,7 @@ def security_txt(request): @never_cache def preflight_check(request: HttpRequest) -> JsonResponse: slack_client_id = SlackIntegration.slack_config().get("SLACK_APP_CLIENT_ID") + hubspot_client_id = settings.HUBSPOT_APP_CLIENT_ID response = { "django": True, @@ -113,6 +114,7 @@ def preflight_check(request: HttpRequest) -> JsonResponse: "available": bool(slack_client_id), "client_id": slack_client_id or None, }, + "data_warehouse_integrations": {"hubspot": {"client_id": hubspot_client_id}}, "object_storage": is_cloud() or is_object_storage_available(), } diff --git a/posthog/warehouse/api/external_data_source.py b/posthog/warehouse/api/external_data_source.py index 48f8babed4a5a..843821e2f2749 100644 --- a/posthog/warehouse/api/external_data_source.py +++ b/posthog/warehouse/api/external_data_source.py @@ -25,6 +25,9 @@ from posthog.temporal.data_imports.pipelines.schemas import ( PIPELINE_TYPE_SCHEMA_DEFAULT_MAPPING, ) +from posthog.temporal.data_imports.pipelines.hubspot.auth import ( + get_access_token_from_code, +) import temporalio logger = structlog.get_logger(__name__) @@ -107,7 +110,6 @@ def get_queryset(self): ) def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: - client_secret = request.data["client_secret"] prefix = request.data.get("prefix", None) source_type = request.data["source_type"] @@ -127,18 +129,12 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: ) # TODO: remove dummy vars - new_source_model = ExternalDataSource.objects.create( - source_id=str(uuid.uuid4()), - connection_id=str(uuid.uuid4()), - destination_id=str(uuid.uuid4()), - team=self.team, - status="Running", - source_type=source_type, - job_inputs={ - "stripe_secret_key": client_secret, - }, - prefix=prefix, - ) + if source_type == ExternalDataSource.Type.STRIPE: + new_source_model = self._handle_stripe_source(request, *args, **kwargs) + elif source_type == ExternalDataSource.Type.HUBSPOT: + new_source_model = self._handle_hubspot_source(request, *args, **kwargs) + else: + raise NotImplementedError(f"Source type {source_type} not implemented") schemas = PIPELINE_TYPE_SCHEMA_DEFAULT_MAPPING[source_type] for schema in schemas: @@ -156,6 +152,54 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: return Response(status=status.HTTP_201_CREATED, data={"id": new_source_model.pk}) + def _handle_stripe_source(self, request: Request, *args: Any, **kwargs: Any) -> ExternalDataSource: + payload = request.data["payload"] + client_secret = payload.get("client_secret") + prefix = request.data.get("prefix", None) + source_type = request.data["source_type"] + + # TODO: remove dummy vars + new_source_model = ExternalDataSource.objects.create( + source_id=str(uuid.uuid4()), + connection_id=str(uuid.uuid4()), + destination_id=str(uuid.uuid4()), + team=self.team, + status="Running", + source_type=source_type, + job_inputs={ + "stripe_secret_key": client_secret, + }, + prefix=prefix, + ) + + return new_source_model + + def _handle_hubspot_source(self, request: Request, *args: Any, **kwargs: Any) -> ExternalDataSource: + payload = request.data["payload"] + code = payload.get("code") + redirect_uri = payload.get("redirect_uri") + prefix = request.data.get("prefix", None) + source_type = request.data["source_type"] + + access_token, refresh_token = get_access_token_from_code(code, redirect_uri=redirect_uri) + + # TODO: remove dummy vars + new_source_model = ExternalDataSource.objects.create( + source_id=str(uuid.uuid4()), + connection_id=str(uuid.uuid4()), + destination_id=str(uuid.uuid4()), + team=self.team, + status="Running", + source_type=source_type, + job_inputs={ + "hubspot_secret_key": access_token, + "hubspot_refresh_token": refresh_token, + }, + prefix=prefix, + ) + + return new_source_model + def prefix_required(self, source_type: str) -> bool: source_type_exists = ExternalDataSource.objects.filter(team_id=self.team.pk, source_type=source_type).exists() return source_type_exists diff --git a/posthog/warehouse/api/test/test_external_data_source.py b/posthog/warehouse/api/test/test_external_data_source.py index 2ad741b453a29..955c032c0373e 100644 --- a/posthog/warehouse/api/test/test_external_data_source.py +++ b/posthog/warehouse/api/test/test_external_data_source.py @@ -30,7 +30,7 @@ def _create_external_data_schema(self, source_id) -> ExternalDataSchema: def test_create_external_data_source(self): response = self.client.post( f"/api/projects/{self.team.id}/external_data_sources/", - data={"source_type": "Stripe", "client_secret": "sk_test_123"}, + data={"source_type": "Stripe", "payload": {"client_secret": "sk_test_123"}}, ) payload = response.json() @@ -46,7 +46,7 @@ def test_prefix_external_data_source(self): response = self.client.post( f"/api/projects/{self.team.id}/external_data_sources/", - data={"source_type": "Stripe", "client_secret": "sk_test_123"}, + data={"source_type": "Stripe", "payload": {"client_secret": "sk_test_123"}}, ) self.assertEqual(response.status_code, 201) @@ -54,7 +54,7 @@ def test_prefix_external_data_source(self): response = self.client.post( f"/api/projects/{self.team.id}/external_data_sources/", - data={"source_type": "Stripe", "client_secret": "sk_test_123"}, + data={"source_type": "Stripe", "payload": {"client_secret": "sk_test_123"}}, ) self.assertEqual(response.status_code, 400) @@ -63,7 +63,7 @@ def test_prefix_external_data_source(self): # Create with prefix response = self.client.post( f"/api/projects/{self.team.id}/external_data_sources/", - data={"source_type": "Stripe", "client_secret": "sk_test_123", "prefix": "test_"}, + data={"source_type": "Stripe", "payload": {"client_secret": "sk_test_123"}, "prefix": "test_"}, ) self.assertEqual(response.status_code, 201) @@ -71,7 +71,7 @@ def test_prefix_external_data_source(self): # Try to create same type with same prefix again response = self.client.post( f"/api/projects/{self.team.id}/external_data_sources/", - data={"source_type": "Stripe", "client_secret": "sk_test_123", "prefix": "test_"}, + data={"source_type": "Stripe", "payload": {"client_secret": "sk_test_123"}, "prefix": "test_"}, ) self.assertEqual(response.status_code, 400) diff --git a/posthog/warehouse/models/external_data_source.py b/posthog/warehouse/models/external_data_source.py index 287a4a3f2cd99..5d8f736a77b94 100644 --- a/posthog/warehouse/models/external_data_source.py +++ b/posthog/warehouse/models/external_data_source.py @@ -8,6 +8,7 @@ class ExternalDataSource(CreatedMetaFields, UUIDModel): class Type(models.TextChoices): STRIPE = "Stripe", "Stripe" + HUBSPOT = "Hubspot", "Hubspot" class Status(models.TextChoices): RUNNING = "Running", "Running" diff --git a/production-unit.Dockerfile b/production-unit.Dockerfile deleted file mode 100644 index 0582057641b23..0000000000000 --- a/production-unit.Dockerfile +++ /dev/null @@ -1,315 +0,0 @@ -# -# This Dockerfile is used for self-hosted production builds. -# -# PostHog has sunset support for self-hosted K8s deployments. -# See: https://posthog.com/blog/sunsetting-helm-support-posthog -# -# Note: for PostHog Cloud remember to update ‘Dockerfile.cloud’ as appropriate. -# -# The stages are used to: -# -# - frontend-build: build the frontend (static assets) -# - plugin-server-build: build plugin-server (Node.js app) & fetch its runtime dependencies -# - posthog-build: fetch PostHog (Django app) dependencies & build Django collectstatic -# - fetch-geoip-db: fetch the GeoIP database -# -# In the last stage, we import the artifacts from the previous -# stages, add some runtime dependencies and build the final image. -# - - -# -# --------------------------------------------------------- -# -FROM node:18.12.1-bullseye-slim AS frontend-build -WORKDIR /code -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -COPY package.json pnpm-lock.yaml ./ -RUN corepack enable && pnpm --version && \ - mkdir /tmp/pnpm-store && \ - pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store --prod && \ - rm -rf /tmp/pnpm-store - -COPY frontend/ frontend/ -COPY ee/frontend/ ee/frontend/ -COPY ./bin/ ./bin/ -COPY babel.config.js tsconfig.json webpack.config.js tailwind.config.js postcss.config.js ./ -RUN pnpm build - - -# -# --------------------------------------------------------- -# -FROM node:18.12.1-bullseye-slim AS plugin-server-build -WORKDIR /code/plugin-server -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -# Compile and install Node.js dependencies. -COPY ./plugin-server/package.json ./plugin-server/pnpm-lock.yaml ./plugin-server/tsconfig.json ./ -COPY ./plugin-server/patches/ ./patches/ -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - "make" \ - "g++" \ - "gcc" \ - "python3" \ - "libssl-dev" \ - "zlib1g-dev" \ - && \ - rm -rf /var/lib/apt/lists/* && \ - corepack enable && \ - mkdir /tmp/pnpm-store && \ - pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store && \ - rm -rf /tmp/pnpm-store - -# Build the plugin server. -# -# Note: we run the build as a separate action to increase -# the cache hit ratio of the layers above. -COPY ./plugin-server/src/ ./src/ -RUN pnpm build - -# As the plugin-server is now built, let’s keep -# only prod dependencies in the node_module folder -# as we will copy it to the last image. -RUN corepack enable && \ - mkdir /tmp/pnpm-store && \ - pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store --prod && \ - rm -rf /tmp/pnpm-store - - -# -# --------------------------------------------------------- -# -FROM python:3.10.10-slim-bullseye AS posthog-build -WORKDIR /code -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -# Compile and install Python dependencies. -# We install those dependencies on a custom folder that we will -# then copy to the last image. -COPY requirements.txt ./ -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - "build-essential" \ - "git" \ - "libpq-dev" \ - "libxmlsec1" \ - "libxmlsec1-dev" \ - "libffi-dev" \ - "pkg-config" \ - && \ - rm -rf /var/lib/apt/lists/* && \ - pip install -r requirements.txt --compile --no-cache-dir --target=/python-runtime - -ENV PATH=/python-runtime/bin:$PATH \ - PYTHONPATH=/python-runtime - -# Add in Django deps and generate Django's static files. -COPY manage.py manage.py -COPY posthog posthog/ -COPY ee ee/ -COPY --from=frontend-build /code/frontend/dist /code/frontend/dist -RUN SKIP_SERVICE_VERSION_REQUIREMENTS=1 SECRET_KEY='unsafe secret key for collectstatic only' DATABASE_URL='postgres:///' REDIS_URL='redis:///' python manage.py collectstatic --noinput - - -# -# --------------------------------------------------------- -# -FROM debian:bullseye-slim AS fetch-geoip-db -WORKDIR /code -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -# Fetch the GeoLite2-City database that will be used for IP geolocation within Django. -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - "ca-certificates" \ - "curl" \ - "brotli" \ - && \ - rm -rf /var/lib/apt/lists/* && \ - mkdir share && \ - ( curl -s -L "https://mmdbcdn.posthog.net/" | brotli --decompress --output=./share/GeoLite2-City.mmdb ) && \ - chmod -R 755 ./share/GeoLite2-City.mmdb - - -# -# --------------------------------------------------------- -# -# Build a version of the unit docker image for python3.10 -# We can remove this step once we are on python3.11 -FROM unit:python3.11 as unit -FROM python:3.10-bullseye as unit-131-python-310 - -# copied from https://github.com/nginx/unit/blob/master/pkg/docker/Dockerfile.python3.11 -LABEL org.opencontainers.image.title="Unit (python3.10)" -LABEL org.opencontainers.image.description="Official build of Unit for Docker." -LABEL org.opencontainers.image.url="https://unit.nginx.org" -LABEL org.opencontainers.image.source="https://github.com/nginx/unit" -LABEL org.opencontainers.image.documentation="https://unit.nginx.org/installation/#docker-images" -LABEL org.opencontainers.image.vendor="NGINX Docker Maintainers " -LABEL org.opencontainers.image.version="1.31.1" - -RUN set -ex \ - && savedAptMark="$(apt-mark showmanual)" \ - && apt-get update \ - && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates mercurial build-essential libssl-dev libpcre2-dev curl pkg-config \ - && mkdir -p /usr/lib/unit/modules /usr/lib/unit/debug-modules \ - && mkdir -p /usr/src/unit \ - && cd /usr/src/unit \ - && hg clone -u 1.31.1-1 https://hg.nginx.org/unit \ - && cd unit \ - && NCPU="$(getconf _NPROCESSORS_ONLN)" \ - && DEB_HOST_MULTIARCH="$(dpkg-architecture -q DEB_HOST_MULTIARCH)" \ - && CC_OPT="$(DEB_BUILD_MAINT_OPTIONS="hardening=+all,-pie" DEB_CFLAGS_MAINT_APPEND="-Wp,-D_FORTIFY_SOURCE=2 -fPIC" dpkg-buildflags --get CFLAGS)" \ - && LD_OPT="$(DEB_BUILD_MAINT_OPTIONS="hardening=+all,-pie" DEB_LDFLAGS_MAINT_APPEND="-Wl,--as-needed -pie" dpkg-buildflags --get LDFLAGS)" \ - && CONFIGURE_ARGS_MODULES="--prefix=/usr \ - --statedir=/var/lib/unit \ - --control=unix:/var/run/control.unit.sock \ - --runstatedir=/var/run \ - --pid=/var/run/unit.pid \ - --logdir=/var/log \ - --log=/var/log/unit.log \ - --tmpdir=/var/tmp \ - --user=unit \ - --group=unit \ - --openssl \ - --libdir=/usr/lib/$DEB_HOST_MULTIARCH" \ - && CONFIGURE_ARGS="$CONFIGURE_ARGS_MODULES \ - --njs" \ - && make -j $NCPU -C pkg/contrib .njs \ - && export PKG_CONFIG_PATH=$(pwd)/pkg/contrib/njs/build \ - && ./configure $CONFIGURE_ARGS --cc-opt="$CC_OPT" --ld-opt="$LD_OPT" --modulesdir=/usr/lib/unit/debug-modules --debug \ - && make -j $NCPU unitd \ - && install -pm755 build/sbin/unitd /usr/sbin/unitd-debug \ - && make clean \ - && ./configure $CONFIGURE_ARGS --cc-opt="$CC_OPT" --ld-opt="$LD_OPT" --modulesdir=/usr/lib/unit/modules \ - && make -j $NCPU unitd \ - && install -pm755 build/sbin/unitd /usr/sbin/unitd \ - && make clean \ - && /bin/true \ - && ./configure $CONFIGURE_ARGS_MODULES --cc-opt="$CC_OPT" --modulesdir=/usr/lib/unit/debug-modules --debug \ - && ./configure python --config=/usr/local/bin/python3-config \ - && make -j $NCPU python3-install \ - && make clean \ - && ./configure $CONFIGURE_ARGS_MODULES --cc-opt="$CC_OPT" --modulesdir=/usr/lib/unit/modules \ - && ./configure python --config=/usr/local/bin/python3-config \ - && make -j $NCPU python3-install \ - && cd \ - && rm -rf /usr/src/unit \ - && for f in /usr/sbin/unitd /usr/lib/unit/modules/*.unit.so; do \ - ldd $f | awk '/=>/{print $(NF-1)}' | while read n; do dpkg-query -S $n; done | sed 's/^\([^:]\+\):.*$/\1/' | sort | uniq >> /requirements.apt; \ - done \ - && apt-mark showmanual | xargs apt-mark auto > /dev/null \ - && { [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; } \ - && /bin/true \ - && mkdir -p /var/lib/unit/ \ - && mkdir -p /docker-entrypoint.d/ \ - && groupadd --gid 998 unit \ - && useradd \ - --uid 998 \ - --gid unit \ - --no-create-home \ - --home /nonexistent \ - --comment "unit user" \ - --shell /bin/false \ - unit \ - && apt-get update \ - && apt-get --no-install-recommends --no-install-suggests -y install curl $(cat /requirements.apt) \ - && apt-get purge -y --auto-remove build-essential \ - && rm -rf /var/lib/apt/lists/* \ - && rm -f /requirements.apt \ - && ln -sf /dev/stdout /var/log/unit.log - -COPY --from=unit /usr/local/bin/docker-entrypoint.sh /usr/local/bin/ -COPY --from=unit /usr/share/unit/welcome/welcome.* /usr/share/unit/welcome/ - -STOPSIGNAL SIGTERM - -ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -EXPOSE 80 -CMD ["unitd", "--no-daemon", "--control", "unix:/var/run/control.unit.sock"] - -# -# --------------------------------------------------------- -# -FROM unit-131-python-310 -WORKDIR /code -SHELL ["/bin/bash", "-o", "pipefail", "-c"] -ENV PYTHONUNBUFFERED 1 - -# Install OS runtime dependencies. -# Note: please add in this stage runtime dependences only! -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - "chromium" \ - "chromium-driver" \ - "libpq-dev" \ - "libxmlsec1" \ - "libxmlsec1-dev" \ - "libxml2" - -# Install NodeJS 18. -RUN apt-get install -y --no-install-recommends \ - "curl" \ - && \ - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y --no-install-recommends \ - "nodejs" \ - && \ - rm -rf /var/lib/apt/lists/* - -# Install and use a non-root user. -RUN groupadd -g 1000 posthog && \ - useradd -u 999 -r -g posthog posthog && \ - chown posthog:posthog /code -USER posthog - -# Add the commit hash -ARG COMMIT_HASH -RUN echo $COMMIT_HASH > /code/commit.txt - -# Add in the compiled plugin-server & its runtime dependencies from the plugin-server-build stage. -COPY --from=plugin-server-build --chown=posthog:posthog /code/plugin-server/dist /code/plugin-server/dist -COPY --from=plugin-server-build --chown=posthog:posthog /code/plugin-server/node_modules /code/plugin-server/node_modules -COPY --from=plugin-server-build --chown=posthog:posthog /code/plugin-server/package.json /code/plugin-server/package.json - -# Copy the Python dependencies and Django staticfiles from the posthog-build stage. -COPY --from=posthog-build --chown=posthog:posthog /code/staticfiles /code/staticfiles -COPY --from=posthog-build --chown=posthog:posthog /python-runtime /python-runtime -ENV PATH=/python-runtime/bin:$PATH \ - PYTHONPATH=/python-runtime - -# Copy the frontend assets from the frontend-build stage. -# TODO: this copy should not be necessary, we should remove it once we verify everything still works. -COPY --from=frontend-build --chown=posthog:posthog /code/frontend/dist /code/frontend/dist - -# Copy the GeoLite2-City database from the fetch-geoip-db stage. -COPY --from=fetch-geoip-db --chown=posthog:posthog /code/share/GeoLite2-City.mmdb /code/share/GeoLite2-City.mmdb - -# Add in the Gunicorn config, custom bin files and Django deps. -COPY --chown=posthog:posthog gunicorn.config.py ./ -COPY --chown=posthog:posthog ./bin ./bin/ -COPY --chown=posthog:posthog manage.py manage.py -COPY --chown=posthog:posthog posthog posthog/ -COPY --chown=posthog:posthog ee ee/ -COPY --chown=posthog:posthog hogvm hogvm/ - -# Keep server command backwards compatible -RUN cp ./bin/docker-server-unit ./bin/docker-server - -# Setup ENV. -ENV NODE_ENV=production \ - CHROME_BIN=/usr/bin/chromium \ - CHROME_PATH=/usr/lib/chromium/ \ - CHROMEDRIVER_BIN=/usr/bin/chromedriver - -# Expose container port and run entry point script. -EXPOSE 8000 - -# Expose the port from which we serve OpenMetrics data. -EXPOSE 8001 -COPY unit.json /docker-entrypoint.d/unit.json -USER root -CMD ["./bin/docker"] diff --git a/production.Dockerfile b/production.Dockerfile index b70c8c437e7a5..0582057641b23 100644 --- a/production.Dockerfile +++ b/production.Dockerfile @@ -26,7 +26,7 @@ WORKDIR /code SHELL ["/bin/bash", "-o", "pipefail", "-c"] COPY package.json pnpm-lock.yaml ./ -RUN corepack enable && \ +RUN corepack enable && pnpm --version && \ mkdir /tmp/pnpm-store && \ pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store --prod && \ rm -rf /tmp/pnpm-store @@ -38,22 +38,6 @@ COPY babel.config.js tsconfig.json webpack.config.js tailwind.config.js postcss. RUN pnpm build -# -# --------------------------------------------------------- -# -FROM node:18.12.1-bullseye-slim AS plugin-transpiler-build -WORKDIR /code -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -COPY plugin-transpiler/ plugin-transpiler/ -WORKDIR /code/plugin-transpiler -RUN corepack enable && \ - mkdir /tmp/pnpm-store && \ - pnpm install --frozen-lockfile --store-dir /tmp/pnpm-store && \ - pnpm build && \ - rm -rf /tmp/pnpm-store - - # # --------------------------------------------------------- # @@ -153,7 +137,104 @@ RUN apt-get update && \ # # --------------------------------------------------------- # -FROM python:3.10.10-slim-bullseye +# Build a version of the unit docker image for python3.10 +# We can remove this step once we are on python3.11 +FROM unit:python3.11 as unit +FROM python:3.10-bullseye as unit-131-python-310 + +# copied from https://github.com/nginx/unit/blob/master/pkg/docker/Dockerfile.python3.11 +LABEL org.opencontainers.image.title="Unit (python3.10)" +LABEL org.opencontainers.image.description="Official build of Unit for Docker." +LABEL org.opencontainers.image.url="https://unit.nginx.org" +LABEL org.opencontainers.image.source="https://github.com/nginx/unit" +LABEL org.opencontainers.image.documentation="https://unit.nginx.org/installation/#docker-images" +LABEL org.opencontainers.image.vendor="NGINX Docker Maintainers " +LABEL org.opencontainers.image.version="1.31.1" + +RUN set -ex \ + && savedAptMark="$(apt-mark showmanual)" \ + && apt-get update \ + && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates mercurial build-essential libssl-dev libpcre2-dev curl pkg-config \ + && mkdir -p /usr/lib/unit/modules /usr/lib/unit/debug-modules \ + && mkdir -p /usr/src/unit \ + && cd /usr/src/unit \ + && hg clone -u 1.31.1-1 https://hg.nginx.org/unit \ + && cd unit \ + && NCPU="$(getconf _NPROCESSORS_ONLN)" \ + && DEB_HOST_MULTIARCH="$(dpkg-architecture -q DEB_HOST_MULTIARCH)" \ + && CC_OPT="$(DEB_BUILD_MAINT_OPTIONS="hardening=+all,-pie" DEB_CFLAGS_MAINT_APPEND="-Wp,-D_FORTIFY_SOURCE=2 -fPIC" dpkg-buildflags --get CFLAGS)" \ + && LD_OPT="$(DEB_BUILD_MAINT_OPTIONS="hardening=+all,-pie" DEB_LDFLAGS_MAINT_APPEND="-Wl,--as-needed -pie" dpkg-buildflags --get LDFLAGS)" \ + && CONFIGURE_ARGS_MODULES="--prefix=/usr \ + --statedir=/var/lib/unit \ + --control=unix:/var/run/control.unit.sock \ + --runstatedir=/var/run \ + --pid=/var/run/unit.pid \ + --logdir=/var/log \ + --log=/var/log/unit.log \ + --tmpdir=/var/tmp \ + --user=unit \ + --group=unit \ + --openssl \ + --libdir=/usr/lib/$DEB_HOST_MULTIARCH" \ + && CONFIGURE_ARGS="$CONFIGURE_ARGS_MODULES \ + --njs" \ + && make -j $NCPU -C pkg/contrib .njs \ + && export PKG_CONFIG_PATH=$(pwd)/pkg/contrib/njs/build \ + && ./configure $CONFIGURE_ARGS --cc-opt="$CC_OPT" --ld-opt="$LD_OPT" --modulesdir=/usr/lib/unit/debug-modules --debug \ + && make -j $NCPU unitd \ + && install -pm755 build/sbin/unitd /usr/sbin/unitd-debug \ + && make clean \ + && ./configure $CONFIGURE_ARGS --cc-opt="$CC_OPT" --ld-opt="$LD_OPT" --modulesdir=/usr/lib/unit/modules \ + && make -j $NCPU unitd \ + && install -pm755 build/sbin/unitd /usr/sbin/unitd \ + && make clean \ + && /bin/true \ + && ./configure $CONFIGURE_ARGS_MODULES --cc-opt="$CC_OPT" --modulesdir=/usr/lib/unit/debug-modules --debug \ + && ./configure python --config=/usr/local/bin/python3-config \ + && make -j $NCPU python3-install \ + && make clean \ + && ./configure $CONFIGURE_ARGS_MODULES --cc-opt="$CC_OPT" --modulesdir=/usr/lib/unit/modules \ + && ./configure python --config=/usr/local/bin/python3-config \ + && make -j $NCPU python3-install \ + && cd \ + && rm -rf /usr/src/unit \ + && for f in /usr/sbin/unitd /usr/lib/unit/modules/*.unit.so; do \ + ldd $f | awk '/=>/{print $(NF-1)}' | while read n; do dpkg-query -S $n; done | sed 's/^\([^:]\+\):.*$/\1/' | sort | uniq >> /requirements.apt; \ + done \ + && apt-mark showmanual | xargs apt-mark auto > /dev/null \ + && { [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; } \ + && /bin/true \ + && mkdir -p /var/lib/unit/ \ + && mkdir -p /docker-entrypoint.d/ \ + && groupadd --gid 998 unit \ + && useradd \ + --uid 998 \ + --gid unit \ + --no-create-home \ + --home /nonexistent \ + --comment "unit user" \ + --shell /bin/false \ + unit \ + && apt-get update \ + && apt-get --no-install-recommends --no-install-suggests -y install curl $(cat /requirements.apt) \ + && apt-get purge -y --auto-remove build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /requirements.apt \ + && ln -sf /dev/stdout /var/log/unit.log + +COPY --from=unit /usr/local/bin/docker-entrypoint.sh /usr/local/bin/ +COPY --from=unit /usr/share/unit/welcome/welcome.* /usr/share/unit/welcome/ + +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +EXPOSE 80 +CMD ["unitd", "--no-daemon", "--control", "unix:/var/run/control.unit.sock"] + +# +# --------------------------------------------------------- +# +FROM unit-131-python-310 WORKDIR /code SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV PYTHONUNBUFFERED 1 @@ -215,6 +296,9 @@ COPY --chown=posthog:posthog posthog posthog/ COPY --chown=posthog:posthog ee ee/ COPY --chown=posthog:posthog hogvm hogvm/ +# Keep server command backwards compatible +RUN cp ./bin/docker-server-unit ./bin/docker-server + # Setup ENV. ENV NODE_ENV=production \ CHROME_BIN=/usr/bin/chromium \ @@ -226,5 +310,6 @@ EXPOSE 8000 # Expose the port from which we serve OpenMetrics data. EXPOSE 8001 - +COPY unit.json /docker-entrypoint.d/unit.json +USER root CMD ["./bin/docker"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 596db7e5fed12..96d3b31c4dc83 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -34,11 +34,6 @@ certifi==2019.11.28 # via # -c requirements.txt # requests - # urllib3 -cffi==1.14.5 - # via - # -c requirements.txt - # cryptography chardet==5.1.0 # via # -c requirements.txt @@ -61,15 +56,12 @@ coreapi==2.3.3 coreschema==0.0.4 # via coreapi coverage[toml]==5.5 - # via pytest-cov -cryptography==37.0.2 # via - # -c requirements.txt - # pyopenssl - # urllib3 + # coverage + # pytest-cov datamodel-code-generator==0.21.5 # via -r requirements-dev.in -django==3.2.19 +django==3.2.23 # via # -c requirements.txt # django-stubs @@ -95,7 +87,9 @@ exceptiongroup==1.1.2 faker==17.5.0 # via -r requirements-dev.in fakeredis[lua]==2.11.0 - # via -r requirements-dev.in + # via + # -r requirements-dev.in + # fakeredis flaky==3.7.0 # via -r requirements-dev.in freezegun==1.2.2 @@ -109,7 +103,6 @@ idna==2.8 # -c requirements.txt # email-validator # requests - # urllib3 importlib-resources==5.10.2 # via openapi-spec-validator inflect==5.6.2 @@ -179,22 +172,15 @@ prance==0.22.2.22.0 # -c requirements.txt # -r requirements-dev.in # datamodel-code-generator -pycparser==2.20 - # via - # -c requirements.txt - # cffi pydantic[email]==2.3.0 # via # -c requirements.txt # datamodel-code-generator + # pydantic pydantic-core==2.6.3 # via # -c requirements.txt # pydantic -pyopenssl==22.0.0 - # via - # -c requirements.txt - # urllib3 pyproject-hooks==1.0.0 # via build pyrsistent==0.18.1 @@ -203,10 +189,6 @@ pyrsistent==0.18.1 # jsonschema pysnooper==1.1.1 # via datamodel-code-generator -pysocks==1.7.1 - # via - # -c requirements.txt - # urllib3 pytest==7.4.0 # via # -r requirements-dev.in @@ -306,7 +288,7 @@ types-markdown==3.3.9 # via -r requirements-dev.in types-python-dateutil==2.8.3 # via -r requirements-dev.in -types-pytz==2023.3 +types-pytz==2023.3.0.0 # via -r requirements-dev.in types-pyyaml==6.0.1 # via @@ -332,15 +314,11 @@ uritemplate==4.1.1 # via # -c requirements.txt # coreapi -urllib3[secure,socks]==1.26.13 +urllib3==1.26.13 # via # -c requirements.txt # requests # responses -urllib3-secure-extra==0.1.0 - # via - # -c requirements.txt - # urllib3 watchdog==2.1.8 # via pytest-watch wheel==0.42.0 diff --git a/requirements.in b/requirements.in index c45e32b576ef3..a1e12d28a3e0d 100644 --- a/requirements.in +++ b/requirements.in @@ -19,7 +19,7 @@ clickhouse-pool==0.5.3 cryptography==37.0.2 defusedxml==0.6.0 dj-database-url==0.5.0 -Django==3.2.19 +Django==3.2.23 django-axes==5.9.0 django-cors-headers==3.5.0 django-deprecate-fields==0.1.1 diff --git a/requirements.txt b/requirements.txt index f0459a383be9b..e1460168e0bea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ aioboto3==12.0.0 aiobotocore[boto3]==2.7.0 # via # aioboto3 + # aiobotocore # s3fs aiohttp==3.9.0 # via @@ -138,7 +139,7 @@ defusedxml==0.6.0 # social-auth-core dj-database-url==0.5.0 # via -r requirements.in -django==3.2.19 +django==3.2.23 # via # -r requirements.in # django-axes @@ -240,6 +241,7 @@ giturlparse==0.12.0 # via dlt google-api-core[grpc]==2.11.1 # via + # google-api-core # google-cloud-bigquery # google-cloud-core google-auth==2.22.0 @@ -260,6 +262,8 @@ googleapis-common-protos==1.60.0 # via # google-api-core # grpcio-status +greenlet==3.0.3 + # via sqlalchemy grpcio==1.57.0 # via # google-api-core