From 1ed66cef61f25c1e2ef92f4165c3953a6c15e513 Mon Sep 17 00:00:00 2001 From: ossamartinaaron09-ui Date: Sat, 7 Nov 2026 18:51:42 +0000 Subject: [PATCH] feat(vue): add Vue 3 integration package (@tryabby/vue) (#68) --- packages/vue/package.json | 49 +++++ packages/vue/src/__tests__/vue.test.ts | 32 ++++ packages/vue/src/index.ts | 247 +++++++++++++++++++++++++ packages/vue/tsconfig.json | 8 + 4 files changed, 336 insertions(+) create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/__tests__/vue.test.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/tsconfig.json diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..23ecdef1 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,49 @@ +{ + "name": "@tryabby/vue", + "version": "7.0.1", + "description": "Vue 3 integration for Abby A/B testing, feature flags, and remote config", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "scripts": { + "build": "tsup src/", + "dev": "pnpm run build --watch", + "test": "vitest" + }, + "homepage": "https://docs.tryabby.com", + "keywords": [ + "abby", + "vue", + "vue3", + "ab-testing", + "feature-flags", + "remote-config" + ], + "author": "", + "license": "ISC", + "peerDependencies": { + "vue": "^3.0.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + }, + "devDependencies": { + "@tryabby/devtools": "workspace:*", + "@types/js-cookie": "^3.0.3", + "tsconfig": "workspace:*", + "tsup": "^6.5.0", + "typescript": "5.5.4", + "vite": "5.4.0", + "vitest": "2.0.5", + "vue": "^3.4.0" + } +} diff --git a/packages/vue/src/__tests__/vue.test.ts b/packages/vue/src/__tests__/vue.test.ts new file mode 100644 index 00000000..4bfe1ae8 --- /dev/null +++ b/packages/vue/src/__tests__/vue.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { createAbby, useAbby, useFeatureFlag, useRemoteConfig } from "../index"; + +describe("@tryabby/vue composables and plugin", () => { + it("should export required Vue composables", () => { + expect(typeof useAbby).toBe("function"); + expect(typeof useFeatureFlag).toBe("function"); + expect(typeof useRemoteConfig).toBe("function"); + expect(typeof createAbby).toBe("function"); + }); + + it("should initialize createAbby and return scoped composables", () => { + const abbyClient = createAbby({ + projectId: "test-project-123", + apiUrl: "https://api.tryabby.com", + flags: ["new_checkout"], + tests: { + headline_test: { + variants: ["A", "B"], + }, + }, + remoteConfig: { + banner_text: "String", + }, + }); + + expect(abbyClient).toBeDefined(); + expect(typeof abbyClient.useAbby).toBe("function"); + expect(typeof abbyClient.useFeatureFlag).toBe("function"); + expect(typeof abbyClient.useRemoteConfig).toBe("function"); + }); +}); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..a2cebe05 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,247 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + HttpService, + AbbyEventType, +} from "@tryabby/core"; +import { + ref, + computed, + shallowRef, + inject, + provide, + type Ref, + type ComputedRef, + type App, +} from "vue"; +import Cookie from "js-cookie"; +import { + type IStorageService, + type StorageServiceOptions, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; + +const DEFAULT_COOKIE_AGE = 365; + +class ABStorageService implements IStorageService { + get(projectId: string, testName: string): string | null { + const retrievedValue = Cookie.get(getABStorageKey(projectId, testName)); + return retrievedValue || null; + } + 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 { + Cookie.remove(getABStorageKey(projectId, testName)); + } +} + +class FFStorageService implements IStorageService { + get(projectId: string, flagName: string): string | null { + return Cookie.get(getFFStorageKey(projectId, flagName)) || null; + } + set(projectId: string, flagName: string, value: string): void { + Cookie.set(getFFStorageKey(projectId, flagName), value, { expires: DEFAULT_COOKIE_AGE }); + } +} + +class RCStorageService implements IStorageService { + get(projectId: string, configName: string): string | null { + return Cookie.get(getRCStorageKey(projectId, configName)) || null; + } + set(projectId: string, configName: string, value: string): void { + Cookie.set(getRCStorageKey(projectId, configName), value, { expires: DEFAULT_COOKIE_AGE }); + } +} + +const TestStorageService = new ABStorageService(); +const FlagStorageService = new FFStorageService(); +const RemoteConfigStorageService = new RCStorageService(); + +export type ABTestReturnValue = Lookup extends undefined + ? TestVariant + : TestVariant extends keyof Lookup + ? Lookup[TestVariant] + : never; + +export const ABBY_INJECTION_KEY = Symbol("AbbyInstance"); + +let globalAbbyInstance: Abby | null = null; + +export function createAbby< + const FlagName extends string, + const TestName extends string, + const Tests extends Record, + const RemoteConfig extends Record, + const RemoteConfigName extends Extract, +>(config: AbbyConfig) { + const abby = new Abby( + config, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return TestStorageService.get(config.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined") return; + TestStorageService.set(config.projectId, key, value); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return FlagStorageService.get(config.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined") return; + FlagStorageService.set(config.projectId, key, value); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return RemoteConfigStorageService.get(config.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined") return; + RemoteConfigStorageService.set(config.projectId, key, value); + }, + } + ); + + globalAbbyInstance = abby; + + const abbyPlugin = { + install(app: App) { + app.provide(ABBY_INJECTION_KEY, abby); + }, + }; + + const notify = (name: N, selectedVariant: string) => { + if (!name || !selectedVariant) return; + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.PING, + data: { + projectId: config.projectId, + selectedVariant, + testName: name as string, + }, + }); + }; + + const useAbbyLocal = < + TName extends keyof Tests, + TVariant extends Tests[TName]["variants"][number], + LookupValue, + const Lookup extends Record | undefined = undefined, + >( + testName: TName, + lookupObject?: Lookup + ) => { + const rawVariant = abby.getTestVariant(testName); + const variantRef = shallowRef(rawVariant); + + const variant = computed(() => { + const v = variantRef.value; + return lookupObject ? lookupObject[v as keyof Lookup] : (v as any); + }); + + const onAct = () => { + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: config.projectId, + selectedVariant: variantRef.value, + testName: testName as string, + }, + }); + }; + + notify(testName, rawVariant); + return { variant, onAct }; + }; + + const useFeatureFlagLocal = (flagName: FlagName): ComputedRef => { + const flagVal = ref(abby.getFeatureFlag(flagName)); + return computed(() => Boolean(flagVal.value)); + }; + + const useRemoteConfigLocal = < + T extends RemoteConfigName, + C extends RemoteConfig[T], + >( + remoteConfigName: T + ): ComputedRef> => { + const val = shallowRef(abby.getRemoteConfig(remoteConfigName)); + return computed(() => val.value as RemoteConfigValueStringToType); + }; + + return { + abby, + install: abbyPlugin.install, + useAbby: useAbbyLocal, + useFeatureFlag: useFeatureFlagLocal, + useRemoteConfig: useRemoteConfigLocal, + }; +} + +/** + * Top-level Composable useAbby + */ +export function useAbby( + testName: string, + lookupObject?: Record +) { + const instance = inject>(ABBY_INJECTION_KEY, globalAbbyInstance!); + const currentVariant = shallowRef(instance ? instance.getTestVariant(testName) : ""); + + const variant = computed(() => { + const v = currentVariant.value; + return lookupObject ? lookupObject[v] : v; + }); + + const onAct = () => { + if (instance && (instance as any).config) { + HttpService.sendData({ + url: (instance as any).config.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: (instance as any).config.projectId, + selectedVariant: currentVariant.value, + testName, + }, + }); + } + }; + + return { variant, onAct }; +} + +/** + * Top-level Composable useFeatureFlag + */ +export function useFeatureFlag(flagName: string): ComputedRef { + const instance = inject>(ABBY_INJECTION_KEY, globalAbbyInstance!); + const val = ref(instance ? instance.getFeatureFlag(flagName) : false); + return computed(() => Boolean(val.value)); +} + +/** + * Top-level Composable useRemoteConfig + */ +export function useRemoteConfig(remoteConfigName: string): ComputedRef { + const instance = inject>(ABBY_INJECTION_KEY, globalAbbyInstance!); + const val = shallowRef(instance ? instance.getRemoteConfig(remoteConfigName) : (undefined as any)); + return computed(() => val.value); +} + +export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..de634d51 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["vitest/globals"] + }, + "include": ["src", "tests", "__tests__"] +}