From 5de0ffefee4f94e8f243488d7d9a599c40c2994d Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Thu, 12 Feb 2026 09:03:53 +0000 Subject: [PATCH] OpenFeature: Refactor frontend usage (#117573) * Refactor core OF implementation to use a domain, remove eval wrapper * Add flag test utils * Add the react provider to use the open feature hooks * Update recentlyViewedDashboards flag usage with react hooks * Update useMTPlugins usage with vanilla OF client * Update faroSessionReplay usage with vanilla OF client * Update datasources.querier.fe-allowed-types usage with vanilla OF client * Update guideline documentation Update contribute/feature-toggles.md Co-authored-by: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> --------- Co-authored-by: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> --- contribute/feature-toggles.md | 71 +++++++++++++++---- package.json | 1 + packages/grafana-runtime/package.json | 2 +- .../grafana-runtime/src/internal/index.ts | 3 +- .../src/internal/openFeature/index.ts | 31 ++++---- .../src/services/pluginMeta/apps.test.ts | 52 +++++++++++--- .../src/services/pluginMeta/apps.ts | 4 +- .../src/services/pluginMeta/panels.test.ts | 52 +++++++++++--- .../src/services/pluginMeta/panels.ts | 4 +- .../src/services/pluginMeta/plugins.test.ts | 44 ++++++++---- .../src/services/pluginMeta/plugins.ts | 4 +- .../src/utils/DataSourceWithBackend.ts | 4 +- packages/grafana-test-utils/package.json | 1 + packages/grafana-test-utils/src/unstable.ts | 2 + .../src/utilities/featureFlags.ts | 49 +++++++++++++ public/app/AppWrapper.tsx | 58 ++++++++------- .../GrafanaJavascriptAgentBackend.ts | 4 +- .../BrowseDashboardsPage.tsx | 10 +-- public/test/test-utils.tsx | 22 +++--- yarn.lock | 14 +++- 20 files changed, 319 insertions(+), 113 deletions(-) create mode 100644 packages/grafana-test-utils/src/utilities/featureFlags.ts diff --git a/contribute/feature-toggles.md b/contribute/feature-toggles.md index aa74b2d7fc2..b4e5cb556d5 100644 --- a/contribute/feature-toggles.md +++ b/contribute/feature-toggles.md @@ -80,26 +80,73 @@ func TestFoo(t *testing.T) { ### Frontend -Use the new OpenFeature-based feature flag client for all new feature flags. There are some differences compared to the legacy `config.featureToggles` system: +Use the OpenFeature React hooks for all new feature flags. The React hooks automatically stay up to date with the latest flag values and integrate seamlessly with React components. -- Feature flag initialisation is async, but will be finished by the time the UI is rendered. This means you cannot get the value of a feature flag at the 'top level' of a module/file -- Call `evaluateBooleanFlag("flagName")` from `@grafana/runtime/internal` instead to get the value of a feature flag -- Feature flag values _may_ change over the lifetime of the session. Do not store the value in a variable that is used for longer than a single render - always call `evaluateBooleanFlag` lazily when you use the value. +#### Using React hooks (recommended) -e.g. +For React components, use the `useBooleanFlagValue` hook from `@openfeature/react-sdk`: + +```tsx +import { useBooleanFlagValue } from '@openfeature/react-sdk'; + +function MyComponent() { + // The hook returns the current value and automatically updates when the flag changes + const isNewPreferencesEnabled = useBooleanFlagValue('newPreferences', false); + + if (isNewPreferencesEnabled) { + return ; + } + + return ; +} +``` + +Flag values _may_ change over the lifetime of the session, so do not store the result elsewhere in a way it will not react to changes in the flag value. + +If using non-boolean flags (a unique feature of the new feature flag system), explore the other exports from `@openfeature/react-sdk` to see how to use them. + +#### Using the client directly (non-React contexts) + +For advanced, non-React contexts (utilities, class methods, callbacks), you can use the OpenFeature client directly. + +However, because this is seperate from the React render loop there are important caveats you must be aware of: + +- Flag values are loaded asynchronously, so you cannot call `getBooleanValue()` just at the top-level of a module. You must wait until `app.ts` has initialised until you call a flag otherwise you will only get the default value +- Flag values can change over the lifetime of the session, so do not store or cache the result. Always evaluate flags just in time when you use them, preferably in the if statement, for example. + +It is strongly preferred to use the React hooks instead of getting the client. ```ts -import { evaluateBooleanFlag } from '@grafana/runtime/internal'; +import { getFeatureFlagClient } from '@grafana/runtime/internal'; -// BAD - Don't do this. The feature toggle will not evaluate correctly -const isEnabled = evaluateBooleanFlag('newPreferences', false); - -function makeAPICall() { - // GOOD - The feature toggle should be called after app initialisation - if (evaluateBooleanFlag('newPreferences', false)) { +// GOOD - The feature toggle should be called after app initialisation +function doThing() { + if (getFeatureFlagClient().getBooleanValue('newPreferences', false)) { // do new things } } + +// BAD - Don't do this. The feature toggle must wait until app initialisation +const isEnabled = getFeatureFlagClient().getBooleanValue('newPreferences', false); + +function doThing() { + if (isEnabled) { + // do new things + } +} + +// BAD - Don't do this. The feature toggle will not change in response to updates +class FooSrv { + constructor() { + this.isEnabled = getFeatureFlagClient().getBooleanValue('newPreferences', false); + } + + doThing() { + if (this.isEnabled) { + // do new things + } + } +} ``` ## Enabling toggles in development diff --git a/package.json b/package.json index 97360986aff..fd38d750994 100644 --- a/package.json +++ b/package.json @@ -313,6 +313,7 @@ "@msagl/core": "^1.1.19", "@msagl/parser": "^1.1.19", "@openfeature/ofrep-web-provider": "^0.3.3", + "@openfeature/react-sdk": "^1.2.0", "@openfeature/web-sdk": "^1.6.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/exporter-collector": "0.25.0", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 1b71e2513ae..98b00416391 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -60,7 +60,7 @@ "@grafana/ui": "12.4.0-pre", "@openfeature/core": "^1.9.0", "@openfeature/ofrep-web-provider": "^0.3.3", - "@openfeature/web-sdk": "^1.6.1", + "@openfeature/react-sdk": "^1.2.0", "@types/systemjs": "6.15.3", "history": "4.10.1", "lodash": "^4.17.23", diff --git a/packages/grafana-runtime/src/internal/index.ts b/packages/grafana-runtime/src/internal/index.ts index 46c78ee66f7..47b642a1523 100644 --- a/packages/grafana-runtime/src/internal/index.ts +++ b/packages/grafana-runtime/src/internal/index.ts @@ -28,7 +28,8 @@ export { export { UserStorage } from '../utils/userStorage'; -export { initOpenFeature, evaluateBooleanFlag } from './openFeature'; +export { initOpenFeature, getFeatureFlagClient } from '../internal/openFeature'; + export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps'; export { useAppPluginMeta, diff --git a/packages/grafana-runtime/src/internal/openFeature/index.ts b/packages/grafana-runtime/src/internal/openFeature/index.ts index e7c589235b6..f285149e704 100644 --- a/packages/grafana-runtime/src/internal/openFeature/index.ts +++ b/packages/grafana-runtime/src/internal/openFeature/index.ts @@ -1,5 +1,5 @@ import { OFREPWebProvider } from '@openfeature/ofrep-web-provider'; -import { OpenFeature } from '@openfeature/web-sdk'; +import { OpenFeature } from '@openfeature/react-sdk'; import { FeatureToggles } from '@grafana/data'; @@ -7,15 +7,16 @@ import { config } from '../../config'; export type FeatureFlagName = keyof FeatureToggles; -export async function initOpenFeature() { - /** - * Note: Currently we don't have a way to override OpenFeature flags for tests or localStorage. - * A few improvements we could make: - * - When running in tests (unit or e2e?), we could use InMemoryProvider instead - * - Use Multi-Provider to combine InMemoryProvider (for localStorage) with OFREPWebProvider - * to allow for overrides https://github.com/open-feature/js-sdk-contrib/tree/main/libs/providers/multi-provider - */ +// The domain creates the unique instance of the OpenFeature client for Grafana core, +// with its own evaluation context and provider. +// Plugins should not use this client or domain, and instead create their own client +// with a different domain to avoid conflicts. +// +// If changing this, you MUST also update the same constant in packages/grafana-test-utils/src/utilities/featureFlags.ts +// to ensure tests work correctly. +export const GRAFANA_CORE_OPEN_FEATURE_DOMAIN = 'internal-grafana-core'; +export async function initOpenFeature() { const subPath = config.appSubUrl || ''; const baseUrl = `${subPath}/apis/features.grafana.app/v0alpha1/namespaces/${config.namespace}`; @@ -25,12 +26,18 @@ export async function initOpenFeature() { timeoutMs: 5_000, }); - await OpenFeature.setProviderAndWait(ofProvider, { + await OpenFeature.setProviderAndWait(GRAFANA_CORE_OPEN_FEATURE_DOMAIN, ofProvider, { targetingKey: config.namespace, ...config.openFeatureContext, }); } -export function evaluateBooleanFlag(flagName: FeatureFlagName, defaultValue: boolean): boolean { - return OpenFeature.getClient().getBooleanValue(flagName, defaultValue); +/** + * Get the OpenFeature client for Grafana core. + * Prefer to instead use the React hooks for evaluating feature flags instead as they remain up to date with the latest flag values. + * If you must use this client directly, do not store the evaluation result for later - always call `getFeatureFlagClient().getFooValue()` just + * in time when you use it to ensure you get the latest value. + */ +export function getFeatureFlagClient() { + return OpenFeature.getClient(GRAFANA_CORE_OPEN_FEATURE_DOMAIN); } diff --git a/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts b/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts index 238fc9ffe53..d44a64c9688 100644 --- a/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts +++ b/packages/grafana-runtime/src/services/pluginMeta/apps.test.ts @@ -1,4 +1,4 @@ -import { evaluateBooleanFlag } from '../../internal/openFeature'; +import { setTestFlags } from '@grafana/test-utils/unstable'; import { getAppPluginMeta, @@ -11,20 +11,22 @@ import { initPluginMetas } from './plugins'; import { app } from './test-fixtures/config.apps'; jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() })); -jest.mock('../../internal/openFeature', () => ({ - ...jest.requireActual('../../internal/openFeature'), - evaluateBooleanFlag: jest.fn(), -})); const initPluginMetasMock = jest.mocked(initPluginMetas); -const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag); describe('when useMTPlugins flag is enabled and apps is not initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: true }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setAppPluginMetas({}); jest.resetAllMocks(); initPluginMetasMock.mockResolvedValue({ items: [] }); - evaluateBooleanFlagMock.mockReturnValue(true); }); it('getAppPluginMetas should call initPluginMetas and return correct result', async () => { @@ -57,10 +59,17 @@ describe('when useMTPlugins flag is enabled and apps is not initialized', () => }); describe('when useMTPlugins flag is enabled and apps is initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: true }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setAppPluginMetas({ 'myorg-someplugin-app': app }); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(true); }); it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { @@ -111,10 +120,17 @@ describe('when useMTPlugins flag is enabled and apps is initialized', () => { }); describe('when useMTPlugins flag is disabled and apps is not initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setAppPluginMetas({}); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(false); }); it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { @@ -147,10 +163,17 @@ describe('when useMTPlugins flag is disabled and apps is not initialized', () => }); describe('when useMTPlugins flag is disabled and apps is initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setAppPluginMetas({ 'myorg-someplugin-app': app }); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(false); }); it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => { @@ -201,10 +224,17 @@ describe('when useMTPlugins flag is disabled and apps is initialized', () => { }); describe('immutability', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setAppPluginMetas({ 'myorg-someplugin-app': app }); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(false); }); it('getAppPluginMetas should return a deep clone', async () => { diff --git a/packages/grafana-runtime/src/services/pluginMeta/apps.ts b/packages/grafana-runtime/src/services/pluginMeta/apps.ts index 7db359b5a4b..9757103dea6 100644 --- a/packages/grafana-runtime/src/services/pluginMeta/apps.ts +++ b/packages/grafana-runtime/src/services/pluginMeta/apps.ts @@ -1,7 +1,7 @@ import type { AppPluginConfig } from '@grafana/data'; import { config } from '../../config'; -import { evaluateBooleanFlag } from '../../internal/openFeature'; +import { getFeatureFlagClient } from '../../internal/openFeature'; import { getAppPluginMapper } from './mappers/mappers'; import { initPluginMetas } from './plugins'; @@ -14,7 +14,7 @@ function initialized(): boolean { } async function initAppPluginMetas(): Promise { - if (!evaluateBooleanFlag('useMTPlugins', false)) { + if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) { // eslint-disable-next-line no-restricted-syntax apps = config.apps; return; diff --git a/packages/grafana-runtime/src/services/pluginMeta/panels.test.ts b/packages/grafana-runtime/src/services/pluginMeta/panels.test.ts index d0264e3fef9..c0fe7702283 100644 --- a/packages/grafana-runtime/src/services/pluginMeta/panels.test.ts +++ b/packages/grafana-runtime/src/services/pluginMeta/panels.test.ts @@ -1,4 +1,4 @@ -import { evaluateBooleanFlag } from '../../internal/openFeature'; +import { setTestFlags } from '@grafana/test-utils/unstable'; import { getListedPanelPluginIds, @@ -12,20 +12,22 @@ import { initPluginMetas } from './plugins'; import { panel } from './test-fixtures/config.panels'; jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() })); -jest.mock('../../internal/openFeature', () => ({ - ...jest.requireActual('../../internal/openFeature'), - evaluateBooleanFlag: jest.fn(), -})); const initPluginMetasMock = jest.mocked(initPluginMetas); -const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag); describe('when useMTPlugins flag is enabled and panels is not initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: true }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setPanelPluginMetas({}); jest.resetAllMocks(); initPluginMetasMock.mockResolvedValue({ items: [] }); - evaluateBooleanFlagMock.mockReturnValue(true); }); it('getPanelPluginMetas should call initPluginMetas and return correct result', async () => { @@ -65,10 +67,17 @@ describe('when useMTPlugins flag is enabled and panels is not initialized', () = }); describe('when useMTPlugins flag is enabled and panels is initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: true }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setPanelPluginMetas({ 'grafana-test-panel': panel }); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(true); }); it('getPanelPluginMetas should not call initPluginMetas and return correct result', async () => { @@ -126,10 +135,17 @@ describe('when useMTPlugins flag is enabled and panels is initialized', () => { }); describe('when useMTPlugins flag is disabled and panels is not initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setPanelPluginMetas({}); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(false); }); it('getPanelPluginMetas should not call initPluginMetas and return correct result', async () => { @@ -169,10 +185,17 @@ describe('when useMTPlugins flag is disabled and panels is not initialized', () }); describe('when useMTPlugins flag is disabled and panels is initialized', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setPanelPluginMetas({ 'grafana-test-panel': panel }); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(false); }); it('getPanelPluginMetas should not call initPluginMetas and return correct result', async () => { @@ -230,10 +253,17 @@ describe('when useMTPlugins flag is disabled and panels is initialized', () => { }); describe('immutability', () => { + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); + }); + beforeEach(() => { setPanelPluginMetas({ 'grafana-test-panel': panel }); jest.resetAllMocks(); - evaluateBooleanFlagMock.mockReturnValue(false); }); it('getPanelPluginMetas should return a deep clone', async () => { diff --git a/packages/grafana-runtime/src/services/pluginMeta/panels.ts b/packages/grafana-runtime/src/services/pluginMeta/panels.ts index 02963cd32c6..0387d389b1b 100644 --- a/packages/grafana-runtime/src/services/pluginMeta/panels.ts +++ b/packages/grafana-runtime/src/services/pluginMeta/panels.ts @@ -1,7 +1,7 @@ import type { PanelPluginMeta } from '@grafana/data'; import { config } from '../../config'; -import { evaluateBooleanFlag } from '../../internal/openFeature'; +import { getFeatureFlagClient } from '../../internal/openFeature'; import { getPanelPluginMapper } from './mappers/mappers'; import { initPluginMetas } from './plugins'; @@ -14,7 +14,7 @@ function initialized(): boolean { } async function initPanelPluginMetas(): Promise { - if (!evaluateBooleanFlag('useMTPlugins', false)) { + if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) { // eslint-disable-next-line no-restricted-syntax panels = config.panels; return; diff --git a/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts b/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts index d2d6adfad5f..137e9d9a710 100644 --- a/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts +++ b/packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts @@ -1,17 +1,11 @@ -import { evaluateBooleanFlag } from '../../internal/openFeature'; +import { setTestFlags } from '@grafana/test-utils/unstable'; + import { invalidateCache, setLogger } from '../../utils/getCachedPromise'; import { type MonitoringLogger } from '../../utils/logging'; import { initPluginMetas } from './plugins'; import { v0alpha1Meta } from './test-fixtures/v0alpha1Response'; -jest.mock('../../internal/openFeature', () => ({ - ...jest.requireActual('../../internal/openFeature'), - evaluateBooleanFlag: jest.fn(), -})); - -const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag); - const originalFetch = global.fetch; let loggerMock: MonitoringLogger; @@ -33,8 +27,12 @@ afterEach(() => { }); describe('when useMTPlugins toggle is enabled and cache is not initialized', () => { - beforeEach(() => { - evaluateBooleanFlagMock.mockReturnValue(true); + beforeAll(() => { + setTestFlags({ useMTPlugins: true }); + }); + + afterAll(() => { + setTestFlags({}); }); it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => { @@ -54,8 +52,12 @@ describe('when useMTPlugins toggle is enabled and cache is not initialized', () }); describe('when useMTPlugins toggle is enabled and errors occur', () => { - beforeEach(() => { - evaluateBooleanFlagMock.mockReturnValue(true); + beforeAll(() => { + setTestFlags({ useMTPlugins: true }); + }); + + afterAll(() => { + setTestFlags({}); }); it('initPluginMetas should log when fetch fails', async () => { @@ -114,7 +116,14 @@ describe('when useMTPlugins toggle is enabled and errors occur', () => { describe('when useMTPlugins toggle is disabled and cache is not initialized', () => { beforeEach(() => { global.fetch = jest.fn(); - evaluateBooleanFlagMock.mockReturnValue(false); + }); + + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); }); it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => { @@ -128,7 +137,14 @@ describe('when useMTPlugins toggle is disabled and cache is not initialized', () describe('when useMTPlugins toggle is disabled and cache is initialized', () => { beforeEach(() => { global.fetch = jest.fn(); - evaluateBooleanFlagMock.mockReturnValue(false); + }); + + beforeAll(() => { + setTestFlags({ useMTPlugins: false }); + }); + + afterAll(() => { + setTestFlags({}); }); it('initPluginMetas should return cache', async () => { diff --git a/packages/grafana-runtime/src/services/pluginMeta/plugins.ts b/packages/grafana-runtime/src/services/pluginMeta/plugins.ts index 256f6cdd472..09667a2b743 100644 --- a/packages/grafana-runtime/src/services/pluginMeta/plugins.ts +++ b/packages/grafana-runtime/src/services/pluginMeta/plugins.ts @@ -1,5 +1,5 @@ import { config } from '../../config'; -import { evaluateBooleanFlag } from '../../internal/openFeature'; +import { getFeatureFlagClient } from '../../internal/openFeature'; import { getCachedPromise } from '../../utils/getCachedPromise'; import type { PluginMetasResponse } from './types'; @@ -9,7 +9,7 @@ function getApiVersion(): string { } async function loadPluginMetas(): Promise { - if (!evaluateBooleanFlag('useMTPlugins', false)) { + if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) { const result = { items: [] }; return result; } diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 3aca1eb8ffd..62aaa1bf880 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -1,4 +1,3 @@ -import { OpenFeature } from '@openfeature/web-sdk'; import { lastValueFrom, merge, Observable, of } from 'rxjs'; import { catchError, switchMap } from 'rxjs/operators'; @@ -22,6 +21,7 @@ import { import { reportInteraction } from '../analytics/utils'; import { config } from '../config'; +import { getFeatureFlagClient } from '../internal/openFeature'; import { BackendSrvRequest, FetchResponse, @@ -215,7 +215,7 @@ class DataSourceWithBackend< // Use the new query service if (config.featureToggles.queryServiceFromUI) { - const allowedTypes = OpenFeature.getClient().getObjectValue('datasources.querier.fe-allowed-types', { + const allowedTypes = getFeatureFlagClient().getObjectValue('datasources.querier.fe-allowed-types', { types: [], }); if (isQueryServiceCompatible(datasources, allowedTypes)) { diff --git a/packages/grafana-test-utils/package.json b/packages/grafana-test-utils/package.json index a5df9ed771d..e9515254d0f 100644 --- a/packages/grafana-test-utils/package.json +++ b/packages/grafana-test-utils/package.json @@ -53,6 +53,7 @@ "test:ci": "jest --maxWorkers 4" }, "dependencies": { + "@openfeature/react-sdk": "^1.2.0", "chance": "^1.1.13", "jest-matcher-utils": "29.7.0", "lodash": "^4.17.23", diff --git a/packages/grafana-test-utils/src/unstable.ts b/packages/grafana-test-utils/src/unstable.ts index 698d57a774c..150162bdcc7 100644 --- a/packages/grafana-test-utils/src/unstable.ts +++ b/packages/grafana-test-utils/src/unstable.ts @@ -11,3 +11,5 @@ export { } from './fixtures/scopes'; export { default as allHandlers } from './handlers/all-handlers'; export { default as scopeHandlers } from './handlers/apis/scope.grafana.app/v0alpha1/handlers'; + +export { setTestFlags, getTestFeatureFlagClient } from './utilities/featureFlags'; diff --git a/packages/grafana-test-utils/src/utilities/featureFlags.ts b/packages/grafana-test-utils/src/utilities/featureFlags.ts new file mode 100644 index 00000000000..f0345e5fa65 --- /dev/null +++ b/packages/grafana-test-utils/src/utilities/featureFlags.ts @@ -0,0 +1,49 @@ +import { InMemoryProvider, JsonValue, OpenFeature } from '@openfeature/react-sdk'; + +let ofProvider: InMemoryProvider; + +// If changing this, you MUST also update the same constant in packages/grafana-runtime/src/internal/openFeature/constants.ts +const GRAFANA_CORE_OPEN_FEATURE_DOMAIN = 'internal-grafana-core'; + +function initTestOpenFeatureClient(): void { + ofProvider ??= new InMemoryProvider(); + OpenFeature.setProvider(GRAFANA_CORE_OPEN_FEATURE_DOMAIN, ofProvider); +} + +/** + * Returns an OpenFeature client configured for use in tests. Not intended for general use im tests - prefer setting mock + * flag values via `setTestFlags` instead. + */ +export function getTestFeatureFlagClient() { + if (!ofProvider) { + initTestOpenFeatureClient(); + } + + return OpenFeature.getClient(GRAFANA_CORE_OPEN_FEATURE_DOMAIN); +} + +type FlagSet = Record; + +/** + * Sets OpenFeature flag values for tests. Call it with an object of flag names and their values. + * This modifies the global environment, so be sure to reset flags in `after` / `afterEach`. + */ +export function setTestFlags(flags: FlagSet = {}) { + if (!ofProvider) { + initTestOpenFeatureClient(); + } + + const flagConfig: Parameters[0] = {}; + + for (const [flagName, value] of Object.entries(flags)) { + flagConfig[flagName] = { + variants: { + testedVariant: value, + }, + defaultVariant: 'testedVariant', + disabled: false, + }; + } + + ofProvider.putConfiguration(flagConfig); +} diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index cfd930ae853..26e6204265b 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -1,3 +1,4 @@ +import { OpenFeatureProvider } from '@openfeature/react-sdk'; import { UNSAFE_PortalProvider } from '@react-aria/overlays'; import { Action, KBarProvider } from 'kbar'; import { Component, ComponentType, Fragment, ReactNode } from 'react'; @@ -6,6 +7,7 @@ import { Provider } from 'react-redux'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { config, navigationLogger, reportInteraction } from '@grafana/runtime'; +import { getFeatureFlagClient } from '@grafana/runtime/internal'; import { ErrorBoundaryAlert, getPortalContainer, GlobalStyles, PortalContainer, TimeRangeProvider } from '@grafana/ui'; import { getAppRoutes } from 'app/routes/routes'; import { store } from 'app/store/store'; @@ -119,33 +121,35 @@ export class AppWrapper extends Component { return ( - - - - - - - - - - -
- - - -
-
-
-
-
-
-
-
-
-
+ + + + + + + + + + + +
+ + + +
+
+
+
+
+
+
+
+
+
+
); diff --git a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts index a260b0a0490..b637408bebf 100644 --- a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts +++ b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts @@ -10,7 +10,7 @@ import { } from '@grafana/faro-web-sdk'; import { TracingInstrumentation } from '@grafana/faro-web-tracing'; import { EchoBackend, EchoEvent, EchoEventType } from '@grafana/runtime'; -import { evaluateBooleanFlag } from '@grafana/runtime/internal'; +import { getFeatureFlagClient } from '@grafana/runtime/internal'; import { EchoSrvTransport } from './EchoSrvTransport'; import { beforeSendHandler } from './beforeSendHandler'; @@ -49,7 +49,7 @@ export class GrafanaJavascriptAgentBackend instrumentations.push(new TracingInstrumentation()); } - if (evaluateBooleanFlag('faroSessionReplay', false)) { + if (getFeatureFlagClient().getBooleanValue('faroSessionReplay', false)) { instrumentations.push( new ReplayInstrumentation({ maskAllInputs: true, diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 14a27277af0..cd417bd9dfa 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -1,11 +1,11 @@ import { css } from '@emotion/css'; +import { useBooleanFlagValue } from '@openfeature/react-sdk'; import { memo, useEffect, useMemo, useRef } from 'react'; import { useLocation, useParams } from 'react-router-dom-v5-compat'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { evaluateBooleanFlag } from '@grafana/runtime/internal'; import { FilterInput, useStyles2, Text, Stack } from '@grafana/ui'; import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks'; import { Page } from 'app/core/components/Page/Page'; @@ -41,7 +41,9 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record new URLSearchParams(location.search), [location.search]); const { isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUID }); - const isRecentlyViewedEnabled = !folderUID && evaluateBooleanFlag('recentlyViewedDashboards', false); + const isRecentlyViewedEnabledValue = useBooleanFlagValue('recentlyViewedDashboards', false); + const isExperimentRecentlyViewedDashboards = useBooleanFlagValue('experimentRecentlyViewedDashboards', false); + const isRecentlyViewedEnabled = !folderUID && isRecentlyViewedEnabledValue; useEffect(() => { stateManager.initStateFromUrl(folderUID); @@ -80,13 +82,13 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record - - - - - {children} - - - - + + + + + + {children} + + + + + ); }; diff --git a/yarn.lock b/yarn.lock index 5419041f443..0de15470270 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3913,7 +3913,7 @@ __metadata: "@grafana/ui": "npm:12.4.0-pre" "@openfeature/core": "npm:^1.9.0" "@openfeature/ofrep-web-provider": "npm:^0.3.3" - "@openfeature/web-sdk": "npm:^1.6.1" + "@openfeature/react-sdk": "npm:^1.2.0" "@rollup/plugin-node-resolve": "npm:16.0.1" "@rollup/plugin-terser": "npm:0.4.4" "@testing-library/dom": "npm:10.4.1" @@ -4084,6 +4084,7 @@ __metadata: version: 0.0.0-use.local resolution: "@grafana/test-utils@workspace:packages/grafana-test-utils" dependencies: + "@openfeature/react-sdk": "npm:^1.2.0" "@swc/core": "npm:1.13.3" "@swc/jest": "npm:^0.2.26" "@types/chance": "npm:^1.1.7" @@ -6566,6 +6567,16 @@ __metadata: languageName: node linkType: hard +"@openfeature/react-sdk@npm:^1.2.0": + version: 1.2.0 + resolution: "@openfeature/react-sdk@npm:1.2.0" + peerDependencies: + "@openfeature/web-sdk": ^1.5.0 + react: ">=16.8.0" + checksum: 10/56411b9e6fbf79e13b2cbe2f45a632ba933127cad9b0e59bc714646d95516b51173e9aadd51fb829f27aba1f29383f2a5cd4d3cd97ae49a673e3175453e30c6e + languageName: node + linkType: hard + "@openfeature/web-sdk@npm:^1.6.1": version: 1.7.2 resolution: "@openfeature/web-sdk@npm:1.7.2" @@ -20685,6 +20696,7 @@ __metadata: "@msagl/parser": "npm:^1.1.19" "@npmcli/package-json": "npm:^6.0.0" "@openfeature/ofrep-web-provider": "npm:^0.3.3" + "@openfeature/react-sdk": "npm:^1.2.0" "@openfeature/web-sdk": "npm:^1.6.1" "@opentelemetry/api": "npm:1.9.0" "@opentelemetry/exporter-collector": "npm:0.25.0"