Skip to content

Commit

Permalink
feat: allow regex patterns and wildcards in survey url (#821)
Browse files Browse the repository at this point in the history
* feat: allow regex patterns and wildcards in survey url

* update survey validation to include urlMatchType

* adjust name

---------

Co-authored-by: Sean Rogers <[email protected]>
  • Loading branch information
neilkakkar and Bonitis authored Oct 4, 2023
1 parent 2e3e1d2 commit ed6de6b
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 6 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Install Yalc to link a local version of `posthog-js` in another JS project: `npm
## Releasing a new version

Just put a `bump patch/minor/major` label on your PR! Once the PR is merged, a new version with the appropriate version bump will be released, and the dependency will be updated in [posthog/PostHog](https://github.com/posthog/PostHog) – automatically.

If you want to release a new version without a PR (e.g. because you forgot to use the label), check out the `master` branch and run `npm version [major | minor | patch] && git push --tags` - this will trigger the automated release process just like the label.

### Prereleases
Expand Down
100 changes: 99 additions & 1 deletion src/__tests__/surveys.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,51 @@ describe('surveys', () => {
start_date: new Date().toISOString(),
end_date: null,
}
const surveyWithRegexUrl = {
name: 'survey with regex url',
description: 'survey with regex url description',
type: SurveyType.Popover,
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with regex url?' }],
conditions: { url: 'regex-url', urlMatchType: 'regex' },
start_date: new Date().toISOString(),
end_date: null,
}
const surveyWithParamRegexUrl = {
name: 'survey with param regex url',
description: 'survey with param regex url description',
type: SurveyType.Popover,
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with param regex url?' }],
conditions: { url: '(\\?|\\&)(name.*)\\=([^&]+)', urlMatchType: 'regex' },
start_date: new Date().toISOString(),
end_date: null,
}
const surveyWithWildcardSubdomainUrl = {
name: 'survey with wildcard subdomain url',
description: 'survey with wildcard subdomain url description',
type: SurveyType.Popover,
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with wildcard subdomain url?' }],
conditions: { url: '(.*.)?subdomain.com', urlMatchType: 'regex' },
start_date: new Date().toISOString(),
end_date: null,
}
const surveyWithWildcardRouteUrl = {
name: 'survey with wildcard route url',
description: 'survey with wildcard route url description',
type: SurveyType.Popover,
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with wildcard route url?' }],
conditions: { url: 'wildcard.com/(.*.)', urlMatchType: 'regex' },
start_date: new Date().toISOString(),
end_date: null,
}
const surveyWithExactUrlMatch = {
name: 'survey with wildcard route url',
description: 'survey with wildcard route url description',
type: SurveyType.Popover,
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with wildcard route url?' }],
conditions: { url: 'https://example.com/exact', urlMatchType: 'exact' },
start_date: new Date().toISOString(),
end_date: null,
}
const surveyWithSelector = {
name: 'survey with selector',
description: 'survey with selector description',
Expand Down Expand Up @@ -200,7 +245,9 @@ describe('surveys', () => {
})

it('returns surveys based on url and selector matching', () => {
given('surveysResponse', () => ({ surveys: [surveyWithUrl, surveyWithSelector, surveyWithUrlAndSelector] }))
given('surveysResponse', () => ({
surveys: [surveyWithUrl, surveyWithSelector, surveyWithUrlAndSelector],
}))
const originalWindowLocation = window.location
delete window.location
// eslint-disable-next-line compat/compat
Expand All @@ -209,6 +256,7 @@ describe('surveys', () => {
expect(data).toEqual([surveyWithUrl])
})
window.location = originalWindowLocation

document.body.appendChild(document.createElement('div')).className = 'test-selector'
given.surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([surveyWithSelector])
Expand All @@ -226,6 +274,55 @@ describe('surveys', () => {
document.body.removeChild(document.querySelector('#foo'))
})

it('returns surveys based on url with urlMatchType settings', () => {
given('surveysResponse', () => ({
surveys: [
surveyWithRegexUrl,
surveyWithParamRegexUrl,
surveyWithWildcardRouteUrl,
surveyWithWildcardSubdomainUrl,
surveyWithExactUrlMatch,
],
}))

const originalWindowLocation = window.location
delete window.location
// eslint-disable-next-line compat/compat
window.location = new URL('https://regex-url.com/test')
given.surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([surveyWithRegexUrl])
})
window.location = originalWindowLocation

// eslint-disable-next-line compat/compat
window.location = new URL('https://example.com?name=something')
given.surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([surveyWithParamRegexUrl])
})
window.location = originalWindowLocation

// eslint-disable-next-line compat/compat
window.location = new URL('https://app.subdomain.com')
given.surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([surveyWithWildcardSubdomainUrl])
})
window.location = originalWindowLocation

// eslint-disable-next-line compat/compat
window.location = new URL('https://wildcard.com/something/other')
given.surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([surveyWithWildcardRouteUrl])
})
window.location = originalWindowLocation

// eslint-disable-next-line compat/compat
window.location = new URL('https://example.com/exact')
given.surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([surveyWithExactUrlMatch])
})
window.location = originalWindowLocation
})

given('decideResponse', () => ({
featureFlags: {
'linked-flag-key': true,
Expand All @@ -234,6 +331,7 @@ describe('surveys', () => {
'survey-targeting-flag-key2': false,
},
}))

it('returns surveys that match linked and targeting feature flags', () => {
given('surveysResponse', () => ({ surveys: [activeSurvey, surveyWithFlags, surveyWithEverything] }))
given.surveys.getActiveMatchingSurveys((data) => {
Expand Down
31 changes: 30 additions & 1 deletion src/__tests__/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
* currently not supported in the browser lib).
*/

import { _copyAndTruncateStrings, _info, _isBlockedUA, DEFAULT_BLOCKED_UA_STRS, loadScript } from '../utils'
import {
_copyAndTruncateStrings,
_info,
_isBlockedUA,
DEFAULT_BLOCKED_UA_STRS,
loadScript,
_isUrlMatchingRegex,
} from '../utils'

function userAgentFor(botString) {
const randOne = (Math.random() + 1).toString(36).substring(7)
Expand Down Expand Up @@ -225,4 +232,26 @@ describe('loadScript', () => {
}
)
})

describe('_isUrlMatchingRegex', () => {
it('returns false when url does not match regex pattern', () => {
// test query params
expect(_isUrlMatchingRegex('https://example.com', '(\\?|\\&)(name.*)\\=([^&]+)')).toEqual(false)
// incorrect route
expect(_isUrlMatchingRegex('https://example.com/something/test', 'example.com/test')).toEqual(false)
// incorrect domain
expect(_isUrlMatchingRegex('https://example.com', 'anotherone.com')).toEqual(false)
})

it('returns true when url matches regex pattern', () => {
// match query params
expect(_isUrlMatchingRegex('https://example.com?name=something', '(\\?|\\&)(name.*)\\=([^&]+)')).toEqual(
true
)
// match subdomain wildcard
expect(_isUrlMatchingRegex('https://app.example.com', '(.*.)?example.com')).toEqual(true)
// match route wildcard
expect(_isUrlMatchingRegex('https://example.com/something/test', 'example.com/(.*.)/test')).toEqual(true)
})
})
})
9 changes: 8 additions & 1 deletion src/posthog-surveys-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export interface SurveyResponse {

export type SurveyCallback = (surveys: Survey[]) => void

export type SurveyUrlMatchType = 'regex' | 'exact' | 'icontains'

export interface Survey {
// Sync this with the backend's SurveyAPISerializer!
id: string
Expand All @@ -88,7 +90,12 @@ export interface Survey {
targeting_flag_key: string | null
questions: SurveyQuestion[]
appearance: SurveyAppearance | null
conditions: { url?: string; selector?: string; seenSurveyWaitPeriodInDays?: number } | null
conditions: {
url?: string
selector?: string
seenSurveyWaitPeriodInDays?: number
urlMatchType?: SurveyUrlMatchType
} | null
start_date: string | null
end_date: string | null
}
13 changes: 11 additions & 2 deletions src/posthog-surveys.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { PostHog } from './posthog-core'
import { SURVEYS } from './constants'
import { SurveyCallback } from './posthog-surveys-types'
import { _isUrlMatchingRegex } from './utils'
import { SurveyCallback, SurveyUrlMatchType } from 'posthog-surveys-types'

export const surveyUrlValidationMap: Record<SurveyUrlMatchType, (conditionsUrl: string) => boolean> = {
icontains: (conditionsUrl) => window.location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1,
regex: (conditionsUrl) => _isUrlMatchingRegex(window.location.href, conditionsUrl),
exact: (conditionsUrl) => window.location.href === conditionsUrl,
}

export class PostHogSurveys {
instance: PostHog
Expand Down Expand Up @@ -36,8 +43,10 @@ export class PostHogSurveys {
if (!survey.conditions) {
return true
}

// use urlMatchType to validate url condition, fallback to contains for backwards compatibility
const urlCheck = survey.conditions?.url
? window.location.href.indexOf(survey.conditions.url) > -1
? surveyUrlValidationMap[survey.conditions?.urlMatchType ?? 'icontains'](survey.conditions.url)
: true
const selectorCheck = survey.conditions?.selector
? document.querySelector(survey.conditions.selector)
Expand Down
14 changes: 14 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,20 @@ export const _isNumber = function (obj: any): obj is number {
return toString.call(obj) == '[object Number]'
}

export const _isValidRegex = function (str: string): boolean {
try {
new RegExp(str)
} catch (error) {
return false
}
return true
}

export const _isUrlMatchingRegex = function (url: string, pattern: string): boolean {
if (!_isValidRegex(pattern)) return false
return new RegExp(pattern).test(url)
}

export const _encodeDates = function (obj: Properties): Properties {
_each(obj, function (v, k) {
if (_isDate(v)) {
Expand Down

0 comments on commit ed6de6b

Please sign in to comment.