From e186505bd6be6e270becf405f5751a9b17e6ee15 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:06:46 +0100 Subject: [PATCH] Runtime frontend feature flags. (#2730) * Implement frontend feature flags. * Implement frontend feature flags. * Implement frontend feature flags. --- src/frontend/src/featureFlags/index.test.ts | 63 ++++++++++++++++++++ src/frontend/src/featureFlags/index.ts | 66 +++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/frontend/src/featureFlags/index.test.ts create mode 100644 src/frontend/src/featureFlags/index.ts diff --git a/src/frontend/src/featureFlags/index.test.ts b/src/frontend/src/featureFlags/index.test.ts new file mode 100644 index 0000000000..d0885545d4 --- /dev/null +++ b/src/frontend/src/featureFlags/index.test.ts @@ -0,0 +1,63 @@ +import { FeatureFlag } from "$src/featureFlags/index"; + +class MockStorage { + #data: Record = {}; + + getItem(key: string): string | null { + return this.#data[key] ?? null; + } + + setItem(key: string, value: string): void { + this.#data[key] = value; + } + + removeItem(key: string): void { + delete this.#data[key]; + } +} + +test("feature flag to be initialized", () => { + const storage = new MockStorage(); + storage.setItem("c", "true"); + storage.setItem("d", "false"); + + const enabledFlag = new FeatureFlag(storage, "a", true); + const disabledFlag = new FeatureFlag(storage, "b", false); + const storedOverrideFlag = new FeatureFlag(storage, "c", false); + const storedDisabledFlag = new FeatureFlag(storage, "d", true); + + expect(enabledFlag.isEnabled()).toEqual(true); + expect(disabledFlag.isEnabled()).toEqual(false); + expect(storedOverrideFlag.isEnabled()).toEqual(true); + expect(storedDisabledFlag.isEnabled()).toEqual(false); +}); + +test("feature flag to be set", () => { + const storage = new MockStorage(); + const enabledFlag = new FeatureFlag(storage, "a", true); + const disabledFlag = new FeatureFlag(storage, "b", false); + + enabledFlag.set(false); + disabledFlag.set(true); + + expect(enabledFlag.isEnabled()).toEqual(false); + expect(disabledFlag.isEnabled()).toEqual(true); + expect(storage.getItem("a")).toEqual("false"); + expect(storage.getItem("b")).toEqual("true"); +}); + +test("feature flag to be reset", () => { + const storage = new MockStorage(); + const enabledFlag = new FeatureFlag(storage, "a", true); + const disabledFlag = new FeatureFlag(storage, "b", false); + + enabledFlag.set(false); + disabledFlag.set(true); + enabledFlag.reset(); + disabledFlag.reset(); + + expect(enabledFlag.isEnabled()).toEqual(true); + expect(disabledFlag.isEnabled()).toEqual(false); + expect(storage.getItem("a")).toEqual(null); + expect(storage.getItem("b")).toEqual(null); +}); diff --git a/src/frontend/src/featureFlags/index.ts b/src/frontend/src/featureFlags/index.ts new file mode 100644 index 0000000000..0b0c8d8aa4 --- /dev/null +++ b/src/frontend/src/featureFlags/index.ts @@ -0,0 +1,66 @@ +// Feature flags with default values +const FEATURE_FLAGS_WITH_DEFAULTS = { + DOMAIN_COMPATIBILITY: false, +} as const satisfies Record; + +const LOCALSTORAGE_FEATURE_FLAGS_PREFIX = "ii-localstorage-feature-flags__"; + +export class FeatureFlag { + readonly #storage: Pick; + readonly #key: string; + readonly #defaultValue: boolean; + #value: boolean; + + constructor( + storage: Pick, + key: string, + defaultValue: boolean + ) { + this.#storage = storage; + this.#key = key; + this.#defaultValue = defaultValue; + const storedValue = this.#storage.getItem(this.#key); + try { + this.#value = + storedValue === null + ? this.#defaultValue + : Boolean(JSON.parse(storedValue)); + } catch { + this.#value = this.#defaultValue; + } + } + + isEnabled(): boolean { + return this.#value; + } + + set(value: boolean) { + this.#value = Boolean(value); + this.#storage.setItem(this.#key, JSON.stringify(this.#value)); + } + + reset(): void { + this.#value = this.#defaultValue; + this.#storage.removeItem(this.#key); + } +} + +// Initialize feature flags with values from localstorage +const initializedFeatureFlags = Object.fromEntries( + Object.entries(FEATURE_FLAGS_WITH_DEFAULTS).map(([key, defaultValue]) => [ + key, + new FeatureFlag( + window.localStorage, + LOCALSTORAGE_FEATURE_FLAGS_PREFIX + key, + defaultValue + ), + ]) +); + +// Make feature flags configurable from browser console +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +window.__featureFlags = initializedFeatureFlags; + +// Export initialized feature flags as named exports +export const { DOMAIN_COMPATIBILITY } = initializedFeatureFlags;