Repository: PostHog/posthog
Pull request: chore: add device type filtering on surveys
## Problem

PR for SDK logic: https://github.com/PostHog/posthog-js/pull/1698

Closes https://github.com/PostHog/posthog/issues/24495



## Changes

![CleanShot 2025-02-04 at 15 50 35@2x](https://github.com/user-attachments/assets/b710aecb-2a9d-4c78-acb4-87b5466d58af)

adds this a new display condition for Surveys: Device Types 

## Does this work well for both Cloud and self-hosted?

Yes

## How did you test this code?

<!-- Briefly describe the steps you took. -->
<!-- Include automated tests if possible, otherwise describe the manual testing routine. -->

Tested on local posthog instance if a Survey set to display on specific device types was only shown on those devices.


Problem statement:
Surveys: Allow targeting by device type
## Problem description

Customers would like to target their surveys by device type in the request. 
Currently, we have a `Latest Device Type` person property which is asynchronous and doesn't always work since it uses the _last_ URL visited by a person. 

We need to introduce something like the URL targeting properties that we have right now.

Hints from issue discussion:
+1 https://posthoghelp.zendesk.com/agent/tickets/21364
a workaround for now would be calling the surveys API manually when you are on the desired device type (renderSurvey)
https://github.com/PostHog/posthog-js/blob/50f08e979ff5daeed10c29cd22281c26a9e29e87/src/posthog-core.ts#L1262-L1265
https://posthoghelp.zendesk.com/agent/tickets/21678 wants targeting by device type for no-code web experiments
dupe here with more context https://github.com/PostHog/posthog/issues/27347

Commit message: chore: add device type filtering on surveys

Patch to review:
diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx
index 5344247507333..c0754b09981e2 100644
--- a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx
+++ b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx
@@ -27,6 +27,7 @@ export interface PropertyValueProps {
     eventNames?: string[]
     addRelativeDateTimeOptions?: boolean
     forceSingleSelect?: boolean
+    inputClassName?: string
 }
 
 export function PropertyValue({
@@ -41,6 +42,7 @@ export function PropertyValue({
     eventNames = [],
     addRelativeDateTimeOptions = false,
     forceSingleSelect = false,
+    inputClassName = undefined,
 }: PropertyValueProps): JSX.Element {
     const { formatPropertyValueForDisplay, describeProperty, options } = useValues(propertyDefinitionsModel)
     const { loadPropertyValues } = useActions(propertyDefinitionsModel)
@@ -130,6 +132,7 @@ export function PropertyValue({
 
     return (
         <LemonInputSelect
+            className={inputClassName}
             data-attr="prop-val"
             loading={options[propertyKey]?.status === 'loading'}
             value={formattedValues}
diff --git a/frontend/src/scenes/surveys/Survey.tsx b/frontend/src/scenes/surveys/Survey.tsx
index f9e5d0e65c887..d2e94fd2baee9 100644
--- a/frontend/src/scenes/surveys/Survey.tsx
+++ b/frontend/src/scenes/surveys/Survey.tsx
@@ -12,9 +12,9 @@ import { FeatureFlagReleaseConditions } from 'scenes/feature-flags/FeatureFlagRe
 import { SceneExport } from 'scenes/sceneTypes'
 import { urls } from 'scenes/urls'
 
-import { FeatureFlagFilters, Survey, SurveyUrlMatchType } from '~/types'
+import { FeatureFlagFilters, Survey, SurveyMatchType } from '~/types'
 
-import { NewSurvey, SurveyUrlMatchTypeLabels } from './constants'
+import { NewSurvey, SurveyMatchTypeLabels } from './constants'
 import SurveyEdit from './SurveyEdit'
 import { surveyLogic } from './surveyLogic'
 import { SurveyView } from './SurveyView'
@@ -127,15 +127,29 @@ export function SurveyDisplaySummary({
                     <div className="flex-row">
                         <span>
                             URL{' '}
-                            {SurveyUrlMatchTypeLabels[
-                                survey.conditions?.urlMatchType || SurveyUrlMatchType.Contains
-                            ].slice(2)}
+                            {SurveyMatchTypeLabels[survey.conditions?.urlMatchType || SurveyMatchType.Contains].slice(
+                                2
+                            )}
                             :
                         </span>{' '}
                         <LemonTag>{survey.conditions.url}</LemonTag>
                     </div>
                 </div>
             )}
+            {survey.conditions?.deviceTypes && (
+                <div className="flex flex-col font-medium gap-1">
+                    <div className="flex-row">
+                        <span>
+                            Device Types{' '}
+                            {SurveyMatchTypeLabels[
+                                survey.conditions?.deviceTypesMatchType || SurveyMatchType.Contains
+                            ].slice(2)}
+                            :
+                        </span>{' '}
+                        <LemonTag>{survey.conditions.deviceTypes.join(', ')}</LemonTag>
+                    </div>
+                </div>
+            )}
             {survey.conditions?.selector && (
                 <div className="flex flex-col font-medium gap-1">
                     <div className="flex-row">
diff --git a/frontend/src/scenes/surveys/SurveyEdit.tsx b/frontend/src/scenes/surveys/SurveyEdit.tsx
index 81a3355099a6c..7c994928a7160 100644
--- a/frontend/src/scenes/surveys/SurveyEdit.tsx
+++ b/frontend/src/scenes/surveys/SurveyEdit.tsx
@@ -20,6 +20,7 @@ import {
 import { BindLogic, useActions, useValues } from 'kea'
 import { EventSelect } from 'lib/components/EventSelect/EventSelect'
 import { FlagSelector } from 'lib/components/FlagSelector'
+import { PropertyValue } from 'lib/components/PropertyFilters/components/PropertyValue'
 import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
 import { FEATURE_FLAGS } from 'lib/constants'
 import { dayjs } from 'lib/dayjs'
@@ -36,14 +37,16 @@ import { FeatureFlagReleaseConditions } from 'scenes/feature-flags/FeatureFlagRe
 import {
     ActionType,
     LinkSurveyQuestion,
+    PropertyFilterType,
+    PropertyOperator,
     RatingSurveyQuestion,
+    SurveyMatchType,
     SurveyQuestion,
     SurveyQuestionType,
     SurveyType,
-    SurveyUrlMatchType,
 } from '~/types'
 
-import { defaultSurveyAppearance, defaultSurveyFieldValues, SurveyUrlMatchTypeLabels } from './constants'
+import { defaultSurveyAppearance, defaultSurveyFieldValues, SurveyMatchTypeLabels } from './constants'
 import { SurveyAPIEditor } from './SurveyAPIEditor'
 import { SurveyAppearancePreview } from './SurveyAppearancePreview'
 import { HTMLEditor, PresentationTypeCard } from './SurveyAppearanceUtils'
@@ -639,8 +642,7 @@ export default function SurveyEdit(): JSX.Element {
                                                                 URL
                                                                 <LemonSelect
                                                                     value={
-                                                                        value?.urlMatchType ||
-                                                                        SurveyUrlMatchType.Contains
+                                                                        value?.urlMatchType || SurveyMatchType.Contains
                                                                     }
                                                                     onChange={(matchTypeVal) => {
                                                                         onChange({
@@ -649,9 +651,9 @@ export default function SurveyEdit(): JSX.Element {
                                                                         })
                                                                     }}
                                                                     data-attr="survey-url-matching-type"
-                                                                    options={Object.keys(SurveyUrlMatchTypeLabels).map(
+                                                                    options={Object.keys(SurveyMatchTypeLabels).map(
                                                                         (key) => ({
-                                                                            label: SurveyUrlMatchTypeLabels[key],
+                                                                            label: SurveyMatchTypeLabels[key],
                                                                             value: key,
                                                                         })
                                                                     )}
@@ -666,6 +668,49 @@ export default function SurveyEdit(): JSX.Element {
                                                                 />
                                                             </div>
                                                         </LemonField.Pure>
+                                                        <LemonField.Pure
+                                                            label="Device Types"
+                                                            info="Select the device types to show the survey on"
+                                                        >
+                                                            <div className="flex flex-row gap-2 items-center">
+                                                                Device Types
+                                                                <LemonSelect
+                                                                    value={
+                                                                        value?.deviceTypesMatchType ||
+                                                                        SurveyMatchType.Contains
+                                                                    }
+                                                                    onChange={(matchTypeVal) => {
+                                                                        onChange({
+                                                                            ...value,
+                                                                            deviceTypesMatchType: matchTypeVal,
+                                                                        })
+                                                                    }}
+                                                                    data-attr="survey-device-types-matching-type"
+                                                                    options={Object.keys(SurveyMatchTypeLabels).map(
+                                                                        (key) => ({
+                                                                            label: SurveyMatchTypeLabels[key],
+                                                                            value: key,
+                                                                        })
+                                                                    )}
+                                                                />
+                                                                <PropertyValue
+                                                                    propertyKey="$device_type"
+                                                                    type={PropertyFilterType.Person}
+                                                                    onSet={(deviceTypes: string[]) =>
+                                                                        onChange({
+                                                                            ...value,
+                                                                            deviceTypes: deviceTypes,
+                                                                        })
+                                                                    }
+                                                                    operator={PropertyOperator.Exact}
+                                                                    placeholder="Enter value..."
+                                                                    autoFocus={true}
+                                                                    addRelativeDateTimeOptions={true}
+                                                                    value={value?.deviceTypes}
+                                                                    inputClassName="flex-1"
+                                                                />
+                                                            </div>
+                                                        </LemonField.Pure>
                                                         <LemonField.Pure label="CSS selector matches:">
                                                             <LemonInput
                                                                 value={value?.selector}
diff --git a/frontend/src/scenes/surveys/constants.tsx b/frontend/src/scenes/surveys/constants.tsx
index 1e884151e6b24..b902d89877d5a 100644
--- a/frontend/src/scenes/surveys/constants.tsx
+++ b/frontend/src/scenes/surveys/constants.tsx
@@ -3,10 +3,10 @@ import { allOperatorsMapping } from 'lib/utils'
 import {
     Survey,
     SurveyAppearance,
+    SurveyMatchType,
     SurveyQuestionDescriptionContentType,
     SurveyQuestionType,
     SurveyType,
-    SurveyUrlMatchType,
 } from '~/types'
 
 export const SURVEY_EVENT_NAME = 'survey sent'
@@ -21,13 +21,13 @@ export const SurveyQuestionLabel = {
 }
 
 // Create SurveyUrlMatchTypeLabels using allOperatorsMapping
-export const SurveyUrlMatchTypeLabels = {
-    [SurveyUrlMatchType.Exact]: allOperatorsMapping[SurveyUrlMatchType.Exact],
-    [SurveyUrlMatchType.IsNot]: allOperatorsMapping[SurveyUrlMatchType.IsNot],
-    [SurveyUrlMatchType.Contains]: allOperatorsMapping[SurveyUrlMatchType.Contains],
-    [SurveyUrlMatchType.NotIContains]: allOperatorsMapping[SurveyUrlMatchType.NotIContains],
-    [SurveyUrlMatchType.Regex]: allOperatorsMapping[SurveyUrlMatchType.Regex],
-    [SurveyUrlMatchType.NotRegex]: allOperatorsMapping[SurveyUrlMatchType.NotRegex],
+export const SurveyMatchTypeLabels = {
+    [SurveyMatchType.Exact]: allOperatorsMapping[SurveyMatchType.Exact],
+    [SurveyMatchType.IsNot]: allOperatorsMapping[SurveyMatchType.IsNot],
+    [SurveyMatchType.Contains]: allOperatorsMapping[SurveyMatchType.Contains],
+    [SurveyMatchType.NotIContains]: allOperatorsMapping[SurveyMatchType.NotIContains],
+    [SurveyMatchType.Regex]: allOperatorsMapping[SurveyMatchType.Regex],
+    [SurveyMatchType.NotRegex]: allOperatorsMapping[SurveyMatchType.NotRegex],
 }
 
 export const defaultSurveyAppearance = {
diff --git a/frontend/src/scenes/surveys/surveyLogic.tsx b/frontend/src/scenes/surveys/surveyLogic.tsx
index 4bd35b25fcece..69e7a70c591b7 100644
--- a/frontend/src/scenes/surveys/surveyLogic.tsx
+++ b/frontend/src/scenes/surveys/surveyLogic.tsx
@@ -23,10 +23,10 @@ import {
     PropertyOperator,
     RatingSurveyQuestion,
     Survey,
+    SurveyMatchType,
     SurveyQuestionBase,
     SurveyQuestionBranchingType,
     SurveyQuestionType,
-    SurveyUrlMatchType,
 } from '~/types'
 
 import { defaultSurveyAppearance, defaultSurveyFieldValues, NEW_SURVEY, NewSurvey } from './constants'
@@ -1033,7 +1033,7 @@ export const surveyLogic = kea<surveyLogicType>([
         urlMatchTypeValidationError: [
             (s) => [s.survey],
             (survey): string | null => {
-                if (survey.conditions?.urlMatchType === SurveyUrlMatchType.Regex && survey.conditions.url) {
+                if (survey.conditions?.urlMatchType === SurveyMatchType.Regex && survey.conditions.url) {
                     try {
                         new RegExp(survey.conditions.url)
                     } catch (e: any) {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 469796bbb91cf..d92c0f748fcf1 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2772,7 +2772,9 @@ export interface Survey {
         url: string
         selector: string
         seenSurveyWaitPeriodInDays?: number
-        urlMatchType?: SurveyUrlMatchType
+        urlMatchType?: SurveyMatchType
+        deviceTypes?: string[]
+        deviceTypesMatchType?: SurveyMatchType
         actions: {
             values: {
                 id: number
@@ -2807,7 +2809,7 @@ export interface Survey {
     response_sampling_daily_limits?: string[] | null
 }
 
-export enum SurveyUrlMatchType {
+export enum SurveyMatchType {
     Exact = PropertyOperator.Exact,
     IsNot = PropertyOperator.IsNot,
     Contains = PropertyOperator.IContains,


Code review comments:
diff_hunk: @@ -666,6 +668,49 @@ export default function SurveyEdit(): JSX.Element {
                                                                 />
                                                             </div>
                                                         </LemonField.Pure>
+                                                        <LemonField.Pure
+                                                            label="Device Types"
+                                                            info="Select the device types to show the survey on"
path: frontend/src/scenes/surveys/SurveyEdit.tsx
text: @user1:
https://github.com/PostHog/posthog.com/pull/10529/
I'd say a bit more than that, and I'd use 'add' instead of 'select'.
I'd also add the possible values as described in the docs, probably the min. version of the SDK required as well

@author:
just updated

diff_hunk: @@ -666,6 +668,49 @@ export default function SurveyEdit(): JSX.Element {
                                                                 />
                                                             </div>
                                                         </LemonField.Pure>
+                                                        <LemonField.Pure
+                                                            label="Device Types"
+                                                            info="Select the device types to show the survey on"
+                                                        >
+                                                            <div className="flex flex-row gap-2 items-center">
+                                                                Device Types
+                                                                <LemonSelect
+                                                                    value={
+                                                                        value?.deviceTypesMatchType ||
+                                                                        SurveyMatchType.Contains
+                                                                    }
+                                                                    onChange={(matchTypeVal) => {
+                                                                        onChange({
+                                                                            ...value,
+                                                                            deviceTypesMatchType: matchTypeVal,
+                                                                        })
+                                                                    }}
+                                                                    data-attr="survey-device-types-matching-type"
+                                                                    options={Object.keys(SurveyMatchTypeLabels).map(
+                                                                        (key) => ({
+                                                                            label: SurveyMatchTypeLabels[key],
+                                                                            value: key,
+                                                                        })
+                                                                    )}
+                                                                />
+                                                                <PropertyValue
+                                                                    propertyKey="$device_type"
path: frontend/src/scenes/surveys/SurveyEdit.tsx
text: @user1:
can we use the constant from the taxonomy here?

@author:
there's no easy constant for the `$device_type` key. I created a new function, `getPropertyKey` to return based on that now

diff_hunk: @@ -666,6 +668,49 @@ export default function SurveyEdit(): JSX.Element {
                                                                 />
                                                             </div>
                                                         </LemonField.Pure>
+                                                        <LemonField.Pure
+                                                            label="Device Types"
+                                                            info="Select the device types to show the survey on"
+                                                        >
+                                                            <div className="flex flex-row gap-2 items-center">
+                                                                Device Types
+                                                                <LemonSelect
+                                                                    value={
+                                                                        value?.deviceTypesMatchType ||
+                                                                        SurveyMatchType.Contains
+                                                                    }
+                                                                    onChange={(matchTypeVal) => {
+                                                                        onChange({
+                                                                            ...value,
+                                                                            deviceTypesMatchType: matchTypeVal,
+                                                                        })
+                                                                    }}
+                                                                    data-attr="survey-device-types-matching-type"
+                                                                    options={Object.keys(SurveyMatchTypeLabels).map(
+                                                                        (key) => ({
+                                                                            label: SurveyMatchTypeLabels[key],
+                                                                            value: key,
+                                                                        })
+                                                                    )}
+                                                                />
+                                                                <PropertyValue
+                                                                    propertyKey="$device_type"
+                                                                    type={PropertyFilterType.Person}
+                                                                    onSet={(deviceTypes: string[]) =>
+                                                                        onChange({
+                                                                            ...value,
+                                                                            deviceTypes: deviceTypes,
+                                                                        })
+                                                                    }
+                                                                    operator={PropertyOperator.Exact}
+                                                                    placeholder="Enter value..."
+                                                                    autoFocus={true}
+                                                                    addRelativeDateTimeOptions={true}
path: frontend/src/scenes/surveys/SurveyEdit.tsx
text: @user1:
can this be ignored? does not feel like we need this

@author:
operator is mandatory, the other three I've removed