diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-03-07 17:47:48 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-03-26 20:20:57 +0200 |
commit | 9ac78350022bfadca4845a42cb600dfa762fb66b (patch) | |
tree | 1fb5e6366cb0e439be7c12ac9d8dbdbaf0c8b637 | |
parent | cb0a23c978efcc296cf29837c9e6e1a657403404 (diff) | |
download | sonarqube-9ac78350022bfadca4845a42cb600dfa762fb66b.tar.gz sonarqube-9ac78350022bfadca4845a42cb600dfa762fb66b.zip |
VSTS-142 Handle authentication to display private project in VSTS widgets
12 files changed, 969 insertions, 103 deletions
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<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 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<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 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<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 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<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 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<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> + ); + } +} 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) { <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 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 ( + <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> + ); +} 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<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} />; } } 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(<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 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<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<>(); |