From 3ec5bf1ec1225222a69c8bcf0a5e2911e98b8aa7 Mon Sep 17 00:00:00 2001 From: isearchxsolve Date: Sun, 25 Oct 2026 00:01:54 +0530 Subject: [PATCH] fix: #68 - Vue Integration --- packages/vue/package.json | 44 ++++ packages/vue/src/StorageService.ts | 54 +++++ packages/vue/src/createAbby.ts | 306 ++++++++++++++++++++++++++ packages/vue/src/index.ts | 12 + packages/vue/tests/createAbby.test.ts | 140 ++++++++++++ packages/vue/tests/types.test.ts | 41 ++++ packages/vue/tsconfig.json | 8 + packages/vue/tsup.config.ts | 11 + packages/vue/vite.config.ts | 10 + 9 files changed, 626 insertions(+) create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/StorageService.ts create mode 100644 packages/vue/src/createAbby.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/tests/createAbby.test.ts create mode 100644 packages/vue/tests/types.test.ts create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/tsup.config.ts create mode 100644 packages/vue/vite.config.ts diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..84278749 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,44 @@ +{ + "name": "@tryabby/vue", + "version": "1.0.0", + "description": "Typed Vue 3 integration for Abby feature flags, remote config, and A/B tests", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup", + "dev": "pnpm build --watch", + "test": "vitest run", + "test:watch": "vitest" + }, + "license": "ISC", + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.5" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "devDependencies": { + "@tryabby/devtools": "workspace:*", + "@types/js-cookie": "^3.0.6", + "@vue/test-utils": "^2.4.6", + "jsdom": "^25.0.1", + "tsconfig": "workspace:*", + "tsup": "^8.3.5", + "typescript": "5.5.4", + "vite": "5.4.10", + "vitest": "2.1.8", + "vue": "^3.5.13" + } +} diff --git a/packages/vue/src/StorageService.ts b/packages/vue/src/StorageService.ts new file mode 100644 index 00000000..b013f413 --- /dev/null +++ b/packages/vue/src/StorageService.ts @@ -0,0 +1,54 @@ +import { + getABStorageKey, + getFFStorageKey, + getRCStorageKey, + type StorageServiceOptions, +} from "@tryabby/core"; +import Cookie from "js-cookie"; + +const DEFAULT_COOKIE_EXPIRY_DAYS = 365; + +function isBrowser(): boolean { + return typeof document !== "undefined"; +} + +class CookieStorage { + constructor( + private readonly keyFor: (projectId: string, name: string) => string + ) {} + + get(projectId: string, name: string): string | null { + if (!isBrowser()) { + return null; + } + + return Cookie.get(this.keyFor(projectId, name)) ?? null; + } + + set( + projectId: string, + name: string, + value: string, + options?: StorageServiceOptions + ): void { + if (!isBrowser()) { + return; + } + + Cookie.set(this.keyFor(projectId, name), value, { + expires: options?.expiresInDays ?? DEFAULT_COOKIE_EXPIRY_DAYS, + }); + } + + remove(projectId: string, name: string): void { + if (!isBrowser()) { + return; + } + + Cookie.remove(this.keyFor(projectId, name)); + } +} + +export const TestStorageService = new CookieStorage(getABStorageKey); +export const FlagStorageService = new CookieStorage(getFFStorageKey); +export const RemoteConfigStorageService = new CookieStorage(getRCStorageKey); diff --git a/packages/vue/src/createAbby.ts b/packages/vue/src/createAbby.ts new file mode 100644 index 00000000..059718cb --- /dev/null +++ b/packages/vue/src/createAbby.ts @@ -0,0 +1,306 @@ +import { + Abby, + AbbyEventType, + HttpService, + type ABConfig, + type AbbyConfig, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, +} from "@tryabby/core"; +import type { AbbyDataResponse } from "@tryabby/core"; +import { + computed, + defineComponent, + getCurrentInstance, + inject, + onMounted, + onUnmounted, + provide, + shallowRef, + type App, + type ComputedRef, + type InjectionKey, + type Plugin, + type PropType, + type Ref, +} from "vue"; +import { + FlagStorageService, + RemoteConfigStorageService, + TestStorageService, +} from "./StorageService"; + +export type ABTestReturnValue = Lookup extends undefined + ? Variant + : Variant extends keyof Lookup + ? Lookup[Variant] + : never; + +type PluginOptions = { + initialData?: AbbyDataResponse; +}; + +export function createAbby< + const FlagName extends string, + const TestName extends string, + const Tests extends Record, + const RemoteConfigName extends string, + const RemoteConfig extends Record< + RemoteConfigName, + RemoteConfigValueString + >, + const User extends Record = Record< + string, + ValidatorType + >, +>( + abbyConfig: AbbyConfig< + FlagName, + Tests, + string[], + RemoteConfigName, + RemoteConfig, + User + > +) { + const abby = new Abby< + FlagName, + TestName, + Tests, + RemoteConfig, + RemoteConfigName, + string[], + User + >( + abbyConfig, + { + get: (key) => TestStorageService.get(abbyConfig.projectId, key), + set: (key, value, options) => { + if (!abbyConfig.cookies?.disableByDefault) { + TestStorageService.set(abbyConfig.projectId, key, value, options); + } + }, + }, + { + get: (key) => FlagStorageService.get(abbyConfig.projectId, key), + set: (key, value, options) => + FlagStorageService.set(abbyConfig.projectId, key, value, options), + }, + { + get: (key) => RemoteConfigStorageService.get(abbyConfig.projectId, key), + set: (key, value, options) => + RemoteConfigStorageService.set( + abbyConfig.projectId, + key, + value, + options + ), + } + ); + + type ProjectData = ReturnType; + + type AbbyContext = { + data: Ref; + }; + + const AbbySymbol: InjectionKey = Symbol("tryabby-vue"); + const globalData = shallowRef(abby.getProjectData()); + + abby.subscribe((nextData) => { + globalData.value = nextData; + }); + + function getData(): Ref { + if (!getCurrentInstance()) { + return globalData; + } + + return inject(AbbySymbol, globalData); + } + + async function loadInto(target: Ref): Promise { + try { + const data = await abby.loadProjectData(); + + if (data) { + target.value = data; + } + } catch { + // Core defaults remain available when the project endpoint is unavailable. + } + } + + const AbbyProvider = defineComponent({ + name: "AbbyProvider", + props: { + initialData: { + type: Object as PropType, + required: false, + }, + }, + setup(props, { slots }) { + const data = shallowRef( + props.initialData ? abby.init(props.initialData) : abby.getProjectData() + ); + + let unsubscribe: (() => void) | undefined; + + provide(AbbySymbol, { data }); + + onMounted(() => { + unsubscribe = abby.subscribe((nextData) => { + data.value = nextData; + }); + + if (!props.initialData) { + void loadInto(data); + } + }); + + onUnmounted(() => { + unsubscribe?.(); + }); + + return () => slots.default?.() ?? null; + }, + }); + + const AbbyPlugin: Plugin = { + install(app: App, options?: PluginOptions) { + const data = shallowRef( + options?.initialData + ? abby.init(options.initialData) + : abby.getProjectData() + ); + + abby.subscribe((nextData) => { + data.value = nextData; + }); + + app.provide(AbbySymbol, { data }); + app.component("AbbyProvider", AbbyProvider); + + if (!options?.initialData && typeof window !== "undefined") { + void loadInto(data); + } + }, + }; + + function useAbby< + K extends keyof Tests, + Variant extends Tests[K]["variants"][number], + LookupValue, + const Lookup extends Record | undefined = undefined, + >( + name: K, + lookup?: Lookup + ): { + variant: ComputedRef>; + onAct: () => void; + } { + const data = getData(); + + const selectedVariant = computed(() => { + const current = data.value.tests[name as TestName]?.selectedVariant; + + return (current ?? abby.getTestVariant(name)) as Variant; + }); + + const sendEvent = (type: AbbyEventType): void => { + const variant = selectedVariant.value; + + if (!variant) { + return; + } + + void HttpService.sendData({ + url: abbyConfig.apiUrl, + type, + data: { + projectId: abbyConfig.projectId, + testName: name as string, + selectedVariant: variant, + }, + }); + }; + + if (getCurrentInstance()) { + onMounted(() => sendEvent(AbbyEventType.PING)); + } else if (typeof window !== "undefined") { + sendEvent(AbbyEventType.PING); + } + + return { + variant: computed(() => { + if (lookup === undefined) { + return selectedVariant.value as ABTestReturnValue; + } + + return lookup[ + selectedVariant.value + ] as ABTestReturnValue; + }), + onAct: () => sendEvent(AbbyEventType.ACT), + }; + } + + function useFeatureFlag(name: FlagName): ComputedRef { + const data = getData(); + + return computed(() => { + void data.value; + return abby.getFeatureFlag(name); + }); + } + + function useRemoteConfig< + K extends RemoteConfigName, + Value extends RemoteConfig[K], + >(name: K): ComputedRef> { + const data = getData(); + + return computed(() => { + void data.value; + return abby.getRemoteConfig(name); + }); + } + + return { + AbbyPlugin, + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, + getABTestValue: < + K extends keyof Tests, + Variant extends Tests[K]["variants"][number], + LookupValue, + Lookup extends Record | undefined = undefined, + >( + name: K, + lookup?: Lookup + ): ABTestReturnValue => { + const variant = abby.getTestVariant(name) as Variant; + + return (lookup === undefined + ? variant + : lookup[variant]) as ABTestReturnValue; + }, + getFeatureFlagValue: (name: FlagName) => abby.getFeatureFlag(name), + getRemoteConfig: < + K extends RemoteConfigName, + Value extends RemoteConfig[K], + >( + name: K + ): RemoteConfigValueStringToType => abby.getRemoteConfig(name), + getVariants: (name: K) => abby.getVariants(name), + getABResetFunction: (name: K) => () => + TestStorageService.remove(abbyConfig.projectId, name as string), + updateUserProperties: ( + user: Parameters[0] + ) => abby.updateUserProperties(user), + __abby__: abby, + }; +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..5208c630 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,12 @@ +export { + createAbby, + type ABTestReturnValue, +} from "./createAbby"; + +export { + defineConfig, + type ABConfig, + type AbbyConfig, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, +} from "@tryabby/core"; diff --git a/packages/vue/tests/createAbby.test.ts b/packages/vue/tests/createAbby.test.ts new file mode 100644 index 00000000..e285de80 --- /dev/null +++ b/packages/vue/tests/createAbby.test.ts @@ -0,0 +1,140 @@ +import { mount } from "@vue/test-utils"; +import { AbbyEventType, HttpService } from "@tryabby/core"; +import { defineComponent, nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAbby } from "../src"; + +describe("@tryabby/vue", () => { + beforeEach(() => { + document.cookie = ""; + vi.restoreAllMocks(); + }); + + it("returns the selected variant and dispatches ACT", async () => { + const sendData = vi.spyOn(HttpService, "sendData").mockResolvedValue(); + + const sdk = createAbby({ + projectId: "vue-test-project", + currentEnvironment: "test", + environments: ["test"], + cookies: { disableByDefault: true }, + tests: { + checkout: { variants: ["control", "streamlined"] }, + }, + }); + + sdk.__abby__.updateLocalVariant("checkout", "streamlined"); + + const Component = defineComponent({ + setup() { + return sdk.useAbby("checkout"); + }, + template: ``, + }); + + const wrapper = mount(Component, { + global: { + plugins: [sdk.AbbyPlugin], + }, + }); + + expect(wrapper.text()).toBe("streamlined"); + + await wrapper.trigger("click"); + + expect(sendData).toHaveBeenCalledWith( + expect.objectContaining({ + type: AbbyEventType.ACT, + data: expect.objectContaining({ + projectId: "vue-test-project", + testName: "checkout", + selectedVariant: "streamlined", + }), + }) + ); + }); + + it("reacts when a feature flag changes in core", async () => { + const sdk = createAbby({ + projectId: "vue-flags", + currentEnvironment: "test", + environments: ["test"], + flags: ["newCheckout"], + }); + + const Component = defineComponent({ + setup() { + return { enabled: sdk.useFeatureFlag("newCheckout") }; + }, + template: `{{ enabled }}`, + }); + + const wrapper = mount(Component, { + global: { plugins: [sdk.AbbyPlugin] }, + }); + + expect(wrapper.text()).toBe("false"); + + sdk.__abby__.updateFlag("newCheckout", true); + await nextTick(); + + expect(wrapper.text()).toBe("true"); + }); + + it("reacts when remote configuration changes in core", async () => { + const sdk = createAbby({ + projectId: "vue-config", + currentEnvironment: "test", + environments: ["test"], + remoteConfig: { + headline: "String", + }, + }); + + const Component = defineComponent({ + setup() { + return { headline: sdk.useRemoteConfig("headline") }; + }, + template: `{{ headline }}`, + }); + + const wrapper = mount(Component, { + global: { plugins: [sdk.AbbyPlugin] }, + }); + + sdk.__abby__.updateRemoteConfig("headline", "Ship faster"); + await nextTick(); + + expect(wrapper.text()).toBe("Ship faster"); + }); + + it("maps variants through a lookup object", () => { + const sdk = createAbby({ + projectId: "vue-lookup", + currentEnvironment: "test", + environments: ["test"], + cookies: { disableByDefault: true }, + tests: { + button: { variants: ["small", "large"] }, + }, + }); + + sdk.__abby__.updateLocalVariant("button", "large"); + + const Component = defineComponent({ + setup() { + return sdk.useAbby("button", { + small: "Small CTA", + large: "Large CTA", + }); + }, + template: `{{ variant }}`, + }); + + const wrapper = mount(Component, { + global: { plugins: [sdk.AbbyPlugin] }, + }); + + expect(wrapper.text()).toBe("Large CTA"); + }); +}); diff --git a/packages/vue/tests/types.test.ts b/packages/vue/tests/types.test.ts new file mode 100644 index 00000000..424aaaff --- /dev/null +++ b/packages/vue/tests/types.test.ts @@ -0,0 +1,41 @@ +import { expectTypeOf, test } from "vitest"; +import { createAbby } from "../src"; + +const abby = createAbby({ + projectId: "type-check", + currentEnvironment: "development", + environments: ["development", "production"], + tests: { + pricing: { + variants: ["control", "annual"] as const, + }, + }, + flags: ["newNav"] as const, + remoteConfig: { + headline: "String", + maxItems: "Number", + visible: "Boolean", + }, +}); + +test("preserves variant, flag, and remote-config types", () => { + const experiment = abby.useAbby("pricing"); + const mappedExperiment = abby.useAbby("pricing", { + control: { title: "Monthly" }, + annual: { title: "Annual" }, + }); + + const enabled = abby.useFeatureFlag("newNav"); + const headline = abby.useRemoteConfig("headline"); + const maxItems = abby.useRemoteConfig("maxItems"); + const visible = abby.useRemoteConfig("visible"); + + expectTypeOf(experiment.variant.value).toEqualTypeOf<"control" | "annual">(); + expectTypeOf(mappedExperiment.variant.value).toEqualTypeOf<{ + title: string; + }>(); + expectTypeOf(enabled.value).toEqualTypeOf(); + expectTypeOf(headline.value).toEqualTypeOf(); + expectTypeOf(maxItems.value).toEqualTypeOf(); + expectTypeOf(visible.value).toEqualTypeOf(); +}); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..482642d5 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "tsconfig/react-library.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["vitest/globals"] + }, + "include": ["src", "tests", "tsup.config.ts", "vite.config.ts"] +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..b96fa459 --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + sourcemap: true, + treeshake: true, + external: ["vue", "@tryabby/core", "js-cookie"], +}); diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..599a76e1 --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + clearMocks: true, + restoreMocks: true, + }, +});