From 0ab35785b57760443324a497523cda9deb583fd0 Mon Sep 17 00:00:00 2001 From: Ben White Date: Tue, 19 Mar 2024 11:23:30 +0100 Subject: [PATCH 1/5] fix: Refactor UpgradeModal logic to not be part of sceneLogic (#20970) --- frontend/src/layout/GlobalModals.tsx | 2 +- .../navigation/OrganizationSwitcher.tsx | 7 +- .../src/layout/navigation/ProjectSwitcher.tsx | 13 +-- .../lib/components/ObjectTags/ObjectTags.tsx | 4 +- .../components/PayGateMini/PayGateMini.tsx | 4 +- .../lib/components/Sharing/SharingModal.tsx | 4 +- .../components/UpgradeModal}/UpgradeModal.tsx | 7 +- .../UpgradeModal/upgradeModalLogic.ts | 96 +++++++++++++++++++ frontend/src/scenes/sceneLogic.ts | 66 +------------ .../session-recordings/SessionRecordings.tsx | 10 +- ...vedSessionRecordingPlaylistsEmptyState.tsx | 9 +- .../settings/project/AddMembersModal.tsx | 19 ++-- .../settings/project/ProjectAccessControl.tsx | 4 +- .../src/scenes/surveys/SurveyAppearance.tsx | 6 +- 14 files changed, 137 insertions(+), 114 deletions(-) rename frontend/src/{scenes => lib/components/UpgradeModal}/UpgradeModal.tsx (85%) create mode 100644 frontend/src/lib/components/UpgradeModal/upgradeModalLogic.ts diff --git a/frontend/src/layout/GlobalModals.tsx b/frontend/src/layout/GlobalModals.tsx index 6340f68af9823..7ef5f0d546afb 100644 --- a/frontend/src/layout/GlobalModals.tsx +++ b/frontend/src/layout/GlobalModals.tsx @@ -2,6 +2,7 @@ import { LemonModal } from '@posthog/lemon-ui' import { actions, kea, path, reducers, useActions, useValues } from 'kea' import { FlaggedFeature } from 'lib/components/FlaggedFeature' import { HedgehogBuddyWithLogic } from 'lib/components/HedgehogBuddy/HedgehogBuddyWithLogic' +import { UpgradeModal } from 'lib/components/UpgradeModal/UpgradeModal' import { Prompt } from 'lib/logic/newPrompt/Prompt' import { Setup2FA } from 'scenes/authentication/Setup2FA' import { CreateOrganizationModal } from 'scenes/organization/CreateOrganizationModal' @@ -9,7 +10,6 @@ import { membersLogic } from 'scenes/organization/membersLogic' import { CreateProjectModal } from 'scenes/project/CreateProjectModal' import { inviteLogic } from 'scenes/settings/organization/inviteLogic' import { InviteModal } from 'scenes/settings/organization/InviteModal' -import { UpgradeModal } from 'scenes/UpgradeModal' import { userLogic } from 'scenes/userLogic' import type { globalModalsLogicType } from './GlobalModalsType' diff --git a/frontend/src/layout/navigation/OrganizationSwitcher.tsx b/frontend/src/layout/navigation/OrganizationSwitcher.tsx index 850f8642669c2..692d0c8846ad1 100644 --- a/frontend/src/layout/navigation/OrganizationSwitcher.tsx +++ b/frontend/src/layout/navigation/OrganizationSwitcher.tsx @@ -1,5 +1,6 @@ import { IconPlus } from '@posthog/icons' import { useActions, useValues } from 'kea' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonDivider } from 'lib/lemon-ui/LemonDivider' import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' @@ -7,7 +8,6 @@ import { Lettermark } from 'lib/lemon-ui/Lettermark' import { membershipLevelToName } from 'lib/utils/permissioning' import { organizationLogic } from 'scenes/organizationLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' -import { sceneLogic } from 'scenes/sceneLogic' import { userLogic } from 'scenes/userLogic' import { AvailableFeature, OrganizationBasicType } from '~/types' @@ -48,7 +48,7 @@ export function OtherOrganizationButton({ export function NewOrganizationButton(): JSX.Element { const { closeAccountPopover } = useActions(navigationLogic) const { showCreateOrganizationModal } = useActions(globalModalsLogic) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) return ( void }): JSX.Element { const { currentOrganization, projectCreationForbiddenReason } = useValues(organizationLogic) const { currentTeam } = useValues(teamLogic) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) const { showCreateProjectModal } = useActions(globalModalsLogic) return ( @@ -49,12 +49,9 @@ export function ProjectSwitcherOverlay({ onClickInside }: { onClickInside?: () = data-attr="new-project-button" onClick={() => { onClickInside?.() - guardAvailableFeature( - AvailableFeature.ORGANIZATIONS_PROJECTS, - showCreateProjectModal, - undefined, - currentOrganization?.teams?.length - ) + guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateProjectModal, { + currentUsage: currentOrganization?.teams?.length, + }) }} > New project diff --git a/frontend/src/lib/components/ObjectTags/ObjectTags.tsx b/frontend/src/lib/components/ObjectTags/ObjectTags.tsx index aa4ca58253c96..b58b4a2caa33e 100644 --- a/frontend/src/lib/components/ObjectTags/ObjectTags.tsx +++ b/frontend/src/lib/components/ObjectTags/ObjectTags.tsx @@ -9,11 +9,11 @@ import { objectTagsLogic } from 'lib/components/ObjectTags/objectTagsLogic' import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' import { colorForString } from 'lib/utils' import { CSSProperties, useMemo } from 'react' -import { sceneLogic } from 'scenes/sceneLogic' import { AvailableFeature } from '~/types' import { SelectGradientOverflow } from '../SelectGradientOverflow' +import { upgradeModalLogic } from '../UpgradeModal/upgradeModalLogic' interface ObjectTagsPropsBase { tags: string[] @@ -61,7 +61,7 @@ export function ObjectTags({ }: ObjectTagsProps): JSX.Element { const objectTagId = useMemo(() => uniqueMemoizedIndex++, []) const logic = objectTagsLogic({ id: objectTagId, onChange, tags }) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) const { addingNewTag, cleanedNewTag, deletedTags } = useValues(logic) const { setAddingNewTag, setNewTag, handleDelete, handleAdd } = useActions(logic) diff --git a/frontend/src/lib/components/PayGateMini/PayGateMini.tsx b/frontend/src/lib/components/PayGateMini/PayGateMini.tsx index 38acd75ace822..5ea5ec55e8d0a 100644 --- a/frontend/src/lib/components/PayGateMini/PayGateMini.tsx +++ b/frontend/src/lib/components/PayGateMini/PayGateMini.tsx @@ -10,10 +10,10 @@ import { useEffect } from 'react' import { billingLogic } from 'scenes/billing/billingLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { getProductIcon } from 'scenes/products/Products' -import { sceneLogic } from 'scenes/sceneLogic' import { AvailableFeature } from '~/types' +import { upgradeModalLogic } from '../UpgradeModal/upgradeModalLogic' import { PayGateMiniButton } from './PayGateMiniButton' import { payGateMiniLogic } from './payGateMiniLogic' @@ -47,7 +47,7 @@ export function PayGateMini({ const { preflight } = useValues(preflightLogic) const { billing, billingLoading } = useValues(billingLogic) const { featureFlags } = useValues(featureFlagLogic) - const { hideUpgradeModal } = useActions(sceneLogic) + const { hideUpgradeModal } = useActions(upgradeModalLogic) useEffect(() => { if (gateVariant) { diff --git a/frontend/src/lib/components/Sharing/SharingModal.tsx b/frontend/src/lib/components/Sharing/SharingModal.tsx index 410af5493ef16..8db90e2bca2f2 100644 --- a/frontend/src/lib/components/Sharing/SharingModal.tsx +++ b/frontend/src/lib/components/Sharing/SharingModal.tsx @@ -15,10 +15,10 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip' import { copyToClipboard } from 'lib/utils/copyToClipboard' import { useEffect, useState } from 'react' import { DashboardCollaboration } from 'scenes/dashboard/DashboardCollaborators' -import { sceneLogic } from 'scenes/sceneLogic' import { AvailableFeature, InsightModel, InsightShortId, InsightType } from '~/types' +import { upgradeModalLogic } from '../UpgradeModal/upgradeModalLogic' import { sharingLogic } from './sharingLogic' export const SHARING_MODAL_WIDTH = 600 @@ -64,7 +64,7 @@ export function SharingModalContent({ shareLink, } = useValues(sharingLogic(logicProps)) const { setIsEnabled, togglePreview } = useActions(sharingLogic(logicProps)) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) const [iframeLoaded, setIframeLoaded] = useState(false) diff --git a/frontend/src/scenes/UpgradeModal.tsx b/frontend/src/lib/components/UpgradeModal/UpgradeModal.tsx similarity index 85% rename from frontend/src/scenes/UpgradeModal.tsx rename to frontend/src/lib/components/UpgradeModal/UpgradeModal.tsx index bea905c356b1d..d731a6774772b 100644 --- a/frontend/src/scenes/UpgradeModal.tsx +++ b/frontend/src/lib/components/UpgradeModal/UpgradeModal.tsx @@ -2,11 +2,12 @@ import { LemonModal } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' -import { sceneLogic } from './sceneLogic' +import { upgradeModalLogic } from './upgradeModalLogic' export function UpgradeModal(): JSX.Element { - const { upgradeModalFeatureKey, upgradeModalFeatureUsage, upgradeModalIsGrandfathered } = useValues(sceneLogic) - const { hideUpgradeModal } = useActions(sceneLogic) + const { upgradeModalFeatureKey, upgradeModalFeatureUsage, upgradeModalIsGrandfathered } = + useValues(upgradeModalLogic) + const { hideUpgradeModal } = useActions(upgradeModalLogic) return upgradeModalFeatureKey ? ( diff --git a/frontend/src/lib/components/UpgradeModal/upgradeModalLogic.ts b/frontend/src/lib/components/UpgradeModal/upgradeModalLogic.ts new file mode 100644 index 0000000000000..9542ac6a208dc --- /dev/null +++ b/frontend/src/lib/components/UpgradeModal/upgradeModalLogic.ts @@ -0,0 +1,96 @@ +import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' +import { userLogic } from 'scenes/userLogic' + +import { AvailableFeature } from '~/types' + +import type { upgradeModalLogicType } from './upgradeModalLogicType' + +export type GuardAvailableFeatureFn = ( + featureKey: AvailableFeature, + featureAvailableCallback?: () => void, + options?: { + guardOnCloud?: boolean + guardOnSelfHosted?: boolean + currentUsage?: number + isGrandfathered?: boolean + } +) => boolean + +export const upgradeModalLogic = kea([ + path(['lib', 'components', 'UpgradeModal', 'upgradeModalLogic']), + connect(() => ({ + values: [preflightLogic, ['preflight'], featureFlagLogic, ['featureFlags'], userLogic, ['hasAvailableFeature']], + })), + actions({ + showUpgradeModal: (featureKey: AvailableFeature, currentUsage?: number, isGrandfathered?: boolean) => ({ + featureKey, + currentUsage, + isGrandfathered, + }), + hideUpgradeModal: true, + }), + reducers({ + upgradeModalFeatureKey: [ + null as AvailableFeature | null, + { + showUpgradeModal: (_, { featureKey }) => featureKey, + hideUpgradeModal: () => null, + }, + ], + upgradeModalFeatureUsage: [ + null as number | null, + { + showUpgradeModal: (_, { currentUsage }) => currentUsage ?? null, + hideUpgradeModal: () => null, + }, + ], + upgradeModalIsGrandfathered: [ + null as boolean | null, + { + showUpgradeModal: (_, { isGrandfathered }) => isGrandfathered ?? null, + hideUpgradeModal: () => null, + }, + ], + }), + selectors(({ actions }) => ({ + guardAvailableFeature: [ + (s) => [s.preflight, s.hasAvailableFeature], + (preflight, hasAvailableFeature): GuardAvailableFeatureFn => { + return (featureKey, featureAvailableCallback, options): boolean => { + const { + guardOnCloud = true, + guardOnSelfHosted = true, + currentUsage, + isGrandfathered, + } = options || {} + let featureAvailable: boolean + if (!preflight) { + featureAvailable = false + } else if (!guardOnCloud && preflight.cloud) { + featureAvailable = true + } else if (!guardOnSelfHosted && !preflight.cloud) { + featureAvailable = true + } else { + featureAvailable = hasAvailableFeature(featureKey, currentUsage) + } + + if (!featureAvailable) { + actions.showUpgradeModal(featureKey, currentUsage, isGrandfathered) + } else { + featureAvailableCallback?.() + } + + return featureAvailable + } + }, + ], + })), + listeners(() => ({ + showUpgradeModal: ({ featureKey }) => { + eventUsageLogic.actions.reportUpgradeModalShown(featureKey) + }, + })), +]) diff --git a/frontend/src/scenes/sceneLogic.ts b/frontend/src/scenes/sceneLogic.ts index bfb5fe46210da..998726e131ec5 100644 --- a/frontend/src/scenes/sceneLogic.ts +++ b/frontend/src/scenes/sceneLogic.ts @@ -4,14 +4,13 @@ import { commandBarLogic } from 'lib/components/CommandBar/commandBarLogic' import { BarStatus } from 'lib/components/CommandBar/types' import { FEATURE_FLAGS, TeamMembershipLevel } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { addProjectIdIfMissing, removeProjectIdIfPresent } from 'lib/utils/router-utils' import posthog from 'posthog-js' import { emptySceneParams, preloadedScenes, redirects, routes, sceneConfigurations } from 'scenes/scenes' import { LoadedScene, Params, Scene, SceneConfig, SceneExport, SceneParams } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' -import { AvailableFeature, ProductKey } from '~/types' +import { ProductKey } from '~/types' import { handleLoginRedirect } from './authentication/loginLogic' import { onboardingLogic, OnboardingStepKey } from './onboarding/onboardingLogic' @@ -52,27 +51,6 @@ export const sceneLogic = kea([ setLoadedScene: (loadedScene: LoadedScene) => ({ loadedScene, }), - showUpgradeModal: (featureKey: AvailableFeature, currentUsage?: number, isGrandfathered?: boolean) => ({ - featureKey, - currentUsage, - isGrandfathered, - }), - guardAvailableFeature: ( - featureKey: AvailableFeature, - featureAvailableCallback?: () => void, - guardOn: { - cloud: boolean - selfHosted: boolean - } = { - cloud: true, - selfHosted: true, - }, - // how much of the feature has been used (eg. number of recording playlists created), - // which will be compared to the limit for their subscriptions - currentUsage?: number, - isGrandfathered?: boolean - ) => ({ featureKey, featureAvailableCallback, guardOn, currentUsage, isGrandfathered }), - hideUpgradeModal: true, reloadBrowserDueToImportError: true, }), reducers({ @@ -105,27 +83,6 @@ export const sceneLogic = kea([ setScene: () => null, }, ], - upgradeModalFeatureKey: [ - null as AvailableFeature | null, - { - showUpgradeModal: (_, { featureKey }) => featureKey, - hideUpgradeModal: () => null, - }, - ], - upgradeModalFeatureUsage: [ - null as number | null, - { - showUpgradeModal: (_, { currentUsage }) => currentUsage ?? null, - hideUpgradeModal: () => null, - }, - ], - upgradeModalIsGrandfathered: [ - null as boolean | null, - { - showUpgradeModal: (_, { isGrandfathered }) => isGrandfathered ?? null, - hideUpgradeModal: () => null, - }, - ], lastReloadAt: [ null as number | null, { persist: true }, @@ -170,27 +127,6 @@ export const sceneLogic = kea([ hashParams: [(s) => [s.sceneParams], (sceneParams): Record => sceneParams.hashParams || {}], }), listeners(({ values, actions, props, selectors }) => ({ - showUpgradeModal: ({ featureKey }) => { - eventUsageLogic.actions.reportUpgradeModalShown(featureKey) - }, - guardAvailableFeature: ({ featureKey, featureAvailableCallback, guardOn, currentUsage, isGrandfathered }) => { - const { preflight } = preflightLogic.values - let featureAvailable: boolean - if (!preflight) { - featureAvailable = false - } else if (!guardOn.cloud && preflight.cloud) { - featureAvailable = true - } else if (!guardOn.selfHosted && !preflight.cloud) { - featureAvailable = true - } else { - featureAvailable = userLogic.values.hasAvailableFeature(featureKey, currentUsage) - } - if (featureAvailable) { - featureAvailableCallback?.() - } else { - actions.showUpgradeModal(featureKey, currentUsage, isGrandfathered) - } - }, setScene: ({ scene, scrollToTop }, _, __, previousState) => { posthog.capture('$pageview') diff --git a/frontend/src/scenes/session-recordings/SessionRecordings.tsx b/frontend/src/scenes/session-recordings/SessionRecordings.tsx index ecf99b63e88b2..383a0f27a5073 100644 --- a/frontend/src/scenes/session-recordings/SessionRecordings.tsx +++ b/frontend/src/scenes/session-recordings/SessionRecordings.tsx @@ -4,6 +4,7 @@ import { useActions, useValues } from 'kea' import { router } from 'kea-router' import { authorizedUrlListLogic, AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic' import { PageHeader } from 'lib/components/PageHeader' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { VersionCheckerBanner } from 'lib/components/VersionChecker/VersionCheckerBanner' import { useAsyncHandler } from 'lib/hooks/useAsyncHandler' import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' @@ -12,7 +13,6 @@ import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' -import { sceneLogic } from 'scenes/sceneLogic' import { SceneExport } from 'scenes/sceneTypes' import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { teamLogic } from 'scenes/teamLogic' @@ -34,7 +34,7 @@ export function SessionsRecordings(): JSX.Element { const { tab } = useValues(sessionRecordingsLogic) const recordingsDisabled = currentTeam && !currentTeam?.session_recording_opt_in const { reportRecordingPlaylistCreated } = useActions(eventUsageLogic) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) const playlistsLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Recent }) const { playlists } = useValues(playlistsLogic) const { openSettingsPanel } = useActions(sidePanelSettingsLogic) @@ -87,8 +87,7 @@ export function SessionsRecordings(): JSX.Element { ? newPlaylistHandler.onEvent?.(e) : saveFiltersPlaylistHandler.onEvent?.(e) }, - undefined, - playlists.count + { currentUsage: playlists.count } ) } > @@ -111,8 +110,7 @@ export function SessionsRecordings(): JSX.Element { guardAvailableFeature( AvailableFeature.RECORDINGS_PLAYLISTS, () => newPlaylistHandler.onEvent?.(e), - undefined, - playlists.count + { currentUsage: playlists.count } ) } data-attr="save-recordings-playlist-button" diff --git a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylistsEmptyState.tsx b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylistsEmptyState.tsx index 7ef7ede4a778e..062b6c5ecf214 100644 --- a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylistsEmptyState.tsx +++ b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylistsEmptyState.tsx @@ -1,8 +1,8 @@ import { IconPlus } from '@posthog/icons' -import { useActions, useValues } from 'kea' +import { useValues } from 'kea' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from 'lib/lemon-ui/LemonButton' -import { sceneLogic } from 'scenes/sceneLogic' import { AvailableFeature, ReplayTabs } from '~/types' @@ -10,7 +10,7 @@ import { createPlaylist } from '../playlist/playlistUtils' import { savedSessionRecordingPlaylistsLogic } from './savedSessionRecordingPlaylistsLogic' export function SavedSessionRecordingPlaylistsEmptyState(): JSX.Element { - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) const playlistsLogic = savedSessionRecordingPlaylistsLogic({ tab: ReplayTabs.Recent }) const { playlists, loadPlaylistsFailed } = useValues(playlistsLogic) return loadPlaylistsFailed ? ( @@ -28,8 +28,7 @@ export function SavedSessionRecordingPlaylistsEmptyState(): JSX.Element { guardAvailableFeature( AvailableFeature.RECORDINGS_PLAYLISTS, () => void createPlaylist({}, true), - undefined, - playlists.count + { currentUsage: playlists.count } ) } > diff --git a/frontend/src/scenes/settings/project/AddMembersModal.tsx b/frontend/src/scenes/settings/project/AddMembersModal.tsx index 1bd544eb04fdd..ea355fa6429e0 100644 --- a/frontend/src/scenes/settings/project/AddMembersModal.tsx +++ b/frontend/src/scenes/settings/project/AddMembersModal.tsx @@ -1,15 +1,15 @@ import { IconPlus } from '@posthog/icons' import { LemonButton, LemonModal, LemonSelect, LemonSelectOption } from '@posthog/lemon-ui' -import { useActions, useValues } from 'kea' +import { useValues } from 'kea' import { Form } from 'kea-forms' import { RestrictedComponentProps } from 'lib/components/RestrictedArea' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { usersLemonSelectOptions } from 'lib/components/UserSelectItem' import { TeamMembershipLevel } from 'lib/constants' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' import { membershipLevelToName, teamMembershipLevelIntegers } from 'lib/utils/permissioning' import { useState } from 'react' -import { sceneLogic } from 'scenes/sceneLogic' import { teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' @@ -20,7 +20,7 @@ import { teamMembersLogic } from './teamMembersLogic' export function AddMembersModalWithButton({ isRestricted }: RestrictedComponentProps): JSX.Element { const { addableMembers, allMembersLoading } = useValues(teamMembersLogic) const { currentTeam } = useValues(teamLogic) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) const { hasAvailableFeature } = useValues(userLogic) const [isVisible, setIsVisible] = useState(false) @@ -35,14 +35,11 @@ export function AddMembersModalWithButton({ isRestricted }: RestrictedComponentP type="primary" data-attr="add-project-members-button" onClick={() => - guardAvailableFeature( - AvailableFeature.PROJECT_BASED_PERMISSIONING, - () => setIsVisible(true), - undefined, - undefined, - !hasAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING) && - currentTeam?.access_control - ) + guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING, () => setIsVisible(true), { + isGrandfathered: + !hasAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING) && + currentTeam?.access_control, + }) } icon={} disabled={isRestricted} diff --git a/frontend/src/scenes/settings/project/ProjectAccessControl.tsx b/frontend/src/scenes/settings/project/ProjectAccessControl.tsx index b250d8386ecc3..fbe81e1942624 100644 --- a/frontend/src/scenes/settings/project/ProjectAccessControl.tsx +++ b/frontend/src/scenes/settings/project/ProjectAccessControl.tsx @@ -2,6 +2,7 @@ import { IconCrown, IconLeave, IconLock, IconUnlock } from '@posthog/icons' import { LemonButton, LemonSelect, LemonSelectOption, LemonSnack, LemonSwitch, LemonTable } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { RestrictedArea, RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { OrganizationMembershipLevel, TeamMembershipLevel } from 'lib/constants' import { IconCancel } from 'lib/lemon-ui/icons' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' @@ -15,7 +16,6 @@ import { teamMembershipLevelIntegers, } from 'lib/utils/permissioning' import { organizationLogic } from 'scenes/organizationLogic' -import { sceneLogic } from 'scenes/sceneLogic' import { isAuthenticatedTeam, teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' @@ -208,7 +208,7 @@ export function ProjectAccessControl(): JSX.Element { const { currentOrganization, currentOrganizationLoading } = useValues(organizationLogic) const { currentTeam, currentTeamLoading } = useValues(teamLogic) const { updateCurrentTeam } = useActions(teamLogic) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) const isRestricted = !!useRestrictedArea({ minimumAccessLevel: OrganizationMembershipLevel.Admin, diff --git a/frontend/src/scenes/surveys/SurveyAppearance.tsx b/frontend/src/scenes/surveys/SurveyAppearance.tsx index bc55c6c4b797f..750b880f7d28c 100644 --- a/frontend/src/scenes/surveys/SurveyAppearance.tsx +++ b/frontend/src/scenes/surveys/SurveyAppearance.tsx @@ -1,10 +1,10 @@ import './SurveyAppearance.scss' import { LemonButton, LemonCheckbox, LemonInput, LemonSelect, Link } from '@posthog/lemon-ui' -import { useActions, useValues } from 'kea' +import { useValues } from 'kea' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' +import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import React, { useEffect, useRef, useState } from 'react' -import { sceneLogic } from 'scenes/sceneLogic' import { AvailableFeature, @@ -138,7 +138,7 @@ export function SurveyAppearance({ export function Customization({ appearance, surveyQuestionItem, onAppearanceChange }: CustomizationProps): JSX.Element { const { surveysStylingAvailable } = useValues(surveysLogic) - const { guardAvailableFeature } = useActions(sceneLogic) + const { guardAvailableFeature } = useValues(upgradeModalLogic) return ( <>
From b986d9ca41360006ee99d2ca14207089187e44a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Far=C3=ADas=20Santana?= Date: Tue, 19 Mar 2024 11:34:47 +0100 Subject: [PATCH 2/5] feat: Add minmax index for inserted_at (#20765) * feat: Add minmax index for inserted_at * fix: We need to coalesce with _timestamp * fix: Import path --- .../0055_add_minmax_index_on_inserted_at.py | 7 +++++++ posthog/models/event/sql.py | 12 ++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 posthog/clickhouse/migrations/0055_add_minmax_index_on_inserted_at.py diff --git a/posthog/clickhouse/migrations/0055_add_minmax_index_on_inserted_at.py b/posthog/clickhouse/migrations/0055_add_minmax_index_on_inserted_at.py new file mode 100644 index 0000000000000..a1458a2b4a391 --- /dev/null +++ b/posthog/clickhouse/migrations/0055_add_minmax_index_on_inserted_at.py @@ -0,0 +1,7 @@ +from posthog.clickhouse.client.migration_tools import run_sql_with_exceptions +from posthog.models.event.sql import EVENTS_TABLE_INSERTED_AT_INDEX_SQL, EVENTS_TABLE_MATERIALIZE_INSERTED_AT_INDEX_SQL + +operations = [ + run_sql_with_exceptions(EVENTS_TABLE_INSERTED_AT_INDEX_SQL), + run_sql_with_exceptions(EVENTS_TABLE_MATERIALIZE_INSERTED_AT_INDEX_SQL), +] diff --git a/posthog/models/event/sql.py b/posthog/models/event/sql.py index 410904ba006d4..8214ac90fdce0 100644 --- a/posthog/models/event/sql.py +++ b/posthog/models/event/sql.py @@ -106,6 +106,18 @@ storage_policy=STORAGE_POLICY(), ) +EVENTS_TABLE_INSERTED_AT_INDEX_SQL = """ +ALTER TABLE {table_name} ON CLUSTER {cluster} +ADD INDEX `minmax_inserted_at` COALESCE(`inserted_at`, `_timestamp`) +TYPE minmax +GRANULARITY 1 +""".format(table_name=EVENTS_DATA_TABLE(), cluster=settings.CLICKHOUSE_CLUSTER) + +EVENTS_TABLE_MATERIALIZE_INSERTED_AT_INDEX_SQL = """ +ALTER TABLE {table_name} ON CLUSTER {cluster} +MATERIALIZE INDEX `minmax_inserted_at` +""".format(table_name=EVENTS_DATA_TABLE(), cluster=settings.CLICKHOUSE_CLUSTER) + # we add the settings to prevent poison pills from stopping ingestion # kafka_skip_broken_messages is an int, not a boolean, so we explicitly set # the max block size to consume from kafka such that we skip _all_ broken messages From bc1c95135472e0813497bfcbd5804b9ef2141cc7 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Tue, 19 Mar 2024 10:38:38 +0000 Subject: [PATCH 3/5] fix(insights): Remove connect logics for rename series (#20972) --- .../filters/ActionFilter/entityFilterLogic.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/frontend/src/scenes/insights/filters/ActionFilter/entityFilterLogic.ts b/frontend/src/scenes/insights/filters/ActionFilter/entityFilterLogic.ts index c743a8bf4848e..9614c49c7542a 100644 --- a/frontend/src/scenes/insights/filters/ActionFilter/entityFilterLogic.ts +++ b/frontend/src/scenes/insights/filters/ActionFilter/entityFilterLogic.ts @@ -13,7 +13,6 @@ import { EntityType, EntityTypes, FilterType, - InsightShortId, } from '~/types' import type { entityFilterLogicType } from './entityFilterLogicType' @@ -73,19 +72,9 @@ export const entityFilterLogic = kea([ props({} as EntityFilterProps), key((props) => props.typeKey), path((key) => ['scenes', 'insights', 'ActionFilter', 'entityFilterLogic', key]), - connect((props: EntityFilterProps) => ({ + connect({ logic: [eventUsageLogic], - actions: [ - insightDataLogic({ - dashboardItemId: props.typeKey as InsightShortId, - // this can be mounted in replay filters - // in which case there's not really an insightDataLogic to mount - // disable attempts to load data that will never work - doNotLoad: props.typeKey === 'session-recordings', - }), - ['loadData'], - ], - })), + }), actions({ selectFilter: (filter: EntityFilter | ActionFilter | null) => ({ filter }), updateFilterMath: ( @@ -193,7 +182,10 @@ export const entityFilterLogic = kea([ await breakpoint(100) - actions.loadData(true) + const dataLogic = insightDataLogic.findMounted({ + dashboardItemId: props.typeKey, + }) + dataLogic?.actions?.loadData(true) }, hideModal: () => { actions.selectFilter(null) From a6eb82c8b19f632ad725b086fd65902a410a61ca Mon Sep 17 00:00:00 2001 From: Ben White Date: Tue, 19 Mar 2024 11:57:45 +0100 Subject: [PATCH 4/5] fix: Unauthenticated api throttling (#20993) --- posthog/api/authentication.py | 7 ++--- posthog/api/test/test_authentication.py | 17 ++++++++++ posthog/api/user.py | 17 ++-------- posthog/rate_limit.py | 42 +++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/posthog/api/authentication.py b/posthog/api/authentication.py index d06e7168d0df2..9b7dc954a97fa 100644 --- a/posthog/api/authentication.py +++ b/posthog/api/authentication.py @@ -22,7 +22,6 @@ from rest_framework.exceptions import APIException from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.throttling import UserRateThrottle from sentry_sdk import capture_exception from social_django.views import auth from two_factor.utils import default_device @@ -36,14 +35,11 @@ from posthog.email import is_email_available from posthog.event_usage import report_user_logged_in, report_user_password_reset from posthog.models import OrganizationDomain, User +from posthog.rate_limit import UserPasswordResetThrottle from posthog.tasks.email import send_password_reset from posthog.utils import get_instance_available_sso_providers -class UserPasswordResetThrottle(UserRateThrottle): - rate = "6/day" - - @csrf_protect def logout(request): if request.user.is_authenticated: @@ -190,6 +186,7 @@ class LoginViewSet(NonCreatingViewSetMixin, viewsets.GenericViewSet): queryset = User.objects.none() serializer_class = LoginSerializer permission_classes = (permissions.AllowAny,) + # NOTE: Throttling is handled by the `axes` package class TwoFactorSerializer(serializers.Serializer): diff --git a/posthog/api/test/test_authentication.py b/posthog/api/test/test_authentication.py index 3d054e4cb1ac9..a33a59dd0549b 100644 --- a/posthog/api/test/test_authentication.py +++ b/posthog/api/test/test_authentication.py @@ -434,6 +434,23 @@ def test_cant_reset_more_than_six_times(self): # Three emails should be sent, fourth should not self.assertEqual(len(mail.outbox), 6) + def test_is_rate_limited_on_email_not_ip(self): + set_instance_setting("EMAIL_HOST", "localhost") + + for email in ["email@posthog.com", "other-email@posthog.com"]: + for i in range(7): + with self.settings(CELERY_TASK_ALWAYS_EAGER=True, SITE_URL="https://my.posthog.net"): + response = self.client.post("/api/reset/", {"email": email}) + if i < 6: + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + else: + # Fourth request should fail + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + self.assertDictContainsSubset( + {"attr": None, "code": "throttled", "type": "throttled_error"}, + response.json(), + ) + # Token validation def test_can_validate_token(self): diff --git a/posthog/api/user.py b/posthog/api/user.py index 7e72d46b88cb8..28b4a42b8620a 100644 --- a/posthog/api/user.py +++ b/posthog/api/user.py @@ -23,7 +23,7 @@ from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response -from rest_framework.throttling import UserRateThrottle + from two_factor.forms import TOTPDeviceForm from two_factor.utils import default_device @@ -46,6 +46,7 @@ from posthog.models.organization import Organization from posthog.models.user import NOTIFICATION_DEFAULTS, Notifications from posthog.permissions import APIScopePermission +from posthog.rate_limit import UserAuthenticationThrottle, UserEmailVerificationThrottle from posthog.tasks import user_identify from posthog.tasks.email import send_email_change_emails from posthog.user_permissions import UserPermissions @@ -53,20 +54,6 @@ from posthog.constants import PERMITTED_FORUM_DOMAINS -class UserAuthenticationThrottle(UserRateThrottle): - rate = "5/minute" - - def allow_request(self, request, view): - # only throttle non-GET requests - if request.method == "GET": - return True - return super().allow_request(request, view) - - -class UserEmailVerificationThrottle(UserRateThrottle): - rate = "6/day" - - class ScenePersonalisationBasicSerializer(serializers.ModelSerializer): class Meta: model = UserScenePersonalisation diff --git a/posthog/rate_limit.py b/posthog/rate_limit.py index dbaa478d9f462..856d1b6cceb32 100644 --- a/posthog/rate_limit.py +++ b/posthog/rate_limit.py @@ -1,3 +1,4 @@ +import hashlib import re import time from functools import lru_cache @@ -222,6 +223,26 @@ def get_bucket_key(self, request): return ident +class UserOrEmailRateThrottle(SimpleRateThrottle): + """ + Typically throttling is on the user or the IP address. + For unauthenticated signup/login requests we want to throttle on the email address. + """ + + scope = "user" + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + # For unauthenticated requests, we want to throttle on something unique to the user they are trying to work with + # This could be email for example when logging in or uuid when verifying email + ident = request.data.get("email") or request.data.get("uuid") or self.get_ident(request) + ident = hashlib.sha256(ident.encode()).hexdigest() + + return self.cache_format % {"scope": self.scope, "ident": ident} + + class BurstRateThrottle(TeamRateThrottle): # Throttle class that's applied on all endpoints (except for capture + decide) # Intended to block quick bursts of requests, per project @@ -262,3 +283,24 @@ class AISustainedRateThrottle(UserRateThrottle): # Intended to block slower but sustained bursts of requests, per user scope = "ai_sustained" rate = "40/day" + + +class UserPasswordResetThrottle(UserOrEmailRateThrottle): + scope = "user_password_reset" + rate = "6/day" + + +class UserAuthenticationThrottle(UserOrEmailRateThrottle): + scope = "user_authentication" + rate = "5/minute" + + def allow_request(self, request, view): + # only throttle non-GET requests + if request.method == "GET": + return True + return super().allow_request(request, view) + + +class UserEmailVerificationThrottle(UserOrEmailRateThrottle): + scope = "user_email_verification" + rate = "6/day" From b6162a686fe33fd8e651e004493dc770b3956695 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 Mar 2024 13:59:01 +0100 Subject: [PATCH 5/5] fix(elements): Avoid matching element tag on classes (#20533) * fix(elements): Avoid matching element tag on classes * Update test_property.py * Make test a bit more robust * Update query snapshots * Update query snapshots --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- ...ickhouse_experiment_secondary_results.ambr | 2 +- posthog/hogql/test/test_property.py | 18 ++++---- posthog/models/property/util.py | 23 +++++----- posthog/models/test/test_event_model.py | 42 ++++++++++++++++++- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr index 3c6b8ef78c385..54b67fa7d359b 100644 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr +++ b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results ''' - /* user_id:107 celery:posthog.tasks.tasks.sync_insight_caching_state */ + /* user_id:108 celery:posthog.tasks.tasks.sync_insight_caching_state */ SELECT team_id, date_diff('second', max(timestamp), now()) AS age FROM events diff --git a/posthog/hogql/test/test_property.py b/posthog/hogql/test/test_property.py index f271ee5e2f4ff..c50615eb6730a 100644 --- a/posthog/hogql/test/test_property.py +++ b/posthog/hogql/test/test_property.py @@ -431,37 +431,39 @@ def test_tag_name_to_expr(self): def test_selector_to_expr(self): self.assertEqual( self._selector_to_expr("div"), - clear_locations(elements_chain_match('div([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))')), + clear_locations(elements_chain_match('(^|;)div([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))')), ) self.assertEqual( self._selector_to_expr("div > div"), clear_locations( elements_chain_match( - 'div([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))div([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s))).*' + '(^|;)div([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))div([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s))).*' ) ), ) self.assertEqual( self._selector_to_expr("a[href='boo']"), clear_locations( - elements_chain_match('a.*?href="boo".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))') + elements_chain_match('(^|;)a.*?href="boo".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))') ), ) self.assertEqual( self._selector_to_expr(".class"), - clear_locations(elements_chain_match('.*?\\.class([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))')), + clear_locations( + elements_chain_match('(^|;).*?\\.class([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))') + ), ) self.assertEqual( self._selector_to_expr("#withid"), clear_locations( - elements_chain_match('.*?attr_id="withid".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))') + elements_chain_match('(^|;).*?attr_id="withid".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))') ), ) self.assertEqual( self._selector_to_expr("#with-dashed-id"), clear_locations( elements_chain_match( - '.*?attr_id="with\\-dashed\\-id".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))' + '(^|;).*?attr_id="with\\-dashed\\-id".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))' ) ), ) @@ -473,7 +475,7 @@ def test_selector_to_expr(self): self._selector_to_expr("#with\\slashed\\id"), clear_locations( elements_chain_match( - '.*?attr_id="with\\\\slashed\\\\id".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))' + '(^|;).*?attr_id="with\\\\slashed\\\\id".*?([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))' ) ), ) @@ -526,7 +528,7 @@ def test_action_to_expr(self): "event = '$autocapture' and elements_chain =~ {regex1} and elements_chain =~ {regex2}", { "regex1": ast.Constant( - value='a.*?\\.active\\..*?nav\\-link([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))' + value='(^|;)a.*?\\.active\\..*?nav\\-link([-_a-zA-Z0-9\\.:"= ]*?)?($|;|:([^;^\\s]*(;|$|\\s)))' ), "regex2": ast.Constant(value="(^|;)a(\\.|$|;|:)"), }, diff --git a/posthog/models/property/util.py b/posthog/models/property/util.py index bd684283049c7..cae1be3340eac 100644 --- a/posthog/models/property/util.py +++ b/posthog/models/property/util.py @@ -847,21 +847,24 @@ def process_ok_values(ok_values: Any, operator: OperatorType) -> List[str]: def build_selector_regex(selector: Selector) -> str: regex = r"" for tag in selector.parts: - if tag.data.get("tag_name") and isinstance(tag.data["tag_name"], str): - if tag.data["tag_name"] == "*": - regex += ".+" - else: - regex += re.escape(tag.data["tag_name"]) + if tag.data.get("tag_name") and isinstance(tag.data["tag_name"], str) and tag.data["tag_name"] != "*": + # The elements in the elements_chain are separated by the semicolon + regex += re.escape(tag.data["tag_name"]) if tag.data.get("attr_class__contains"): - regex += r".*?\.{}".format(r"\..*?".join([re.escape(s) for s in sorted(tag.data["attr_class__contains"])])) + regex += r".*?\." + r"\..*?".join([re.escape(s) for s in sorted(tag.data["attr_class__contains"])]) if tag.ch_attributes: - regex += ".*?" + regex += r".*?" for key, value in sorted(tag.ch_attributes.items()): - regex += '{}="{}".*?'.format(re.escape(key), re.escape(str(value))) + regex += rf'{re.escape(key)}="{re.escape(str(value))}".*?' regex += r'([-_a-zA-Z0-9\.:"= ]*?)?($|;|:([^;^\s]*(;|$|\s)))' if tag.direct_descendant: - regex += ".*" - return regex + regex += r".*" + if regex: + # Always start matching at the beginning of an element in the chain string + # This is to avoid issues like matching elements with class "foo" when looking for elements with tag name "foo" + return r"(^|;)" + regex + else: + return r"" class HogQLPropertyChecker(TraversingVisitor): diff --git a/posthog/models/test/test_event_model.py b/posthog/models/test/test_event_model.py index e918291ca15e4..cbe6a2bcad70c 100644 --- a/posthog/models/test/test_event_model.py +++ b/posthog/models/test/test_event_model.py @@ -72,8 +72,15 @@ def _setup_action_selector_events(self): attr_class=["one-class"], ), Element(tag_name="button", nth_child=0, nth_of_type=0), - Element(tag_name="div", nth_child=0, nth_of_type=0), - Element(tag_name="div", nth_child=0, nth_of_type=0, attr_id="nested"), + Element( + # Important that in this hierarchy the div is sandwiched between button and section. + # This way makes sure that any conditions which should match this element also work + # if the element is neither first nor last in the hierarchy. + tag_name="div", + nth_child=0, + nth_of_type=0, + ), + Element(tag_name="section", nth_child=0, nth_of_type=0, attr_id="nested"), ], ) @@ -417,6 +424,37 @@ def test_with_class_with_escaped_slashes(self): self.assertEqual(events[0].uuid, event1_uuid) self.assertEqual(len(events), 1) + def test_with_tag_matching_class_selector(self): + _create_person(distinct_ids=["whatever"], team=self.team) + action1 = Action.objects.create(team=self.team) + ActionStep.objects.create( + event="$autocapture", + action=action1, + selector="input", # This should ONLY match the tag, but not a class named `input` + ) + event_matching_tag_uuid = _create_event( + event="$autocapture", + team=self.team, + distinct_id="whatever", + elements=[ + Element(tag_name="span", attr_class=None), + Element(tag_name="input", attr_class=["button"]), # Should match + ], + ) + _create_event( + event="$autocapture", + team=self.team, + distinct_id="whatever", + elements=[ + Element(tag_name="span", attr_class=None), + Element(tag_name="button", attr_class=["input"]), # Cannot match + ], + ) + + events = _get_events_for_action(action1) + self.assertEqual(len(events), 1) + self.assertEqual(events[0].uuid, event_matching_tag_uuid) + def test_attributes(self): _create_person(distinct_ids=["whatever"], team=self.team) event1_uuid = _create_event(