]> source.dussan.org Git - sonarqube.git/commitdiff
VSTS-142 Handle authentication to display private project in VSTS widgets
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Wed, 7 Mar 2018 16:47:48 +0000 (17:47 +0100)
committerSonarTech <sonartech@sonarsource.com>
Mon, 26 Mar 2018 18:20:57 +0000 (20:20 +0200)
12 files changed:
server/sonar-web/src/main/js/app/integration/vsts/components/Configuration.tsx
server/sonar-web/src/main/js/app/integration/vsts/components/LoginForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/integration/vsts/components/LoginLink.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelector.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelectorItem.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/integration/vsts/components/QGWidget.tsx
server/sonar-web/src/main/js/app/integration/vsts/components/SonarCloudIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/integration/vsts/components/Widget.tsx
server/sonar-web/src/main/js/app/integration/vsts/index.js
server/sonar-web/src/main/js/app/integration/vsts/utils.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/integration/vsts/vsts.css
sonar-plugin-api/src/main/java/org/sonar/api/web/ServletFilter.java

index 838114c4cbd752f594f8504746fe4248d24e1fe0..6afeb86b3a4a33036bd056bf4335dab2d891ebec 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { searchProjects } from '../../../../api/components';
-
-interface Settings {
-  project: string;
-}
+import LoginForm from './LoginForm';
+import ProjectSelector from './ProjectSelector';
+import { Component, searchProjects } from '../../../../api/components';
+import {
+  Settings,
+  VSTSWidgetSettings,
+  VSTSConfigurationContext,
+  serializeWidgetSettings,
+  parseWidgetSettings
+} from '../utils';
+import { getCurrentUser } from '../../../../api/users';
+import { CurrentUser } from '../../../types';
 
 interface Props {
+  contribution: string;
   widgetHelpers: any;
 }
 
 interface State {
+  currentUser?: CurrentUser;
   loading: boolean;
-  organizations?: Array<{ key: string; name: string }>;
-  projects?: Array<{ label: string; value: string }>;
+  projects: Component[];
   settings: Settings;
-  widgetConfigurationContext?: any;
+  selectedProject?: Component;
+  widgetConfigurationContext?: VSTSConfigurationContext;
 }
 
