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/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/scenes/batch_exports/BatchExports.stories.tsx b/frontend/src/scenes/batch_exports/BatchExports.stories.tsx index 5b1c2f9966acf..a10466a01c79b 100644 --- a/frontend/src/scenes/batch_exports/BatchExports.stories.tsx +++ b/frontend/src/scenes/batch_exports/BatchExports.stories.tsx @@ -25,6 +25,7 @@ export default { createExportServiceHandlers({ 1: { id: '1', + team_id: 1, name: 'My S3 Exporter', destination: { type: 'S3', diff --git a/frontend/src/scenes/batch_exports/batchExportEditLogic.ts b/frontend/src/scenes/batch_exports/batchExportEditLogic.ts index 55ba36bb16404..4cd159728fc8e 100644 --- a/frontend/src/scenes/batch_exports/batchExportEditLogic.ts +++ b/frontend/src/scenes/batch_exports/batchExportEditLogic.ts @@ -174,7 +174,7 @@ export const batchExportsEditLogic = kea([ config: config, } as unknown as BatchExportDestinationSnowflake) - const data: Omit = { + const data: Omit = { paused, name, interval, 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 4174817b26fcd..0961fe223e8f5 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/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/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/types.ts b/frontend/src/types.ts index c7ff9cde3bd7f..e2a4ef187a92b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -231,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 @@ -3499,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' diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py index 9f898aa86d312..63283a026942f 100644 --- a/posthog/api/__init__.py +++ b/posthog/api/__init__.py @@ -190,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"] ) 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/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/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