diff --git a/README.md b/README.md index 6f498757a..66a7a4b31 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/__tests__/surveys.js b/src/__tests__/surveys.js index d0b9924f6..7fee65890 100644 --- a/src/__tests__/surveys.js +++ b/src/__tests__/surveys.js @@ -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', @@ -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 @@ -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]) @@ -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, @@ -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) => { diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js index b60b98b70..007d8deac 100644 --- a/src/__tests__/utils.js +++ b/src/__tests__/utils.js @@ -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) @@ -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) + }) + }) }) diff --git a/src/posthog-surveys-types.ts b/src/posthog-surveys-types.ts index f8af82ff1..310e240b9 100644 --- a/src/posthog-surveys-types.ts +++ b/src/posthog-surveys-types.ts @@ -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 @@ -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 } diff --git a/src/posthog-surveys.ts b/src/posthog-surveys.ts index d33fefae3..5011ced91 100644 --- a/src/posthog-surveys.ts +++ b/src/posthog-surveys.ts @@ -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 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 @@ -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) diff --git a/src/utils.ts b/src/utils.ts index 012107fb6..88b2b78b8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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)) {