diff --git a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts index e93d91ae7c..44ef958369 100644 --- a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts +++ b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts @@ -38,7 +38,7 @@ export const updateClusterSettings = async ( notebookTolerationSettings, modelServingPlatformEnabled, } = request.body; - const dashConfig = getDashboardConfig(); + const dashConfig = getDashboardConfig(request); const isJupyterEnabled = checkJupyterEnabled(); try { if ( @@ -124,10 +124,11 @@ export const updateClusterSettings = async ( export const getClusterSettings = async ( fastify: KubeFastifyInstance, + request: FastifyRequest, ): Promise => { const coreV1Api = fastify.kube.coreV1Api; const namespace = fastify.kube.namespace; - const dashConfig = getDashboardConfig(); + const dashConfig = getDashboardConfig(request); const isJupyterEnabled = checkJupyterEnabled(); const clusterSettings: ClusterSettings = { ...DEFAULT_CLUSTER_SETTINGS, diff --git a/backend/src/routes/api/cluster-settings/index.ts b/backend/src/routes/api/cluster-settings/index.ts index 9b106fe99d..b7a7832dca 100644 --- a/backend/src/routes/api/cluster-settings/index.ts +++ b/backend/src/routes/api/cluster-settings/index.ts @@ -7,7 +7,7 @@ export default async (fastify: FastifyInstance): Promise => { fastify.get( '/', secureAdminRoute(fastify)(async (request: FastifyRequest, reply: FastifyReply) => { - return getClusterSettings(fastify) + return getClusterSettings(fastify, request) .then((res) => { return res; }) diff --git a/backend/src/routes/api/storage/index.ts b/backend/src/routes/api/storage/index.ts index f146965bad..4a93fa3894 100644 --- a/backend/src/routes/api/storage/index.ts +++ b/backend/src/routes/api/storage/index.ts @@ -14,7 +14,7 @@ export default async (fastify: FastifyInstance): Promise => { reply: FastifyReply, ) => { try { - const dashConfig = getDashboardConfig(); + const dashConfig = getDashboardConfig(request); if (dashConfig?.spec.dashboardConfig.disableS3Endpoint !== false) { reply.code(404).send('Not found'); return reply; @@ -49,7 +49,7 @@ export default async (fastify: FastifyInstance): Promise => { reply: FastifyReply, ) => { try { - const dashConfig = getDashboardConfig(); + const dashConfig = getDashboardConfig(request); if (dashConfig?.spec.dashboardConfig.disableS3Endpoint !== false) { reply.code(404).send('Not found'); return reply; diff --git a/backend/src/utils/resourceUtils.ts b/backend/src/utils/resourceUtils.ts index 3feeb60a5c..dd7465d001 100644 --- a/backend/src/utils/resourceUtils.ts +++ b/backend/src/utils/resourceUtils.ts @@ -34,6 +34,7 @@ import { getIsAppEnabled, getRouteForApplication, getRouteForClusterId } from '. import { createCustomError } from './requestUtils'; import { getDetectedAccelerators } from '../routes/api/accelerators/acceleratorUtils'; import { RecursivePartial } from '../typeHelpers'; +import { FastifyRequest } from 'fastify'; const dashboardConfigMapName = 'odh-dashboard-config'; const consoleLinksGroup = 'console.openshift.io'; @@ -548,8 +549,32 @@ export const initializeWatchedResources = (fastify: KubeFastifyInstance): void = consoleLinksWatcher = new ResourceWatcher(fastify, fetchConsoleLinks); }; -export const getDashboardConfig = (): DashboardConfig => { - return dashboardConfigWatcher.getResources()?.[0]; +const FEATURE_FLAGS_HEADER = 'x-odh-feature-flags'; + +// if inspecting feature flags, provide the request to ensure overridden feature flags are considered +export const getDashboardConfig = (request?: FastifyRequest): DashboardConfig => { + const dashboardConfig = dashboardConfigWatcher.getResources()?.[0]; + if (request) { + const flagsHeader = request.headers[FEATURE_FLAGS_HEADER]; + if (typeof flagsHeader === 'string') { + try { + const featureFlags = JSON.parse(flagsHeader); + return { + ...dashboardConfig, + spec: { + ...dashboardConfig.spec, + dashboardConfig: { + ...dashboardConfig.spec.dashboardConfig, + ...featureFlags, + }, + }, + }; + } catch { + // ignore + } + } + } + return dashboardConfig; }; export const updateDashboardConfig = (): Promise => { diff --git a/frontend/.eslintrc b/frontend/.eslintrc index 99d78939d6..58e1f1550e 100755 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -111,18 +111,25 @@ "no-restricted-imports": [ "error", { + "paths": [ + { + "name": "^axios$", + "importNames": ["default"], + "message": "Import from `~/utilities/axios` instead." + } + ], "patterns": [ { "group": ["~/api/**"], - "message": "Read from '~/api' instead." + "message": "Import from '~/api' instead." }, { "group": ["~/components/table/**", "!~/components/table/useTableColumnSort"], - "message": "Read from '~/components/table' instead." + "message": "Import from '~/components/table' instead." }, { "group": ["~/concepts/area/**"], - "message": "Read from '~/concepts/area' instead." + "message": "Import from '~/concepts/area' instead." }, { "group": ["~/components/table/useTableColumnSort"], @@ -290,7 +297,10 @@ ], "overrides": [ { - "files": ["./src/__tests__/cypress/cypress/pages/*.ts", "./src/__tests__/cypress/cypress/tests/e2e/*.ts"], + "files": [ + "./src/__tests__/cypress/cypress/pages/*.ts", + "./src/__tests__/cypress/cypress/tests/e2e/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", diff --git a/frontend/src/api/k8s/__tests__/projects.spec.ts b/frontend/src/api/k8s/__tests__/projects.spec.ts index a6a863a65f..40ad337a29 100644 --- a/frontend/src/api/k8s/__tests__/projects.spec.ts +++ b/frontend/src/api/k8s/__tests__/projects.spec.ts @@ -4,7 +4,7 @@ import { k8sUpdateResource, k8sDeleteResource, } from '@openshift/dynamic-plugin-sdk-utils'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import { mockAxiosError } from '~/__mocks__/mockAxiosError'; @@ -40,7 +40,7 @@ jest.mock('~/api/k8s/servingRuntimes.ts', () => ({ listServingRuntimes: jest.fn(), })); -jest.mock('axios'); +jest.mock('~/utilities/axios'); const mockedAxios = jest.mocked(axios); const k8sListResourceMock = jest.mocked(k8sListResource); diff --git a/frontend/src/api/k8s/projects.ts b/frontend/src/api/k8s/projects.ts index c4b7a8df6e..8f9f800aea 100644 --- a/frontend/src/api/k8s/projects.ts +++ b/frontend/src/api/k8s/projects.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import { k8sCreateResource, k8sDeleteResource, @@ -6,6 +5,7 @@ import { K8sResourceCommon, k8sUpdateResource, } from '@openshift/dynamic-plugin-sdk-utils'; +import axios from '~/utilities/axios'; import { CustomWatchK8sResult } from '~/types'; import { K8sAPIOptions, ProjectKind } from '~/k8sTypes'; import { ProjectModel, ProjectRequestModel } from '~/api/models'; diff --git a/frontend/src/api/prometheus/__tests__/distributedWorkloads.spec.ts b/frontend/src/api/prometheus/__tests__/distributedWorkloads.spec.ts index a977e44f93..50c6e79b8e 100644 --- a/frontend/src/api/prometheus/__tests__/distributedWorkloads.spec.ts +++ b/frontend/src/api/prometheus/__tests__/distributedWorkloads.spec.ts @@ -1,5 +1,5 @@ import { act } from '@testing-library/react'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import { mockPrometheusQueryVectorResponse } from '~/__mocks__/mockPrometheusQueryVectorResponse'; import { mockWorkloadK8sResource } from '~/__mocks__/mockWorkloadK8sResource'; import { WorkloadKind, WorkloadOwnerType } from '~/k8sTypes'; @@ -310,7 +310,7 @@ describe('getTopResourceConsumingWorkloads', () => { }); }); -jest.mock('axios', () => ({ +jest.mock('~/utilities/axios', () => ({ post: jest.fn(), })); diff --git a/frontend/src/api/prometheus/__tests__/pvcs.spec.ts b/frontend/src/api/prometheus/__tests__/pvcs.spec.ts index 9c17f9c3a6..671edb52df 100644 --- a/frontend/src/api/prometheus/__tests__/pvcs.spec.ts +++ b/frontend/src/api/prometheus/__tests__/pvcs.spec.ts @@ -1,12 +1,12 @@ import { act } from '@testing-library/react'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import { mockPVCK8sResource } from '~/__mocks__/mockPVCK8sResource'; import { mockPrometheusQueryResponse } from '~/__mocks__/mockPrometheusQueryResponse'; import { testHook } from '~/__tests__/unit/testUtils/hooks'; import { usePVCFreeAmount } from '~/api/prometheus/pvcs'; import { POLL_INTERVAL } from '~/utilities/const'; -jest.mock('axios', () => ({ +jest.mock('~/utilities/axios', () => ({ post: jest.fn(), })); diff --git a/frontend/src/api/prometheus/__tests__/usePrometheusQuery.spec.ts b/frontend/src/api/prometheus/__tests__/usePrometheusQuery.spec.ts index 9e2b411911..84a74b7160 100644 --- a/frontend/src/api/prometheus/__tests__/usePrometheusQuery.spec.ts +++ b/frontend/src/api/prometheus/__tests__/usePrometheusQuery.spec.ts @@ -1,10 +1,10 @@ import { act } from '@testing-library/react'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import { mockPrometheusQueryResponse } from '~/__mocks__/mockPrometheusQueryResponse'; import { standardUseFetchState, testHook } from '~/__tests__/unit/testUtils/hooks'; import usePrometheusQuery from '~/api/prometheus/usePrometheusQuery'; -jest.mock('axios', () => ({ +jest.mock('~/utilities/axios', () => ({ post: jest.fn(), })); diff --git a/frontend/src/api/prometheus/__tests__/usePrometheusQueryRange.spec.ts b/frontend/src/api/prometheus/__tests__/usePrometheusQueryRange.spec.ts index 70b1ce8266..4c4ac31942 100644 --- a/frontend/src/api/prometheus/__tests__/usePrometheusQueryRange.spec.ts +++ b/frontend/src/api/prometheus/__tests__/usePrometheusQueryRange.spec.ts @@ -1,11 +1,11 @@ -import axios from 'axios'; import { act } from '@testing-library/react'; +import axios from '~/utilities/axios'; import { testHook } from '~/__tests__/unit/testUtils/hooks'; import { mockPrometheusServing } from '~/__mocks__/mockPrometheusServing'; import usePrometheusQueryRange from '~/api/prometheus/usePrometheusQueryRange'; import { PrometheusQueryRangeResponseData } from '~/types'; -jest.mock('axios', () => ({ +jest.mock('~/utilities/axios', () => ({ post: jest.fn(), })); diff --git a/frontend/src/api/prometheus/usePrometheusQuery.ts b/frontend/src/api/prometheus/usePrometheusQuery.ts index baabb4e7e5..19ce8cf9b4 100644 --- a/frontend/src/api/prometheus/usePrometheusQuery.ts +++ b/frontend/src/api/prometheus/usePrometheusQuery.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import { PrometheusQueryResponse } from '~/types'; import useFetchState, { FetchOptions, FetchState, NotReadyError } from '~/utilities/useFetchState'; diff --git a/frontend/src/api/prometheus/usePrometheusQueryRange.ts b/frontend/src/api/prometheus/usePrometheusQueryRange.ts index 45214cfcc8..874daaf3af 100644 --- a/frontend/src/api/prometheus/usePrometheusQueryRange.ts +++ b/frontend/src/api/prometheus/usePrometheusQueryRange.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import useFetchState, { FetchOptions, diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 6988612dc8..082417d7e0 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -20,6 +20,7 @@ import useDetectUser from '~/utilities/useDetectUser'; import ProjectsContextProvider from '~/concepts/projects/ProjectsContext'; import useStorageClasses from '~/concepts/k8s/useStorageClasses'; import AreaContextProvider from '~/concepts/areas/AreaContext'; +import useDevFeatureFlags from './useDevFeatureFlags'; import Header from './Header'; import AppRoutes from './AppRoutes'; import NavSidebar from './NavSidebar'; @@ -29,6 +30,7 @@ import { useApplicationSettings } from './useApplicationSettings'; import TelemetrySetup from './TelemetrySetup'; import { logout } from './appUtils'; import QuickStarts from './QuickStarts'; +import DevFeatureFlagsBanner from './DevFeatureFlagsBanner'; import './App.scss'; @@ -38,11 +40,14 @@ const App: React.FC = () => { const buildStatuses = useWatchBuildStatus(); const { - dashboardConfig, + dashboardConfig: dashboardConfigFromServer, loaded: configLoaded, loadError: fetchConfigError, } = useApplicationSettings(); + const { dashboardConfig, ...devFeatureFlagsProps } = + useDevFeatureFlags(dashboardConfigFromServer); + const [storageClasses] = useStorageClasses(); useDetectUser(); @@ -115,6 +120,10 @@ const App: React.FC = () => { data-testid={DASHBOARD_MAIN_CONTAINER_ID} > + diff --git a/frontend/src/app/DevFeatureFlagsBanner.tsx b/frontend/src/app/DevFeatureFlagsBanner.tsx new file mode 100644 index 0000000000..2a4ba5524a --- /dev/null +++ b/frontend/src/app/DevFeatureFlagsBanner.tsx @@ -0,0 +1,131 @@ +import { + Banner, + Button, + Checkbox, + Grid, + GridItem, + Modal, + Split, + SplitItem, + Tooltip, +} from '@patternfly/react-core'; +import { CloseIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { allFeatureFlags } from '~/concepts/areas/const'; +import { isFeatureFlag } from '~/concepts/areas/utils'; +import { DashboardCommonConfig } from '~/k8sTypes'; +import { DevFeatureFlags } from '~/types'; + +type Props = { dashboardConfig: Partial } & DevFeatureFlags; + +const DevFeatureFlagsBanner: React.FC = ({ + dashboardConfig, + devFeatureFlags, + setDevFeatureFlag, + resetDevFeatureFlags, + setDevFeatureFlagQueryVisible, +}) => { + const [isBannerHidden, setBannerHidden] = React.useState(false); + const [isModalOpen, setModalOpen] = React.useState(false); + + if (!devFeatureFlags || isBannerHidden) { + return null; + } + + const renderDevFeatureFlags = () => ( + + {allFeatureFlags + .filter(isFeatureFlag) + .toSorted() + .map((key) => { + const value = devFeatureFlags[key] ?? dashboardConfig[key]; + return ( + + + { + setDevFeatureFlag(key, checked); + }} + /> + + {`${value ?? ''}${ + key in devFeatureFlags ? ' (overridden)' : '' + }`} + + ); + })} + + ); + return ( + <> + + + + Feature flags are{' '} + {' '} + in the current session.{' '} + {' '} + to reset back to defaults. + + + + , + ]} + > + {renderDevFeatureFlags()} + + + ); +}; + +export default DevFeatureFlagsBanner; diff --git a/frontend/src/app/__tests__/DevFeatureFlagsBanner.spec.tsx b/frontend/src/app/__tests__/DevFeatureFlagsBanner.spec.tsx new file mode 100644 index 0000000000..4672b57072 --- /dev/null +++ b/frontend/src/app/__tests__/DevFeatureFlagsBanner.spec.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import DevFeatureFlagsBanner from '~/app/DevFeatureFlagsBanner'; + +describe('DevFeatureFlagsBanner', () => { + it('should not render if no feature flags are overridden', () => { + const result = render( + undefined} + resetDevFeatureFlags={() => undefined} + devFeatureFlags={null} + setDevFeatureFlagQueryVisible={() => undefined} + />, + ); + expect(result.container).toBeEmptyDOMElement(); + }); + + it('should render banner and open modal', () => { + const resetFn = jest.fn(); + const visibleFn = jest.fn(); + const result = render( + undefined} + resetDevFeatureFlags={resetFn} + setDevFeatureFlagQueryVisible={visibleFn} + devFeatureFlags={{}} + />, + ); + expect(result.container).not.toBeEmptyDOMElement(); + act(() => result.getByTestId('override-feature-flags-button').click()); + result.getByTestId('dev-feature-flags-modal'); + + act(() => result.getByTestId('reset-feature-flags-button').click()); + expect(resetFn).toHaveBeenCalled(); + expect(visibleFn).toHaveBeenLastCalledWith(true); + act(() => result.getByRole('button', { name: 'Close' }).click()); + expect(visibleFn).toHaveBeenLastCalledWith(false); + }); + + it('should render and set feature flags', () => { + const setFeatureFlagFn = jest.fn(); + const resetFn = jest.fn(); + const result = render( + undefined} + devFeatureFlags={{ + disableHome: true, + }} + />, + ); + expect(result.container).not.toBeEmptyDOMElement(); + act(() => result.getByTestId('override-feature-flags-button').click()); + screen.getByTestId('dev-feature-flags-modal'); + + act(() => result.getByTestId('reset-feature-flags-button').click()); + expect(resetFn).toHaveBeenCalled(); + + expect(result.getByTestId('disableHome-checkbox')).toBeChecked(); + expect(result.getByTestId('disableAcceleratorProfiles-checkbox')).not.toBeChecked(); + expect(result.getByTestId('enablement-checkbox')).toBePartiallyChecked(); + + expect(result.getByTestId('disableHome-value').textContent).toBe('true (overridden)'); + expect(result.getByTestId('disableAcceleratorProfiles-value').textContent).toBe('false'); + expect(result.getByTestId('enablement-value').textContent).toBe(''); + + act(() => { + result.getByTestId('disableHome-checkbox').click(); + result.getByTestId('disableAcceleratorProfiles-checkbox').click(); + result.getByTestId('enablement-checkbox').click(); + }); + + expect(setFeatureFlagFn).toHaveBeenCalledTimes(3); + expect(setFeatureFlagFn.mock.calls).toEqual([ + ['disableHome', false], + ['disableAcceleratorProfiles', true], + ['enablement', true], + ]); + }); +}); diff --git a/frontend/src/app/__tests__/useDevFeatureFlags.spec.ts b/frontend/src/app/__tests__/useDevFeatureFlags.spec.ts new file mode 100644 index 0000000000..06f3e6ac33 --- /dev/null +++ b/frontend/src/app/__tests__/useDevFeatureFlags.spec.ts @@ -0,0 +1,157 @@ +import { merge } from 'lodash-es'; +import { act } from '@testing-library/react'; +import { useSearchParams } from 'react-router-dom'; +import { testHook } from '~/__tests__/unit/testUtils/hooks'; +import useDevFeatureFlags from '~/app/useDevFeatureFlags'; +import { useBrowserStorage } from '~/components/browserStorage'; +import { DashboardCommonConfig, DashboardConfigKind } from '~/k8sTypes'; +import axios from '~/utilities/axios'; + +jest.mock('react-router-dom', () => ({ + useSearchParams: jest.fn(() => [ + { get: jest.fn(() => null), has: jest.fn(() => false), delete: jest.fn() }, + jest.fn(), + ]), +})); +jest.mock('~/components/browserStorage', () => ({ + useBrowserStorage: jest.fn(() => [null, jest.fn()]), +})); +jest.mock('~/utilities/axios', () => ({ + __esModule: true, + default: { + defaults: { + headers: { + common: [], + }, + }, + }, +})); + +const axiosMock = jest.mocked(axios); +const useSearchParamsMock = jest.mocked(useSearchParams); +const useBrowserStorageMock = jest.mocked(useBrowserStorage); + +const mockSession = (sessionFlags: Partial | null) => { + const setSessionFn = jest.fn(); + useBrowserStorageMock.mockReturnValue([sessionFlags, setSessionFn]); + return { sessionFlags, setSessionFn }; +}; + +const mockUseSearchParams = (queryFlags: { [key in string]: boolean } | null) => { + const backing = new URLSearchParams({ + foo: 'bar', + ...(queryFlags + ? { + devFeatureFlags: Object.entries(queryFlags) + .map(([key, value]) => `${key}=${value}`) + .join(','), + } + : {}), + }); + const getFn = jest.fn((name: string) => backing.get(name)); + const hasFn = jest.fn((name: string) => backing.has(name)); + const deleteFn = jest.fn((name: string) => backing.delete(name)); + const searchParams = { + get: getFn, + has: hasFn, + delete: deleteFn, + toString: () => backing.toString(), + } as unknown as ReturnType[0]; + const setSearchParamsFn = jest.fn(); + useSearchParamsMock.mockReturnValue([searchParams, setSearchParamsFn]); + return { queryFlags, searchParams, setSearchParamsFn }; +}; + +describe('useDevFeatureFlags', () => { + it('should pass through dashboardConfig if no dev feature flags set', () => { + const dashboardConfig = {} as DashboardConfigKind; + const renderResult = testHook(useDevFeatureFlags)(dashboardConfig); + expect(renderResult.result.current.dashboardConfig).toBe(dashboardConfig); + expect(renderResult.result.current).toEqual({ + dashboardConfig, + devFeatureFlags: null, + resetDevFeatureFlags: expect.any(Function), + setDevFeatureFlag: expect.any(Function), + setDevFeatureFlagQueryVisible: expect.any(Function), + }); + }); + + it('should load flags from session', () => { + const { sessionFlags, setSessionFn } = mockSession({ + disableHome: true, + disableAppLauncher: false, + }); + const dashboardConfig = { + spec: { dashboardConfig: { enablement: true } }, + } as DashboardConfigKind; + const renderResult = testHook(useDevFeatureFlags)(dashboardConfig); + expect(renderResult.result.current).toEqual({ + dashboardConfig: merge(dashboardConfig, { spec: { dashboardConfig: sessionFlags } }), + devFeatureFlags: sessionFlags, + resetDevFeatureFlags: expect.any(Function), + setDevFeatureFlag: expect.any(Function), + setDevFeatureFlagQueryVisible: expect.any(Function), + }); + + expect(axiosMock.defaults.headers.common['x-odh-feature-flags']).toEqual( + JSON.stringify({ + disableHome: true, + disableAppLauncher: false, + }), + ); + + act(() => renderResult.result.current.resetDevFeatureFlags()); + expect(setSessionFn).toHaveBeenCalledWith(null); + act(() => renderResult.result.current.setDevFeatureFlag('disableInfo', false)); + expect(setSessionFn).toHaveBeenLastCalledWith({ + disableAppLauncher: false, + disableHome: true, + disableInfo: false, + }); + }); + + it('should load flags from query string', () => { + const { setSessionFn } = mockSession(null); + const { searchParams, setSearchParamsFn } = mockUseSearchParams({ + disableHome: true, + enablement: true, + info: false, + invalid: true, + }); + const dashboardConfig = { + spec: { dashboardConfig: { disableAppLauncher: true } }, + } as DashboardConfigKind; + const renderResult = testHook(useDevFeatureFlags)(dashboardConfig); + expect(renderResult.result.current).toEqual({ + dashboardConfig: merge(dashboardConfig, { + spec: { + dashboardConfig: { + disableAppLauncher: true, + disableHome: true, + enablement: true, + disableInfo: true, + }, + }, + }), + devFeatureFlags: { + disableHome: true, + enablement: true, + disableInfo: true, + }, + resetDevFeatureFlags: expect.any(Function), + setDevFeatureFlag: expect.any(Function), + setDevFeatureFlagQueryVisible: expect.any(Function), + }); + + expect(searchParams.delete).toHaveBeenCalledWith('devFeatureFlags'); + expect(setSearchParamsFn.mock.calls[0][0].toString()).toEqual( + new URLSearchParams({ foo: 'bar' }).toString(), + ); + + expect(setSessionFn).toHaveBeenCalledWith({ + disableHome: true, + enablement: true, + disableInfo: true, + }); + }); +}); diff --git a/frontend/src/app/useDevFeatureFlags.ts b/frontend/src/app/useDevFeatureFlags.ts new file mode 100644 index 0000000000..21b6891f40 --- /dev/null +++ b/frontend/src/app/useDevFeatureFlags.ts @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useBrowserStorage } from '~/components/browserStorage'; +import { isFeatureFlag } from '~/concepts/areas/utils'; +import { DashboardCommonConfig, DashboardConfigKind } from '~/k8sTypes'; +import { DevFeatureFlags } from '~/types'; +import axios from '~/utilities/axios'; + +const PARAM_NAME = 'devFeatureFlags'; +const SESSION_KEY = 'odh-feature-flags'; +const HEADER_NAME = 'x-odh-feature-flags'; + +const capitalize = (v: string) => v.charAt(0).toUpperCase() + v.slice(1); + +/** + * Override dashboard config feature flags in the query string: eg. + * `devFeatureFlags=disableHome=false,appLauncher,support=true` + * Results in: + * - disableHome = false + * - disableAppLauncher = false + * - disableSupport = false + */ +const useDevFeatureFlags = ( + dashboardConfig?: DashboardConfigKind | null, +): { + dashboardConfig: DashboardConfigKind | null; +} & DevFeatureFlags => { + const [isDevFeatureFlagQueryVisible, setDevFeatureFlagQueryVisible] = React.useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const [sessionFlags, setSessionFlags] = useBrowserStorage | null>( + SESSION_KEY, + null, + true, + true, + ); + + // only keep valid feature flags + const sanitizedSessionFlags = React.useMemo | null>(() => { + if (sessionFlags) { + const entries = Object.entries(sessionFlags); + const filteredEntries = entries.filter( + ([key, value]) => isFeatureFlag(key) && typeof value === 'boolean', + ); + if (entries.length === filteredEntries.length) { + // return the original object if valid + // keep stable reference + return sessionFlags; + } + // return the sanitized object + return filteredEntries.reduce>((acc, [key, v]) => { + if (isFeatureFlag(key)) { + acc[key] = v; + } + return acc; + }, {}); + } + return null; + }, [sessionFlags]); + + // read from query string at first then fallback to session + const firstLoad = React.useRef(true); + const devFeatureFlags = + (firstLoad.current + ? (() => { + const devFlagsParam = searchParams.get(PARAM_NAME); + if (devFlagsParam != null) { + return devFlagsParam.split(',').reduce>((acc, v) => { + const [name, bool] = v.split('='); + if (isFeatureFlag(name)) { + acc[name] = bool === 'true'; + } else { + const fullName = `disable${capitalize(name)}`; + if (isFeatureFlag(fullName)) { + acc[fullName] = bool === 'false'; + } + } + return acc; + }, {}); + } + return null; + })() + : null) ?? sanitizedSessionFlags; + firstLoad.current = false; + + React.useEffect(() => { + if (isDevFeatureFlagQueryVisible) { + searchParams.set( + PARAM_NAME, + devFeatureFlags + ? Object.entries(devFeatureFlags) + .map(([key, value]) => `${key}=${value}`) + .join(',') + : '', + ); + setSearchParams(searchParams, { replace: true }); + } else if (searchParams.has(PARAM_NAME)) { + // clean up query string + searchParams.delete(PARAM_NAME); + setSearchParams(searchParams, { replace: true }); + } + // do not react to changes in searchParams or setSearchParams + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDevFeatureFlagQueryVisible, devFeatureFlags]); + + // update session + React.useEffect(() => { + // assign axios default header + if (devFeatureFlags) { + axios.defaults.headers.common[HEADER_NAME] = JSON.stringify(devFeatureFlags); + } else { + delete axios.defaults.headers.common[HEADER_NAME]; + } + + // update session storage + // compares against original sessionFlags object on purpose and not the sanitizedSessionFlags + if (devFeatureFlags !== sessionFlags) { + setSessionFlags(devFeatureFlags); + } + }, [devFeatureFlags, sessionFlags, setSessionFlags]); + + // construct the new dashbaord config by merging in the dev feature flags + const newDashboardConfig = React.useMemo(() => { + if (dashboardConfig && devFeatureFlags) { + return { + ...dashboardConfig, + spec: { + ...dashboardConfig.spec, + dashboardConfig: { + ...dashboardConfig.spec.dashboardConfig, + ...devFeatureFlags, + }, + }, + }; + } + return dashboardConfig ?? null; + }, [devFeatureFlags, dashboardConfig]); + + // api functions + const resetDevFeatureFlags = React.useCallback(() => { + setDevFeatureFlagQueryVisible(false); + setSessionFlags(null); + }, [setSessionFlags]); + + const setDevFeatureFlag = React.useCallback( + (flag: keyof DashboardCommonConfig, value: boolean) => { + setSessionFlags({ ...sessionFlags, [flag]: value }); + }, + [sessionFlags, setSessionFlags], + ); + + return { + dashboardConfig: newDashboardConfig, + devFeatureFlags, + setDevFeatureFlag, + resetDevFeatureFlags, + setDevFeatureFlagQueryVisible, + }; +}; + +export default useDevFeatureFlags; diff --git a/frontend/src/components/browserStorage/BrowserStorageContext.tsx b/frontend/src/components/browserStorage/BrowserStorageContext.tsx index ff903a9cfa..f52cba24e0 100644 --- a/frontend/src/components/browserStorage/BrowserStorageContext.tsx +++ b/frontend/src/components/browserStorage/BrowserStorageContext.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; import { useEventListener } from '~/utilities/useEventListener'; type ValueMap = { [storageKey: string]: unknown }; @@ -48,8 +49,11 @@ export const useBrowserStorage = ( [isSessionStorage, jsonify, setJSONValue, setStringValue, storageKey], ); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return [(getValue(storageKey, jsonify, isSessionStorage) as T) ?? defaultValue, setValue]; + const value = useDeepCompareMemoize( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (getValue(storageKey, jsonify, isSessionStorage) as T) ?? defaultValue, + ); + return [value, setValue]; }; type BrowserStorageContextProviderProps = { diff --git a/frontend/src/concepts/areas/__tests__/useFetchDscStatus.spec.ts b/frontend/src/concepts/areas/__tests__/useFetchDscStatus.spec.ts index 2a55f8d3c5..43bbff8aa8 100644 --- a/frontend/src/concepts/areas/__tests__/useFetchDscStatus.spec.ts +++ b/frontend/src/concepts/areas/__tests__/useFetchDscStatus.spec.ts @@ -1,10 +1,10 @@ import { act } from '@testing-library/react'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import { standardUseFetchState, testHook } from '~/__tests__/unit/testUtils/hooks'; import { mockDscStatus } from '~/__mocks__/mockDscStatus'; import useFetchDscStatus from '~/concepts/areas/useFetchDscStatus'; -jest.mock('axios', () => ({ +jest.mock('~/utilities/axios', () => ({ get: jest.fn(), })); diff --git a/frontend/src/concepts/areas/__tests__/useFetchDsciStatus.spec.ts b/frontend/src/concepts/areas/__tests__/useFetchDsciStatus.spec.ts index c4bd271592..39c8c033cf 100644 --- a/frontend/src/concepts/areas/__tests__/useFetchDsciStatus.spec.ts +++ b/frontend/src/concepts/areas/__tests__/useFetchDsciStatus.spec.ts @@ -1,10 +1,10 @@ import { act } from '@testing-library/react'; -import axios from 'axios'; +import axios from '~/utilities/axios'; import { standardUseFetchState, testHook } from '~/__tests__/unit/testUtils/hooks'; import { mockDsciStatus } from '~/__mocks__/mockDsciStatus'; import useFetchDsciStatus from '~/concepts/areas/useFetchDsciStatus'; -jest.mock('axios', () => ({ +jest.mock('~/utilities/axios', () => ({ get: jest.fn(), })); diff --git a/frontend/src/concepts/areas/const.ts b/frontend/src/concepts/areas/const.ts index 10834bdb4a..b643127c5c 100644 --- a/frontend/src/concepts/areas/const.ts +++ b/frontend/src/concepts/areas/const.ts @@ -1,5 +1,35 @@ +import { DashboardCommonConfig } from '~/k8sTypes'; import { StackCapability, StackComponent, SupportedArea, SupportedAreasState } from './types'; +export const allFeatureFlags: string[] = Object.keys({ + enablement: false, + disableInfo: false, + disableSupport: false, + disableClusterManager: false, + disableTracking: false, + disableBYONImageStream: false, + disableISVBadges: false, + disableAppLauncher: false, + disableUserManagement: false, + disableHome: false, + disableProjects: false, + disableModelServing: false, + disableProjectSharing: false, + disableCustomServingRuntimes: false, + disablePipelines: false, + disableBiasMetrics: false, + disablePerformanceMetrics: false, + disableKServe: false, + disableKServeAuth: false, + disableKServeMetrics: false, + disableModelMesh: false, + disableAcceleratorProfiles: false, + disablePipelineExperiments: false, + disableS3Endpoint: false, + disableDistributedWorkloads: false, + disableModelRegistry: false, +} satisfies DashboardCommonConfig); + export const SupportedAreasStateMap: SupportedAreasState = { [SupportedArea.BYON]: { featureFlags: ['disableBYONImageStream'], diff --git a/frontend/src/concepts/areas/useFetchDscStatus.ts b/frontend/src/concepts/areas/useFetchDscStatus.ts index aa5f1ded45..b6362d6bb4 100644 --- a/frontend/src/concepts/areas/useFetchDscStatus.ts +++ b/frontend/src/concepts/areas/useFetchDscStatus.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import useFetchState, { FetchState } from '~/utilities/useFetchState'; import { DataScienceClusterKindStatus } from '~/k8sTypes'; diff --git a/frontend/src/concepts/areas/useFetchDsciStatus.ts b/frontend/src/concepts/areas/useFetchDsciStatus.ts index 1da899f2bb..5fb65b6d2e 100644 --- a/frontend/src/concepts/areas/useFetchDsciStatus.ts +++ b/frontend/src/concepts/areas/useFetchDsciStatus.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import useFetchState, { FetchState } from '~/utilities/useFetchState'; import { DataScienceClusterInitializationKindStatus } from '~/k8sTypes'; diff --git a/frontend/src/concepts/areas/utils.ts b/frontend/src/concepts/areas/utils.ts index 5926dc80a3..0b712b4f56 100644 --- a/frontend/src/concepts/areas/utils.ts +++ b/frontend/src/concepts/areas/utils.ts @@ -4,19 +4,17 @@ import { DataScienceClusterKindStatus, } from '~/k8sTypes'; import { IsAreaAvailableStatus, FeatureFlag, SupportedArea } from './types'; -import { SupportedAreasStateMap } from './const'; +import { SupportedAreasStateMap, allFeatureFlags } from './const'; + +export const isFeatureFlag = (key: string): key is FeatureFlag => allFeatureFlags.includes(key); type FlagState = { [flag in FeatureFlag]?: boolean }; const getFlags = (dashboardConfigSpec: DashboardConfigKind['spec']): FlagState => { const flags = dashboardConfigSpec.dashboardConfig; - // TODO: Improve to be a list of items - const isFeatureFlag = (key: string, value: unknown): key is FeatureFlag => - typeof value === 'boolean'; - return { ...Object.entries(flags).reduce((acc, [key, value]) => { - if (isFeatureFlag(key, value)) { + if (isFeatureFlag(key)) { acc[key] = key.startsWith('disable') ? !value : value; } return acc; diff --git a/frontend/src/redux/actions/actions.ts b/frontend/src/redux/actions/actions.ts index 5d292b1870..71392a3139 100644 --- a/frontend/src/redux/actions/actions.ts +++ b/frontend/src/redux/actions/actions.ts @@ -1,6 +1,6 @@ -import axios from 'axios'; import { ThunkAction } from 'redux-thunk'; import { Action } from 'redux'; +import axios from '~/utilities/axios'; import { Actions, AppNotification, AppState, GetUserAction, StatusResponse } from '~/redux/types'; import { AllowedUser } from '~/pages/notebookController/screens/admin/types'; diff --git a/frontend/src/services/acceleratorProfileService.ts b/frontend/src/services/acceleratorProfileService.ts index 1e381b07e5..9419176acc 100644 --- a/frontend/src/services/acceleratorProfileService.ts +++ b/frontend/src/services/acceleratorProfileService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { AcceleratorProfileKind } from '~/k8sTypes'; import { ResponseStatus } from '~/types'; diff --git a/frontend/src/services/acceleratorService.ts b/frontend/src/services/acceleratorService.ts index 814eec6906..7cbb625b83 100644 --- a/frontend/src/services/acceleratorService.ts +++ b/frontend/src/services/acceleratorService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { DetectedAccelerators } from '~/types'; export const getDetectedAccelerators = (): Promise => { diff --git a/frontend/src/services/buildsService.ts b/frontend/src/services/buildsService.ts index 3f70fea83e..9a3c6b6f08 100644 --- a/frontend/src/services/buildsService.ts +++ b/frontend/src/services/buildsService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { BuildStatus } from '~/types'; export const fetchBuildStatuses = (): Promise => { diff --git a/frontend/src/services/clusterSettingsService.ts b/frontend/src/services/clusterSettingsService.ts index 4ef275d622..5dcbdd4c8c 100644 --- a/frontend/src/services/clusterSettingsService.ts +++ b/frontend/src/services/clusterSettingsService.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; import { ClusterSettingsType } from '~/types'; +import axios from '~/utilities/axios'; export const fetchClusterSettings = (): Promise => { const url = '/api/cluster-settings'; diff --git a/frontend/src/services/componentsServices.ts b/frontend/src/services/componentsServices.ts index e162f73b33..60285d7061 100644 --- a/frontend/src/services/componentsServices.ts +++ b/frontend/src/services/componentsServices.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { OdhApplication } from '~/types'; export const fetchComponents = (installed: boolean): Promise => { diff --git a/frontend/src/services/consoleLinksService.ts b/frontend/src/services/consoleLinksService.ts index 0ec8bc2163..be682a9de4 100644 --- a/frontend/src/services/consoleLinksService.ts +++ b/frontend/src/services/consoleLinksService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { ConsoleLinkKind } from '~/k8sTypes'; export const fetchConsoleLinks = (): Promise => { diff --git a/frontend/src/services/dashboardConfigService.ts b/frontend/src/services/dashboardConfigService.ts index 92aac1cb8d..498c0a4d78 100644 --- a/frontend/src/services/dashboardConfigService.ts +++ b/frontend/src/services/dashboardConfigService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { DashboardConfigKind } from '~/k8sTypes'; export const fetchDashboardConfig = (): Promise => { diff --git a/frontend/src/services/dashboardService.ts b/frontend/src/services/dashboardService.ts index c5a4e75e39..46904ca7e0 100644 --- a/frontend/src/services/dashboardService.ts +++ b/frontend/src/services/dashboardService.ts @@ -1,5 +1,5 @@ // TODO: Delete once we refactor Admin panel to support Passthrough API -import axios from 'axios'; +import axios from '~/utilities/axios'; import { DashboardConfigKind } from '~/k8sTypes'; import { DASHBOARD_CONFIG } from '~/utilities/const'; diff --git a/frontend/src/services/docsService.ts b/frontend/src/services/docsService.ts index 69be335130..726c7b81a2 100644 --- a/frontend/src/services/docsService.ts +++ b/frontend/src/services/docsService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { OdhDocument } from '~/types'; export const fetchDocs = (docType?: string): Promise => { diff --git a/frontend/src/services/envService.ts b/frontend/src/services/envService.ts index 751e08c671..1c596547b5 100644 --- a/frontend/src/services/envService.ts +++ b/frontend/src/services/envService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { ConfigMap, Secret } from '~/types'; export const getEnvSecret = (namespace: string, name: string): Promise => { diff --git a/frontend/src/services/groupSettingsService.ts b/frontend/src/services/groupSettingsService.ts index 2e957a342d..830d514add 100644 --- a/frontend/src/services/groupSettingsService.ts +++ b/frontend/src/services/groupSettingsService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { GroupsConfig } from '~/pages/groupSettings/groupTypes'; export const fetchGroupsSettings = (): Promise => { diff --git a/frontend/src/services/imagesService.ts b/frontend/src/services/imagesService.ts index e806227c24..1192b0f1ff 100644 --- a/frontend/src/services/imagesService.ts +++ b/frontend/src/services/imagesService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { BYONImage, ImageInfo, ResponseStatus } from '~/types'; export const fetchImages = (): Promise => { diff --git a/frontend/src/services/impersonateService.ts b/frontend/src/services/impersonateService.ts index 654087a705..d90a3659a3 100644 --- a/frontend/src/services/impersonateService.ts +++ b/frontend/src/services/impersonateService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; export const updateImpersonateSettings = (impersonate: boolean): Promise => { const url = '/api/dev-impersonate'; diff --git a/frontend/src/services/modelRegistrySettingsService.ts b/frontend/src/services/modelRegistrySettingsService.ts index a0671ada05..c77cf3b2f7 100644 --- a/frontend/src/services/modelRegistrySettingsService.ts +++ b/frontend/src/services/modelRegistrySettingsService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { ModelRegistryKind } from '~/k8sTypes'; import { RecursivePartial } from '~/typeHelpers'; diff --git a/frontend/src/services/notebookEventsService.ts b/frontend/src/services/notebookEventsService.ts index 52944f125c..f1b440fce9 100644 --- a/frontend/src/services/notebookEventsService.ts +++ b/frontend/src/services/notebookEventsService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { K8sEvent } from '~/types'; export const getNotebookEvents = ( diff --git a/frontend/src/services/notebookService.ts b/frontend/src/services/notebookService.ts index 73bad81027..eb43c35633 100644 --- a/frontend/src/services/notebookService.ts +++ b/frontend/src/services/notebookService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { RecursivePartial } from '~/typeHelpers'; import { Notebook, NotebookState, NotebookData, NotebookRunningState } from '~/types'; diff --git a/frontend/src/services/quickStartsService.ts b/frontend/src/services/quickStartsService.ts index d633c59fcc..50568c6f93 100644 --- a/frontend/src/services/quickStartsService.ts +++ b/frontend/src/services/quickStartsService.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; import { QuickStart } from '@patternfly/quickstarts'; +import axios from '~/utilities/axios'; export const fetchQuickStarts = (): Promise => { const url = '/api/quickstarts'; diff --git a/frontend/src/services/roleBindingService.ts b/frontend/src/services/roleBindingService.ts index bbf264b9a9..b117139363 100644 --- a/frontend/src/services/roleBindingService.ts +++ b/frontend/src/services/roleBindingService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { RoleBinding } from '~/types'; export const getRoleBinding = (projectName: string, rbName: string): Promise => { diff --git a/frontend/src/services/routeService.ts b/frontend/src/services/routeService.ts index f7fd510a2d..f611b420e5 100644 --- a/frontend/src/services/routeService.ts +++ b/frontend/src/services/routeService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { Route } from '~/types'; export const getRoute = (namespace: string, routeName: string): Promise => { diff --git a/frontend/src/services/segmentKeyService.ts b/frontend/src/services/segmentKeyService.ts index 9b58fedc1e..2438936c9f 100644 --- a/frontend/src/services/segmentKeyService.ts +++ b/frontend/src/services/segmentKeyService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; import { ODHSegmentKey } from '~/types'; export const fetchSegmentKey = (): Promise => { diff --git a/frontend/src/services/storageService.ts b/frontend/src/services/storageService.ts index 690b205969..7327e8586d 100644 --- a/frontend/src/services/storageService.ts +++ b/frontend/src/services/storageService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; export const MAX_STORAGE_OBJECT_SIZE = 1e8; diff --git a/frontend/src/services/templateService.ts b/frontend/src/services/templateService.ts index 67e107b40c..88ddac572f 100644 --- a/frontend/src/services/templateService.ts +++ b/frontend/src/services/templateService.ts @@ -1,6 +1,6 @@ // TODO: Delete once we refactor Admin panel to support Passthrough API -import axios from 'axios'; import YAML from 'yaml'; +import axios from '~/utilities/axios'; import { assembleServingRuntimeTemplate } from '~/api'; import { ServingRuntimeKind, TemplateKind } from '~/k8sTypes'; import { ServingRuntimeAPIProtocol, ServingRuntimePlatform } from '~/types'; diff --git a/frontend/src/services/validateIsvService.ts b/frontend/src/services/validateIsvService.ts index e0a147320e..20c01d7254 100644 --- a/frontend/src/services/validateIsvService.ts +++ b/frontend/src/services/validateIsvService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from '~/utilities/axios'; export const postValidateIsv = ( appName: string, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a3fcc4bcdd..82922367db 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -8,9 +8,21 @@ import { } from '@openshift/dynamic-plugin-sdk-utils'; import { AxiosError } from 'axios'; import { EnvironmentFromVariable } from '~/pages/projects/types'; -import { AcceleratorProfileKind, ImageStreamKind, ImageStreamSpecTagType } from './k8sTypes'; +import { + AcceleratorProfileKind, + DashboardCommonConfig, + ImageStreamKind, + ImageStreamSpecTagType, +} from './k8sTypes'; import { EitherNotBoth } from './typeHelpers'; +export type DevFeatureFlags = { + devFeatureFlags: Partial | null; + setDevFeatureFlag: (flag: keyof DashboardCommonConfig, value: boolean) => void; + resetDevFeatureFlags: () => void; + setDevFeatureFlagQueryVisible: (visible: boolean) => void; +}; + export type PrometheusQueryResponse = { data: { result: ({ diff --git a/frontend/src/utilities/__tests__/useDetectUser.spec.ts b/frontend/src/utilities/__tests__/useDetectUser.spec.ts index 7e01b238d6..542031d6b4 100644 --- a/frontend/src/utilities/__tests__/useDetectUser.spec.ts +++ b/frontend/src/utilities/__tests__/useDetectUser.spec.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; import { act, waitFor, renderHook } from '@testing-library/react'; +import axios from '~/utilities/axios'; import { StatusResponse } from '~/redux/types'; import { useAppDispatch } from '~/redux/hooks'; import useDetectUser from '~/utilities/useDetectUser'; @@ -7,7 +7,7 @@ import { getUserFulfilled, getUserPending, getUserRejected } from '~/redux/actio import { testHook } from '~/__tests__/unit/testUtils/hooks'; // Mock Axios -jest.mock('axios'); +jest.mock('~/utilities/axios'); // Mock the useDispatch hook jest.mock('~/redux/hooks', () => ({ diff --git a/frontend/src/utilities/axios.ts b/frontend/src/utilities/axios.ts new file mode 100644 index 0000000000..a77f833cb0 --- /dev/null +++ b/frontend/src/utilities/axios.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line no-restricted-imports +import axios from 'axios'; + +export default axios.create(); diff --git a/frontend/src/utilities/useDetectUser.ts b/frontend/src/utilities/useDetectUser.ts index 0548f91f45..fda651e4ce 100644 --- a/frontend/src/utilities/useDetectUser.ts +++ b/frontend/src/utilities/useDetectUser.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; import * as React from 'react'; +import axios from '~/utilities/axios'; import { getUserFulfilled, getUserPending, getUserRejected } from '~/redux/actions/actions'; import { useAppDispatch } from '~/redux/hooks'; import { POLL_INTERVAL } from './const';