mirror of
https://github.com/grafana/grafana.git
synced 2026-02-18 18:20:52 -05:00
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>
This commit is contained in:
parent
aee6c2cfb0
commit
5de0ffefee
20 changed files with 319 additions and 113 deletions
|
|
@ -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 <NewPreferencesUI />;
|
||||
}
|
||||
|
||||
return <LegacyPreferencesUI />;
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (!evaluateBooleanFlag('useMTPlugins', false)) {
|
||||
if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
apps = config.apps;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (!evaluateBooleanFlag('useMTPlugins', false)) {
|
||||
if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
panels = config.panels;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<PluginMetasResponse> {
|
||||
if (!evaluateBooleanFlag('useMTPlugins', false)) {
|
||||
if (!getFeatureFlagClient().getBooleanValue('useMTPlugins', false)) {
|
||||
const result = { items: [] };
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
49
packages/grafana-test-utils/src/utilities/featureFlags.ts
Normal file
49
packages/grafana-test-utils/src/utilities/featureFlags.ts
Normal file
|
|
@ -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<string, boolean | string | number | JsonValue>;
|
||||
|
||||
/**
|
||||
* 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<typeof ofProvider.putConfiguration>[0] = {};
|
||||
|
||||
for (const [flagName, value] of Object.entries(flags)) {
|
||||
flagConfig[flagName] = {
|
||||
variants: {
|
||||
testedVariant: value,
|
||||
},
|
||||
defaultVariant: 'testedVariant',
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
ofProvider.putConfiguration(flagConfig);
|
||||
}
|
||||
|
|
@ -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<AppWrapperProps, AppWrapperState> {
|
|||
return (
|
||||
<Provider store={store}>
|
||||
<ErrorBoundaryAlert boundaryName="app-wrapper" style="page">
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<ThemeProvider value={config.theme2}>
|
||||
<CacheProvider name={this.iconCacheID}>
|
||||
<KBarProvider
|
||||
actions={[]}
|
||||
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
|
||||
>
|
||||
<MaybeTimeRangeProvider>
|
||||
<ScopesContextProvider>
|
||||
<ExtensionRegistriesProvider registries={registries}>
|
||||
<ExtensionSidebarContextProvider>
|
||||
<UNSAFE_PortalProvider getContainer={getPortalContainer}>
|
||||
<GlobalStyles />
|
||||
<div className="grafana-app">
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
</UNSAFE_PortalProvider>
|
||||
</ExtensionSidebarContextProvider>
|
||||
</ExtensionRegistriesProvider>
|
||||
</ScopesContextProvider>
|
||||
</MaybeTimeRangeProvider>
|
||||
</KBarProvider>
|
||||
</CacheProvider>
|
||||
</ThemeProvider>
|
||||
</GrafanaContext.Provider>
|
||||
<OpenFeatureProvider client={getFeatureFlagClient()}>
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<ThemeProvider value={config.theme2}>
|
||||
<CacheProvider name={this.iconCacheID}>
|
||||
<KBarProvider
|
||||
actions={[]}
|
||||
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
|
||||
>
|
||||
<MaybeTimeRangeProvider>
|
||||
<ScopesContextProvider>
|
||||
<ExtensionRegistriesProvider registries={registries}>
|
||||
<ExtensionSidebarContextProvider>
|
||||
<UNSAFE_PortalProvider getContainer={getPortalContainer}>
|
||||
<GlobalStyles />
|
||||
<div className="grafana-app">
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
</UNSAFE_PortalProvider>
|
||||
</ExtensionSidebarContextProvider>
|
||||
</ExtensionRegistriesProvider>
|
||||
</ScopesContextProvider>
|
||||
</MaybeTimeRangeProvider>
|
||||
</KBarProvider>
|
||||
</CacheProvider>
|
||||
</ThemeProvider>
|
||||
</GrafanaContext.Provider>
|
||||
</OpenFeatureProvider>
|
||||
</ErrorBoundaryAlert>
|
||||
</Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string
|
|||
const location = useLocation();
|
||||
const search = useMemo(() => 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<string
|
|||
}
|
||||
|
||||
hasEmittedExposureEvent.current = true;
|
||||
const isExperimentTreatment = evaluateBooleanFlag('experimentRecentlyViewedDashboards', false);
|
||||
const isExperimentTreatment = isExperimentRecentlyViewedDashboards;
|
||||
|
||||
reportInteraction('dashboards_browse_list_viewed', {
|
||||
experiment_dashboard_list_recently_viewed: isExperimentTreatment ? 'treatment' : 'control',
|
||||
has_recently_viewed_component: isExperimentTreatment,
|
||||
});
|
||||
}, [isRecentlyViewedEnabled]);
|
||||
}, [isRecentlyViewedEnabled, isExperimentRecentlyViewedDashboards]);
|
||||
|
||||
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
|
||||
const [saveFolder] = useUpdateFolder();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { OpenFeatureProvider } from '@openfeature/react-sdk';
|
||||
import { Store } from '@reduxjs/toolkit';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
setChromeHeaderHeightHook,
|
||||
setLocationService,
|
||||
} from '@grafana/runtime';
|
||||
import { getTestFeatureFlagClient } from '@grafana/test-utils/unstable';
|
||||
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
|
||||
import { ModalsContextProvider } from 'app/core/context/ModalsContextProvider';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
|
@ -86,15 +88,17 @@ const getWrapper = ({
|
|||
return function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Provider store={reduxStore}>
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<PotentialRouter>
|
||||
<LocationServiceProvider service={locationService}>
|
||||
<PotentialCompatRouter>
|
||||
<ModalsContextProvider>{children}</ModalsContextProvider>
|
||||
</PotentialCompatRouter>
|
||||
</LocationServiceProvider>
|
||||
</PotentialRouter>
|
||||
</GrafanaContext.Provider>
|
||||
<OpenFeatureProvider client={getTestFeatureFlagClient()}>
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<PotentialRouter>
|
||||
<LocationServiceProvider service={locationService}>
|
||||
<PotentialCompatRouter>
|
||||
<ModalsContextProvider>{children}</ModalsContextProvider>
|
||||
</PotentialCompatRouter>
|
||||
</LocationServiceProvider>
|
||||
</PotentialRouter>
|
||||
</GrafanaContext.Provider>
|
||||
</OpenFeatureProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
14
yarn.lock
14
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue