]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14349 Consume the new "api/features/list" endpoint for monorepo feature
authorPhilippe Perrin <philippe.perrin@sonarsource.com>
Tue, 19 Jul 2022 09:09:13 +0000 (11:09 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 21 Jul 2022 20:03:05 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/features.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/available-features/AvailableFeaturesContext.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/available-features/__tests__/withAvailableFeatures-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/available-features/withAvailableFeatures.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/index.ts
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/AlmSpecificForm-test.tsx
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/__snapshots__/PRDecorationBindingRenderer-test.tsx.snap
server/sonar-web/src/main/js/types/features.ts [new file with mode: 0644]

diff --git a/server/sonar-web/src/main/js/api/features.ts b/server/sonar-web/src/main/js/api/features.ts
new file mode 100644 (file)
index 0000000..65ce90d
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { getJSON } from '../helpers/request';
+import { Feature } from '../types/features';
+
+export function getAvailableFeatures(): Promise<Feature[]> {
+  return getJSON('/api/features/list');
+}
diff --git a/server/sonar-web/src/main/js/app/components/available-features/AvailableFeaturesContext.tsx b/server/sonar-web/src/main/js/app/components/available-features/AvailableFeaturesContext.tsx
new file mode 100644 (file)
index 0000000..13867d6
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import * as React from 'react';
+import { Feature } from '../../../types/features';
+
+export const DEFAULT_AVAILABLE_FEATURES = [];
+
+export const AvailableFeaturesContext = React.createContext<Feature[]>(DEFAULT_AVAILABLE_FEATURES);
diff --git a/server/sonar-web/src/main/js/app/components/available-features/__tests__/withAvailableFeatures-test.tsx b/server/sonar-web/src/main/js/app/components/available-features/__tests__/withAvailableFeatures-test.tsx
new file mode 100644 (file)
index 0000000..071e941
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { Feature } from '../../../../types/features';
+import withAvailableFeatures, { WithAvailableFeaturesProps } from '../withAvailableFeatures';
+
+jest.mock('../AvailableFeaturesContext', () => {
+  return {
+    AvailableFeaturesContext: {
+      Consumer: ({ children }: { children: (props: {}) => React.ReactNode }) => {
+        return children([Feature.MonoRepositoryPullRequestDecoration]);
+      }
+    }
+  };
+});
+
+class Wrapped extends React.Component<WithAvailableFeaturesProps> {
+  render() {
+    return <div />;
+  }
+}
+
+const UnderTest = withAvailableFeatures(Wrapped);
+
+it('should provide a way to check if a feature is available', () => {
+  const wrapper = shallow(<UnderTest />);
+  expect(wrapper.dive().type()).toBe(Wrapped);
+  expect(
+    wrapper
+      .dive<Wrapped>()
+      .props()
+      .hasFeature(Feature.MonoRepositoryPullRequestDecoration)
+  ).toBe(true);
+});
diff --git a/server/sonar-web/src/main/js/app/components/available-features/withAvailableFeatures.tsx b/server/sonar-web/src/main/js/app/components/available-features/withAvailableFeatures.tsx
new file mode 100644 (file)
index 0000000..ea13325
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import * as React from 'react';
+import { getWrappedDisplayName } from '../../../components/hoc/utils';
+import { Feature } from '../../../types/features';
+import { AvailableFeaturesContext } from './AvailableFeaturesContext';
+
+export interface WithAvailableFeaturesProps {
+  hasFeature: (feature: Feature) => boolean;
+}
+
+export default function withAvailableFeatures<P>(
+  WrappedComponent: React.ComponentType<P & WithAvailableFeaturesProps>
+) {
+  return class WithAvailableFeatures extends React.PureComponent<
+    Omit<P, keyof WithAvailableFeaturesProps>
+  > {
+    static displayName = getWrappedDisplayName(WrappedComponent, 'withAvailableFeaturesContext');
+
+    render() {
+      return (
+        <AvailableFeaturesContext.Consumer>
+          {availableFeatures => (
+            <WrappedComponent
+              hasFeature={feature => availableFeatures.includes(feature)}
+              {...(this.props as P)}
+            />
+          )}
+        </AvailableFeaturesContext.Consumer>
+      );
+    }
+  };
+}
index 86c7224278302c063e82f49d31df2a4a273a880c..02c1cb1ffa02e7a9b72f20cc8322951420053a48 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { getAvailableFeatures } from '../api/features';
 import { getGlobalNavigation } from '../api/navigation';
 import { getCurrentUser } from '../api/users';
 import { installExtensionsHandler, installWebAnalyticsHandler } from '../helpers/extensionsHandler';
@@ -29,10 +30,11 @@ installExtensionsHandler();
 initApplication();
 
 async function initApplication() {
-  const [l10nBundle, currentUser, appState] = await Promise.all([
+  const [l10nBundle, currentUser, appState, availableFeatures] = await Promise.all([
     loadL10nBundle(),
     isMainApp() ? getCurrentUser() : undefined,
-    isMainApp() ? getGlobalNavigation() : undefined
+    isMainApp() ? getGlobalNavigation() : undefined,
+    isMainApp() ? getAvailableFeatures() : undefined
   ]).catch(error => {
     // eslint-disable-next-line no-console
     console.error('Application failed to start', error);
@@ -40,7 +42,7 @@ async function initApplication() {
   });
 
   const startReactApp = await import('./utils/startReactApp').then(i => i.default);
-  startReactApp(l10nBundle.locale, currentUser, appState);
+  startReactApp(l10nBundle.locale, currentUser, appState, availableFeatures);
 }
 
 function isMainApp() {
index 3bd41cee3be8a2d0af0e75591268be9353f75c53..898aceaf914918adae5f713e4864448dfa9acbc8 100644 (file)
@@ -61,11 +61,16 @@ import webhooksRoutes from '../../apps/webhooks/routes';
 import { omitNil } from '../../helpers/request';
 import { getBaseUrl } from '../../helpers/system';
 import { AppState } from '../../types/appstate';
+import { Feature } from '../../types/features';
 import { CurrentUser } from '../../types/users';
 import AdminContainer from '../components/AdminContainer';
 import App from '../components/App';
 import { DEFAULT_APP_STATE } from '../components/app-state/AppStateContext';
 import AppStateContextProvider from '../components/app-state/AppStateContextProvider';
+import {
+  AvailableFeaturesContext,
+  DEFAULT_AVAILABLE_FEATURES
+} from '../components/available-features/AvailableFeaturesContext';
 import ComponentContainer from '../components/ComponentContainer';
 import CurrentUserContextProvider from '../components/current-user/CurrentUserContextProvider';
 import GlobalAdminPageExtension from '../components/extensions/GlobalAdminPageExtension';
@@ -227,7 +232,8 @@ function renderAdminRoutes() {
 export default function startReactApp(
   lang: string,
   currentUser?: CurrentUser,
-  appState?: AppState
+  appState?: AppState,
+  availableFeatures?: Feature[]
 ) {
   exportModulesAsGlobals();
 
@@ -236,77 +242,78 @@ export default function startReactApp(
   render(
     <HelmetProvider>
       <AppStateContextProvider appState={appState ?? DEFAULT_APP_STATE}>
-        <CurrentUserContextProvider currentUser={currentUser}>
-          <IntlProvider defaultLocale={lang} locale={lang}>
-            <GlobalMessagesContainer />
-            <BrowserRouter basename={getBaseUrl()}>
-              <Routes>
-                {renderRedirects()}
+        <AvailableFeaturesContext.Provider value={availableFeatures ?? DEFAULT_AVAILABLE_FEATURES}>
+          <CurrentUserContextProvider currentUser={currentUser}>
+            <IntlProvider defaultLocale={lang} locale={lang}>
+              <GlobalMessagesContainer />
+              <BrowserRouter basename={getBaseUrl()}>
+                <Routes>
+                  {renderRedirects()}
+                  <Route path="formatting/help" element={<FormattingHelp />} />
 
-                <Route path="formatting/help" element={<FormattingHelp />} />
+                  <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
 
-                <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
+                  <Route element={<MigrationContainer />}>
+                    {sessionsRoutes()}
 
-                <Route element={<MigrationContainer />}>
-                  {sessionsRoutes()}
+                    <Route path="/" element={<App />}>
+                      <Route index={true} element={<Landing />} />
 
-                  <Route path="/" element={<App />}>
-                    <Route index={true} element={<Landing />} />
+                      <Route element={<GlobalContainer />}>
+                        {accountRoutes()}
 
-                    <Route element={<GlobalContainer />}>
-                      {accountRoutes()}
+                        {codingRulesRoutes()}
 
-                      {codingRulesRoutes()}
+                        {documentationRoutes()}
 
-                      {documentationRoutes()}
+                        <Route
+                          path="extension/:pluginKey/:extensionKey"
+                          element={<GlobalPageExtension />}
+                        />
 
-                      <Route
-                        path="extension/:pluginKey/:extensionKey"
-                        element={<GlobalPageExtension />}
-                      />
-
-                      {globalIssuesRoutes()}
+                        {globalIssuesRoutes()}
 
-                      {projectsRoutes()}
+                        {projectsRoutes()}
 
-                      {qualityGatesRoutes()}
-                      {qualityProfilesRoutes()}
+                        {qualityGatesRoutes()}
+                        {qualityProfilesRoutes()}
 
-                      <Route path="portfolios" element={<PortfoliosPage />} />
-                      {webAPIRoutes()}
+                        <Route path="portfolios" element={<PortfoliosPage />} />
+                        {webAPIRoutes()}
 
-                      {renderComponentRoutes()}
+                        {renderComponentRoutes()}
 
-                      {renderAdminRoutes()}
-                    </Route>
-                    <Route
-                      // We don't want this route to have any menu.
-                      // That is why we can not have it under the accountRoutes
-                      path="account/reset_password"
-                      element={<ResetPassword />}
-                    />
+                        {renderAdminRoutes()}
+                      </Route>
+                      <Route
+                        // We don't want this route to have any menu.
+                        // That is why we can not have it under the accountRoutes
+                        path="account/reset_password"
+                        element={<ResetPassword />}
+                      />
 
-                    <Route
-                      // We don't want this route to have any menu. This is why we define it here
-                      // rather than under the admin routes.
-                      path="admin/change_admin_password"
-                      element={<ChangeAdminPasswordApp />}
-                    />
+                      <Route
+                        // We don't want this route to have any menu. This is why we define it here
+                        // rather than under the admin routes.
+                        path="admin/change_admin_password"
+                        element={<ChangeAdminPasswordApp />}
+                      />
 
-                    <Route
-                      // We don't want this route to have any menu. This is why we define it here
-                      // rather than under the admin routes.
-                      path="admin/plugin_risk_consent"
-                      element={<PluginRiskConsent />}
-                    />
-                    <Route path="not_found" element={<NotFound />} />
-                    <Route path="*" element={<NotFound />} />
+                      <Route
+                        // We don't want this route to have any menu. This is why we define it here
+                        // rather than under the admin routes.
+                        path="admin/plugin_risk_consent"
+                        element={<PluginRiskConsent />}
+                      />
+                      <Route path="not_found" element={<NotFound />} />
+                      <Route path="*" element={<NotFound />} />
+                    </Route>
                   </Route>
-                </Route>
-              </Routes>
-            </BrowserRouter>
-          </IntlProvider>
-        </CurrentUserContextProvider>
+                </Routes>
+              </BrowserRouter>
+            </IntlProvider>
+          </CurrentUserContextProvider>
+        </AvailableFeaturesContext.Provider>
       </AppStateContextProvider>
     </HelmetProvider>,
     el
index 6f05ba109f17a41c009009f165fec386b9a0e5ab..29376b1af9f48273a8a86ae706a1eb420cceeb46 100644 (file)
@@ -20,7 +20,9 @@
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { Link } from 'react-router-dom';
-import withAppStateContext from '../../../../app/components/app-state/withAppStateContext';
+import withAvailableFeatures, {
+  WithAvailableFeaturesProps
+} from '../../../../app/components/available-features/withAvailableFeatures';
 import Toggle from '../../../../components/controls/Toggle';
 import { Alert } from '../../../../components/ui/Alert';
 import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker';
@@ -32,16 +34,14 @@ import {
   AlmSettingsInstance,
   ProjectAlmBindingResponse
 } from '../../../../types/alm-settings';
-import { AppState } from '../../../../types/appstate';
-import { EditionKey } from '../../../../types/editions';
+import { Feature } from '../../../../types/features';
 import { Dict } from '../../../../types/types';
 
-export interface AlmSpecificFormProps {
+export interface AlmSpecificFormProps extends WithAvailableFeaturesProps {
   alm: AlmKeys;
   instances: AlmSettingsInstance[];
   formData: Omit<ProjectAlmBindingResponse, 'alm'>;
   onFieldChange: (id: keyof ProjectAlmBindingResponse, value: string | boolean) => void;
-  appState: AppState;
 }
 
 interface LabelProps {
@@ -147,8 +147,7 @@ export function AlmSpecificForm(props: AlmSpecificFormProps) {
   const {
     alm,
     instances,
-    formData: { repository, slug, summaryCommentEnabled, monorepo },
-    appState
+    formData: { repository, slug, summaryCommentEnabled, monorepo }
   } = props;
 
   let formFields: JSX.Element;
@@ -278,10 +277,7 @@ export function AlmSpecificForm(props: AlmSpecificFormProps) {
       break;
   }
 
-  // This feature trigger will be replaced when SONAR-14349 is implemented
-  const monorepoEnabled = [EditionKey.enterprise, EditionKey.datacenter].includes(
-    appState.edition as EditionKey
-  );
+  const monorepoEnabled = props.hasFeature(Feature.MonoRepositoryPullRequestDecoration);
 
   return (
     <>
@@ -310,4 +306,4 @@ export function AlmSpecificForm(props: AlmSpecificFormProps) {
   );
 }
 
-export default withAppStateContext(AlmSpecificForm);
+export default withAvailableFeatures(AlmSpecificForm);
index cdc9538d83af264673c5533285c4ab25e4ea6ac0..23185bb1ed6e917a8aecb0ac1937897cd9c77a7a 100644 (file)
@@ -20,9 +20,7 @@
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import { mockAlmSettingsInstance } from '../../../../../helpers/mocks/alm-settings';
-import { mockAppState } from '../../../../../helpers/testMocks';
 import { AlmKeys, AlmSettingsInstance } from '../../../../../types/alm-settings';
-import { EditionKey } from '../../../../../types/editions';
 import { AlmSpecificForm, AlmSpecificFormProps } from '../AlmSpecificForm';
 
 it.each([
@@ -50,9 +48,7 @@ it.each([
 );
 
 it('should render the monorepo field when the feature is supported', () => {
-  expect(
-    shallowRender(AlmKeys.Azure, { appState: mockAppState({ edition: EditionKey.enterprise }) })
-  ).toMatchSnapshot();
+  expect(shallowRender(AlmKeys.Azure, { hasFeature: jest.fn(() => true) })).toMatchSnapshot();
 });
 
 function shallowRender(alm: AlmKeys, props: Partial<AlmSpecificFormProps> = {}) {
@@ -67,7 +63,7 @@ function shallowRender(alm: AlmKeys, props: Partial<AlmSpecificFormProps> = {})
         monorepo: false
       }}
       onFieldChange={jest.fn()}
-      appState={mockAppState({ edition: EditionKey.developer })}
+      hasFeature={jest.fn()}
       {...props}
     />
   );
index 958ef5a75343ec0b5096366ec4d84223200a13a4..4a1dbfb63b8f21cfd2822c072db3c4e452b0869b 100644 (file)
@@ -176,7 +176,7 @@ exports[`should render correctly: when there are configuration errors (admin use
         />
       </div>
     </div>
-    <withAppStateContext(AlmSpecificForm)
+    <withAvailableFeaturesContext(AlmSpecificForm)
       alm="github"
       formData={
         Object {
@@ -733,7 +733,7 @@ exports[`should render correctly: with a valid and saved form 1`] = `
         />
       </div>
     </div>
-    <withAppStateContext(AlmSpecificForm)
+    <withAvailableFeaturesContext(AlmSpecificForm)
       alm="github"
       formData={
         Object {
diff --git a/server/sonar-web/src/main/js/types/features.ts b/server/sonar-web/src/main/js/types/features.ts
new file mode 100644 (file)
index 0000000..c21b1f6
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+export enum Feature {
+  MonoRepositoryPullRequestDecoration = 'monorepo'
+}