Skip to content

Commit

Permalink
feat: add cookie consent options
Browse files Browse the repository at this point in the history
  • Loading branch information
cstrnt committed Jul 10, 2024
1 parent 3086d0a commit 648a918
Show file tree
Hide file tree
Showing 11 changed files with 121 additions and 30 deletions.
7 changes: 7 additions & 0 deletions .changeset/two-masks-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tryabby/svelte": minor
"@tryabby/react": minor
"@tryabby/core": minor
---

add cookie consent options
1 change: 1 addition & 0 deletions apps/web/abby.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ export default defineConfig(
remoteConfig: {
abc: "JSON",
},
cookies: { disableByDefault: true, expiresInDays: 30 },
}
);
1 change: 1 addition & 0 deletions apps/web/src/components/Pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ function PricingElement({
<Fragment key={feature}>
<AiOutlineCheckCircle className={clsx("text-xl")} />
<span
suppressHydrationWarning
className={clsx(
"flex items-center",
i === 0 ? "font-semibold" : "te"
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const { useAbby, AbbyProvider, useFeatureFlag, withDevtools, __abby__ } =
},
},
flags: ["ToggleMeIfYoureExcited", "showEasterEgg", "showHeading"],
cookies: { disableByDefault: true, expiresInDays: 30 },
});

export const AbbyProdDevtools = withDevtools(abbyDevtools, {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const { useAbby, AbbyProvider, useFeatureFlag, __abby__, withDevtools } =
},
},
flags: ["ForceDarkTheme"],
cookies: { disableByDefault: true, expiresInDays: 30 },
});

const Devtools = withDevtools(DevtoolsFactory, {
Expand Down
63 changes: 59 additions & 4 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import {
ABBY_RC_STORAGE_PREFIX,
remoteConfigStringToType,
ABBY_WINDOW_KEY,
StorageServiceOptions,
} from "./shared/";
import { HttpService } from "./shared";
import { F, O } from "ts-toolbelt";
import { getWeightedRandomVariant } from "./mathHelpers";
import { F } from "ts-toolbelt";
import { getVariantWithHeighestWeightOrFirst, getWeightedRandomVariant } from "./mathHelpers";
import { parseCookies } from "./helpers";

export * from "./shared/index";
Expand Down Expand Up @@ -72,7 +73,7 @@ type LocalData<

interface PersistentStorage {
get: (key: string) => string | null;
set: (key: string, value: string) => void;
set: (key: string, value: string, options?: StorageServiceOptions) => void;
}

export type AbbyConfig<
Expand All @@ -95,6 +96,10 @@ export type AbbyConfig<
settings?: Settings<F.NoInfer<FlagName>, F.NoInfer<RemoteConfigName>, F.NoInfer<RemoteConfig>>;
debug?: boolean;
fetch?: (typeof globalThis)["fetch"];
cookies?: {
disableByDefault?: boolean;
expiresInDays?: number;
};
__experimentalCdnUrl?: string;
};

Expand Down Expand Up @@ -125,6 +130,8 @@ export class Abby<
private testOverrides: Map<keyof Tests, Tests[keyof Tests]["variants"][number]> = new Map();
private remoteConfigOverrides = new Map<string, RemoteConfigValue>();

private COOKIE_CONSENT_KEY = "$_abcc_$";

constructor(
private config: F.Narrow<
AbbyConfig<FlagName, Tests, Environments, RemoteConfigName, RemoteConfig>
Expand Down Expand Up @@ -153,6 +160,17 @@ export class Abby<
},
{} as Record<RemoteConfigName, RemoteConfigValue>
);

if (persistantTestStorage) {
this.persistantTestStorage = {
get: (...args) => persistantTestStorage.get(...args),
set: (...args) => {
if (config.cookies?.disableByDefault) return;
const [key, value] = args;
persistantTestStorage.set(key, value, { expiresInDays: config.cookies?.expiresInDays });
},
};
}
}

