diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails.test.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails.test.tsx index 0031f2b438..41b30b4295 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails.test.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails.test.tsx @@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from 'utils/testRenderer'; import { FeatureOverviewSidePanelDetails } from './FeatureOverviewSidePanelDetails'; -import { IDependency, IFeatureToggle } from 'interfaces/featureToggle'; +import { IFeatureToggle } from 'interfaces/featureToggle'; import { testServerRoute, testServerSetup } from 'utils/testServer'; import ToastRenderer from 'component/common/ToastRenderer/ToastRenderer'; @@ -30,6 +30,19 @@ const setupApi = () => { 'delete', 200, ); + testServerRoute(server, '/api/admin/projects/default/dependencies', false); +}; + +const setupOssWithExistingDependencies = () => { + testServerRoute(server, '/api/admin/ui-config', { + flags: { + dependentFeatures: true, + }, + versionInfo: { + current: { oss: 'some value' }, + }, + }); + testServerRoute(server, '/api/admin/projects/default/dependencies', true); }; const setupChangeRequestApi = () => { @@ -92,6 +105,36 @@ test('show dependency dialogue', async () => { ).toBeInTheDocument(); }); +test('show dependency dialogue for OSS with dependencies', async () => { + setupOssWithExistingDependencies(); + render( + , + children: [] as string[], + } as IFeatureToggle + } + header={''} + />, + { + permissions: [ + { permission: 'UPDATE_FEATURE_DEPENDENCY', project: 'default' }, + ], + }, + ); + + const addParentButton = await screen.findByText('Add parent feature'); + + addParentButton.click(); + + expect( + screen.getByText('Add parent feature dependency'), + ).toBeInTheDocument(); +}); + test('show child', async () => { render( ({ display: 'flex', @@ -28,7 +28,7 @@ export const FeatureOverviewSidePanelDetails = ({ }: IFeatureOverviewSidePanelDetailsProps) => { const { locationSettings } = useLocationSettings(); const { uiConfig } = useUiConfig(); - const dependentFeatures = useUiFlag('dependentFeatures'); + const showDependentFeatures = useShowDependentFeatures(feature.project); const showLastSeenByEnvironment = Boolean( uiConfig.flags.lastSeenByEnvironment, @@ -56,7 +56,7 @@ export const FeatureOverviewSidePanelDetails = ({ )} } /> diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/useShowDependentFeatures.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/useShowDependentFeatures.ts new file mode 100644 index 0000000000..9108c883b0 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/useShowDependentFeatures.ts @@ -0,0 +1,13 @@ +import { useUiFlag } from 'hooks/useUiFlag'; +import { useCheckDependenciesExist } from 'hooks/api/getters/useCheckDependenciesExist/useCheckDependenciesExist'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; + +export const useShowDependentFeatures = (project: string) => { + const dependentFeatures = useUiFlag('dependentFeatures'); + const { dependenciesExist } = useCheckDependenciesExist(project); + const { isOss } = useUiConfig(); + + return Boolean( + isOss() ? dependenciesExist && dependentFeatures : dependentFeatures, + ); +}; diff --git a/frontend/src/hooks/api/getters/useCheckDependenciesExist/useCheckDependenciesExist.ts b/frontend/src/hooks/api/getters/useCheckDependenciesExist/useCheckDependenciesExist.ts new file mode 100644 index 0000000000..6e8508a5fe --- /dev/null +++ b/frontend/src/hooks/api/getters/useCheckDependenciesExist/useCheckDependenciesExist.ts @@ -0,0 +1,32 @@ +import { SWRConfiguration } from 'swr'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; + +export const useCheckDependenciesExist = ( + project: string, + options: SWRConfiguration = {}, +) => { + const path = formatApiPath(`/api/admin/projects/${project}/dependencies`); + const { data, error } = useConditionalSWR( + project, + false, + path, + fetcher, + options, + ); + + return { + dependenciesExist: data, + error, + loading: !error && !data, + }; +}; + +const fetcher = async (path: string): Promise => { + const res = await fetch(path).then( + handleErrorResponses('Dependencies exist check'), + ); + const data = await res.json(); + return data; +}; diff --git a/frontend/src/utils/testServer.ts b/frontend/src/utils/testServer.ts index ef32b0789f..9f1edc038d 100644 --- a/frontend/src/utils/testServer.ts +++ b/frontend/src/utils/testServer.ts @@ -14,7 +14,7 @@ export const testServerSetup = (): SetupServerApi => { export const testServerRoute = ( server: SetupServerApi, path: string, - json: object, + json: object | boolean | string | number, method: 'get' | 'post' | 'put' | 'delete' = 'get', status: number = 200, ) => { diff --git a/src/lib/features/dependent-features/dependent-features-controller.ts b/src/lib/features/dependent-features/dependent-features-controller.ts index 3ee99c9496..5923bcaeeb 100644 --- a/src/lib/features/dependent-features/dependent-features-controller.ts +++ b/src/lib/features/dependent-features/dependent-features-controller.ts @@ -38,6 +38,7 @@ interface DeleteDependencyParams extends ProjectParams { const PATH = '/:projectId/features'; const PATH_FEATURE = `${PATH}/:child`; const PATH_DEPENDENCIES = `${PATH_FEATURE}/dependencies`; +const PATH_DEPENDENCIES_CHECK = `/:projectId/dependencies`; const PATH_PARENTS = `${PATH_FEATURE}/parents`; const PATH_DEPENDENCY = `${PATH_FEATURE}/dependencies/:parent`; @@ -165,6 +166,26 @@ export default class DependentFeaturesController extends Controller { }), ], }); + + this.route({ + method: 'get', + path: PATH_DEPENDENCIES_CHECK, + handler: this.checkDependenciesExist, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Dependencies'], + summary: 'Check dependencies exist.', + description: + 'Check if any dependencies exist in this Unleash instance', + operationId: 'checkDependenciesExist', + responses: { + 200: createResponseSchema('dependenciesExistSchema'), + ...getStandardResponses(401, 403), + }, + }), + ], + }); } async addFeatureDependency( @@ -255,4 +276,21 @@ export default class DependentFeaturesController extends Controller { ); } } + + async checkDependenciesExist( + req: IAuthRequest, + res: Response, + ): Promise { + const { child } = req.params; + + if (this.config.flagResolver.isEnabled('dependentFeatures')) { + const exist = + await this.dependentFeaturesService.checkDependenciesExist(); + res.send(exist); + } else { + throw new InvalidOperationError( + 'Dependent features are not enabled', + ); + } + } } diff --git a/src/lib/features/dependent-features/dependent-features-read-model-type.ts b/src/lib/features/dependent-features/dependent-features-read-model-type.ts index ba45d19f24..4cf5cf681b 100644 --- a/src/lib/features/dependent-features/dependent-features-read-model-type.ts +++ b/src/lib/features/dependent-features/dependent-features-read-model-type.ts @@ -10,4 +10,5 @@ export interface IDependentFeaturesReadModel { getDependencies(children: string[]): Promise; getParentOptions(child: string): Promise; hasDependencies(feature: string): Promise; + hasAnyDependencies(): Promise; } diff --git a/src/lib/features/dependent-features/dependent-features-read-model.ts b/src/lib/features/dependent-features/dependent-features-read-model.ts index bc69095884..35c6d5271f 100644 --- a/src/lib/features/dependent-features/dependent-features-read-model.ts +++ b/src/lib/features/dependent-features/dependent-features-read-model.ts @@ -90,4 +90,12 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { return parents.length > 0; } + + async hasAnyDependencies(): Promise { + const result = await this.db.raw( + `SELECT EXISTS (SELECT 1 FROM dependent_features) AS present`, + ); + const { present } = result.rows[0]; + return present; + } } diff --git a/src/lib/features/dependent-features/dependent-features-service.ts b/src/lib/features/dependent-features/dependent-features-service.ts index f397fecc5f..fd76030946 100644 --- a/src/lib/features/dependent-features/dependent-features-service.ts +++ b/src/lib/features/dependent-features/dependent-features-service.ts @@ -197,6 +197,10 @@ export class DependentFeaturesService { return this.dependentFeaturesReadModel.getParentOptions(feature); } + async checkDependenciesExist(): Promise { + return this.dependentFeaturesReadModel.hasAnyDependencies(); + } + private async stopWhenChangeRequestsEnabled(project: string, user?: User) { const canBypass = await this.changeRequestAccessReadModel.canBypassChangeRequestForProject( diff --git a/src/lib/features/dependent-features/dependent.features.e2e.test.ts b/src/lib/features/dependent-features/dependent.features.e2e.test.ts index 5d19807800..3fac621fa3 100644 --- a/src/lib/features/dependent-features/dependent.features.e2e.test.ts +++ b/src/lib/features/dependent-features/dependent.features.e2e.test.ts @@ -44,6 +44,10 @@ afterAll(async () => { await db.destroy(); }); +beforeEach(async () => { + await db.stores.dependentFeaturesStore.deleteAll(); +}); + const addFeatureDependency = async ( childFeature: string, payload: CreateDependentFeatureSchema, @@ -86,6 +90,12 @@ const getParentOptions = async (childFeature: string, expectedCode = 200) => { .expect(expectedCode); }; +const checkDependenciesExist = async (expectedCode = 200) => { + return app.request + .get(`/api/admin/projects/default/dependencies`) + .expect(expectedCode); +}; + test('should add and delete feature dependencies', async () => { const parent = uuidv4(); const child = uuidv4(); @@ -167,3 +177,20 @@ test('should not allow to add archived parent dependency', async () => { 403, ); }); + +test('should check if any dependencies exist', async () => { + const parent = uuidv4(); + const child = uuidv4(); + await app.createFeature(child); + await app.createFeature(parent); + + const { body: dependenciesExistBefore } = await checkDependenciesExist(); + expect(dependenciesExistBefore).toBe(false); + + await addFeatureDependency(child, { + feature: parent, + }); + + const { body: dependenciesExistAfter } = await checkDependenciesExist(); + expect(dependenciesExistAfter).toBe(true); +}); diff --git a/src/lib/features/dependent-features/fake-dependent-features-read-model.ts b/src/lib/features/dependent-features/fake-dependent-features-read-model.ts index c9a85918f0..1828b12dc9 100644 --- a/src/lib/features/dependent-features/fake-dependent-features-read-model.ts +++ b/src/lib/features/dependent-features/fake-dependent-features-read-model.ts @@ -27,4 +27,8 @@ export class FakeDependentFeaturesReadModel getOrphanParents(parentsAndChildren: string[]): Promise { return Promise.resolve([]); } + + hasAnyDependencies(): Promise { + return Promise.resolve(false); + } } diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index b9d3e640f9..0d752ae730 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -164,6 +164,7 @@ import { dependentFeatureSchema, createDependentFeatureSchema, parentFeatureOptionsSchema, + dependenciesExistSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -391,6 +392,7 @@ export const schemas: UnleashSchemas = { createDependentFeatureSchema, parentFeatureOptionsSchema, featureDependenciesSchema, + dependenciesExistSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/meta-schema-rules.test.ts b/src/lib/openapi/meta-schema-rules.test.ts index a87d332dfc..7a4f710197 100644 --- a/src/lib/openapi/meta-schema-rules.test.ts +++ b/src/lib/openapi/meta-schema-rules.test.ts @@ -35,7 +35,7 @@ const metaRules: Rule[] = [ metaSchema: { type: 'object', properties: { - type: { type: 'string', enum: ['object', 'array'] }, + type: { type: 'string', enum: ['object', 'array', 'boolean'] }, }, required: ['type'], }, diff --git a/src/lib/openapi/spec/dependencies-exist-schema.ts b/src/lib/openapi/spec/dependencies-exist-schema.ts new file mode 100644 index 0000000000..220836e7e0 --- /dev/null +++ b/src/lib/openapi/spec/dependencies-exist-schema.ts @@ -0,0 +1,13 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const dependenciesExistSchema = { + $id: '#/components/schemas/dependenciesExistSchema', + type: 'boolean', + description: + '`true` when any dependencies exist, `false` when no dependencies exist.', + components: {}, +} as const; + +export type DependenciesExistSchema = FromSchema< + typeof dependenciesExistSchema +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 1db23c86fe..296751e350 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -164,3 +164,4 @@ export * from './dependent-feature-schema'; export * from './create-dependent-feature-schema'; export * from './parent-feature-options-schema'; export * from './feature-dependencies-schema'; +export * from './dependencies-exist-schema';