Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow regex patterns and wildcards in survey url #821

Merged
merged 6 commits into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -134,6 +134,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 @@ -193,7 +238,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 @@ -202,6 +249,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 @@ -219,6 +267,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 @@ -227,6 +324,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
Loading