-declare const VSS: any;
+declare const VSS: {
+  register: (contributionId: string, callback: Function) => void;
+  resize: Function;
+};
+
+const PAGE_SIZE = 10;
 
 export default class Configuration extends React.PureComponent<Props, State> {
   mounted = false;
-  state: State = { loading: true, settings: { project: '' } };
+  state: State = { loading: true, projects: [], settings: { project: '' } };
 
   componentDidMount() {
     this.mounted = true;
-    this.props.widgetHelpers.IncludeWidgetConfigurationStyles();
-    VSS.register('e56c6ff0-c6f9-43d0-bdef-b3f1aa0dc6dd', () => {
+    VSS.register(this.props.contribution, () => {
       return { load: this.load, onSave: this.onSave };
     });
   }
 
+  componentDidUpdate() {
+    VSS.resize();
+  }
+
   componentWillUnmount() {
     this.mounted = false;
   }
 
-  load = (widgetSettings: any, widgetConfigurationContext: any) => {
-    const settings: Settings = JSON.parse(widgetSettings.customSettings.data);
+  load = (
+    widgetSettings: VSTSWidgetSettings,
+    widgetConfigurationContext: VSTSConfigurationContext
+  ) => {
+    const settings = parseWidgetSettings(widgetSettings);
     if (this.mounted) {
       this.setState({ settings: settings || {}, widgetConfigurationContext });
-      this.fetchProjects();
+      this.fetchInitialData();
     }
     return this.props.widgetHelpers.WidgetStatusHelper.Success();
   };
 
   onSave = () => {
-    if (!this.state.settings || !this.state.settings.project) {
+    const { settings } = this.state;
+    if (!settings.project) {
       return this.props.widgetHelpers.WidgetConfigurationSave.Invalid();
     }
-    return this.props.widgetHelpers.WidgetConfigurationSave.Valid({
-      data: JSON.stringify(this.state.settings)
-    });
+    return this.props.widgetHelpers.WidgetConfigurationSave.Valid(
+      serializeWidgetSettings(settings)
+    );
   };
 
-  fetchProjects = (organization?: string) => {
+  fetchInitialData = () => {
     this.setState({ loading: true });
-    searchProjects({ organization, ps: 100 }).then(
-      ({ components }) => {
-        if (this.mounted) {
-          this.setState({
-            projects: components.map(c => ({ label: c.name, value: c.key })),
-            loading: false
-          });
+    getCurrentUser()
+      .then(currentUser => {
+        this.setState({ currentUser });
+        const params: { ps: number; filter?: string } = { ps: PAGE_SIZE };
+        if (currentUser.isLoggedIn) {
+          params.filter = 'isFavorite';
         }
-      },
-      () => {
-        this.setState({
-          projects: [],
-          loading: false
-        });
-      }
-    );
+        return searchProjects(params);
+      })
+      .then(this.handleSearchProjectsResult, this.stopLoading);
+  };
+
+  handleReload = () => {
+    this.fetchInitialData();
   };
 
   handleProjectChange = (
@@ -106,13 +125,41 @@ export default class Configuration extends React.PureComponent<Props, State> {
     const { widgetHelpers } = this.props;
     if (widgetConfigurationContext && widgetConfigurationContext.notify) {
       const eventName = widgetHelpers.WidgetEvent.ConfigurationChange;
-      const eventArgs = widgetHelpers.WidgetEvent.Args({ data: JSON.stringify(settings) });
+      const eventArgs = widgetHelpers.WidgetEvent.Args(serializeWidgetSettings(settings));
       widgetConfigurationContext.notify(eventName, eventArgs);
     }
   };
 
+  handleProjectSearch = (query: string) => {
+    const searchParams: { ps: number; filter?: string } = { ps: PAGE_SIZE };
+    if (query) {
+      searchParams.filter = query;
+    }
+    return searchProjects(searchParams).then(this.handleSearchProjectsResult, this.stopLoading);
+  };
+
+  handleProjectSelect = (project: Component) => {
+    this.setState(
+      ({ settings }) => ({
+        selectedProject: project,
+        settings: { ...settings, project: project.key }
+      }),
+      this.notifyChange
+    );
+  };
+
+  handleSearchProjectsResult = ({ components }: { components: Component[] }) => {
+    if (this.mounted) {
+      this.setState({ loading: false, projects: components });
+    }
+  };
+
+  stopLoading = () => {
+    this.setState({ loading: false });
+  };
+
   render() {
-    const { projects, loading, settings } = this.state;
+    const { currentUser, projects, loading, selectedProject, settings } = this.state;
     if (loading) {
       return (
         <div className="vsts-loading">
@@ -120,27 +167,27 @@ export default class Configuration extends React.PureComponent<Props, State> {
         </div>
       );
     }
+
+    const isLoggedIn = Boolean(currentUser && currentUser.isLoggedIn);
+    const selected = selectedProject || projects.find(project => project.key === settings.project);
     return (
-      <div className="widget-configuration">
-        <div className="dropdown" id="project">
+      <div className="widget-configuration vsts-configuration bowtie">
+        <div className="dropdown config-settings-field" id="sonarcloud-project">
           <label>SonarCloud project</label>
-          <div className="wrapper">
-            <select
-              onBlur={this.handleProjectChange}
-              onChange={this.handleProjectChange}
-              value={settings.project}>
-              <option disabled={true} hidden={true} value="">
-                Select a project...
-              </option>
-              {projects &&
-                projects.map(project => (
-                  <option key={project.value} value={project.value}>
-                    {project.label}
-                  </option>
-                ))}
-            </select>
-          </div>
+          <ProjectSelector
+            isLoggedIn={isLoggedIn}
+            onQueryChange={this.handleProjectSearch}
+            onSelect={this.handleProjectSelect}
+            projects={projects}
+            selected={selected}
+          />
         </div>
+        {!isLoggedIn && (
+          <div className="config-settings-field">
+            <label>You must be logged in to see your private projects :</label>
+            <LoginForm onReload={this.handleReload} />
+          </div>
+        )}
       </div>
     );
   }
diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/LoginForm.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/LoginForm.tsx
new file mode 100644 (file)
index 0000000..1036759
--- /dev/null
@@ -0,0 +1,100 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 LoginLink from './LoginLink';
+import SonarCloudIcon from './SonarCloudIcon';
+import * as theme from '../../../../app/theme';
+import { IdentityProvider } from '../../../types';
+import { getIdentityProviders } from '../../../../api/users';
+import { getTextColor } from '../../../../helpers/colors';
+import { getBaseUrl } from '../../../../helpers/urls';
+
+interface Props {
+  onReload: () => void;
+  title?: string;
+}
+
+interface State {
+  identityProviders?: IdentityProvider[];
+}
+
+export default class LoginForm extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = {};
+
+  componentDidMount() {
+    this.mounted = true;
+    getIdentityProviders().then(
+      identityProvidersResponse => {
+        if (this.mounted) {
+          this.setState({
+            identityProviders: identityProvidersResponse.identityProviders
+          });
+        }
+      },
+      () => {}
+    );
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  render() {
+    const { onReload, title } = this.props;
+    const { identityProviders } = this.state;
+    const vstsProvider =
+      identityProviders && identityProviders.find(provider => provider.key === 'microsoft');
+
+    return (
+      <div className="vsts-widget-login">
+        {title && <SonarCloudIcon size={32} />}
+        {title && <p className="login-message-text">{title}</p>}
+        {identityProviders && (
+          <section className="oauth-providers">
+            {vstsProvider && (
+              <LoginLink
+                onReload={onReload}
+                sessionUrl={`sessions/init/${vstsProvider.key}`}
+                style={{
+                  backgroundColor: vstsProvider.backgroundColor,
+                  color: getTextColor(vstsProvider.backgroundColor, theme.secondFontColor)
+                }}>
+                <img
+                  alt={vstsProvider.name}
+                  height="20"
+                  src={getBaseUrl() + vstsProvider.iconPath}
+                  width="20"
+                />
+                <span>{vstsProvider.name} log in</span>
+              </LoginLink>
+            )}
+          </section>
+        )}
+
+        <div className="text-center">
+          <LoginLink onReload={onReload} sessionUrl={'sessions/new'}>
+            {vstsProvider ? 'More options' : 'Log in on SonarCloud'}
+          </LoginLink>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/LoginLink.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/LoginLink.tsx
new file mode 100644 (file)
index 0000000..a4dc36a
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { getBaseUrl } from '../../../../helpers/urls';
+
+interface Props {
+  className?: string;
+  children: React.ReactNode;
+  onReload: () => void;
+  style?: React.CSSProperties;
+  sessionUrl: string;
+}
+
+export default class LoginLink extends React.PureComponent<Props> {
+  handleLoginClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.stopPropagation();
+    event.currentTarget.blur();
+
+    (window as any).authenticationDone = () => {
+      this.props.onReload();
+    };
+
+    const returnTo = encodeURIComponent(window.location.pathname + '?type=authenticated');
+    window.open(
+      `${getBaseUrl()}/${this.props.sessionUrl}?return_to=${returnTo}`,
+      'Login on SonarCloud',
+      'toolbar=0,status=0,width=377,height=380'
+    );
+  };
+
+  render() {
+    return (
+      <a
+        className={this.props.className}
+        href="#"
+        onClick={this.handleLoginClick}
+        style={this.props.style}>
+        {this.props.children}
+      </a>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelector.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelector.tsx
new file mode 100644 (file)
index 0000000..d28fa4d
--- /dev/null
@@ -0,0 +1,264 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as classNames from 'classnames';
+import { debounce } from 'lodash';
+import ProjectSelectorItem from './ProjectSelectorItem';
+import { Component } from '../../../../api/components';
+
+interface Props {
+  isLoggedIn: boolean;
+  onQueryChange: (query: string) => Promise<void>;
+  onSelect: (component: Component) => void;
+  projects: Component[];
+  selected?: Component;
+}
+
+interface State {
+  activeIdx: number;
+  activeKey?: string;
+  favorite: boolean;
+  open: boolean;
+  search: string;
+  searching: boolean;
+}
+
+export default class ProjectSelector extends React.PureComponent<Props, State> {
+  node?: HTMLElement | null;
+  debouncedHandleSearch: () => void;
+
+  constructor(props: Props) {
+    super(props);
+    const firstProject = props.projects[0];
+    this.state = {
+      activeIdx: firstProject ? 0 : -1,
+      activeKey: firstProject && firstProject.key,
+      favorite: props.isLoggedIn,
+      open: false,
+      search: '',
+      searching: false
+    };
+    this.debouncedHandleSearch = debounce(this.handleSearch, 250);
+  }
+
+  componentDidMount() {
+    window.addEventListener('click', this.handleClickOutside);
+    if (this.node) {
+      this.node.addEventListener('keydown', this.handleKeyDown, true);
+    }
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (this.props.projects !== nextProps.projects) {
+      let activeIdx = nextProps.projects.findIndex(project => project.key === this.state.activeKey);
+      activeIdx = activeIdx >= 0 ? activeIdx : 0;
+      this.setState({ activeIdx, activeKey: this.getActiveKey(activeIdx) });
+    }
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('click', this.handleClickOutside);
+    if (this.node) {
+      this.node.removeEventListener('keydown', this.handleKeyDown);
+    }
+  }
+
+  getActiveKey = (idx: number) => {
+    const { projects } = this.props;
+    return projects[idx] && projects[idx].key;
+  };
+
+  getEmptyMessage = () => {
+    const { favorite, search } = this.state;
+    if (search) {
+      return 'No project matching your search.';
+    } else if (favorite) {
+      return "You don't have any favorite projects yet.";
+    }
+    return 'No project have been found';
+  };
+
+  handleClickOutside = (event: Event) => {
+    if (!this.node || !this.node.contains(event.target as HTMLElement)) {
+      this.setState({ open: false });
+    }
+  };
+
+  handleFilterAll = () => {
+    this.setState({ favorite: false, searching: true }, this.handleSearch);
+  };
+
+  handleFilterFavorite = () => {
+    this.setState({ favorite: true, searching: true }, this.handleSearch);
+  };
+
+  handleItemHover = (item: Component) => {
+    let activeIdx = this.props.projects.findIndex(project => project.key === item.key);
+    activeIdx = activeIdx >= 0 ? activeIdx : 0;
+    this.setState({ activeIdx, activeKey: this.getActiveKey(activeIdx) });
+  };
+
+  handleKeyDown = (evt: KeyboardEvent) => {
+    switch (evt.keyCode) {
+      case 40: // down
+        evt.stopPropagation();
+        evt.preventDefault();
+        this.setState(this.selectNextItem);
+        break;
+      case 38: // up
+        evt.stopPropagation();
+        evt.preventDefault();
+        this.setState(this.selectPreviousItem);
+        break;
+      case 37: // left
+      case 39: // right
+        evt.stopPropagation();
+        break;
+      case 13: // enter
+        if (this.state.activeIdx >= 0) {
+          this.handleSelect(this.props.projects[this.state.activeIdx]);
+        }
+        break;
+      case 27: // escape
+        this.setState({ open: false });
+        break;
+    }
+  };
+
+  handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    this.setState(
+      { search: event.currentTarget.value, searching: true },
+      this.debouncedHandleSearch
+    );
+  };
+
+  handleSearch = () => {
+    const filter = [];
+    if (this.state.favorite) {
+      filter.push('isFavorite');
+    }
+    if (this.state.search) {
+      filter.push(`query = "${this.state.search}"`);
+    }
+    this.props.onQueryChange(filter.join(' and ')).then(this.stopSearching, this.stopSearching);
+  };
+
+  handleSelect = (project: Component) => {
+    this.props.onSelect(project);
+    this.setState({ open: false });
+  };
+
+  selectNextItem = ({ activeIdx }: State, { projects }: Props) => {
+    let newActiveIdx = activeIdx + 1;
+    if (activeIdx < 0 || activeIdx >= projects.length - 1) {
+      newActiveIdx = 0;
+    }
+    return { activeIdx: newActiveIdx, activeKey: this.getActiveKey(newActiveIdx) };
+  };
+
+  selectPreviousItem = ({ activeIdx }: State, { projects }: Props) => {
+    let newActiveIdx = activeIdx - 1;
+    if (activeIdx <= 0) {
+      newActiveIdx = projects.length - 1;
+    }
+    return { activeIdx: newActiveIdx, activeKey: this.getActiveKey(newActiveIdx) };
+  };
+
+  stopSearching = () => {
+    this.setState({ searching: false });
+  };
+
+  toggleOpen = () => {
+    this.setState(({ open }) => ({ open: !open }));
+  };
+
+  render() {
+    const { isLoggedIn, projects, selected } = this.props;
+    const { activeIdx, favorite, open, search, searching } = this.state;
+    return (
+      <div className="project-picker" ref={node => (this.node = node)}>
+        <div
+          className="filtered-list-dropdown-menu"
+          onClick={this.toggleOpen}
+          role="button"
+          tabIndex={0}>
+          <span className="selected-item-text">
+            {selected ? selected.name : 'Select a project...'}
+          </span>
+          <span className="drop-icon bowtie-icon bowtie-chevron-down-light" />
+        </div>
+        {open && (
+          <div className="filtered-list-popup" role="dialog">
+            <div className="filtered-list-control bowtie-filtered-list">
+              <div className="filter-container">
+                {isLoggedIn && (
+                  <div className="views">
+                    <ul className="pivot-view" role="tablist">
+                      <li
+                        className={classNames('filtered-list-tab', { selected: favorite })}
+                        role="presentation">
+                        <a onClick={this.handleFilterFavorite} role="tab" tabIndex={0}>
+                          My Projects
+                        </a>
+                      </li>
+                      <li
+                        className={classNames('filtered-list-tab', { selected: !favorite })}
+                        role="presentation">
+                        <a onClick={this.handleFilterAll} role="tab" tabIndex={-1}>
+                          All
+                        </a>
+                      </li>
+                    </ul>
+                  </div>
+                )}
+                <div className="filtered-list-search-container bowtie-style">
+                  <input
+                    autoFocus={true}
+                    className="filtered-list-search"
+                    onChange={this.handleSearchChange}
+                    placeholder="Search by project name"
+                    type="text"
+                    value={search}
+                  />
+                  {searching && <i className="spinner" />}
+                </div>
+              </div>
+              <ul className="filtered-list">
+                {projects.map((project, idx) => (
+                  <ProjectSelectorItem
+                    isActive={activeIdx === idx}
+                    isSelected={Boolean(selected && selected.key === project.key)}
+                    key={project.key}
+                    onHover={this.handleItemHover}
+                    onSelect={this.handleSelect}
+                    project={project}
+                  />
+                ))}
+                {projects.length <= 0 && (
+                  <li className="filtered-list-message">{this.getEmptyMessage()}</li>
+                )}
+              </ul>
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelectorItem.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelectorItem.tsx
new file mode 100644 (file)
index 0000000..6dd56d5
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 * as classNames from 'classnames';
+import { Component } from '../../../../api/components';
+
+interface Props {
+  isActive: boolean;
+  isSelected: boolean;
+  onHover: (project: Component) => void;
+  onSelect: (project: Component) => void;
+  project: Component;
+}
+
+export default class ProjectSelectorItem extends React.PureComponent<Props> {
+  handleClick = () => {
+    this.props.onSelect(this.props.project);
+  };
+
+  handleHover = () => {
+    this.props.onHover(this.props.project);
+  };
+
+  render() {
+    return (
+      <li
+        className={classNames('filtered-list-item', {
+          'current-item': this.props.isSelected,
+          'active-item': this.props.isActive
+        })}
+        onClick={this.handleClick}
+        onFocus={this.handleHover}
+        onMouseOver={this.handleHover}>
+        {this.props.project.name}
+      </li>
+    );
+  }
+}
index d7e3910f9e106f983c2dd3b0d83dcdf05fccc8de..51a08eec4ced4eb2c35d2fa8f2817d20deab6ef3 100644 (file)
  */
 import * as React from 'react';
 import * as classNames from 'classnames';
+import SonarCloudIcon from './SonarCloudIcon';
 import { MeasureComponent } from '../../../../api/measures';
-import { Metric } from '../../../types';
 import { getPathUrlAsString, getProjectUrl } from '../../../../helpers/urls';
 
 interface Props {
   component: MeasureComponent;
-  metrics: Metric[];
 }
 
 const QG_LEVELS: { [level: string]: string } = {
@@ -35,8 +34,7 @@ const QG_LEVELS: { [level: string]: string } = {
   NONE: 'None'
 };
 
-export default function QGWidget({ component, metrics }: Props) {
-  const qgMetric = metrics && metrics.find(m => m.key === 'alert_status');
+export default function QGWidget({ component }: Props) {
   const qgMeasure = component && component.measures.find(m => m.metric === 'alert_status');
 
   if (!qgMeasure || !qgMeasure.value) {
@@ -49,7 +47,7 @@ export default function QGWidget({ component, metrics }: Props) {
         <h2 className="title truncated-text-ellipsis">{component.name}</h2>
         <div className="big-value truncated-text-ellipsis">{QG_LEVELS[qgMeasure.value]}</div>
         <div className="footer truncated-text-ellipsis">
-          {qgMetric ? qgMetric.name : 'Quality Gate'}
+          <SonarCloudIcon fill="#FFF" /> Quality Gate
         </div>
       </a>
     </div>
diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/SonarCloudIcon.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/SonarCloudIcon.tsx
new file mode 100644 (file)
index 0000000..17cbb78
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { IconProps } from '../../../../components/icons-components/types';
+
+export default function SonarCloudIcon({ className, fill = '#f3702a', size = 22 }: IconProps) {
+  return (
+    <svg
+      className={className}
+      height={size}
+      version="1.1"
+      viewBox="0 0 22 22"
+      width={size}
+      xmlSpace="preserve"
+      xmlnsXlink="http://www.w3.org/1999/xlink">
+      <path
+        d="M20.24 10.65l.15.29.14.3.12.3.1.32.09.32.07.33.04.34.03.34.02.35-.06.8-.16.76-.25.73-.34.68-.42.62-.5.57-.56.5-.63.42-.68.34-.72.25-.77.16-.8.05-.39-.01-.39-.04-.38-.06-.37-.09-.37-.11-.35-.13-.34-.16-.33-.17-.31-.2-.31-.21-.28-.23-.28-.25-.27.25-.29.24-.3.22-.32.2-.33.17-.35.16-.35.14-.37.11-.37.09-.39.06-.39.04-.4.02-.8-.06-.76-.15-.73-.26-.68-.34-.63-.42-.56-.5-.5-.56-.42-.63-.34-.68-.25-.72-.16-.77-.05-.8.03-.64.1-.63.17-.6.23-.57.28-.55.34-.51.39-.47.43-.42.48-.38.52-.34.55-.28.59-.22v-.08l.05-.8.16-.76.25-.73.34-.68.43-.63.49-.56.57-.5.63-.42.67-.34.73-.25.77-.16L11 2l.79.05.77.16.73.25.68.34.62.42.57.5.49.56.43.63.34.68.25.73.16.76.05.8v.07l.04.01.27.09.27.11.26.12.25.13.24.14.24.15.23.17.22.17.21.19.21.19.19.21.19.21v.01l-.01-.01.19.26.19.27.17.28zm-3.77-.64l-.11.27-.12.25-.13.25-.15.24-.16.24-.17.23-.18.21-.19.21-.2.2-.21.2-.22.18-.22.17-.03.02-.03.02-.02.01-.03.02-.03.01-.03.02-.04.01-.03.01-.03.01h-.04l-.03.01h-.03l-.04.01h-.03l-.09-.01-.08-.01-.08-.03-.08-.03-.07-.04-.07-.05-.06-.05-.05-.06-.05-.07-.04-.07-.03-.07-.02-.08-.02-.09v-.19l.01-.05.02-.05.01-.05.02-.05.02-.04.03-.05.03-.04.03-.04.03-.04.04-.03.04-.03.04-.03.01-.01.23-.18.22-.2.21-.2.19-.22.18-.24.16-.24.15-.26.12-.27.12-.27.09-.29.07-.29.05-.3.04-.31.01-.31-.03-.51-.09-.5-.14-.48-.19-.45-.24-.42-.28-.39-.32-.36-.36-.33-.39-.28-.43-.23-.45-.2-.48-.14-.49-.08-.51-.03-.5.03-.49.08-.46.13-.44.18-.42.23-.39.27-.35.3-.32.35-.29.37-.24.41-.2.43-.15.46-.1.48-.05.49H7.04l.16.01.15.01.15.01.15.02.15.02.14.03.15.02.14.03.14.04.14.04.14.04.13.04.13.05.02.01.08.02.07.03.07.03.07.03.07.03.07.04.07.03.06.04.07.04.06.04.06.04.07.04.06.04.06.05.03.03.03.03.03.03.03.04.02.04.03.03.02.04.01.05.02.04.01.04.01.05.01.04v.05l.01.05-.01.08-.01.09-.03.08-.03.07-.04.08-.05.06-.05.06-.06.06-.07.04-.07.04-.07.04-.08.02-.09.01-.08.01h-.04l-.04-.01h-.04l-.04-.01-.04-.01-.03-.01-.04-.01-.03-.01-.03-.02-.04-.02-.03-.01-.03-.02-.03-.03-.03-.02.01.01-.03-.02-.03-.02-.03-.02-.04-.02-.03-.02-.03-.02-.04-.02-.03-.01-.04-.02-.04-.01-.03-.02-.04-.01-.04-.02-.04-.01h-.01l.03.01-.03-.01-.08-.03-.1-.03-.11-.03-.11-.03-.11-.03-.1-.03-.12-.02-.11-.02-.11-.01-.11-.02-.12-.01h-.11L7 9.17h-.11l-.52.03-.49.09-.48.14-.45.19-.42.23-.4.29-.36.32-.32.36-.28.39-.24.42-.19.46-.14.47-.09.5-.02.51.02.51.09.5.14.47.19.45.24.43.28.39.32.36.36.32.4.28.42.24.45.19.48.14.49.09.52.03.26-.01.27-.02.26-.04.25-.06.25-.06.24-.09.24-.09.23-.11.22-.12.22-.14.2-.14.2-.16.18-.17.18-.18-.1-.16-.1-.2-.1-.2-.09-.2-.08-.21-.07-.22-.07-.21-.06-.22-.04-.22-.05-.23-.03-.23-.02-.23-.01-.23-.01-.24v-.01l.01-.08.01-.09.02-.08.04-.07.04-.07.04-.07.06-.06.06-.06.06-.04.08-.04.07-.03.08-.03.09-.01.08-.01.09.01.08.01.08.03.08.03.07.04.06.04.07.06.05.06.05.07.04.07.03.07.02.08.02.09v.08l.03.52.09.49.14.48.19.45.24.42.28.39.32.36.36.33.4.28.42.24.45.19.48.14.49.08.52.03-.01-.01.52-.03.49-.09.48-.14.45-.19.42-.24.4-.28.36-.32.32-.36.28-.39.24-.43.19-.45.14-.47.08-.5.03-.51-.01-.4-.06-.39-.08-.38-.12-.36-.15-.35-.18-.34-.2-.32-.23-.29-.25-.28-.28-.26-.3-.23-.32-.2-.34-.18-.35-.15-.01.04-.09.27-.1.27zm-6.36 6.6l-.02-.03.02.03z"
+        style={{ fill }}
+      />
+    </svg>
+  );
+}
index 98ac6c85aa764635d1d0aeb77210215d78691cb4..276633320a8c6bf12570f82b292bf70735f83831 100644 (file)
  */
 import * as React from 'react';
 import QGWidget from './QGWidget';
+import LoginForm from './LoginForm';
 import { getMeasuresAndMeta, MeasureComponent } from '../../../../api/measures';
 import { Metric } from '../../../types';
+import { Settings } from '../utils';
 
 interface Props {
-  widgetHelpers: any;
+  settings: Settings;
 }
 
 interface State {
   component?: MeasureComponent;
   loading: boolean;
   metrics?: Metric[];
+  unauthorized: boolean;
 }
-
-declare const VSS: any;
-
 export default class Widget extends React.PureComponent<Props, State> {
   mounted = false;
-  state: State = { loading: true };
+  state: State = { loading: true, unauthorized: false };
 
   componentDidMount() {
     this.mounted = true;
-    this.props.widgetHelpers.IncludeWidgetStyles();
-    VSS.register('3c598f25-01c1-4c09-97c6-926476882688', () => {
-      return { load: this.load, reload: this.load };
-    });
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
+    const { settings } = this.props;
+    if (settings.project) {
+      this.fetchProjectMeasures(settings.project);
+    } else {
+      this.setState({ loading: false });
+    }
   }
 
-  load = (widgetSettings: any) => {
-    const settings = JSON.parse(widgetSettings.customSettings.data);
-    if (this.mounted) {
-      if (settings && settings.project) {
-        this.fetchProjectMeasures(settings.project);
+  componentWillReceiveProps(nextProps: Props) {
+    const { project } = nextProps.settings;
+    if (project !== this.props.settings.project) {
+      if (project) {
+        this.fetchProjectMeasures(project);
       } else {
-        this.setState({ loading: false });
+        this.setState({ component: undefined });
       }
     }
-    return this.props.widgetHelpers.WidgetStatusHelper.Success();
-  };
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
 
   fetchProjectMeasures = (project: string) => {
     this.setState({ loading: true });
     getMeasuresAndMeta(project, ['alert_status'], { additionalFields: 'metrics' }).then(
       ({ component, metrics }) => {
         if (this.mounted) {
-          this.setState({ component, loading: false, metrics });
+          this.setState({ component, loading: false, metrics, unauthorized: false });
         }
       },
-      () => {
-        this.setState({ loading: false });
+      response => {
+        if (response && response.response.status === 403) {
+          this.setState({ loading: false, unauthorized: true });
+        } else {
+          this.setState({ loading: false });
+        }
       }
     );
   };
 
+  handleReload = () => {
+    const { settings } = this.props;
+    if (settings.project) {
+      this.fetchProjectMeasures(settings.project);
+    }
+  };
+
   render() {
-    const { component, loading, metrics } = this.state;
+    const { component, loading, metrics, unauthorized } = this.state;
     if (loading) {
       return (
         <div className="vsts-loading">
@@ -86,10 +98,18 @@ export default class Widget extends React.PureComponent<Props, State> {
       );
     }
 
+    if (unauthorized) {
+      return (
+        <div className="widget">
+          <LoginForm onReload={this.handleReload} title="Authentication on SonarCloud required" />
+        </div>
+      );
+    }
+
     if (!component || !metrics) {
       return (
         <div className="vsts-widget-configure widget">
-          <h2 className="title">Quality Widget</h2>
+          <h2 className="title">Code Quality</h2>
           <div className="content">
             <div>Configure widget</div>
             <img
@@ -101,6 +121,6 @@ export default class Widget extends React.PureComponent<Props, State> {
       );
     }
 
-    return <QGWidget component={component} metrics={metrics} />;
+    return <QGWidget component={component} />;
   }
 }
index 433334447b66d6dd8a8e84ceab78b8d5498ab8b1..468f519069843c1d520e15b7b374a12eb82fcb51 100644 (file)
@@ -22,21 +22,50 @@ import React from 'react';
 import { render } from 'react-dom';
 import Configuration from './components/Configuration';
 import Widget from './components/Widget';
+import { parseWidgetSettings } from './utils';
 import './vsts.css';
 
-VSS.init({
-  explicitNotifyLoaded: true,
-  usePlatformStyles: true
-});
+const container = document.getElementById('content');
+const query = parse(window.location.search.replace('?', ''));
 
-VSS.require('TFS/Dashboards/WidgetHelpers', widgetHelpers => {
-  const container = document.getElementById('content');
-  const query = parse(window.location.search.replace('?', ''));
-
-  if (query.type === 'configuration') {
-    render(<Configuration widgetHelpers={widgetHelpers} />, container);
-  } else {
-    render(<Widget widgetHelpers={widgetHelpers} />, container);
+if (query.type === 'authenticated') {
+  if (window.opener && window.opener.authenticationDone) {
+    window.opener.authenticationDone();
   }
-  VSS.notifyLoadSucceeded();
-});
+  window.close();
+} else if (VSS && query.contribution && VSS.init && VSS.require) {
+  VSS.init({
+    explicitNotifyLoaded: true,
+    usePlatformStyles: true
+  });
+
+  VSS.require('TFS/Dashboards/WidgetHelpers', WidgetHelpers => {
+    WidgetHelpers.IncludeWidgetStyles();
+    WidgetHelpers.IncludeWidgetConfigurationStyles();
+
+    if (query.type === 'configuration') {
+      render(
+        <Configuration contribution={query.contribution} widgetHelpers={WidgetHelpers} />,
+        container
+      );
+    } else {
+      VSS.register(query.contribution, () => {
+        const loadFunction = loadVSTSWidget(WidgetHelpers);
+        return { load: loadFunction, reload: loadFunction };
+      });
+    }
+    VSS.notifyLoadSucceeded();
+  });
+}
+
+function loadVSTSWidget(WidgetHelpers) {
+  return widgetSettings => {
+    try {
+      render(<Widget settings={parseWidgetSettings(widgetSettings)} />, container);
+    } catch (error) {
+      return WidgetHelpers.WidgetStatusHelper.Failure(error);
+    }
+
+    return WidgetHelpers.WidgetStatusHelper.Success();
+  };
+}
diff --git a/server/sonar-web/src/main/js/app/integration/vsts/utils.ts b/server/sonar-web/src/main/js/app/integration/vsts/utils.ts
new file mode 100644 (file)
index 0000000..ec9bc46
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 interface VSTSConfigurationContext {
+  notify: Function;
+}
+
+export interface VSTSCustomSettings {
+  data: string;
+}
+
+export interface VSTSWidgetSettings {
+  customSettings: VSTSCustomSettings;
+}
+
+export interface Settings {
+  project?: string;
+}
+
+export function parseWidgetSettings(widgetSettings: VSTSWidgetSettings): Settings {
+  try {
+    return JSON.parse(widgetSettings.customSettings.data) || {};
+  } catch (e) {
+    return {};
+  }
+}
+
+export function serializeWidgetSettings(parsedSettings: Settings): VSTSCustomSettings {
+  return { data: JSON.stringify(parsedSettings) };
+}
index 8c0b5a62242af584d8e9cebd946c6e5288b0fd36..634a9bd345ab2f4dc545d984f1505ad39ff91a23 100644 (file)
   color: white;
 }
 
+.widget .footer {
+  display: flex;
+  align-items: center;
+}
+
+.widget .footer svg {
+  margin-right: 8px;
+}
+
+.vsts-widget-login {
+  text-align: center;
+  padding-top: 4px;
+}
+
+.vsts-widget-login .login-message-text {
+  color: #666;
+  margin: 0;
+}
+
+.vsts-widget-login .oauth-providers {
+  margin-top: 8px;
+  margin-bottom: 8px;
+}
+.vsts-widget-login .oauth-providers a {
+  display: inline-block;
+  line-height: 22px;
+  padding: 4px 6px;
+  border: 1px solid rgba(0, 0, 0, 0.15);
+  border-radius: 2px;
+  box-sizing: border-box;
+  background-color: var(--darkBlue);
+  color: #fff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.vsts-widget-login .oauth-providers a:hover,
+.vsts-widget-login .oauth-providers a:focus {
+  box-shadow: 0 0 16px rgba(0, 0, 0, 0.2);
+}
+
+.vsts-widget-login .oauth-providers span {
+  padding-left: 4px;
+}
+
+.vsts-widget-login .oauth-providers img {
+  vertical-align: top;
+}
+
+.vsts-configuration {
+  min-height: 540px;
+}
+
+.vsts-configuration .config-settings-field {
+  margin-bottom: 20px;
+}
+
 .big-value {
   font-size: 36px;
   line-height: 68px;
 .Select {
   width: 100%;
 }
+
+.project-picker {
+  position: relative;
+  width: 100%;
+  height: 32px;
+}
+
+.filtered-list-dropdown-menu {
+  white-space: nowrap;
+  position: relative;
+  cursor: pointer;
+  padding: 6px;
+  border: 1px solid #c8c8c8;
+}
+
+.filtered-list-dropdown-menu .drop-icon {
+  float: right;
+  position: relative;
+  overflow: hidden;
+  vertical-align: middle;
+}
+
+.filtered-list-dropdown-menu .selected-item-text {
+  width: 90%;
+  padding-left: 5px;
+  padding-right: 5px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  word-wrap: normal;
+  vertical-align: middle;
+  display: inline-block;
+}
+
+.filtered-list-popup {
+  position: absolute;
+  display: block;
+  width: 100%;
+  top: 30px;
+  left: 0;
+  z-index: 20000;
+  overflow-y: auto;
+  max-height: 400px;
+  font-size: 12px;
+  background-color: #fff;
+  border: 1px solid #c8c8c8;
+  box-shadow: 0 2.5px 5px rgba(0, 0, 0, 0.4);
+}
+
+.filtered-list-popup .filtered-list-control .pivot-view {
+  margin-left: 0;
+}
+
+.filtered-list-control.bowtie-filtered-list .filtered-list-tab > a {
+  outline: none;
+}
+
+.filtered-list-tab.selected::before {
+  content: '';
+  position: absolute;
+  bottom: 0;
+  left: 10px;
+  right: 10px;
+  height: 2px;
+  background-color: #0078d7;
+}
+
+.filtered-list-tab:first-child.selected::before {
+  left: 0;
+}
+
+.filtered-list-control .filter-container {
+  position: relative;
+}
+
+.filtered-list-search::placeholder {
+  font-size: 12px;
+}
+
+.filtered-list-search-container .spinner {
+  position: absolute;
+  right: 16px;
+  bottom: 18px;
+}
+
+.filtered-list-control.bowtie-filtered-list .filter-container {
+  padding: 10px;
+  width: 100%;
+}
+
+.filtered-list-control.bowtie-filtered-list .filtered-list {
+  padding: 0;
+  margin: 0 0 4px 0;
+  max-height: 300px;
+  overflow: auto;
+}
+
+.filtered-list-control .filtered-list > li {
+  list-style-type: none;
+  padding: 5px 10px;
+  border: none;
+  margin: 0;
+  height: 30px;
+  line-height: 20px;
+  cursor: pointer;
+  position: relative;
+  vertical-align: middle;
+  outline: none;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  word-wrap: normal;
+  white-space: pre;
+}
+
+.filtered-list-control .filtered-list > li.filtered-list-message {
+  white-space: normal;
+  color: #666;
+  cursor: default;
+  overflow: visible;
+}
+
+.filtered-list-control .filtered-list > li.filtered-list-item.current-item {
+  font-weight: 700;
+  color: #212121;
+  background-color: #f4f4f4;
+}
+
+.filtered-list-control .filtered-list > li.filtered-list-item.active-item {
+  font-weight: normal;
+  color: #212121;
+  background-color: #eff6fc;
+}
+
+.filtered-list-control .filtered-list > li.filtered-list-item.active-item::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  pointer-events: none;
+  border: 1px solid #a6a6a6;
+}
+
+.filtered-list-control .filtered-list > li.filtered-list-item.current-item.active-item {
+  font-weight: 700;
+}
index 5da7fdb1c1245dbbef88b78667b7549bfef8e26b..5fd7540ac1def5f3c5d000a952a9526682a87528 100644 (file)
@@ -140,8 +140,10 @@ public abstract class ServletFilter implements Filter {
      */
     public static class Builder {
       private static final String WILDCARD_CHAR = "*";
-      private static final Collection<String> STATIC_RESOURCES = unmodifiableList(asList("*.css", "*.css.map", "*.ico", "*.png", "*.gif", "*.svg", "*.js", "*.js.map", "*.eot", "*.ttf", "*.woff", "/static/*",
-        "/robots.txt", "/favicon.ico", "/apple-touch-icon*", "/mstile*"));
+      private static final Collection<String> STATIC_RESOURCES = unmodifiableList(asList(
+        "*.css", "*.css.map", "*.ico", "*.png", "*.gif", "*.svg", "*.js", "*.js.map", "*.eot", "*.ttf", "*.woff",
+        "/static/*", "/robots.txt","/favicon.ico", "/apple-touch-icon*", "/mstile*"
+      ));
 
       private final Set<String> inclusions = new LinkedHashSet<>();
       private final Set<String> exclusions = new LinkedHashSet<>();