From 9ac78350022bfadca4845a42cb600dfa762fb66b Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Wed, 7 Mar 2018 17:47:48 +0100 Subject: [PATCH] VSTS-142 Handle authentication to display private project in VSTS widgets --- .../vsts/components/Configuration.tsx | 157 +++++++---- .../integration/vsts/components/LoginForm.tsx | 100 +++++++ .../integration/vsts/components/LoginLink.tsx | 60 ++++ .../vsts/components/ProjectSelector.tsx | 264 ++++++++++++++++++ .../vsts/components/ProjectSelectorItem.tsx | 55 ++++ .../integration/vsts/components/QGWidget.tsx | 8 +- .../vsts/components/SonarCloudIcon.tsx | 39 +++ .../integration/vsts/components/Widget.tsx | 74 +++-- .../src/main/js/app/integration/vsts/index.js | 57 +++- .../src/main/js/app/integration/vsts/utils.ts | 47 ++++ .../src/main/js/app/integration/vsts/vsts.css | 205 ++++++++++++++ .../java/org/sonar/api/web/ServletFilter.java | 6 +- 12 files changed, 969 insertions(+), 103 deletions(-) create mode 100644 server/sonar-web/src/main/js/app/integration/vsts/components/LoginForm.tsx create mode 100644 server/sonar-web/src/main/js/app/integration/vsts/components/LoginLink.tsx create mode 100644 server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelector.tsx create mode 100644 server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelectorItem.tsx create mode 100644 server/sonar-web/src/main/js/app/integration/vsts/components/SonarCloudIcon.tsx create mode 100644 server/sonar-web/src/main/js/app/integration/vsts/utils.ts diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/Configuration.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/Configuration.tsx index 838114c4cbd..6afeb86b3a4 100644 --- a/server/sonar-web/src/main/js/app/integration/vsts/components/Configuration.tsx +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/Configuration.tsx @@ -18,78 +18,97 @@ * 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 { 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 { 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 (
@@ -120,27 +167,27 @@ export default class Configuration extends React.PureComponent {
); } + + const isLoggedIn = Boolean(currentUser && currentUser.isLoggedIn); + const selected = selectedProject || projects.find(project => project.key === settings.project); return ( -
-
+
+
-
- -
+
+ {!isLoggedIn && ( +
+ + +
+ )}
); } 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 index 00000000000..10367598a92 --- /dev/null +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/LoginForm.tsx @@ -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 { + 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 ( +
+ {title && } + {title &&

{title}

} + {identityProviders && ( +
+ {vstsProvider && ( + + {vstsProvider.name} + {vstsProvider.name} log in + + )} +
+ )} + +
+ + {vstsProvider ? 'More options' : 'Log in on SonarCloud'} + +
+
+ ); + } +} 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 index 00000000000..a4dc36adc92 --- /dev/null +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/LoginLink.tsx @@ -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 { + handleLoginClick = (event: React.MouseEvent) => { + 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 ( + + {this.props.children} + + ); + } +} 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 index 00000000000..d28fa4dc504 --- /dev/null +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelector.tsx @@ -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; + 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 { + 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) => { + 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 ( +
(this.node = node)}> +
+ + {selected ? selected.name : 'Select a project...'} + + +
+ {open && ( +
+
+
+ {isLoggedIn && ( + + )} +
+ + {searching && } +
+
+
    + {projects.map((project, idx) => ( + + ))} + {projects.length <= 0 && ( +
  • {this.getEmptyMessage()}
  • + )} +
+
+
+ )} +
+ ); + } +} 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 index 00000000000..6dd56d58056 --- /dev/null +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/ProjectSelectorItem.tsx @@ -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 { + handleClick = () => { + this.props.onSelect(this.props.project); + }; + + handleHover = () => { + this.props.onHover(this.props.project); + }; + + render() { + return ( +
  • + {this.props.project.name} +
  • + ); + } +} diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/QGWidget.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/QGWidget.tsx index d7e3910f9e1..51a08eec4ce 100644 --- a/server/sonar-web/src/main/js/app/integration/vsts/components/QGWidget.tsx +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/QGWidget.tsx @@ -19,13 +19,12 @@ */ 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) {

    {component.name}

    {QG_LEVELS[qgMeasure.value]}
    - {qgMetric ? qgMetric.name : 'Quality Gate'} + Quality Gate
    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 index 00000000000..17cbb789252 --- /dev/null +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/SonarCloudIcon.tsx @@ -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 ( + + + + ); +} diff --git a/server/sonar-web/src/main/js/app/integration/vsts/components/Widget.tsx b/server/sonar-web/src/main/js/app/integration/vsts/components/Widget.tsx index 98ac6c85aa7..276633320a8 100644 --- a/server/sonar-web/src/main/js/app/integration/vsts/components/Widget.tsx +++ b/server/sonar-web/src/main/js/app/integration/vsts/components/Widget.tsx @@ -19,65 +19,77 @@ */ 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 { 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 (
    @@ -86,10 +98,18 @@ export default class Widget extends React.PureComponent { ); } + if (unauthorized) { + return ( +
    + +
    + ); + } + if (!component || !metrics) { return (
    -

    Quality Widget

    +

    Code Quality

    Configure widget
    { ); } - return ; + return ; } } diff --git a/server/sonar-web/src/main/js/app/integration/vsts/index.js b/server/sonar-web/src/main/js/app/integration/vsts/index.js index 433334447b6..468f5190698 100644 --- a/server/sonar-web/src/main/js/app/integration/vsts/index.js +++ b/server/sonar-web/src/main/js/app/integration/vsts/index.js @@ -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(, container); - } else { - render(, 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( + , + container + ); + } else { + VSS.register(query.contribution, () => { + const loadFunction = loadVSTSWidget(WidgetHelpers); + return { load: loadFunction, reload: loadFunction }; + }); + } + VSS.notifyLoadSucceeded(); + }); +} + +function loadVSTSWidget(WidgetHelpers) { + return widgetSettings => { + try { + render(, 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 index 00000000000..ec9bc4688b8 --- /dev/null +++ b/server/sonar-web/src/main/js/app/integration/vsts/utils.ts @@ -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) }; +} diff --git a/server/sonar-web/src/main/js/app/integration/vsts/vsts.css b/server/sonar-web/src/main/js/app/integration/vsts/vsts.css index 8c0b5a62242..634a9bd345a 100644 --- a/server/sonar-web/src/main/js/app/integration/vsts/vsts.css +++ b/server/sonar-web/src/main/js/app/integration/vsts/vsts.css @@ -61,6 +61,64 @@ 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; @@ -87,3 +145,150 @@ .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; +} diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/web/ServletFilter.java b/sonar-plugin-api/src/main/java/org/sonar/api/web/ServletFilter.java index 5da7fdb1c12..5fd7540ac1d 100644 --- a/sonar-plugin-api/src/main/java/org/sonar/api/web/ServletFilter.java +++ b/sonar-plugin-api/src/main/java/org/sonar/api/web/ServletFilter.java @@ -140,8 +140,10 @@ public abstract class ServletFilter implements Filter { */ public static class Builder { private static final String WILDCARD_CHAR = "*"; - private static final Collection 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 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 inclusions = new LinkedHashSet<>(); private final Set exclusions = new LinkedHashSet<>(); -- 2.39.5