Skip to content

Commit 3558784

Browse files
authored
feat: add support for "simple" activation url rules (#731)
1 parent aef0e5a commit 3558784

File tree

7 files changed

+713
-56
lines changed

7 files changed

+713
-56
lines changed

.changeset/lemon-monkeys-hunt.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@knocklabs/client": patch
3+
"@knocklabs/react": patch
4+
---
5+
6+
add support for activation url rules in guide client

packages/client/src/clients/guide/client.ts

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
findDefaultGroup,
1414
formatFilters,
1515
mockDefaultGroup,
16+
newUrl,
17+
predicateUrlPatterns,
18+
predicateUrlRules,
1619
} from "./helpers";
1720
import {
1821
Any,
@@ -144,36 +147,17 @@ const predicate = (
144147
return false;
145148
}
146149

147-
const locationRules = guide.activation_location_rules || [];
150+
const url = location ? newUrl(location) : undefined;
148151

149-
if (locationRules.length > 0 && location) {
150-
const allowed = locationRules.reduce<boolean | undefined>((acc, rule) => {
151-
// Any matched block rule prevails so no need to evaluate further
152-
// as soon as there is one.
153-
if (acc === false) return false;
154-
155-
// At this point we either have a matched allow rule (acc is true),
156-
// or no matched rule found yet (acc is undefined).
157-
158-
switch (rule.directive) {
159-
case "allow": {
160-
// No need to evaluate more allow rules once we matched one
161-
// since any matched allowed rule means allow.
162-
if (acc === true) return true;
163-
164-
const matched = rule.pattern.test(location);
165-
return matched ? true : undefined;
166-
}
167-
168-
case "block": {
169-
// Always test block rules (unless already matched to block)
170-
// because they'd prevail over matched allow rules.
171-
const matched = rule.pattern.test(location);
172-
return matched ? false : acc;
173-
}
174-
}
175-
}, undefined);
152+
const urlRules = guide.activation_url_rules || [];
153+
const urlPatterns = guide.activation_url_patterns || [];
176154

155+
// A guide can have either activation url rules XOR url patterns, but not both.
156+
if (url && urlRules.length > 0) {
157+
const allowed = predicateUrlRules(url, urlRules);
158+
if (!allowed) return false;
159+
} else if (url && urlPatterns.length > 0) {
160+
const allowed = predicateUrlPatterns(url, urlPatterns);
177161
if (!allowed) return false;
178162
}
179163

@@ -814,8 +798,8 @@ export class KnockGuideClient {
814798
return localStep;
815799
});
816800

817-
localGuide.activation_location_rules =
818-
remoteGuide.activation_location_rules.map((rule) => {
801+
localGuide.activation_url_patterns =
802+
remoteGuide.activation_url_patterns.map((rule) => {
819803
return {
820804
...rule,
821805
pattern: new URLPattern({ pathname: rule.pathname }),

packages/client/src/clients/guide/helpers.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
2+
GuideActivationUrlRuleData,
23
GuideData,
34
GuideGroupData,
45
KnockGuide,
6+
KnockGuideActivationUrlPattern,
57
SelectFilterParams,
68
} from "./types";
79

@@ -96,3 +98,100 @@ export const checkIfThrottled = (
9698
// accurate regardless of local timezones.
9799
return currentTimeInMilliseconds <= throttleWindowEndInMilliseconds;
98100
};
101+
102+
// Safely parse and build a new URL object.
103+
export const newUrl = (location: string) => {
104+
try {
105+
return new URL(location);
106+
} catch {
107+
return undefined;
108+
}
109+
};
110+
111+
// Evaluates whether the given location url satisfies the url rule.
112+
export const evaluateUrlRule = (
113+
url: URL,
114+
urlRule: GuideActivationUrlRuleData,
115+
) => {
116+
if (urlRule.variable === "pathname") {
117+
if (urlRule.operator === "equal_to") {
118+
const argument = urlRule.argument.startsWith("/")
119+
? urlRule.argument
120+
: `/${urlRule.argument}`;
121+
122+
return argument === url.pathname;
123+
}
124+
125+
if (urlRule.operator === "contains") {
126+
return url.pathname.includes(urlRule.argument);
127+
}
128+
129+
return false;
130+
}
131+
132+
return false;
133+
};
134+
135+
export const predicateUrlRules = (
136+
url: URL,
137+
urlRules: GuideActivationUrlRuleData[],
138+
) => {
139+
return urlRules.reduce<boolean | undefined>((acc, urlRule) => {
140+
// Any matched block rule prevails so no need to evaluate further
141+
// as soon as there is one.
142+
if (acc === false) return false;
143+
144+
// At this point we either have a matched allow rule (acc is true),
145+
// or no matched rule found yet (acc is undefined).
146+
147+
switch (urlRule.directive) {
148+
case "allow": {
149+
// No need to evaluate more allow rules once we matched one
150+
// since any matched allowed rule means allow.
151+
if (acc === true) return true;
152+
153+
const matched = evaluateUrlRule(url, urlRule);
154+
return matched ? true : undefined;
155+
}
156+
157+
case "block": {
158+
// Always test block rules (unless already matched to block)
159+
// because they'd prevail over matched allow rules.
160+
const matched = evaluateUrlRule(url, urlRule);
161+
return matched ? false : acc;
162+
}
163+
}
164+
}, undefined);
165+
};
166+
167+
export const predicateUrlPatterns = (
168+
url: URL,
169+
urlPatterns: KnockGuideActivationUrlPattern[],
170+
) => {
171+
return urlPatterns.reduce<boolean | undefined>((acc, urlPattern) => {
172+
// Any matched block rule prevails so no need to evaluate further
173+
// as soon as there is one.
174+
if (acc === false) return false;
175+
176+
// At this point we either have a matched allow rule (acc is true),
177+
// or no matched rule found yet (acc is undefined).
178+
179+
switch (urlPattern.directive) {
180+
case "allow": {
181+
// No need to evaluate more allow rules once we matched one
182+
// since any matched allowed rule means allow.
183+
if (acc === true) return true;
184+
185+
const matched = urlPattern.pattern.test(url);
186+
return matched ? true : undefined;
187+
}
188+
189+
case "block": {
190+
// Always test block rules (unless already matched to block)
191+
// because they'd prevail over matched allow rules.
192+
const matched = urlPattern.pattern.test(url);
193+
return matched ? false : acc;
194+
}
195+
}
196+
}, undefined);
197+
};

packages/client/src/clients/guide/types.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ export interface GuideStepData<TContent = Any> {
2525
content: TContent;
2626
}
2727

28-
interface GuideActivationLocationRuleData {
28+
export interface GuideActivationUrlRuleData {
29+
directive: "allow" | "block";
30+
variable: "pathname";
31+
operator: "equal_to" | "contains";
32+
argument: string;
33+
}
34+
35+
interface GuideActivationUrlPatternData {
2936
directive: "allow" | "block";
3037
pathname: string;
3138
}
@@ -38,7 +45,8 @@ export interface GuideData<TContent = Any> {
3845
type: string;
3946
semver: string;
4047
steps: GuideStepData<TContent>[];
41-
activation_location_rules: GuideActivationLocationRuleData[];
48+
activation_url_rules: GuideActivationUrlRuleData[];
49+
activation_url_patterns: GuideActivationUrlPatternData[];
4250
bypass_global_group_limit: boolean;
4351
inserted_at: string;
4452
updated_at: string;
@@ -157,14 +165,14 @@ export interface KnockGuideStep<TContent = Any>
157165
markAsArchived: () => void;
158166
}
159167

160-
interface KnockGuideActivationLocationRule
161-
extends GuideActivationLocationRuleData {
168+
export interface KnockGuideActivationUrlPattern
169+
extends GuideActivationUrlPatternData {
162170
pattern: URLPattern;
163171
}
164172

165173
export interface KnockGuide<TContent = Any> extends GuideData<TContent> {
166174
steps: KnockGuideStep<TContent>[];
167-
activation_location_rules: KnockGuideActivationLocationRule[];
175+
activation_url_patterns: KnockGuideActivationUrlPattern[];
168176
getStep: () => KnockGuideStep<TContent> | undefined;
169177
}
170178

0 commit comments

Comments
 (0)