From f691f1e9a1f960035aa4bc4e868dafceb4027a15 Mon Sep 17 00:00:00 2001 From: Ben White Date: Mon, 12 Feb 2024 08:45:57 +0000 Subject: [PATCH] chore: Remove old prompts work (#20229) --- cypress/support/e2e.ts | 2 - .../lib/components/HelpButton/HelpButton.tsx | 17 - .../inAppPromptEventCaptureLogic.ts | 77 --- .../inAppPrompt/inAppPromptLogic.test.ts | 401 -------------- .../logic/inAppPrompt/inAppPromptLogic.tsx | 517 ------------------ frontend/src/lib/logic/promptLogic.tsx | 2 +- frontend/src/mocks/handlers.ts | 3 - frontend/src/scenes/App.tsx | 3 +- .../dashboard/dashboards/Dashboards.tsx | 3 - pnpm-lock.yaml | 5 +- posthog/api/__init__.py | 2 - posthog/api/prompt.py | 245 --------- .../api/test/__snapshots__/test_api_docs.ambr | 2 - posthog/api/test/test_prompt.py | 226 -------- posthog/models/__init__.py | 10 +- .../prompt.py => _deprecated_prompts.py} | 3 + posthog/models/prompt/__init__.py | 2 - posthog/models/prompt/constants.py | 17 - posthog/tasks/__init__.py | 2 - posthog/tasks/prompts.py | 15 - posthog/urls.py | 2 - 21 files changed, 13 insertions(+), 1543 deletions(-) delete mode 100644 frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts delete mode 100644 frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.test.ts delete mode 100644 frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx delete mode 100644 posthog/api/prompt.py delete mode 100644 posthog/api/test/test_prompt.py rename posthog/models/{prompt/prompt.py => _deprecated_prompts.py} (97%) delete mode 100644 posthog/models/prompt/__init__.py delete mode 100644 posthog/models/prompt/constants.py delete mode 100644 posthog/tasks/prompts.py diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index b2ce0bb71285c..6f99ed422be4c 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -23,8 +23,6 @@ beforeEach(() => { Cypress.env('POSTHOG_PROPERTY_CURRENT_TEST_FULL_TITLE', Cypress.currentTest.titlePath.join(' > ')) Cypress.env('POSTHOG_PROPERTY_GITHUB_ACTION_RUN_URL', process.env.GITHUB_ACTION_RUN_URL) - cy.intercept('api/prompts/my_prompts/', { sequences: [], state: {} }) - cy.intercept('https://app.posthog.com/decide/*', (req) => req.reply( decideResponse({ diff --git a/frontend/src/lib/components/HelpButton/HelpButton.tsx b/frontend/src/lib/components/HelpButton/HelpButton.tsx index 15cb15f1407d4..e5c56a1bc7884 100644 --- a/frontend/src/lib/components/HelpButton/HelpButton.tsx +++ b/frontend/src/lib/components/HelpButton/HelpButton.tsx @@ -9,12 +9,10 @@ import { IconBugReport, IconFeedback, IconHelpOutline, - IconMessages, IconQuestionAnswer, IconSupport, } from 'lib/lemon-ui/icons' import { LemonMenu } from 'lib/lemon-ui/LemonMenu' -import { DefaultAction, inAppPromptLogic } from 'lib/logic/inAppPrompt/inAppPromptLogic' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' @@ -83,9 +81,6 @@ export function HelpButton({ const { reportHelpButtonUsed } = useActions(eventUsageLogic) const { isHelpVisible } = useValues(helpButtonLogic({ key: customKey })) const { toggleHelp, hideHelp } = useActions(helpButtonLogic({ key: customKey })) - const { validProductTourSequences } = useValues(inAppPromptLogic) - const { runFirstValidSequence, promptAction } = useActions(inAppPromptLogic) - const { isPromptVisible } = useValues(inAppPromptLogic) const { openSupportForm } = useActions(supportLogic) const { isCloudOrDev } = useValues(preflightLogic) @@ -152,18 +147,6 @@ export function HelpButton({ to: `https://posthog.com/docs${HELP_UTM_TAGS}`, targetBlank: true, }, - validProductTourSequences.length > 0 && { - label: isPromptVisible ? 'Stop tutorial' : 'Explain this page', - icon: , - onClick: () => { - if (isPromptVisible) { - promptAction(DefaultAction.SKIP) - } else { - runFirstValidSequence({ runDismissedOrCompleted: true }) - } - hideHelp() - }, - }, ], }, ]} diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts b/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts deleted file mode 100644 index b8ede572b2214..0000000000000 --- a/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { actions, kea, listeners, path } from 'kea' -import posthog from 'posthog-js' - -import type { inAppPromptEventCaptureLogicType } from './inAppPromptEventCaptureLogicType' -import { PromptType } from './inAppPromptLogic' - -const inAppPromptEventCaptureLogic = kea([ - path(['lib', 'logic', 'inAppPrompt', 'eventCapture']), - actions({ - reportPromptShown: (type: PromptType, sequence: string, step: number, totalSteps: number) => ({ - type, - sequence, - step, - totalSteps, - }), - reportPromptForward: (sequence: string, step: number, totalSteps: number) => ({ sequence, step, totalSteps }), - reportPromptBackward: (sequence: string, step: number, totalSteps: number) => ({ sequence, step, totalSteps }), - reportPromptSequenceDismissed: (sequence: string, step: number, totalSteps: number) => ({ - sequence, - step, - totalSteps, - }), - reportPromptSequenceCompleted: (sequence: string, step: number, totalSteps: number) => ({ - sequence, - step, - totalSteps, - }), - reportProductTourStarted: true, - reportProductTourSkipped: true, - }), - listeners({ - reportPromptShown: ({ type, sequence, step, totalSteps }) => { - posthog.capture('prompt shown', { - type, - sequence, - step, - totalSteps, - }) - }, - reportPromptForward: ({ sequence, step, totalSteps }) => { - posthog.capture('prompt forward', { - sequence, - step, - totalSteps, - }) - }, - reportPromptBackward: ({ sequence, step, totalSteps }) => { - posthog.capture('prompt backward', { - sequence, - step, - totalSteps, - }) - }, - reportPromptSequenceDismissed: ({ sequence, step, totalSteps }) => { - posthog.capture('prompt sequence dismissed', { - sequence, - step, - totalSteps, - }) - }, - reportPromptSequenceCompleted: ({ sequence, step, totalSteps }) => { - posthog.capture('prompt sequence completed', { - sequence, - step, - totalSteps, - }) - }, - reportProductTourStarted: () => { - posthog.capture('product tour started') - }, - reportProductTourSkipped: () => { - posthog.capture('product tour skipped') - }, - }), -]) - -export { inAppPromptEventCaptureLogic } diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.test.ts b/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.test.ts deleted file mode 100644 index e1a029a9d2db0..0000000000000 --- a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { router } from 'kea-router' -import { expectLogic } from 'kea-test-utils' -import api from 'lib/api' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { urls } from 'scenes/urls' - -import { useMocks } from '~/mocks/jest' -import { initKeaTests } from '~/test/init' - -import { inAppPromptEventCaptureLogic } from './inAppPromptEventCaptureLogic' -import { inAppPromptLogic, PromptConfig, PromptUserState } from './inAppPromptLogic' - -const configProductTours: PromptConfig & { state: PromptUserState } = { - sequences: [ - { - key: 'experiment-events-product-tour', - prompts: [ - { - step: 0, - type: 'tooltip', - text: "Welcome! We'd like to give you a quick tour!", - placement: 'top-start', - buttons: [{ action: 'skip', label: 'Skip tutorial' }], - reference: 'tooltip-test', - icon: 'live-events', - }, - { - step: 1, - type: 'tooltip', - text: "Here you can see all events from the past 12 months. Things look a bit quiet, so let's turn on automatic refresh to see events in real-time.", - placement: 'top-start', - reference: 'tooltip-test', - icon: 'live-events', - }, - { - step: 2, - type: 'tooltip', - text: "If you aren't seeing the data you expect then you can always ask for help. For now, lets analyze some data. Click 'Dashboards' in the sidebar.", - placement: 'top-start', - buttons: [{ url: 'https://posthog.com/questions', label: 'Ask for help' }], - icon: 'live-events', - reference: 'tooltip-test', - }, - ], - path_match: ['/events'], - path_exclude: [], - type: 'product-tour', - }, - { - key: 'experiment-dashboards-product-tour', - prompts: [ - { - step: 0, - type: 'tooltip', - text: "In PostHog, you analyse data with Insights which can be added to Dashboards to aid collaboration. Let's create a new Dashboard by selecting 'New Dashboard'. ", - placement: 'top-start', - icon: 'dashboard', - reference: 'tooltip-test', - }, - { - step: 1, - type: 'tooltip', - text: "In PostHog, you analyse data with Insights which can be added to Dashboards to aid collaboration. Let's create a new Dashboard by selecting 'New Dashboard'. ", - placement: 'top-start', - icon: 'dashboard', - reference: 'tooltip-test', - }, - ], - path_match: ['/dashboard'], - path_exclude: ['/dashboard/*'], - type: 'product-tour', - }, - ], - state: { - 'experiment-events-product-tour': { - key: 'experiment-events-product-tour', - step: 0, - completed: false, - dismissed: false, - last_updated_at: '2022-07-26T16:32:55.153Z', - }, - 'experiment-dashboards-product-tour': { - key: 'experiment-dashboards-product-tour', - step: null, - completed: false, - dismissed: false, - last_updated_at: '2022-07-26T16:32:55.153Z', - }, - }, -} - -const configOptIn: PromptConfig & { state: PromptUserState } = { - sequences: [ - { - key: 'experiment-one-off-intro', - prompts: [ - { - step: 0, - type: 'tooltip', - text: 'This is welcome message to ask users to opt-in', - placement: 'top-start', - icon: 'dashboard', - reference: 'tooltip-test', - }, - ], - path_match: ['/*'], - path_exclude: [], - type: 'one-off', - }, - { - key: 'experiment-one-off', - prompts: [ - { - step: 0, - type: 'tooltip', - text: 'This is a one off prompt that requires opt-in', - placement: 'top-start', - icon: 'dashboard', - reference: 'tooltip-test', - }, - ], - path_match: ['/*'], - path_exclude: [], - requires_opt_in: true, - type: 'one-off', - }, - ], - state: { - 'experiment-one-off-intro': { - key: 'experiment-one-off-intro', - step: null, - completed: false, - dismissed: false, - last_updated_at: '2022-07-26T16:32:55.153Z', - }, - 'experiment-one-off': { - key: 'experiment-one-off', - step: null, - completed: false, - dismissed: false, - last_updated_at: '2022-07-26T16:32:55.153Z', - }, - }, -} - -describe('inAppPromptLogic', () => { - let logic: ReturnType - - describe('opt-in prompts', () => { - beforeEach(async () => { - const div = document.createElement('div') - div['data-attr'] = 'tooltip-test' - const spy = jest.spyOn(document, 'querySelector') - spy.mockReturnValue(div) - jest.spyOn(api, 'update') - useMocks({ - patch: { - '/api/prompts/my_prompts/': configOptIn, - }, - }) - localStorage.clear() - initKeaTests() - featureFlagLogic.mount() - logic = inAppPromptLogic() - logic.mount() - await expectLogic(logic).toMount([inAppPromptEventCaptureLogic]) - }) - - afterEach(() => logic.unmount()) - - it('correctly opts in', async () => { - logic.actions.optInProductTour() - await expectLogic(logic).toMatchValues({ - canShowProductTour: true, - }) - }) - - it('correctly opts out when skipping', async () => { - logic.actions.optInProductTour() - await expectLogic(logic, () => { - logic.actions.promptAction('skip') - }) - .toDispatchActions([ - 'closePrompts', - 'optOutProductTour', - inAppPromptEventCaptureLogic.actionCreators.reportProductTourSkipped(), - ]) - .toMatchValues({ - canShowProductTour: false, - }) - }) - - it('correctly sets valid sequences respecting opt-out and opt-in', async () => { - logic.actions.optOutProductTour() - await expectLogic(logic, () => { - logic.actions.syncState({ forceRun: true }) - }) - .toDispatchActions(['setSequences', 'findValidSequences', 'setValidSequences']) - .toMatchValues({ - sequences: configOptIn.sequences, - userState: configOptIn.state, - canShowProductTour: false, - validSequences: [ - { - sequence: configOptIn.sequences[0], - state: { - step: 0, - completed: false, - }, - }, - ], - }) - - logic.actions.optInProductTour() - logic.actions.findValidSequences() - await expectLogic(logic).toMatchValues({ - canShowProductTour: true, - validSequences: [ - { - sequence: configOptIn.sequences[0], - state: { - step: 0, - completed: false, - }, - }, - { - sequence: configOptIn.sequences[1], - state: { - step: 0, - completed: false, - }, - }, - ], - }) - }) - }) - - describe('product tours', () => { - beforeEach(async () => { - const div = document.createElement('div') - div['data-attr'] = 'tooltip-test' - const spy = jest.spyOn(document, 'querySelector') - spy.mockReturnValue(div) - jest.spyOn(api, 'update') - useMocks({ - patch: { - '/api/prompts/my_prompts/': configProductTours, - }, - }) - localStorage.clear() - initKeaTests() - featureFlagLogic.mount() - logic = inAppPromptLogic() - logic.mount() - logic.actions.optInProductTour() - await expectLogic(logic).toMount([inAppPromptEventCaptureLogic]) - await expectLogic(logic, () => { - logic.actions.syncState({ forceRun: true }) - }) - .toDispatchActions(['setUserState', 'setSequences', 'findValidSequences', 'setValidSequences']) - .toMatchValues({ - sequences: configProductTours.sequences, - userState: configProductTours.state, - validSequences: [], - }) - }) - - afterEach(() => logic.unmount()) - - it('changes route and dismissed the sequence in an excluded path', async () => { - router.actions.push(urls.dashboard('my-dashboard')) - await expectLogic(logic) - .toDispatchActions(['closePrompts', 'findValidSequences', 'setValidSequences', 'runFirstValidSequence']) - .toNotHaveDispatchedActions(['runSequence']) - }) - - it('changes route and correctly triggers an unseen sequence', async () => { - router.actions.push(urls.dashboards()) - await expectLogic(logic) - .toDispatchActions(['closePrompts', 'findValidSequences', 'setValidSequences']) - .toMatchValues({ - validSequences: [ - { - sequence: configProductTours.sequences[1], - state: { - step: 0, - completed: false, - }, - }, - ], - }) - .toDispatchActions([ - 'closePrompts', - logic.actionCreators.runSequence(configProductTours.sequences[1], 0), - inAppPromptEventCaptureLogic.actionCreators.reportPromptShown( - 'tooltip', - configProductTours.sequences[1].key, - 0, - 2 - ), - 'promptShownSuccessfully', - ]) - .toMatchValues({ - currentSequence: configProductTours.sequences[1], - currentStep: 0, - }) - }) - - it('can dismiss a sequence', async () => { - router.actions.push(urls.dashboards()) - await expectLogic(logic).toDispatchActions(['promptShownSuccessfully']).toMatchValues({ - isPromptVisible: true, - }) - await expectLogic(logic, () => { - logic.actions.dismissSequence() - }) - .toDispatchActions([ - inAppPromptEventCaptureLogic.actionCreators.reportPromptSequenceDismissed( - configProductTours.sequences[1].key, - 0, - 2 - ), - ]) - .toMatchValues({ - isPromptVisible: false, - }) - }) - - it('can complete sequence, then go back, then dismiss it', async () => { - router.actions.push(urls.dashboards()) - await expectLogic(logic).toDispatchActions(['promptShownSuccessfully']).toMatchValues({ - isPromptVisible: true, - }) - await expectLogic(logic, () => { - logic.actions.nextPrompt() - }) - .toDispatchActions([ - logic.actionCreators.runSequence(configProductTours.sequences[1], 1), - inAppPromptEventCaptureLogic.actionCreators.reportPromptForward( - configProductTours.sequences[1].key, - 1, - 2 - ), - inAppPromptEventCaptureLogic.actionCreators.reportPromptSequenceCompleted( - configProductTours.sequences[1].key, - 1, - 2 - ), - inAppPromptEventCaptureLogic.actionCreators.reportPromptShown( - 'tooltip', - configProductTours.sequences[1].key, - 1, - 2 - ), - 'promptShownSuccessfully', - ]) - .toMatchValues({ - currentStep: 1, - }) - await expectLogic(logic, () => { - logic.actions.previousPrompt() - }) - .toDispatchActions([ - logic.actionCreators.runSequence(configProductTours.sequences[1], 0), - inAppPromptEventCaptureLogic.actionCreators.reportPromptBackward( - configProductTours.sequences[1].key, - 0, - 2 - ), - inAppPromptEventCaptureLogic.actionCreators.reportPromptShown( - 'tooltip', - configProductTours.sequences[1].key, - 0, - 2 - ), - 'promptShownSuccessfully', - ]) - .toMatchValues({ - currentStep: 0, - }) - await expectLogic(logic, () => { - logic.actions.dismissSequence() - }) - .toDispatchActions(['clearSequence']) - .toNotHaveDispatchedActions([ - inAppPromptEventCaptureLogic.actionCreators.reportPromptSequenceDismissed( - configProductTours.sequences[0].key, - 1, - 2 - ), - ]) - }) - - it('does not run a sequence left unfinished', async () => { - router.actions.push(urls.events()) - await expectLogic(logic).toNotHaveDispatchedActions(['promptShownSuccessfully']).toMatchValues({ - isPromptVisible: false, - }) - }) - }) -}) diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx b/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx deleted file mode 100644 index 4c5cd22f04084..0000000000000 --- a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx +++ /dev/null @@ -1,517 +0,0 @@ -import { Placement } from '@floating-ui/react' -import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea' -import { router, urlToAction } from 'kea-router' -import api from 'lib/api' -import { now } from 'lib/dayjs' -import { - IconApps, - IconBarChart, - IconCoffee, - IconCohort, - IconComment, - IconExperiment, - IconFlag, - IconGauge, - IconLive, - IconMessages, - IconPerson, - IconRecording, - IconTools, - IconTrendUp, - IconUnverifiedEvent, -} from 'lib/lemon-ui/icons' -import { - LemonActionableTooltip, - LemonActionableTooltipProps, -} from 'lib/lemon-ui/LemonActionableTooltip/LemonActionableTooltip' -import { Lettermark } from 'lib/lemon-ui/Lettermark' -import { createRoot } from 'react-dom/client' -import wcmatch from 'wildcard-match' - -import { inAppPromptEventCaptureLogic } from './inAppPromptEventCaptureLogic' -import type { inAppPromptLogicType } from './inAppPromptLogicType' - -/** To be extended with other types of notifications e.g. modals, bars */ -export type PromptType = 'tooltip' - -export type PromptButton = { - url?: string - action?: string - label: string -} - -export type Prompt = { - step: number - type: PromptType - text: string - placement: Placement - reference: string | null - title?: string - buttons?: PromptButton[] - icon?: string -} - -export type Tooltip = Prompt & { type: 'tooltip' } - -export type PromptSequence = { - key: string - prompts: Prompt[] - path_match: string[] - path_exclude: string[] - must_be_completed?: string[] - requires_opt_in?: boolean - type: string -} - -export type PromptConfig = { - sequences: PromptSequence[] -} - -export type PromptState = { - key: string - last_updated_at: string - step: number | null - completed?: boolean - dismissed?: boolean -} - -export type ValidSequenceWithState = { - sequence: PromptSequence - state: { step: number; completed?: boolean } -} - -export type PromptUserState = { - [key: string]: PromptState -} - -export enum DefaultAction { - NEXT = 'next', - PREVIOUS = 'previous', - START_PRODUCT_TOUR = 'start-product-tour', - SKIP = 'skip', -} - -// we show a new sequence with 1 second delay, because users immediately dismiss prompts that are invasive -const NEW_SEQUENCE_DELAY = 1000 -// make sure to change this prefix in case the schema of cached values is changed -// otherwise the code will try to run with cached deprecated values -const CACHE_PREFIX = 'v5' - -const iconMap = { - home: , - 'live-events': , - dashboard: , - insight: , - messages: , - recordings: , - 'feature-flags': , - experiments: , - 'web-performance': , - 'data-management': , - persons: , - cohorts: , - annotations: , - apps: , - toolbar: , - 'trend-up': , -} - -/** Display a with the ability to remove it from the DOM */ -function cancellableTooltipWithRetries( - tooltip: Tooltip, - onAction: (action: string) => void, - options: { maxSteps: number; onClose: () => void; next: () => void; previous: () => void } -): { close: () => void; show: Promise } { - let trigger = (): void => {} - const close = (): number => window.setTimeout(trigger, 1) - const show = new Promise((resolve, reject) => { - const div = document.createElement('div') - const root = createRoot(div) - function destroy(): void { - root.unmount() - if (div.parentNode) { - div.parentNode.removeChild(div) - } - } - - document.body.appendChild(div) - trigger = destroy - - const tryRender = function (retries: number): void { - try { - let props: LemonActionableTooltipProps = { - title: tooltip.title, - text: tooltip.text, - placement: tooltip.placement, - step: tooltip.step, - maxSteps: options.maxSteps, - next: () => { - destroy() - options.next() - }, - previous: () => { - destroy() - options.previous() - }, - close: () => { - destroy() - options.onClose() - }, - visible: true, - buttons: tooltip.buttons - ? tooltip.buttons.map((button) => { - if (button.action) { - return { - ...button, - action: () => onAction(button.action as string), - } - } - return { - url: button.url, - label: button.label, - } - }) - : [], - icon: tooltip.icon ? iconMap[tooltip.icon] : null, - } - if (tooltip.reference) { - const element = tooltip.reference - ? (document.querySelector(`[data-attr="${tooltip.reference}"]`) as HTMLElement) - : null - if (!element) { - throw 'Prompt reference element not found' - } - props = { ...props, element } - } - - root.render() - - resolve(true) - } catch (e) { - if (retries == 0) { - reject(e) - } else { - setTimeout(function () { - tryRender(retries - 1) - }, 1000) - } - } - } - tryRender(3) - }) - - return { - close, - show, - } -} - -export const inAppPromptLogic = kea([ - path(['lib', 'logic', 'inAppPrompt']), - connect(inAppPromptEventCaptureLogic), - actions({ - findValidSequences: true, - setValidSequences: (validSequences: ValidSequenceWithState[]) => ({ validSequences }), - runFirstValidSequence: (options: { runDismissedOrCompleted?: boolean }) => ({ options }), - runSequence: (sequence: PromptSequence, step: number) => ({ sequence, step }), - promptShownSuccessfully: true, - closePrompts: true, - dismissSequence: true, - clearSequence: true, - nextPrompt: true, - previousPrompt: true, - updatePromptState: (update: Partial) => ({ update }), - setUserState: (state: PromptUserState, sync = true) => ({ state, sync }), - syncState: (options: { forceRun?: boolean }) => ({ options }), - setSequences: (sequences: PromptSequence[]) => ({ sequences }), - promptAction: (action: string) => ({ action }), - optInProductTour: true, - optOutProductTour: true, - }), - reducers(() => ({ - sequences: [ - [] as PromptSequence[], - { persist: true, prefix: CACHE_PREFIX }, - { - setSequences: (_, { sequences }) => sequences, - }, - ], - currentSequence: [ - null as PromptSequence | null, - { - runSequence: (_, { sequence }) => sequence, - clearSequence: () => null, - }, - ], - currentStep: [ - 0, - { - runSequence: (_, { step }) => step, - clearSequence: () => 0, - }, - ], - userState: [ - {} as PromptUserState, - { persist: true, prefix: CACHE_PREFIX }, - { - setUserState: (_, { state }) => state, - }, - ], - canShowProductTour: [ - false, - { persist: true, prefix: CACHE_PREFIX }, - { - optInProductTour: () => true, - optOutProductTour: () => false, - }, - ], - validSequences: [ - [] as ValidSequenceWithState[], - { - setValidSequences: (_, { validSequences }) => validSequences, - }, - ], - validProductTourSequences: [ - [] as ValidSequenceWithState[], - { - setValidSequences: (_, { validSequences }) => - validSequences?.filter((v) => v.sequence.type === 'product-tour') || [], - }, - ], - isPromptVisible: [ - false, - { - promptShownSuccessfully: () => true, - closePrompts: () => false, - dismissSequence: () => false, - }, - ], - })), - selectors(() => ({ - prompts: [(s) => [s.currentSequence], (sequence: PromptSequence | null) => sequence?.prompts ?? []], - sequenceKey: [(s) => [s.currentSequence], (sequence: PromptSequence | null) => sequence?.key], - })), - listeners(({ actions, values, cache }) => ({ - syncState: async ({ options }, breakpoint) => { - await breakpoint(100) - try { - const updatedState = await api.update(`api/prompts/my_prompts`, values.userState) - if (updatedState) { - if (JSON.stringify(values.userState) !== JSON.stringify(updatedState['state'])) { - actions.setUserState(updatedState['state'], false) - } - if ( - JSON.stringify(values.sequences) !== JSON.stringify(updatedState['sequences']) || - options.forceRun - ) { - actions.setSequences(updatedState['sequences']) - } - } - } catch (error: any) { - console.error(error) - } - }, - closePrompts: () => cache.runOnClose?.(), - setSequences: actions.findValidSequences, - runSequence: async ({ sequence, step = 0 }) => { - const prompt = sequence.prompts.find((prompt) => prompt.step === step) - if (prompt) { - switch (prompt.type) { - case 'tooltip': { - const { close, show } = cancellableTooltipWithRetries(prompt, actions.promptAction, { - maxSteps: values.prompts.length, - onClose: actions.dismissSequence, - previous: () => actions.promptAction(DefaultAction.PREVIOUS), - next: () => actions.promptAction(DefaultAction.NEXT), - }) - cache.runOnClose = close - - try { - await show - const updatedState: Partial = { - step: values.currentStep, - } - if (step === sequence.prompts.length - 1) { - updatedState.completed = true - inAppPromptEventCaptureLogic.actions.reportPromptSequenceCompleted( - sequence.key, - step, - values.prompts.length - ) - } - actions.updatePromptState(updatedState) - inAppPromptEventCaptureLogic.actions.reportPromptShown( - prompt.type, - sequence.key, - step, - values.prompts.length - ) - actions.promptShownSuccessfully() - } catch (e) { - console.error(e) - } - break - } - default: - break - } - } - }, - updatePromptState: ({ update }) => { - if (values.sequenceKey) { - const key = values.sequenceKey - const currentState = values.userState[key] || { key, step: 0 } - actions.setUserState({ - ...values.userState, - [key]: { - ...currentState, - ...update, - last_updated_at: now().toISOString(), - }, - }) - } - }, - previousPrompt: () => { - if (values.currentSequence) { - actions.runSequence(values.currentSequence, values.currentStep - 1) - inAppPromptEventCaptureLogic.actions.reportPromptBackward( - values.currentSequence.key, - values.currentStep, - values.currentSequence.prompts.length - ) - } - }, - nextPrompt: () => { - if (values.currentSequence) { - actions.runSequence(values.currentSequence, values.currentStep + 1) - inAppPromptEventCaptureLogic.actions.reportPromptForward( - values.currentSequence.key, - values.currentStep, - values.currentSequence.prompts.length - ) - } - }, - findValidSequences: () => { - const pathname = router.values.currentLocation.pathname - const valid = [] - for (const sequence of values.sequences) { - // for now the only valid rule is related to the pathname, can be extended - const must_match = [...sequence.path_match] - if (must_match.includes('/*')) { - must_match.push('/**') - } - const isMatchingPath = must_match.some((value) => wcmatch(value)(pathname)) - if (!isMatchingPath) { - continue - } - const isMatchingExclusion = sequence.path_exclude.some((value) => wcmatch(value)(pathname)) - if (isMatchingExclusion) { - continue - } - const hasOptedInToSequence = sequence.requires_opt_in ? values.canShowProductTour : true - if (!values.userState[sequence.key]) { - continue - } - const sequenceState = values.userState[sequence.key] - const completed = !!sequenceState.completed || sequenceState.step === sequence.prompts.length - const canRun = !sequenceState.dismissed && hasOptedInToSequence - if (!canRun) { - continue - } - if (sequence.type !== 'product-tour' && (completed || sequenceState.step === sequence.prompts.length)) { - continue - } - valid.push({ - sequence, - state: { - step: sequenceState.step ? sequenceState.step + 1 : 0, - completed, - }, - }) - } - actions.setValidSequences(valid) - }, - setValidSequences: () => { - if (!values.isPromptVisible) { - actions.runFirstValidSequence({}) - } - }, - runFirstValidSequence: ({ options }) => { - if (values.validSequences) { - actions.closePrompts() - let firstValid = null - if (options.runDismissedOrCompleted) { - firstValid = values.validSequences[0] - } else { - // to make it less greedy, we don't allow half-run sequences to be started automatically - firstValid = values.validSequences.filter( - (sequence) => !sequence.state.completed && sequence.state.step === 0 - )?.[0] - } - if (firstValid) { - const { sequence, state } = firstValid - setTimeout(() => actions.runSequence(sequence, state.step), NEW_SEQUENCE_DELAY) - } - } - }, - dismissSequence: () => { - if (values.sequenceKey) { - const key = values.sequenceKey - const currentState = values.userState[key] - if (currentState && !currentState.completed) { - actions.updatePromptState({ - dismissed: true, - }) - if (values.currentStep < values.prompts.length) { - inAppPromptEventCaptureLogic.actions.reportPromptSequenceDismissed( - values.sequenceKey, - values.currentStep, - values.prompts.length - ) - } - } - actions.clearSequence() - } - }, - setUserState: ({ sync }) => sync && actions.syncState({}), - promptAction: ({ action }) => { - actions.closePrompts() - switch (action) { - case DefaultAction.NEXT: - actions.nextPrompt() - break - case DefaultAction.PREVIOUS: - actions.previousPrompt() - break - case DefaultAction.START_PRODUCT_TOUR: - actions.optInProductTour() - inAppPromptEventCaptureLogic.actions.reportProductTourStarted() - actions.runFirstValidSequence({ runDismissedOrCompleted: true }) - break - case DefaultAction.SKIP: - actions.optOutProductTour() - inAppPromptEventCaptureLogic.actions.reportProductTourSkipped() - break - default: { - const potentialSequence = values.sequences.find((s) => s.key === action) - if (potentialSequence) { - actions.runSequence(potentialSequence, 0) - } - break - } - } - }, - })), - urlToAction(({ actions }) => ({ - '*': () => { - actions.closePrompts() - if (!['login', 'signup', 'ingestion'].find((path) => router.values.location.pathname.includes(path))) { - actions.findValidSequences() - } - }, - })), - afterMount(({ actions }) => { - actions.syncState({ forceRun: true }) - }), - beforeUnmount(({ cache }) => cache.runOnClose?.()), -]) diff --git a/frontend/src/lib/logic/promptLogic.tsx b/frontend/src/lib/logic/promptLogic.tsx index 371cc7cdc7d25..d6f78532b8ed0 100644 --- a/frontend/src/lib/logic/promptLogic.tsx +++ b/frontend/src/lib/logic/promptLogic.tsx @@ -106,7 +106,7 @@ function Prompt({ ) } -export function cancellablePrompt(config: Pick): { +function cancellablePrompt(config: Pick): { cancel: () => void promise: Promise } { diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 93d80167f6ca7..9025128a3ae11 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -111,8 +111,5 @@ export const defaultMocks: Mocks = { 'https://app.posthog.com/engage/': (): MockSignature => [200, 'ok'], '/api/projects/:team_id/insights/:insight_id/viewed/': (): MockSignature => [201, null], }, - patch: { - '/api/prompts/my_prompts': (): MockSignature => [200, {}], - }, } export const handlers = mocksToHandlers(defaultMocks) diff --git a/frontend/src/scenes/App.tsx b/frontend/src/scenes/App.tsx index 6c96864370cf3..9c5663b23fd26 100644 --- a/frontend/src/scenes/App.tsx +++ b/frontend/src/scenes/App.tsx @@ -4,7 +4,6 @@ import { use3000Body } from 'lib/hooks/use3000Body' import { ToastCloseButton } from 'lib/lemon-ui/LemonToast/LemonToast' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { inAppPromptLogic } from 'lib/logic/inAppPrompt/inAppPromptLogic' import { Slide, ToastContainer } from 'react-toastify' import { frontendAppsLogic } from 'scenes/apps/frontendAppsLogic' import { appScenes } from 'scenes/appScenes' @@ -29,7 +28,7 @@ window.process = MOCK_NODE_PROCESS export const appLogic = kea([ path(['scenes', 'App']), - connect([teamLogic, organizationLogic, frontendAppsLogic, inAppPromptLogic, actionsModel, cohortsModel]), + connect([teamLogic, organizationLogic, frontendAppsLogic, actionsModel, cohortsModel]), actions({ enableDelayedSpinner: true, ignoreFeatureFlags: true, diff --git a/frontend/src/scenes/dashboard/dashboards/Dashboards.tsx b/frontend/src/scenes/dashboard/dashboards/Dashboards.tsx index f58102bc85a70..d05b32840f29c 100644 --- a/frontend/src/scenes/dashboard/dashboards/Dashboards.tsx +++ b/frontend/src/scenes/dashboard/dashboards/Dashboards.tsx @@ -2,7 +2,6 @@ import { useActions, useValues } from 'kea' import { PageHeader } from 'lib/components/PageHeader' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonTab, LemonTabs } from 'lib/lemon-ui/LemonTabs' -import { inAppPromptLogic } from 'lib/logic/inAppPrompt/inAppPromptLogic' import { dashboardsLogic, DashboardsTab } from 'scenes/dashboard/dashboards/dashboardsLogic' import { DashboardsTableContainer } from 'scenes/dashboard/dashboards/DashboardsTable' import { DashboardTemplatesTable } from 'scenes/dashboard/dashboards/templates/DashboardTemplatesTable' @@ -26,7 +25,6 @@ export function Dashboards(): JSX.Element { const { setCurrentTab } = useActions(dashboardsLogic) const { dashboards, currentTab, isFiltering } = useValues(dashboardsLogic) const { showNewDashboardModal } = useActions(newDashboardLogic) - const { closePrompts } = useActions(inAppPromptLogic) const enabledTabs: LemonTab[] = [ { @@ -49,7 +47,6 @@ export function Dashboards(): JSX.Element { { - closePrompts() showNewDashboardModal() }} type="primary" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00b8dc6632721..fab53a6407cbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' settings: autoInstallPeers: true @@ -350,7 +350,7 @@ dependencies: optionalDependencies: fsevents: specifier: ^2.3.2 - version: 2.3.3 + version: 2.3.2 devDependencies: '@babel/core': @@ -12697,7 +12697,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /fsevents@2.3.3: diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py index 0953bb915a35c..5bf0e339080e2 100644 --- a/posthog/api/__init__.py +++ b/posthog/api/__init__.py @@ -31,7 +31,6 @@ personal_api_key, plugin, plugin_log_entry, - prompt, property_definition, query, search, @@ -64,7 +63,6 @@ def api_not_found(request): router.register(r"plugin_config", plugin.LegacyPluginConfigViewSet, "legacy_plugin_configs") router.register(r"feature_flag", feature_flag.LegacyFeatureFlagViewSet) # Used for library side feature flag evaluation -router.register(r"prompts", prompt.PromptSequenceViewSet, "user_prompts") # User prompts # Nested endpoints shared projects_router = router.register(r"projects", team.TeamViewSet) diff --git a/posthog/api/prompt.py b/posthog/api/prompt.py deleted file mode 100644 index 8c6f1633016ab..0000000000000 --- a/posthog/api/prompt.py +++ /dev/null @@ -1,245 +0,0 @@ -import json -from typing import Any, Dict, List - -from dateutil import parser -from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt -from rest_framework import exceptions, request, serializers, status, viewsets -from rest_framework.decorators import action -from rest_framework.response import Response - -from posthog.api.routing import StructuredViewSetMixin -from posthog.api.utils import get_token -from posthog.exceptions import generate_exception_response -from posthog.models.prompt import Prompt, PromptSequence, UserPromptState -from posthog.tasks.prompts import trigger_prompt_for_user -from posthog.utils_cors import cors_response - - -class PromptButtonSerializer(serializers.Serializer): - label = serializers.CharField() # type: ignore - url = serializers.CharField(required=False) - action = serializers.CharField(required=False) - - -class PromptSerializer(serializers.ModelSerializer): - buttons = PromptButtonSerializer(many=True, required=False, default=[]) - reference = serializers.CharField(default=None) - placement = serializers.CharField(default="top") - type = serializers.CharField(default="tooltip") - - class Meta: - model = Prompt - fields = ["step", "type", "title", "text", "placement", "reference", "buttons"] - - -class PromptSequenceSerializer(serializers.ModelSerializer): - prompts = PromptSerializer(many=True) - path_match = serializers.ListField(child=serializers.CharField(), default=["/*"]) - path_exclude = serializers.ListField(child=serializers.CharField(), default=[]) - status = serializers.CharField(default="active") - requires_opt_in = serializers.BooleanField(default=False) - type = serializers.CharField(default="one-off") - autorun = serializers.BooleanField(default=False) - - class Meta: - model = PromptSequence - fields = [ - "key", - "path_match", - "path_exclude", - "requires_opt_in", - "type", - "status", - "prompts", - "autorun", - ] - - -class UserPromptStateSerializer(serializers.ModelSerializer): - class Meta: - model = UserPromptState - fields = ["last_updated_at", "step", "completed", "dismissed"] - - -class PromptSequenceViewSet(StructuredViewSetMixin, viewsets.ViewSet): - """ - Create, read, update and delete prompt sequences state for a person. - """ - - @action(methods=["PATCH"], detail=False) - def my_prompts(self, request: request.Request, **kwargs): - if not request.user.is_authenticated: # for mypy otherwise check on distict_id below fails - raise exceptions.NotAuthenticated() - local_states: List[UserPromptState] = [] - local_state_keys = set() - all_sequences = PromptSequence.objects.filter(status="active") - # get the local state (sent from localstorage), and filter it based on active sequences - for key in request.data: - if key not in local_state_keys: - try: - sequence = all_sequences.get(key=key) - parsed_state: Dict[str, Any] = dict( - request.data[key], - last_updated_at=parser.isoparse(request.data[key]["last_updated_at"]), - sequence=sequence, - ) - local_states.append( - UserPromptState( - user=request.user, - **parsed_state, - ) - ) - local_state_keys.add(key) # prevent duplicates - except: - continue - - states_to_update: List[UserPromptState] = [] - states_to_create: List[UserPromptState] = [] - - saved_states = UserPromptState.objects.filter(user=request.user) - up_to_date_states: List[UserPromptState] = [] - - # for each sequence, we check if either the local state, or the one saved in the db is more up to date - # if the local state is more up to date, we update the db state - # if the db state is more up to date, we send it back to the frontend - for sequence in all_sequences: - local_state = next((s for s in local_states if sequence == s.sequence), None) - saved_state = next((s for s in saved_states if sequence == s.sequence), None) - - state = None - # check if the local state is more recent than the one in the db, then update accordingly - if local_state: - if saved_state and local_state.last_updated_at > saved_state.last_updated_at: - saved_state.last_updated_at = local_state.last_updated_at - saved_state.step = local_state.step - saved_state.completed = local_state.completed - saved_state.dismissed = local_state.dismissed - states_to_update.append(saved_state) - state = saved_state - elif saved_state is not None: - state = saved_state - else: - states_to_create.append(local_state) - state = local_state - else: - if saved_state: - state = saved_state - # if the sequence should autorun for all users, we create a state with no step, meaning the user has not seen it but should start seeing it - elif sequence.autorun: - state = UserPromptState(user=request.user, sequence=sequence, step=None) - - if state: - up_to_date_states.append(state) - - my_prompts: Dict[str, Any] = {"state": {}, "sequences": []} - # filter only the sequences where `must_be_completed` rule passes - # this allows to run certain sequences only if other have been completed first - for state in up_to_date_states: - sequence = state.sequence - must_have_completed = sequence.must_have_completed.all() - if len(must_have_completed) > 0: - current_state = next( - (s for s in up_to_date_states if s.sequence in must_have_completed), - None, - ) - if not current_state or (current_state and not current_state.completed): - continue - my_prompts["state"][sequence.key] = UserPromptStateSerializer(state).data - my_prompts["sequences"].append(PromptSequenceSerializer(sequence).data) - - # update or create any state to db - if states_to_create: - UserPromptState.objects.bulk_create(states_to_create) - if states_to_update: - UserPromptState.objects.bulk_update(states_to_update, ["last_updated_at", "step", "completed", "dismissed"]) - - return Response(my_prompts) - - -class WebhookSerializer(serializers.Serializer): - sequence = PromptSequenceSerializer() - emails = serializers.ListField(child=serializers.EmailField(), required=False) - - -class WebhookSequenceSerializer(serializers.ModelSerializer): - path_match = serializers.ListField(child=serializers.CharField(), default=["/*"]) - path_exclude = serializers.ListField(child=serializers.CharField(), default=[]) - requires_opt_in = serializers.BooleanField(default=False) - status = serializers.CharField(default="active") - autorun = serializers.BooleanField(default=False) - type = serializers.CharField(default="one-off") - - class Meta: - model = PromptSequence - fields = [ - "key", - "path_match", - "path_exclude", - "type", - "status", - "requires_opt_in", - "autorun", - ] - - -@csrf_exempt -def prompt_webhook(request: request.Request): - if request.method == "POST": - data = json.loads(request.body) - else: - return cors_response( - request, - generate_exception_response( - "prompts_webhook", - "No data found. Make sure to use a POST request when sending the payload in the body of the request.", - code="no_data", - ), - ) - - token = get_token(data, request) - - if not token: - return cors_response( - request, - generate_exception_response( - "prompts_webhook", - "API key not provided. You can find your project API key in PostHog project settings.", - type="authentication_error", - code="missing_api_key", - status_code=status.HTTP_401_UNAUTHORIZED, - ), - ) - - serializer = WebhookSerializer(data=data) - if not serializer.is_valid(): - return cors_response( - request, - generate_exception_response( - "prompts_webhook", - serializer.errors, - status_code=status.HTTP_400_BAD_REQUEST, - ), - ) - serialized_data = serializer.validated_data - - prompt_data = [] - for prompt in serialized_data["sequence"]["prompts"]: - prompt_data.append(PromptSerializer(prompt).data) - - # get or create sequence from webhook - sequence_data = WebhookSequenceSerializer(serialized_data["sequence"]).data - try: - sequence = PromptSequence.objects.get(key=sequence_data["key"]) - except PromptSequence.DoesNotExist: - sequence = PromptSequence.objects.create(**sequence_data) - for prompt in prompt_data: - new_prompt = Prompt.objects.create(**prompt) - sequence.prompts.add(new_prompt) - - # trigger the sequence for users matching the emails, by creating empty states for them - if serialized_data.get("emails"): - for email in serialized_data["emails"]: - trigger_prompt_for_user.delay(email, sequence.id) - - return cors_response(request, JsonResponse(status=status.HTTP_202_ACCEPTED, data={"success": True})) diff --git a/posthog/api/test/__snapshots__/test_api_docs.ambr b/posthog/api/test/__snapshots__/test_api_docs.ambr index c59eda95b7757..5a47d45cd3567 100644 --- a/posthog/api/test/__snapshots__/test_api_docs.ambr +++ b/posthog/api/test/__snapshots__/test_api_docs.ambr @@ -176,8 +176,6 @@ '/home/runner/work/posthog/posthog/posthog/warehouse/api/table.py: Warning [TableViewSet > TableSerializer]: unable to resolve type hint for function "get_external_schema". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/warehouse/api/view_link.py: Warning [ViewLinkViewSet]: could not derive type of path parameter "project_id" because model "posthog.warehouse.models.view_link.DataWarehouseViewLink" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', "/home/runner/work/posthog/posthog/posthog/warehouse/api/view_link.py: Warning [ViewLinkViewSet]: could not resolve authenticator . There was no OpenApiAuthenticationExtension registered for that class. Try creating one by subclassing it. Ignoring for now.", - '/home/runner/work/posthog/posthog/posthog/api/prompt.py: Error [PromptSequenceViewSet]: unable to guess serializer. This is graceful fallback handling for APIViews. Consider using GenericAPIView as view base class, if view is under your control. Either way you may want to add a serializer_class (or method). Ignoring view for now.', - "/home/runner/work/posthog/posthog/posthog/api/prompt.py: Warning [PromptSequenceViewSet]: could not resolve authenticator . There was no OpenApiAuthenticationExtension registered for that class. Try creating one by subclassing it. Ignoring for now.", 'Warning: operationId "batch_exports_list" has collisions [(\'/api/organizations/{parent_lookup_organization_id}/batch_exports/\', \'get\'), (\'/api/projects/{project_id}/batch_exports/\', \'get\')]. resolving with numeral suffixes.', 'Warning: operationId "batch_exports_create" has collisions [(\'/api/organizations/{parent_lookup_organization_id}/batch_exports/\', \'post\'), (\'/api/projects/{project_id}/batch_exports/\', \'post\')]. resolving with numeral suffixes.', 'Warning: operationId "batch_exports_retrieve" has collisions [(\'/api/organizations/{parent_lookup_organization_id}/batch_exports/{id}/\', \'get\'), (\'/api/projects/{project_id}/batch_exports/{id}/\', \'get\')]. resolving with numeral suffixes.', diff --git a/posthog/api/test/test_prompt.py b/posthog/api/test/test_prompt.py deleted file mode 100644 index 7a56060eb712d..0000000000000 --- a/posthog/api/test/test_prompt.py +++ /dev/null @@ -1,226 +0,0 @@ -from freezegun.api import freeze_time -from rest_framework import status - -from posthog.models import User -from posthog.models.prompt import Prompt, PromptSequence, UserPromptState -from posthog.test.base import APIBaseTest - - -def _setup_prompts() -> None: - prompt1 = Prompt.objects.create( - step=0, - type="tooltip", - title="Welcome to PostHog!", - text="We have prepared a list of suggestions and resources to improve your experience with the tool. You can access it at any time by clicking on the question mark icon in the top right corner of the screen, and then selecting 'How to be successful with PostHog'.", - placement="bottom-start", - reference="help-button", - buttons=[{"action": "activation-checklist", "label": "Show me suggestions"}], - ) - sequence1 = PromptSequence.objects.create( - key="start-flow", - type="one-off", - path_match=["/*"], - path_exclude=["/ingestion", "/ingestion/*"], - status="active", - ) - sequence1.prompts.add(prompt1) - - prompt2 = Prompt.objects.create( - step=0, - type="tooltip", - title="Track your marketing websites", - text="PostHog may have been built for product analytics, but that doesn’t mean you can only deploy it on your core product — you can also use it to gather analytics from your marketing website too.", - placement="bottom-start", - reference="help-button", - buttons=[ - { - "url": "https://posthog.com/blog/how-and-why-track-your-website-with-posthog", - "label": "How (and why) to track your website with PostHog", - } - ], - ) - sequence2 = PromptSequence.objects.create( - key="activation-checklist", - type="one-off", - path_match=["/*"], - path_exclude=["/ingestion", "/ingestion/*"], - status="active", - ) - sequence2.prompts.add(prompt2) - sequence2.must_have_completed.add(sequence1) - - -_webhook_prompt = { - "key": "start-flow", - "prompts": [ - { - "step": 0, - "title": "Welcome to PostHog!", - "text": "We have prepared a list of suggestions and resources to improve your experience with the tool. You can access it at any time by clicking on the question mark icon in the top right corner of the screen, and then selecting 'How to be successful with PostHog'.", - } - ], -} - - -class TestPrompt(APIBaseTest): - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - def setUp(cls): - distinct_id_user = User.objects.create_and_join(cls.organization, "distinct_id_user@posthog.com", None) - distinct_id_user.distinct_id = "distinct_id" - distinct_id_user.save() - cls.user = distinct_id_user - - @freeze_time("2022-08-25T22:09:14.252Z") - def test_my_prompts(self): - self.client.force_login(self.user) - _setup_prompts() - # receive only the one sequence which doesn't have prerequisites - response = self.client.patch(f"/api/prompts/my_prompts", {}, format="json") - assert response.status_code == status.HTTP_200_OK - json_response = response.json() - assert len(json_response["sequences"]) == 1 - assert json_response["sequences"][0]["key"] == "start-flow" - - # updates the saved state using the more recent local state - local_state = { - "start-flow": { - "sequence": "start-flow", - "last_updated_at": "2022-08-25T22:09:14.252Z", - "step": 0, - "completed": True, - "dismissed": False, - } - } - response = self.client.patch(f"/api/prompts/my_prompts", local_state, format="json") - assert response.status_code == status.HTTP_200_OK - json_response = response.json() - # we now also receive the other sequences, as the first one has been marked as completed - assert len(json_response["sequences"]) == 2 - assert json_response["state"]["start-flow"]["step"] == 0 - assert json_response["state"]["start-flow"]["completed"] is True - - saved_states = list(UserPromptState.objects.filter(user=self.user)) - assert len(saved_states) == 1 - first_saved_state = list(saved_states)[0] - assert first_saved_state.step == 0 - assert first_saved_state.completed is True - - # ignores the local state as it is less recent - local_state = { - "start-flow": { - "sequence": "start-flow", - "last_updated_at": "2022-08-24T22:09:14.252Z", - "step": 1, - "completed": False, - "dismissed": False, - } - } - response = self.client.patch(f"/api/prompts/my_prompts", local_state, format="json") - assert response.status_code == status.HTTP_200_OK - json_response = response.json() - assert len(json_response["sequences"]) == 2 - assert json_response["state"]["start-flow"]["step"] == 0 - assert json_response["state"]["start-flow"]["completed"] is True - - def test_webhook_rejects_missing_token(self): - # we send a webhook with a new sequence, but it's missing an api_token so it should get rejected - webhook_data = { - "emails": [], - "sequence": _webhook_prompt, - } - response = self.client.post("/api/prompts/webhook", webhook_data, format="json") - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - def test_webhook_rejects_get_request(self): - # we send a webhook with a GET call so it should get rejected - response = self.client.get("/api/prompts/webhook", format="json") - assert response.json()["code"] == "no_data" - assert response.status_code == status.HTTP_400_BAD_REQUEST - - def test_webhook_invalid_data(self): - # we send a webhook with invalid data so it should get rejected - webhook_data = { - "api_key": self.team.api_token, - "emails": self.user.email, # this should be a list - "sequence": { # key is missing - "type": "one-off", - "path_match": ["/*"], - "status": "active", - "path_exclude": ["/ingestion", "/ingestion/*"], - "prompts": [ - { - "step": 0, - "type": "tooltip", - "title": "Welcome to PostHog!", - "text": "We have prepared a list of suggestions and resources to improve your experience with the tool. You can access it at any time by clicking on the question mark icon in the top right corner of the screen, and then selecting 'How to be successful with PostHog'.", - "placement": "bottom-start", - "reference": "help-button", - "buttons": [ - { - "action": "activation-checklist", - "label": "Show me suggestions", - } - ], - } - ], - }, - } - response = self.client.post("/api/prompts/webhook", webhook_data, format="json") - assert response.json()["code"] == "invalid" - assert response.status_code == status.HTTP_400_BAD_REQUEST - - def test_webhook_creates_sequence_and_state(self): - # we send a webhook with a new sequence, and we want to trigger it for a user - webhook_data = { - "api_key": self.team.api_token, - "emails": [self.user.email], - "sequence": _webhook_prompt, - } - - # there is no sequence or prompt saved yet - saved_sequences = list(PromptSequence.objects.all()) - assert len(saved_sequences) == 0 - saved_prompts = list(Prompt.objects.all()) - assert len(saved_prompts) == 0 - - response = self.client.post("/api/prompts/webhook", webhook_data, format="json") - assert response.status_code == status.HTTP_202_ACCEPTED - assert response.json() == {"success": True} - - # assert that the sequence and prompt have been saved correctly matching the webhook data - saved_prompts = list(Prompt.objects.all()) - assert len(saved_prompts) == 1 - first_saved_prompt = list(saved_prompts)[0] - assert first_saved_prompt.step == 0 - assert first_saved_prompt.type == "tooltip" - assert first_saved_prompt.title == "Welcome to PostHog!" - assert ( - first_saved_prompt.text - == "We have prepared a list of suggestions and resources to improve your experience with the tool. You can access it at any time by clicking on the question mark icon in the top right corner of the screen, and then selecting 'How to be successful with PostHog'." - ) - assert first_saved_prompt.placement == "top" - assert first_saved_prompt.reference is None - assert first_saved_prompt.buttons == [] - - saved_sequences = list(PromptSequence.objects.all()) - assert len(saved_sequences) == 1 - first_saved_sequence = list(saved_sequences)[0] - assert first_saved_sequence.key == "start-flow" - assert first_saved_sequence.type == "one-off" - assert first_saved_sequence.path_match == ["/*"] - assert first_saved_sequence.path_exclude == [] - assert first_saved_sequence.status == "active" - assert first_saved_sequence.autorun is False - assert first_saved_sequence.must_have_completed.count() == 0 - assert first_saved_prompt in first_saved_sequence.prompts.all() - - # assert that the user prompt state has been created correctly, with step = None so that it triggers the first prompt on first load - saved_states = list(UserPromptState.objects.filter(user=self.user)) - assert len(saved_states) == 1 - first_saved_state = list(saved_states)[0] - assert first_saved_state.sequence == first_saved_sequence - assert first_saved_state.step is None - assert first_saved_state.completed is False diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index 001e98d076e1e..d9634fbbdd4c1 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -56,7 +56,6 @@ PluginLogEntry, PluginSourceFile, ) -from .prompt.prompt import Prompt, PromptSequence, UserPromptState from .property import Property from .property_definition import PropertyDefinition from .sharing_configuration import SharingConfiguration @@ -68,6 +67,8 @@ from .user import User, UserManager from .user_scene_personalisation import UserScenePersonalisation +from ._deprecated_prompts import Prompt, PromptSequence, UserPromptState + __all__ = [ "Action", "ActionStep", @@ -121,8 +122,6 @@ "PluginConfig", "PluginLogEntry", "PluginSourceFile", - "Prompt", - "PromptSequence", "Property", "PropertyDefinition", "RetentionFilter", @@ -140,7 +139,10 @@ "User", "UserScenePersonalisation", "UserManager", - "UserPromptState", "DataWarehouseTable", "ScheduledChange", + # Deprecated models here for backwards compatibility + "Prompt", + "PromptSequence", + "UserPromptState", ] diff --git a/posthog/models/prompt/prompt.py b/posthog/models/_deprecated_prompts.py similarity index 97% rename from posthog/models/prompt/prompt.py rename to posthog/models/_deprecated_prompts.py index 2d975a54b3e1a..780703017519e 100644 --- a/posthog/models/prompt/prompt.py +++ b/posthog/models/_deprecated_prompts.py @@ -3,6 +3,7 @@ from django.utils import timezone +# DEPRECATED - DO NOT USE class Prompt(models.Model): step: models.IntegerField = models.IntegerField() type: models.CharField = models.CharField(max_length=200) # tooltip, modal, etc @@ -18,6 +19,7 @@ class Prompt(models.Model): icon: models.CharField = models.CharField(max_length=200) # sync with iconMap in frontend +# DEPRECATED - DO NOT USE class PromptSequence(models.Model): class Meta: constraints = [ @@ -39,6 +41,7 @@ class Meta: ) # whether to run this sequence automatically for all users +# DEPRECATED - DO NOT USE class UserPromptState(models.Model): class Meta: constraints = [models.UniqueConstraint(fields=["user", "sequence"], name="unique_user_prompt_state")] diff --git a/posthog/models/prompt/__init__.py b/posthog/models/prompt/__init__.py deleted file mode 100644 index e93a2091875d9..0000000000000 --- a/posthog/models/prompt/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .constants import * -from .prompt import * diff --git a/posthog/models/prompt/constants.py b/posthog/models/prompt/constants.py deleted file mode 100644 index 98d83bbb4fc28..0000000000000 --- a/posthog/models/prompt/constants.py +++ /dev/null @@ -1,17 +0,0 @@ -prompts_config = [ - { - "key": "session-recording-playlist-announcement", - "prompts": [ - { - "step": 0, - "type": "tooltip", - "title": "Save your filters as playlists!", - "text": "You can now save your search as a playlist which will keep up to date as new recordings come in matching the filters you set. Sharing with your team has never been easier!", - "placement": "bottom-start", - "reference": "save-recordings-playlist-button", - } - ], - "rule": {"path": {"must_match": ["/replay/recent"]}}, - "type": "one-off", - }, -] diff --git a/posthog/tasks/__init__.py b/posthog/tasks/__init__.py index 2e9fb05dbefa6..5539ffe9eac9d 100644 --- a/posthog/tasks/__init__.py +++ b/posthog/tasks/__init__.py @@ -9,7 +9,6 @@ email, exporter, process_scheduled_changes, - prompts, split_person, sync_all_organization_available_features, tasks, @@ -28,7 +27,6 @@ "email", "exporter", "process_scheduled_changes", - "prompts", "split_person", "sync_all_organization_available_features", "tasks", diff --git a/posthog/tasks/prompts.py b/posthog/tasks/prompts.py deleted file mode 100644 index 13828740f3e95..0000000000000 --- a/posthog/tasks/prompts.py +++ /dev/null @@ -1,15 +0,0 @@ -from celery import shared_task -from django.db import IntegrityError - -from posthog.models.prompt.prompt import PromptSequence, UserPromptState -from posthog.models.user import User - - -@shared_task() -def trigger_prompt_for_user(email: str, sequence_id: int) -> None: - try: - sequence = PromptSequence.objects.get(pk=sequence_id) - user = User.objects.get(email=email) - UserPromptState.objects.get_or_create(user=user, sequence=sequence, step=None) - except (User.DoesNotExist, IntegrityError): - pass diff --git a/posthog/urls.py b/posthog/urls.py index 17c2751a745c9..b047f897307e4 100644 --- a/posthog/urls.py +++ b/posthog/urls.py @@ -41,7 +41,6 @@ ) from posthog.api.decide import hostname_in_allowed_url_list from posthog.api.early_access_feature import early_access_features -from posthog.api.prompt import prompt_webhook from posthog.api.survey import surveys from posthog.demo.legacy import demo_route from posthog.models import User @@ -182,7 +181,6 @@ def opt_slash_path(route: str, view: Callable, name: Optional[str] = None) -> UR opt_slash_path("api/user/redirect_to_site", user.redirect_to_site), opt_slash_path("api/user/redirect_to_website", user.redirect_to_website), opt_slash_path("api/user/test_slack_webhook", user.test_slack_webhook), - opt_slash_path("api/prompts/webhook", prompt_webhook), opt_slash_path("api/early_access_features", early_access_features), opt_slash_path("api/surveys", surveys), opt_slash_path("api/signup", signup.SignupViewset.as_view()),