diff --git a/.github/workflows/ux-label-mention.yml b/.github/workflows/ux-label-mention.yml new file mode 100644 index 0000000..cb9f65c --- /dev/null +++ b/.github/workflows/ux-label-mention.yml @@ -0,0 +1,16 @@ +name: Mention Teams + +on: + pull_request: + types: + - labeled + +jobs: + react-to-labels: + uses: configcat/.github/.github/workflows/ux-label-mention.yml@master + with: + id: ${{ github.event.pull_request.number }} + repo: ${{ github.repository }} + secrets: + gh_token: ${{ secrets.GITHUB_TOKEN }} + slack_webhook_url: ${{ secrets.TEXT_REVIEW_SLACK_WEBHOOK_URL }} diff --git a/package-lock.json b/package-lock.json index 6774cc6..76da4e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "configcat-react", - "version": "4.7.0", + "version": "4.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "configcat-react", - "version": "4.7.0", + "version": "4.8.0", "license": "MIT", "dependencies": { "configcat-common": "9.3.1", diff --git a/package.json b/package.json index 067502a..4f283b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "configcat-react", - "version": "4.7.0", + "version": "4.8.0", "scripts": { "build": "npm run build:esm && npm run build:cjs", "build:esm": "tsc -p tsconfig.build.esm.json && gulp esm", diff --git a/src/FlagOverrides.test.tsx b/src/FlagOverrides.test.tsx new file mode 100644 index 0000000..dd09bc8 --- /dev/null +++ b/src/FlagOverrides.test.tsx @@ -0,0 +1,152 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import React from "react"; +import { useFeatureFlag } from "./ConfigCatHooks"; +import ConfigCatProvider from "./ConfigCatProvider"; +import type { IQueryStringProvider, IReactAutoPollOptions } from "."; +import { OverrideBehaviour, createFlagOverridesFromQueryParams } from "."; + +const sdkKey = "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"; + +afterEach(cleanup); + +describe("Flag Overrides", () => { + it("Query string override should work - changes not watched", async () => { + const TestComponent = () => { + const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT"); + return (
Feature flag value: {featureFlag}
); + }; + + const queryStringProvider = { + currentValue: "?cc-stringDefaultCat=OVERRIDE_CAT&stringDefaultCat=NON_OVERRIDE_CAT" + } satisfies IQueryStringProvider; + + const options: IReactAutoPollOptions = { + flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, false, void 0, queryStringProvider) + }; + + const ui = ; + + await render(ui); + await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 }); + + cleanup(); + queryStringProvider.currentValue = "?cc-stringDefaultCat=CHANGED_OVERRIDE_CAT"; + + await render(ui); + await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 }); + }); + + it("Query string override should work - changes watched", async () => { + const TestComponent = () => { + const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT"); + return (
Feature flag value: {featureFlag}
); + }; + + const queryStringProvider = { + currentValue: "?cc-stringDefaultCat=OVERRIDE_CAT" + } satisfies IQueryStringProvider; + + const options: IReactAutoPollOptions = { + flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, true, void 0, queryStringProvider) + }; + + const ui = ; + + await render(ui); + await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 }); + + cleanup(); + queryStringProvider.currentValue = "?cc-stringDefaultCat=CHANGED_OVERRIDE_CAT"; + + await render(ui); + await screen.findByText("Feature flag value: CHANGED_OVERRIDE_CAT", void 0, { timeout: 2000 }); + }); + + it("Query string override should work - parsed query string", async () => { + const TestComponent = () => { + const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT"); + return (
Feature flag value: {featureFlag}
); + }; + + const queryStringProvider = { + currentValue: { "cc-stringDefaultCat": "OVERRIDE_CAT" as string | ReadonlyArray } + } satisfies IQueryStringProvider; + + const options: IReactAutoPollOptions = { + flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, true, void 0, queryStringProvider) + }; + + const ui = ; + + await render(ui); + await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 }); + + cleanup(); + queryStringProvider.currentValue = { "cc-stringDefaultCat": ["OVERRIDE_CAT", "CHANGED_OVERRIDE_CAT"] }; + + await render(ui); + await screen.findByText("Feature flag value: CHANGED_OVERRIDE_CAT", void 0, { timeout: 2000 }); + }); + + it("Query string override should work - respects custom parameter name prefix", async () => { + const TestComponent = () => { + const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT"); + return (
Feature flag value: {featureFlag}
); + }; + + const queryStringProvider = { + currentValue: "?stringDefaultCat=OVERRIDE_CAT&cc-stringDefaultCat=NON_OVERRIDE_CAT" + } satisfies IQueryStringProvider; + + const options: IReactAutoPollOptions = { + flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, "", queryStringProvider) + }; + + const ui = ; + + await render(ui); + await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 }); + }); + + it("Query string override should work - respects force string value suffix", async () => { + const TestComponent = () => { + const { value: boolFeatureFlag } = useFeatureFlag("boolDefaultFalse", false); + const { value: stringFeatureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT"); + return (
Feature flag values: {boolFeatureFlag ? "true" : "false"} ({typeof boolFeatureFlag}), {stringFeatureFlag} ({typeof stringFeatureFlag})
); + }; + + const queryStringProvider = { + currentValue: "?stringDefaultCat;str=TRUE&boolDefaultFalse=TRUE" + } satisfies IQueryStringProvider; + + const options: IReactAutoPollOptions = { + flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, "", queryStringProvider) + }; + + const ui = ; + + await render(ui); + await screen.findByText("Feature flag values: true (boolean), TRUE (string)", void 0, { timeout: 2000 }); + }); + + it("Query string override should work - handles query string edge cases", async () => { + const TestComponent = () => { + const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT"); + return (
Feature flag value: {featureFlag}
); + }; + + const queryStringProvider = { + currentValue: "?&some&=garbage&&cc-stringDefaultCat=OVERRIDE_CAT&=cc-stringDefaultCat&cc-stringDefaultCat" + } satisfies IQueryStringProvider; + + const options: IReactAutoPollOptions = { + flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, void 0, queryStringProvider) + }; + + const ui = ; + + await render(ui); + await screen.findByText("Feature flag value:", void 0, { timeout: 2000 }); + await expect(() => screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 })).rejects.toThrow(); + }); +}); diff --git a/src/FlagOverrides.ts b/src/FlagOverrides.ts new file mode 100644 index 0000000..30be838 --- /dev/null +++ b/src/FlagOverrides.ts @@ -0,0 +1,207 @@ +import { type FlagOverrides, OverrideBehaviour, type SettingValue, createFlagOverridesFromMap } from "configcat-common"; + +const DEFAULT_PARAM_PREFIX = "cc-"; +const FORCE_STRING_VALUE_SUFFIX = ";str"; + +export interface IQueryStringProvider { + readonly currentValue?: string | { [key: string]: string | ReadonlyArray }; +} + +class DefaultQueryStringProvider implements IQueryStringProvider { + get currentValue() { return window?.location.search; } +} + +let defaultQueryStringProvider: DefaultQueryStringProvider | undefined; + +type SettingMap = { [name: string]: Setting }; + +export class QueryParamsOverrideDataSource implements IOverrideDataSource { + private readonly watchChanges?: boolean; + private readonly paramPrefix: string; + private readonly queryStringProvider: IQueryStringProvider; + private queryString: string | undefined; + private settings: SettingMap; + + constructor(watchChanges?: boolean, paramPrefix?: string, queryStringProvider?: IQueryStringProvider) { + this.watchChanges = watchChanges; + this.paramPrefix = paramPrefix ?? DEFAULT_PARAM_PREFIX; + + queryStringProvider ??= defaultQueryStringProvider ??= new DefaultQueryStringProvider(); + this.queryStringProvider = queryStringProvider; + + const currentQueryStringOrParams = queryStringProvider.currentValue; + this.settings = extractSettings(currentQueryStringOrParams, this.paramPrefix); + this.queryString = getQueryString(currentQueryStringOrParams); + } + + getOverrides(): Promise { + return Promise.resolve(this.getOverridesSync()); + } + + getOverridesSync(): SettingMap { + if (this.watchChanges) { + const currentQueryStringOrParams = this.queryStringProvider.currentValue; + const currentQueryString = getQueryString(currentQueryStringOrParams); + if (this.queryString !== currentQueryString) { + this.settings = extractSettings(currentQueryStringOrParams, this.paramPrefix); + this.queryString = currentQueryString; + } + } + + return this.settings; + } +} + +function getQueryString(queryStringOrParams: string | { [key: string]: string | ReadonlyArray } | undefined) { + if (queryStringOrParams == null) { + return ""; + } + + if (typeof queryStringOrParams === "string") { + return queryStringOrParams; + } + + let queryString = "", separator = "?"; + + for (const key in queryStringOrParams) { + if (!Object.prototype.hasOwnProperty.call(queryStringOrParams, key)) continue; + + const values = queryStringOrParams[key] as string | string[]; + let value: string, length: number; + + if (!Array.isArray(values)) value = values, length = 1; + else if (values.length) value = values[0], length = values.length; + else continue; + + for (let i = 0; ;) { + queryString += separator + encodeURIComponent(key) + "=" + encodeURIComponent(value); + if (++i >= length) break; + separator = "&"; + value = values[i]; + } + } + + return queryString; +} + +function extractSettings(queryStringOrParams: string | { [key: string]: string | ReadonlyArray } | undefined, paramPrefix: string) { + const settings: SettingMap = {}; + + if (typeof queryStringOrParams === "string") { + extractSettingFromQueryString(queryStringOrParams, paramPrefix, settings); + } + else if (queryStringOrParams != null) { + extractSettingsFromQueryParams(queryStringOrParams, paramPrefix, settings); + } + + return settings; +} + +function extractSettingsFromQueryParams(queryParams: { [key: string]: string | ReadonlyArray } | undefined, paramPrefix: string, settings: SettingMap) { + for (const key in queryParams) { + if (!Object.prototype.hasOwnProperty.call(queryParams, key)) continue; + + const values = queryParams[key] as string | string[]; + let value: string, length: number; + + if (!Array.isArray(values)) value = values, length = 1; + else if (values.length) value = values[0], length = values.length; + else continue; + + for (let i = 0; ;) { + extractSettingFromQueryParam(key, value, paramPrefix, settings); + if (++i >= length) break; + value = values[i]; + } + } +} + +function extractSettingFromQueryString(queryString: string, paramPrefix: string, settings: SettingMap) { + if (!queryString + || queryString.lastIndexOf("?", 0) < 0) { // identical to `!queryString.startsWith("?")` + return; + } + + const parts = queryString.substring(1).split("&"); + for (let part of parts) { + part = part.replace(/\+/g, " "); + const index = part.indexOf("="); + + const key = decodeURIComponent(index >= 0 ? part.substring(0, index) : part); + const value = index >= 0 ? decodeURIComponent(part.substring(index + 1)) : ""; + + extractSettingFromQueryParam(key, value, paramPrefix, settings); + } +} + +function extractSettingFromQueryParam(key: string, value: string, paramPrefix: string, settings: SettingMap) { + if (!key + || key.length <= paramPrefix.length + || key.lastIndexOf(paramPrefix, 0) < 0) { // identical to `!key.startsWith(paramPrefix)` + return; + } + + key = key.substring(paramPrefix.length); + + const interpretValueAsString = key.length > FORCE_STRING_VALUE_SUFFIX.length + && key.indexOf(FORCE_STRING_VALUE_SUFFIX, key.length - FORCE_STRING_VALUE_SUFFIX.length) >= 0; // identical to `key.endsWith(strSuffix)` + + if (interpretValueAsString) { + key = key.substring(0, key.length - FORCE_STRING_VALUE_SUFFIX.length); + } + else { + value = parseSettingValue(value) as unknown as string; + } + + settings[key] = settingConstuctor.fromValue(value); +} + +function parseSettingValue(value: string): NonNullable { + switch (value.toLowerCase()) { + case "false": + return false; + case "true": + return true; + default: + const number = parseFloatStrict(value); + return !isNaN(number) ? number : value; + } +} + +function parseFloatStrict(value: string): number { + // NOTE: JS's float to string conversion is too forgiving, it converts whitespace string to 0 and accepts hex numbers. + + if (!value.length || /^\s*$|^\s*0[^\d.e]/.test(value)) { + return NaN; + } + + return +value; +} + +// The following types and functions aren't part of configcat-common's public API, +// so for now we need this hack to make things work. +// TODO: move the flag override data source into the new unified JS SDK and +// get rid of this workaround as soon as it's released. + +type IOverrideDataSource = FlagOverrides["dataSource"]; + +type Setting = ReturnType[""]; + +type FlagOverridesConstructor = { + new(dataSource: IOverrideDataSource, behaviour: OverrideBehaviour): FlagOverrides; +} + +type SettingConstructor = { + fromValue(value: NonNullable): Setting; +}; + +const [flagOverridesConstructor, settingConstuctor] = (() => { + const dummyFlagOverrides = createFlagOverridesFromMap({ "$": 0 }, OverrideBehaviour.LocalOnly); + const dummySetting = dummyFlagOverrides.dataSource.getOverridesSync()["$"]; + return [ + (dummyFlagOverrides as any).constructor as FlagOverridesConstructor, + (dummySetting as any).constructor as SettingConstructor + ]; +})(); + +export { flagOverridesConstructor }; diff --git a/src/index.ts b/src/index.ts index cbf3414..7c425be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,36 @@ "use client"; -import type { IAutoPollOptions, IConfigCatLogger, ILazyLoadingOptions, IManualPollOptions } from "configcat-common"; +import type { FlagOverrides, IAutoPollOptions, IConfigCatLogger, ILazyLoadingOptions, IManualPollOptions, OverrideBehaviour } from "configcat-common"; import type { GetValueType, WithConfigCatClientProps } from "./ConfigCatHOC"; import withConfigCatClient from "./ConfigCatHOC"; import { useConfigCatClient, useFeatureFlag } from "./ConfigCatHooks"; import ConfigCatProvider from "./ConfigCatProvider"; +import type { IQueryStringProvider } from "./FlagOverrides"; +import { QueryParamsOverrideDataSource, flagOverridesConstructor } from "./FlagOverrides"; export { createConsoleLogger, createFlagOverridesFromMap } from "configcat-common"; +/** + * Creates an instance of `FlagOverrides` that uses query string parameters as data source. + * @param behaviour The override behaviour. + * Specifies whether the local values should override the remote values + * or local values should only be used when a remote value doesn't exist + * or the local values should be used only. + * @param watchChanges If set to `true`, the query string will be tracked for changes. + * @param paramPrefix The parameter name prefix used to indicate which query string parameters + * specify feature flag override values. Parameters whose name doesn't start with the + * prefix will be ignored. Defaults to `cc-`. + * @param queryStringProvider The provider object used to obtain the query string. + * Defaults to a provider that returns the value of `window.location.search`. + */ +export function createFlagOverridesFromQueryParams(behaviour: OverrideBehaviour, + watchChanges?: boolean, paramPrefix?: string, queryStringProvider?: IQueryStringProvider +): FlagOverrides { + return new flagOverridesConstructor(new QueryParamsOverrideDataSource(watchChanges, paramPrefix, queryStringProvider), behaviour); +} + +export type { IQueryStringProvider }; + /** Options used to configure the ConfigCat SDK in the case of Auto Polling mode. */ export type IReactAutoPollOptions = IAutoPollOptions; @@ -68,4 +91,3 @@ export { OverrideBehaviour } from "configcat-common"; export { ClientCacheState, RefreshResult } from "configcat-common"; export type { IProvidesHooks, HookEvents } from "configcat-common"; -