Repository navigation
feat(vue): add @tryabby/vue package with AbbyProvider, useAbby, useFeatureFlag & useRemoteConfig (#68) #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| # `@tryabby/vue` | ||
|
|
||
| Vue 3 integration for Abby A/B Testing, Feature Flags, and Remote Config (`#68`). | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| pnpm add @tryabby/vue @tryabby/core | ||
| ``` | ||
|
|
||
| ## Setup & Usage | ||
|
|
||
| 1. Initialize Abby with `defineConfig` and `createAbby`: | ||
|
|
||
| ```ts | ||
| import { createAbby, defineConfig } from "@tryabby/vue"; | ||
|
|
||
| export const abbyConfig = defineConfig({ | ||
| projectId: "your-project-id", | ||
| currentEnvironment: "production", | ||
| environments: ["production", "development"], | ||
| tests: { | ||
| heroBanner: { variants: ["A", "B"] }, | ||
| }, | ||
| flags: ["newCheckout"], | ||
| remoteConfig: { | ||
| welcomeTitle: "string", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the supported remote-config type name. Core accepts
|
||
| }, | ||
| }); | ||
|
|
||
| export const { | ||
| AbbyProvider, | ||
| useAbby, | ||
| useFeatureFlag, | ||
| useRemoteConfig, | ||
| } = createAbby(abbyConfig); | ||
| ``` | ||
|
|
||
| 2. Wrap your application root in `<AbbyProvider>` (optionally passing `initialData` for SSR hydration): | ||
|
|
||
| ```vue | ||
| <template> | ||
| <AbbyProvider :initial-data="ssrData"> | ||
| <App /> | ||
| </AbbyProvider> | ||
| </template> | ||
| ``` | ||
|
|
||
| 3. Consume `useAbby`, `useFeatureFlag`, and `useRemoteConfig` inside any descendant Vue component: | ||
|
|
||
| ```ts | ||
| const { variant, onAct } = useAbby("heroBanner", { | ||
| A: "Welcome Variant A", | ||
| B: "Welcome Variant B", | ||
| }); | ||
| const isNewCheckout = useFeatureFlag("newCheckout"); | ||
| const welcomeTitle = useRemoteConfig("welcomeTitle"); | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| { | ||
| "name": "@tryabby/vue", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add the new workspace package to the lockfile.
|
||
| "version": "0.1.0", | ||
| "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", | ||
| "exports": { | ||
| ".": { | ||
| "import": "./dist/index.mjs", | ||
| "require": "./dist/index.js", | ||
| "types": "./dist/index.d.ts" | ||
| } | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "scripts": { | ||
| "build": "tsup src/index.ts --format cjs,esm --dts", | ||
| "test": "vitest run", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Build core before running the Vue tests. On a fresh checkout, the tests import
|
||
| "typecheck": "tsc --noEmit" | ||
|
Comment on lines
+19
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declare the tools used by these scripts. Neither this package nor the workspace root declares
|
||
| }, | ||
| "peerDependencies": { | ||
| "vue": "^3.3.0" | ||
| }, | ||
| "dependencies": { | ||
| "@tryabby/core": "workspace:*" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,246 @@ | ||
| import { | ||
| type ABConfig, | ||
| Abby, | ||
| type AbbyConfig, | ||
| type AbbyDataResponse, | ||
| AbbyEventType, | ||
| HttpService, | ||
| type RemoteConfigValueString, | ||
| type RemoteConfigValueStringToType, | ||
| type ValidatorType, | ||
| defineConfig, | ||
| } from "@tryabby/core"; | ||
| import { | ||
| computed, | ||
| defineComponent, | ||
| inject, | ||
| onMounted, | ||
| onUnmounted, | ||
| provide, | ||
| ref, | ||
| watchEffect, | ||
| type ComputedRef, | ||
| type InjectionKey, | ||
| type PropType, | ||
| type Ref, | ||
| } from "vue"; | ||
|
|
||
| export { type ABConfig, type AbbyConfig, defineConfig }; | ||
|
|
||
| export type ABTestReturnValue<Lookup, TestVariant> = Lookup extends undefined | ||
| ? TestVariant | ||
| : TestVariant extends keyof Lookup | ||
| ? Lookup[TestVariant] | ||
| : never; | ||
|
|
||
| export function createAbby< | ||
| const FlagName extends string, | ||
| const TestName extends string, | ||
| const Tests extends Record<TestName, ABConfig>, | ||
| const RemoteConfig extends Record<RemoteConfigName, RemoteConfigValueString>, | ||
| const RemoteConfigName extends Extract<keyof RemoteConfig, string>, | ||
| const User extends Record<string, ValidatorType> = Record<string, ValidatorType>, | ||
| >( | ||
| abbyConfig: AbbyConfig< | ||
| FlagName, | ||
| Tests, | ||
| string[], | ||
| RemoteConfigName, | ||
| RemoteConfig, | ||
| User | ||
| > | ||
| ) { | ||
| const memoryStore = new Map<string, string>(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Persist browser test assignments across page reloads. Each factory starts with an empty
|
||
| const storageAdapter = { | ||
| get: (key: string) => memoryStore.get(`${abbyConfig.projectId}:${key}`) ?? null, | ||
| set: (key: string, value: any) => { | ||
| memoryStore.set(`${abbyConfig.projectId}:${key}`, String(value)); | ||
| }, | ||
| }; | ||
|
|
||
| const abby = new Abby< | ||
| FlagName, | ||
| TestName, | ||
| Tests, | ||
| RemoteConfig, | ||
| RemoteConfigName, | ||
| string[], | ||
| User | ||
| >(abbyConfig, storageAdapter, storageAdapter, storageAdapter); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separate the storage namespaces. All three storage slots share the same keys. In development, if a flag and test share a name,
|
||
|
|
||
| type AbbyProjectData = ReturnType<typeof abby.getProjectData>; | ||
|
|
||
| // Per-factory injection key so nested providers from separate createAbby instances never collide | ||
| const abbyInjectionKey: InjectionKey<Ref<AbbyProjectData>> = Symbol("AbbyInjectionKey"); | ||
|
|
||
| const useAbbyData = (): Ref<AbbyProjectData> => { | ||
| const injected = inject(abbyInjectionKey, null); | ||
| if (!injected) { | ||
| throw new Error( | ||
| "useAbbyData must be used within an AbbyProvider. Wrap a parent component in <AbbyProvider> to fix this error." | ||
| ); | ||
| } | ||
| return injected; | ||
| }; | ||
|
|
||
| const AbbyProvider = defineComponent({ | ||
| name: "AbbyProvider", | ||
| props: { | ||
| initialData: { | ||
| type: Object as PropType<AbbyDataResponse>, | ||
| required: false, | ||
| }, | ||
| }, | ||
| setup(props, { slots }) { | ||
| if (props.initialData) { | ||
| abby.setResponse(props.initialData); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Call the supported core lifecycle methods.
Also applies to: 109-109
|
||
| } | ||
| const projectData = ref<AbbyProjectData>(abby.getProjectData()) as Ref<AbbyProjectData>; | ||
|
|
||
| // Subscribe synchronously during setup before child component setup executes | ||
| const unsubscribe = abby.subscribe(() => { | ||
| projectData.value = abby.getProjectData(); | ||
| }); | ||
|
Comment on lines
+101
to
+103
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not retain provider subscriptions during SSR. With a shared factory, every server render retains another subscription. Vue 3.3 does not register
|
||
|
|
||
| provide(abbyInjectionKey, projectData); | ||
|
|
||
| onMounted(() => { | ||
| if (!props.initialData) { | ||
| void abby.load(); | ||
| } | ||
| }); | ||
|
|
||
| onUnmounted(() => { | ||
| if (typeof unsubscribe === "function") { | ||
| unsubscribe(); | ||
| } | ||
| }); | ||
|
|
||
| return () => (slots.default ? slots.default() : null); | ||
| }, | ||
| }); | ||
|
|
||
| const useAbby = < | ||
| K extends keyof Tests, | ||
| TestVariant extends Tests[K]["variants"][number], | ||
| LookupValue = unknown, | ||
| const Lookup extends Record<TestVariant, LookupValue> | undefined = undefined, | ||
| >( | ||
| name: K, | ||
| lookupObject?: Lookup | ||
| ): { | ||
| variant: ComputedRef<ABTestReturnValue<Lookup, TestVariant>>; | ||
| onAct: () => void; | ||
| } => { | ||
| const projectData = useAbbyData(); | ||
|
|
||
| const selectedVariant = computed<TestVariant>(() => { | ||
| const snap = projectData.value.tests?.[name as unknown as TestName]?.selectedVariant; | ||
| if (snap !== undefined) { | ||
| return snap as TestVariant; | ||
| } | ||
| return abby.getTestVariant(name) as TestVariant; | ||
| }); | ||
|
|
||
| const variant = computed<ABTestReturnValue<Lookup, TestVariant>>(() => { | ||
| const raw = selectedVariant.value; | ||
| if (lookupObject === undefined) { | ||
| return raw as unknown as ABTestReturnValue<Lookup, TestVariant>; | ||
| } | ||
| return lookupObject[raw] as unknown as ABTestReturnValue<Lookup, TestVariant>; | ||
| }); | ||
|
|
||
| watchEffect(() => { | ||
| const currentVariant = selectedVariant.value; | ||
| const testId = projectData.value.tests?.[name as unknown as TestName]?.id; | ||
| if (!name || !currentVariant || !testId) return; | ||
|
Comment on lines
+155
to
+156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the nonexistent snapshot-ID requirement. Core test snapshots contain Also applies to: 170-171
|
||
| void HttpService.sendData({ | ||
| url: abbyConfig.apiUrl, | ||
| type: AbbyEventType.PING, | ||
| data: { | ||
| projectId: abbyConfig.projectId, | ||
| selectedVariant: currentVariant, | ||
| testId, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Send The core event schema requires Also applies to: 178-178
|
||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| const onAct = () => { | ||
| const currentVariant = selectedVariant.value; | ||
| const testId = projectData.value.tests?.[name as unknown as TestName]?.id; | ||
| if (!currentVariant || !testId) return; | ||
| void HttpService.sendData({ | ||
| url: abbyConfig.apiUrl, | ||
| type: AbbyEventType.ACT, | ||
| data: { | ||
| projectId: abbyConfig.projectId, | ||
| selectedVariant: currentVariant, | ||
| testId, | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| return { variant, onAct }; | ||
| }; | ||
|
|
||
| const useFeatureFlag = <K extends FlagName>(flagName: K): ComputedRef<boolean> => { | ||
| const projectData = useAbbyData(); | ||
| return computed<boolean>(() => { | ||
| const val = projectData.value.flags?.[flagName]; | ||
| if (typeof val === "boolean") return val; | ||
| return Boolean(abby.getFeatureFlag(flagName)); | ||
| }); | ||
| }; | ||
|
|
||
| const useRemoteConfig = < | ||
| T extends RemoteConfigName, | ||
| Config extends RemoteConfig[T], | ||
| >( | ||
| configKey: T | ||
| ): ComputedRef<RemoteConfigValueStringToType<Config>> => { | ||
| const projectData = useAbbyData(); | ||
| return computed<RemoteConfigValueStringToType<Config>>(() => { | ||
| const entry = projectData.value.remoteConfig?.[configKey]; | ||
| if (entry && "value" in entry) { | ||
| return entry.value as RemoteConfigValueStringToType<Config>; | ||
| } | ||
| return abby.getRemoteConfig(configKey); | ||
| }); | ||
| }; | ||
|
|
||
| const getFeatureFlagValue = (name: FlagName): boolean => abby.getFeatureFlag(name); | ||
|
|
||
| const getRemoteConfig = < | ||
| T extends RemoteConfigName, | ||
| Config extends RemoteConfig[T], | ||
| >( | ||
| remoteConfigName: T | ||
| ): RemoteConfigValueStringToType<Config> => abby.getRemoteConfig(remoteConfigName); | ||
|
|
||
| const getABTestValue = < | ||
| K extends keyof Tests, | ||
| TestVariant extends Tests[K]["variants"][number], | ||
| LookupValue = unknown, | ||
| const Lookup extends Record<TestVariant, LookupValue> | undefined = undefined, | ||
| >( | ||
| testName: K, | ||
| lookupObject?: Lookup | ||
| ): ABTestReturnValue<Lookup, TestVariant> => { | ||
| const v = abby.getTestVariant(testName) as TestVariant; | ||
| if (lookupObject === undefined) { | ||
| return v as unknown as ABTestReturnValue<Lookup, TestVariant>; | ||
| } | ||
| return lookupObject[v] as unknown as ABTestReturnValue<Lookup, TestVariant>; | ||
| }; | ||
|
|
||
| return { | ||
| AbbyProvider, | ||
| useAbby, | ||
| useFeatureFlag, | ||
| useRemoteConfig, | ||
| getFeatureFlagValue, | ||
| getRemoteConfig, | ||
| getABTestValue, | ||
| __abby__: abby, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { describe, expectTypeOf, it } from "vitest"; | ||
| import type { ComputedRef } from "vue"; | ||
| import { createAbby, defineConfig } from "../src/index"; | ||
|
|
||
| describe("@tryabby/vue type inference", () => { | ||
| const config = defineConfig({ | ||
| projectId: "proj_vue_types_68", | ||
| currentEnvironment: "production", | ||
| environments: ["production"], | ||
| tests: { | ||
| checkoutFlow: { variants: ["control", "streamlined"] }, | ||
| }, | ||
| flags: ["newSidebar"], | ||
| remoteConfig: { | ||
| retryCount: "number", | ||
| greeting: "string", | ||
| }, | ||
| }); | ||
|
|
||
| const { getABTestValue, getFeatureFlagValue, getRemoteConfig } = createAbby(config); | ||
|
|
||
| it("infers variant union and lookup return types without explicit type arguments", () => { | ||
| const rawVariant = getABTestValue("checkoutFlow"); | ||
| expectTypeOf(rawVariant).toEqualTypeOf<"control" | "streamlined">(); | ||
|
|
||
| const mapped = getABTestValue("checkoutFlow", { | ||
| control: "Old Checkout", | ||
| streamlined: "New Checkout", | ||
| }); | ||
| expectTypeOf(mapped).toEqualTypeOf<string>(); | ||
|
|
||
| expectTypeOf(getFeatureFlagValue("newSidebar")).toEqualTypeOf<boolean>(); | ||
| expectTypeOf(getRemoteConfig("retryCount")).toEqualTypeOf<number>(); | ||
| expectTypeOf(getRemoteConfig("greeting")).toEqualTypeOf<string>(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { createAbby, defineConfig } from "../src/index"; | ||
|
|
||
| describe("@tryabby/vue runtime integration", () => { | ||
| const config = defineConfig({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass both required configuration arguments. Core
|
||
| projectId: "proj_vue_68", | ||
| currentEnvironment: "production", | ||
| environments: ["production", "development"], | ||
| tests: { | ||
| heroBanner: { variants: ["A", "B"] }, | ||
| }, | ||
| flags: ["darkMode"], | ||
| remoteConfig: { | ||
| maxItems: "number", | ||
| welcomeBanner: "string", | ||
|
Comment on lines
+14
to
+15
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the core remote-config type literals. Core accepts
|
||
| }, | ||
| }); | ||
|
|
||
| it("creates independent AbbyProvider and composables with selectedVariant & ACT reporting", () => { | ||
| const abby = createAbby(config); | ||
| expect(abby.AbbyProvider).toBeDefined(); | ||
| expect(typeof abby.useAbby).toBe("function"); | ||
| expect(typeof abby.useFeatureFlag).toBe("function"); | ||
| expect(typeof abby.useRemoteConfig).toBe("function"); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pass both arguments to
defineConfig.The exported core function requires separate dynamic and static configuration arguments. This one-argument example fails TypeScript checking. Split the example into the two required arguments. (raw.githubusercontent.com)