/**
Expand Down Expand Up @@ -425,7 +443,9 @@ export class Abby<

return persistedValue;
}

if (this.config.cookies?.disableByDefault) {
return getVariantWithHeighestWeightOrFirst(variants, weights);
}
const weightedVariant = getWeightedRandomVariant(variants, weights);
this.persistantTestStorage?.set(key as string, weightedVariant);

Expand Down Expand Up @@ -531,6 +551,13 @@ export class Abby<
""
);

// special case for the cookie consent
if (testName === this.COOKIE_CONSENT_KEY) {
this.config.cookies ??= {};
this.config.cookies.disableByDefault = cookieValue !== "true";
return;
}

this.testOverrides.set(testName as TestName, cookieValue);
this.persistantTestStorage?.set(testName as TestName, cookieValue);
}
Expand Down Expand Up @@ -604,4 +631,32 @@ export class Abby<
value: RemoteConfigValueStringToType<RemoteConfig[RemoteConfigName]>;
}>;
}

/**
* Enables the usage of cookies for the storage of user data
* and also sets the cookies if possible
*/
enableCookies() {
this.config.cookies ??= {};
this.config.cookies.disableByDefault = false;
this.persistantTestStorage?.set(this.COOKIE_CONSENT_KEY, "true");

Object.keys(this.#data.tests).forEach((testName) => {
this.persistantTestStorage?.set(testName, this.getTestVariant(testName as TestName));
});
}

/**
* Disables the usage of cookies for storage of user data
* and also removes all set cookies if possible
*/
disableCookies() {
this.config.cookies ??= {};
this.config.cookies.disableByDefault = true;
this.persistantTestStorage?.set(this.COOKIE_CONSENT_KEY, "false");

Object.keys(this.#data.tests).forEach((testName) => {
this.persistantTestStorage?.set(testName, this.getTestVariant(testName as TestName));
});
}
}
48 changes: 31 additions & 17 deletions packages/core/src/mathHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ function getRandomDecimal() {
return Math.random();
}

