Skip to content

Commit 1c486d3

Browse files
authored
feat: support search param in activation url pattern (#837)
1 parent 0ceb165 commit 1c486d3

4 files changed

Lines changed: 150 additions & 2 deletions

File tree

.changeset/every-colts-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@knocklabs/client": patch
3+
---
4+
5+
[guides] support search param in guide activation url patterns

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,7 +975,10 @@ export class KnockGuideClient {
975975
remoteGuide.activation_url_patterns.map((rule) => {
976976
return {
977977
...rule,
978-
pattern: new URLPattern({ pathname: rule.pathname }),
978+
pattern: new URLPattern({
979+
pathname: rule.pathname ?? undefined,
980+
search: rule.search ?? undefined,
981+
}),
979982
};
980983
});
981984

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export interface GuideActivationUrlRuleData {
3333

3434
interface GuideActivationUrlPatternData {
3535
directive: "allow" | "block";
36-
pathname: string;
36+
// At least one part should be present.
37+
pathname?: string;
38+
search?: string;
3739
}
3840

3941
export interface GuideData<TContent = Any> {

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

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,4 +540,142 @@ describe("predicateUrlPatterns", () => {
540540
expect(predicateUrlPatterns(differentUrl, patterns)).toBe(true);
541541
});
542542
});
543+
544+
describe("with search patterns", () => {
545+
test("returns true when URL matches an allow pattern with search params", () => {
546+
const patterns: KnockGuideActivationUrlPattern[] = [
547+
{
548+
directive: "allow",
549+
pattern: new URLPattern({ pathname: "/dashboard", search: "tab=settings" }),
550+
},
551+
];
552+
553+
const matchingUrl = new URL("https://example.com/dashboard?tab=settings");
554+
const nonMatchingUrl = new URL("https://example.com/dashboard?tab=overview");
555+
556+
expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(true);
557+
expect(predicateUrlPatterns(nonMatchingUrl, patterns)).toBe(undefined);
558+
});
559+
560+
test("returns false when URL matches a block pattern with search params", () => {
561+
const patterns: KnockGuideActivationUrlPattern[] = [
562+
{
563+
directive: "block",
564+
pattern: new URLPattern({ pathname: "/admin", search: "mode=debug" }),
565+
},
566+
];
567+
568+
const matchingUrl = new URL("https://example.com/admin?mode=debug");
569+
const nonMatchingUrl = new URL("https://example.com/admin?mode=normal");
570+
571+
expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(false);
572+
expect(predicateUrlPatterns(nonMatchingUrl, patterns)).toBe(true);
573+
});
574+
575+
test("handles wildcard patterns in search params", () => {
576+
const patterns: KnockGuideActivationUrlPattern[] = [
577+
{
578+
directive: "allow",
579+
pattern: new URLPattern({ pathname: "/page", search: "id=*" }),
580+
},
581+
];
582+
583+
const matchingUrl = new URL("https://example.com/page?id=123");
584+
const anotherMatchingUrl = new URL("https://example.com/page?id=abc");
585+
const nonMatchingUrl = new URL("https://example.com/page?other=value");
586+
587+
expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(true);
588+
expect(predicateUrlPatterns(anotherMatchingUrl, patterns)).toBe(true);
589+
expect(predicateUrlPatterns(nonMatchingUrl, patterns)).toBe(undefined);
590+
});
591+
592+
test("matches when pathname matches but no search pattern specified", () => {
593+
const patterns: KnockGuideActivationUrlPattern[] = [
594+
{
595+
directive: "allow",
596+
pattern: new URLPattern({ pathname: "/dashboard" }),
597+
},
598+
];
599+
600+
// Should match regardless of search params when no search pattern specified
601+
const urlWithSearch = new URL("https://example.com/dashboard?tab=settings");
602+
const urlWithoutSearch = new URL("https://example.com/dashboard");
603+
604+
expect(predicateUrlPatterns(urlWithSearch, patterns)).toBe(true);
605+
expect(predicateUrlPatterns(urlWithoutSearch, patterns)).toBe(true);
606+
});
607+
608+
test("block pattern with search takes precedence over allow pattern without search", () => {
609+
const patterns: KnockGuideActivationUrlPattern[] = [
610+
{
611+
directive: "allow",
612+
pattern: new URLPattern({ pathname: "/settings/*" }),
613+
},
614+
{
615+
directive: "block",
616+
pattern: new URLPattern({ pathname: "/settings/admin", search: "dangerous=true" }),
617+
},
618+
];
619+
620+
const blockedUrl = new URL("https://example.com/settings/admin?dangerous=true");
621+
const allowedUrl = new URL("https://example.com/settings/admin?dangerous=false");
622+
623+
expect(predicateUrlPatterns(blockedUrl, patterns)).toBe(false);
624+
expect(predicateUrlPatterns(allowedUrl, patterns)).toBe(true);
625+
});
626+
627+
test("handles multiple search params in pattern", () => {
628+
const patterns: KnockGuideActivationUrlPattern[] = [
629+
{
630+
directive: "allow",
631+
pattern: new URLPattern({ pathname: "/report", search: "type=sales&year=2024" }),
632+
},
633+
];
634+
635+
const matchingUrl = new URL("https://example.com/report?type=sales&year=2024");
636+
const partialMatchUrl = new URL("https://example.com/report?type=sales");
637+
const wrongOrderUrl = new URL("https://example.com/report?year=2024&type=sales");
638+
639+
expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(true);
640+
expect(predicateUrlPatterns(partialMatchUrl, patterns)).toBe(undefined);
641+
// URLPattern is sensitive to search param order
642+
expect(predicateUrlPatterns(wrongOrderUrl, patterns)).toBe(undefined);
643+
});
644+
645+
test("handles multiple search params in pattern, to match a single search param regardless of the order", () => {
646+
const patterns: KnockGuideActivationUrlPattern[] = [
647+
{
648+
directive: "allow",
649+
pattern: new URLPattern({ pathname: "/report", search: "*role=admin*" }),
650+
},
651+
];
652+
653+
const url1 = new URL("https://example.com/report?role=admin");
654+
const url2 = new URL("https://example.com/report?year=2022&role=admin");
655+
const url3 = new URL("https://example.com/report?role=admin&year=2022");
656+
const url4 = new URL("https://example.com/report?location=nyc&role=admin&year=2022");
657+
const url5 = new URL("https://example.com/report?location=nyc&year=2022");
658+
659+
expect(predicateUrlPatterns(url1, patterns)).toBe(true);
660+
expect(predicateUrlPatterns(url2, patterns)).toBe(true);
661+
expect(predicateUrlPatterns(url3, patterns)).toBe(true);
662+
expect(predicateUrlPatterns(url4, patterns)).toBe(true);
663+
expect(predicateUrlPatterns(url5, patterns)).toBe(undefined);
664+
});
665+
666+
test("handles search pattern with wildcard for any search params", () => {
667+
const patterns: KnockGuideActivationUrlPattern[] = [
668+
{
669+
directive: "block",
670+
pattern: new URLPattern({ pathname: "/api", search: "*" }),
671+
},
672+
];
673+
674+
const urlWithSearch = new URL("https://example.com/api?key=value");
675+
const urlWithoutSearch = new URL("https://example.com/api");
676+
677+
expect(predicateUrlPatterns(urlWithSearch, patterns)).toBe(false);
678+
expect(predicateUrlPatterns(urlWithoutSearch, patterns)).toBe(false);
679+
});
680+
});
543681
});

0 commit comments

Comments
 (0)