function getWeightedRandomNumber<T extends Record<string, number>>(
spec: T
): keyof T {
function getWeightedRandomNumber<T extends Record<string, number>>(spec: T): keyof T {
let i: keyof T;
let sum = 0;
const r = getRandomDecimal();
Expand All @@ -27,32 +25,48 @@ function getWeightedRandomNumber<T extends Record<string, number>>(
return i!;
}

function getDefaultWeights<Variants extends ReadonlyArray<string>>(
variants: Variants
) {
return Array.from<number>({ length: variants.length }).fill(
1 / variants.length
);
function getDefaultWeights<Variants extends ReadonlyArray<string>>(variants: Variants) {
return Array.from<number>({ length: variants.length }).fill(1 / variants.length);
}

export function validateWeights<
Variants extends ReadonlyArray<string>,
Weights extends Array<number>
Weights extends Array<number>,
>(variants: Variants, weights?: Weights): Weights {
const sum = weights?.reduce((acc, weight) => acc + weight, 0);
return weights != null && sum === 1 && variants.length === weights.length
? weights
: (getDefaultWeights(variants) as Weights);
}

export function getWeightedRandomVariant<
Variants extends ReadonlyArray<string>
>(variants: Variants, weights?: Array<number>): Variants[number] {
export function getWeightedRandomVariant<Variants extends ReadonlyArray<string>>(
variants: Variants,
weights?: Array<number>
): Variants[number] {
const validatedWeights = validateWeights(variants, weights);
return getWeightedRandomNumber(
variants.reduce((acc, variant, index) => {
acc[variant as Variants[number]] = validatedWeights[index];
return acc;
}, {} as Record<Variants[number], number>)
variants.reduce(
(acc, variant, index) => {
acc[variant as Variants[number]] = validatedWeights[index];
return acc;
},
{} as Record<Variants[number], number>
)
);
}

export function getVariantWithHeighestWeightOrFirst<Variants extends ReadonlyArray<string>>(
variants: Variants,
weights?: Array<number>
): Variants[number] {
const validatedWeights = validateWeights(variants, weights);
let variantWithHeighestWeight = variants[0];

for (let i = 1; i < variants.length; i++) {
if (validatedWeights[i] > validatedWeights[i - 1]) {
variantWithHeighestWeight = variants[i];
}
}

return variantWithHeighestWeight;
}
6 changes: 5 additions & 1 deletion packages/core/src/shared/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export interface StorageServiceOptions {
expiresInDays?: number;
}

export interface IStorageService {
get(projectId: string, key: string): string | null;
set(projectId: string, key: string, value: string): void;
set(projectId: string, key: string, value: string, options?: StorageServiceOptions): void;
remove(projectId: string, key: string): void;
}
13 changes: 9 additions & 4 deletions packages/react/src/StorageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
getRCStorageKey,
type IStorageService,
} from "@tryabby/core";
import type { StorageServiceOptions } from "@tryabby/core/dist/shared/interfaces";
import Cookie from "js-cookie";

const DEFAULT_COOKIE_AGE = 365;

class ABStorageService implements IStorageService {
get(projectId: string, testName: string): string | null {
const retrievedValue = Cookie.get(getABStorageKey(projectId, testName));
Expand All @@ -14,8 +17,10 @@ class ABStorageService implements IStorageService {
return retrievedValue;
}

set(projectId: string, testName: string, value: string): void {
Cookie.set(getABStorageKey(projectId, testName), value);
set(projectId: string, testName: string, value: string, options?: StorageServiceOptions): void {
Cookie.set(getABStorageKey(projectId, testName), value, {
expires: options?.expiresInDays ? options.expiresInDays : DEFAULT_COOKIE_AGE,
});
}

remove(projectId: string, testName: string): void {
Expand All @@ -32,7 +37,7 @@ class FFStorageService implements IStorageService {
}

set(projectId: string, flagName: string, value: string): void {
Cookie.set(getFFStorageKey(projectId, flagName), value);
Cookie.set(getFFStorageKey(projectId, flagName), value, { expires: DEFAULT_COOKIE_AGE });
}

remove(projectId: string, flagName: string): void {
Expand All @@ -47,7 +52,7 @@ class RCStorageService implements IStorageService {
}

set(projectId: string, key: string, value: string): void {
Cookie.set(getRCStorageKey(projectId, key), value);
Cookie.set(getRCStorageKey(projectId, key), value, { expires: DEFAULT_COOKIE_AGE });
}

remove(projectId: string, key: string): void {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function createAbby<
return TestStorageService.get(abbyConfig.projectId, key);
},
set: (key: string, value: any) => {
if (typeof window === "undefined") return;
if (typeof window === "undefined" || config.cookies?.disableByDefault) return;
TestStorageService.set(abbyConfig.projectId, key, value);
},
},
Expand Down
8 changes: 5 additions & 3 deletions packages/svelte/src/lib/StorageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getRCStorageKey,
type IStorageService,
} from "@tryabby/core";
import type { StorageServiceOptions } from "@tryabby/core/dist/shared/interfaces";
import Cookie from "js-cookie";

class ABStorageService implements IStorageService {
Expand All @@ -14,10 +15,11 @@ class ABStorageService implements IStorageService {
return retrievedValue;
}

set(projectId: string, testName: string, value: string): void {
Cookie.set(getABStorageKey(projectId, testName), value);
set(projectId: string, testName: string, value: string, options?: StorageServiceOptions): void {
Cookie.set(getABStorageKey(projectId, testName), value, {
expires: options?.expiresInDays ? options.expiresInDays : 365,
});
}

remove(projectId: string, testName: string): void {
Cookie.remove(getABStorageKey(projectId, testName));
}
Expand Down

0 comments on commit 648a918

Please sign in to comment.