@@ -49,6 +49,10 @@ th.hide-overflow { | |||
margin-top: -1px; | |||
} | |||
.nudged-down { | |||
margin-top: 1px; | |||
} | |||
.spacer { | |||
margin: 8px !important; | |||
} |
@@ -507,6 +507,11 @@ declare namespace T { | |||
type: string; | |||
} | |||
export interface NotificationProject { | |||
project: string; | |||
projectName: string; | |||
} | |||
export interface OrganizationActions { | |||
admin?: boolean; | |||
delete?: boolean; |
@@ -231,3 +231,21 @@ | |||
margin-top: 30px; | |||
text-align: center; | |||
} | |||
.notifications-table { | |||
margin-top: calc(-2 * var(--gridSize)); | |||
} | |||
.notifications-add-project-no-search-results { | |||
padding: var(--gridSize); | |||
} | |||
.notifications-add-project-search-results li { | |||
padding: var(--gridSize); | |||
cursor: pointer; | |||
} | |||
.notifications-add-project-search-results li:hover, | |||
.notifications-add-project-search-results li.active { | |||
background-color: var(--barBackgroundColor); | |||
} |
@@ -17,36 +17,31 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { groupBy, partition, uniq, uniqBy, uniqWith } from 'lodash'; | |||
import { partition, uniqWith } from 'lodash'; | |||
import * as React from 'react'; | |||
import Helmet from 'react-helmet'; | |||
import { Alert } from 'sonar-ui-common/components/ui/Alert'; | |||
import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import * as api from '../../../api/notifications'; | |||
import { withAppState } from '../../../components/hoc/withAppState'; | |||
import GlobalNotifications from './GlobalNotifications'; | |||
import Projects from './Projects'; | |||
import { NotificationProject } from './types'; | |||
export interface Props { | |||
appState: Pick<T.AppState, 'organizationsEnabled'>; | |||
fetchOrganizations: (organizations: string[]) => void; | |||
} | |||
interface State { | |||
channels: string[]; | |||
globalTypes: string[]; | |||
initialProjectNotificationsCount: number; | |||
loading: boolean; | |||
notifications: T.Notification[]; | |||
perProjectTypes: string[]; | |||
} | |||
export class Notifications extends React.PureComponent<Props, State> { | |||
export default class Notifications extends React.PureComponent<{}, State> { | |||
mounted = false; | |||
state: State = { | |||
channels: [], | |||
globalTypes: [], | |||
initialProjectNotificationsCount: 0, | |||
loading: true, | |||
notifications: [], | |||
perProjectTypes: [] | |||
@@ -65,16 +60,13 @@ export class Notifications extends React.PureComponent<Props, State> { | |||
api.getNotifications().then( | |||
response => { | |||
if (this.mounted) { | |||
if (this.props.appState.organizationsEnabled) { | |||
const organizations = uniq(response.notifications | |||
.filter(n => n.organization) | |||
.map(n => n.organization) as string[]); | |||
this.props.fetchOrganizations(organizations); | |||
} | |||
const { notifications } = response; | |||
const { projectNotifications } = this.getNotificationUpdates(notifications); | |||
this.setState({ | |||
channels: response.channels, | |||
globalTypes: response.globalTypes, | |||
initialProjectNotificationsCount: projectNotifications.length, | |||
loading: false, | |||
notifications: response.notifications, | |||
perProjectTypes: response.perProjectTypes | |||
@@ -90,9 +82,10 @@ export class Notifications extends React.PureComponent<Props, State> { | |||
}; | |||
addNotificationToState = (added: T.Notification) => { | |||
this.setState(state => ({ | |||
notifications: uniqWith([...state.notifications, added], areNotificationsEqual) | |||
})); | |||
this.setState(state => { | |||
const notifications = uniqWith([...state.notifications, added], areNotificationsEqual); | |||
return { notifications }; | |||
}); | |||
}; | |||
removeNotificationFromState = (removed: T.Notification) => { | |||
@@ -125,20 +118,20 @@ export class Notifications extends React.PureComponent<Props, State> { | |||
}); | |||
}; | |||
getNotificationUpdates = (notifications: T.Notification[]) => { | |||
const [globalNotifications, projectNotifications] = partition(notifications, n => !n.project); | |||
return { | |||
globalNotifications, | |||
projectNotifications | |||
}; | |||
}; | |||
render() { | |||
const [globalNotifications, projectNotifications] = partition( | |||
this.state.notifications, | |||
n => !n.project | |||
const { initialProjectNotificationsCount, notifications } = this.state; | |||
const { globalNotifications, projectNotifications } = this.getNotificationUpdates( | |||
notifications | |||
); | |||
const projects = uniqBy( | |||
projectNotifications.map(n => ({ | |||
key: n.project, | |||
name: n.projectName, | |||
organization: n.organization | |||
})) as NotificationProject[], | |||
project => project.key | |||
); | |||
const notificationsByProject = groupBy(projectNotifications, n => n.project); | |||
return ( | |||
<div className="account-body account-container"> | |||
@@ -157,8 +150,8 @@ export class Notifications extends React.PureComponent<Props, State> { | |||
<Projects | |||
addNotification={this.addNotification} | |||
channels={this.state.channels} | |||
notificationsByProject={notificationsByProject} | |||
projects={projects} | |||
initialProjectNotificationsCount={initialProjectNotificationsCount} | |||
notifications={projectNotifications} | |||
removeNotification={this.removeNotification} | |||
types={this.state.perProjectTypes} | |||
/> | |||
@@ -170,8 +163,6 @@ export class Notifications extends React.PureComponent<Props, State> { | |||
} | |||
} | |||
export default withAppState(Notifications); | |||
function areNotificationsEqual(a: T.Notification, b: T.Notification) { | |||
return a.channel === b.channel && a.type === b.type && a.project === b.project; | |||
} |
@@ -1,29 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 { connect } from 'react-redux'; | |||
import { fetchOrganizations } from '../../../store/rootActions'; | |||
import Notifications, { Props } from './Notifications'; | |||
const mapDispatchToProps = { fetchOrganizations } as Pick<Props, 'fetchOrganizations'>; | |||
export default connect( | |||
null, | |||
mapDispatchToProps | |||
)(Notifications); |
@@ -0,0 +1,231 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 classNames from 'classnames'; | |||
import { debounce } from 'lodash'; | |||
import * as React from 'react'; | |||
import { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; | |||
import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown'; | |||
import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; | |||
import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import { getSuggestions } from '../../../api/components'; | |||
interface Props { | |||
addedProjects: T.NotificationProject[]; | |||
closeModal: VoidFunction; | |||
onSubmit: (project: T.NotificationProject) => void; | |||
} | |||
interface State { | |||
highlighted?: T.NotificationProject; | |||
loading?: boolean; | |||
query?: string; | |||
open?: boolean; | |||
selectedProject?: T.NotificationProject; | |||
suggestions?: T.NotificationProject[]; | |||
} | |||
export default class ProjectModal extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = {}; | |||
this.handleSearch = debounce(this.handleSearch, 250); | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
handleKeyDown = (event: React.KeyboardEvent) => { | |||
switch (event.keyCode) { | |||
case 13: | |||
event.preventDefault(); | |||
this.handleSelectHighlighted(); | |||
break; | |||
case 38: | |||
event.preventDefault(); | |||
this.handleHighlightPrevious(); | |||
break; | |||
case 40: | |||
event.preventDefault(); | |||
this.handleHighlightNext(); | |||
break; | |||
} | |||
}; | |||
getCurrentIndex = () => { | |||
const { highlighted, suggestions } = this.state; | |||
return highlighted && suggestions | |||
? suggestions.findIndex(suggestion => suggestion.project === highlighted.project) | |||
: -1; | |||
}; | |||
highlightIndex = (index: number) => { | |||
const { suggestions } = this.state; | |||
if (suggestions && suggestions.length > 0) { | |||
if (index < 0) { | |||
index = suggestions.length - 1; | |||
} else if (index >= suggestions.length) { | |||
index = 0; | |||
} | |||
this.setState({ | |||
highlighted: suggestions[index] | |||
}); | |||
} | |||
}; | |||
handleHighlightPrevious = () => { | |||
this.highlightIndex(this.getCurrentIndex() - 1); | |||
}; | |||
handleHighlightNext = () => { | |||
this.highlightIndex(this.getCurrentIndex() + 1); | |||
}; | |||
handleSelectHighlighted = () => { | |||
const { highlighted, selectedProject } = this.state; | |||
if (highlighted !== undefined) { | |||
if (selectedProject !== undefined && highlighted.project === selectedProject.project) { | |||
this.handleSubmit(); | |||
} else { | |||
this.handleSelect(highlighted); | |||
} | |||
} | |||
}; | |||
handleSearch = (query: string) => { | |||
const { addedProjects } = this.props; | |||
if (query.length < 2) { | |||
this.setState({ open: false, query }); | |||
return Promise.resolve([]); | |||
} | |||
this.setState({ loading: true, query }); | |||
return getSuggestions(query).then( | |||
r => { | |||
if (this.mounted) { | |||
let suggestions = undefined; | |||
const projects = r.results.find(domain => domain.q === 'TRK'); | |||
if (projects && projects.items.length > 0) { | |||
suggestions = projects.items | |||
.filter(item => !addedProjects.find(p => p.project === item.key)) | |||
.map(item => ({ | |||
project: item.key, | |||
projectName: item.name | |||
})); | |||
} | |||
this.setState({ loading: false, open: true, suggestions }); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ loading: false, open: false }); | |||
} | |||
} | |||
); | |||
}; | |||
handleSelect = (selectedProject: T.NotificationProject) => { | |||
this.setState({ | |||
open: false, | |||
query: selectedProject.projectName, | |||
selectedProject | |||
}); | |||
}; | |||
handleSubmit = () => { | |||
const { selectedProject } = this.state; | |||
if (selectedProject) { | |||
this.props.onSubmit(selectedProject); | |||
} | |||
}; | |||
render() { | |||
const { closeModal } = this.props; | |||
const { highlighted, loading, query, open, selectedProject, suggestions } = this.state; | |||
const header = translate('my_account.set_notifications_for.title'); | |||
return ( | |||
<SimpleModal header={header} onClose={closeModal} onSubmit={this.handleSubmit}> | |||
{({ onCloseClick, onFormSubmit }) => ( | |||
<form onSubmit={onFormSubmit}> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
<div className="modal-field abs-width-400"> | |||
<label>{translate('my_account.set_notifications_for')}</label> | |||
<SearchBox | |||
autoFocus={true} | |||
onChange={this.handleSearch} | |||
onKeyDown={this.handleKeyDown} | |||
placeholder={translate('search.placeholder')} | |||
value={query} | |||
/> | |||
{loading && <i className="spinner spacer-left" />} | |||
{!loading && open && ( | |||
<div className="position-relative"> | |||
<DropdownOverlay className="abs-width-400" noPadding={true}> | |||
{suggestions && suggestions.length > 0 ? ( | |||
<ul className="notifications-add-project-search-results"> | |||
{suggestions.map(suggestion => ( | |||
<li | |||
className={classNames({ | |||
active: highlighted && highlighted.project === suggestion.project | |||
})} | |||
key={suggestion.project} | |||
onClick={() => this.handleSelect(suggestion)}> | |||
{suggestion.projectName} | |||
</li> | |||
))} | |||
</ul> | |||
) : ( | |||
<div className="notifications-add-project-no-search-results"> | |||
{translate('no_results')} | |||
</div> | |||
)} | |||
</DropdownOverlay> | |||
</div> | |||
)} | |||
</div> | |||
</div> | |||
<footer className="modal-foot"> | |||
<div> | |||
<SubmitButton disabled={selectedProject === undefined}> | |||
{translate('add_verb')} | |||
</SubmitButton> | |||
<ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink> | |||
</div> | |||
</footer> | |||
</form> | |||
)} | |||
</SimpleModal> | |||
); | |||
} | |||
} |
@@ -18,60 +18,51 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { Link } from 'react-router'; | |||
import BoxedGroupAccordion from 'sonar-ui-common/components/controls/BoxedGroupAccordion'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import Organization from '../../../components/shared/Organization'; | |||
import { getProjectUrl } from '../../../helpers/urls'; | |||
import NotificationsList from './NotificationsList'; | |||
import { NotificationProject } from './types'; | |||
interface Props { | |||
addNotification: (n: T.Notification) => void; | |||
channels: string[]; | |||
collapsed: boolean; | |||
notifications: T.Notification[]; | |||
project: NotificationProject; | |||
project: T.NotificationProject; | |||
removeNotification: (n: T.Notification) => void; | |||
types: string[]; | |||
} | |||
export default class ProjectNotifications extends React.PureComponent<Props> { | |||
getCheckboxId = (type: string, channel: string) => { | |||
return `project-notification-${this.props.project.key}-${type}-${channel}`; | |||
export default function ProjectNotifications(props: Props) { | |||
const { collapsed, project, channels } = props; | |||
const [isCollapsed, setCollapsed] = React.useState<boolean>(collapsed); | |||
const getCheckboxId = (type: string, channel: string) => { | |||
return `project-notification-${props.project.project}-${type}-${channel}`; | |||
}; | |||
handleAddNotification = ({ channel, type }: { channel: string; type: string }) => { | |||
this.props.addNotification({ | |||
channel, | |||
type, | |||
project: this.props.project.key, | |||
projectName: this.props.project.name, | |||
organization: this.props.project.organization | |||
}); | |||
const handleAddNotification = ({ channel, type }: { channel: string; type: string }) => { | |||
props.addNotification({ ...props.project, channel, type }); | |||
}; | |||
handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => { | |||
this.props.removeNotification({ | |||
const handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => { | |||
props.removeNotification({ | |||
...props.project, | |||
channel, | |||
type, | |||
project: this.props.project.key | |||
type | |||
}); | |||
}; | |||
render() { | |||
const { project, channels } = this.props; | |||
const toggleExpanded = () => setCollapsed(!isCollapsed); | |||
return ( | |||
<table className="form big-spacer-bottom" key={project.key}> | |||
return ( | |||
<BoxedGroupAccordion | |||
onClick={toggleExpanded} | |||
open={!isCollapsed} | |||
title={<h4 className="display-inline-block">{project.projectName}</h4>}> | |||
<table className="data zebra notifications-table" key={project.project}> | |||
<thead> | |||
<tr> | |||
<th> | |||
<span className="text-normal"> | |||
<Organization organizationKey={project.organization} /> | |||
</span> | |||
<h4 className="display-inline-block"> | |||
<Link to={getProjectUrl(project.key)}>{project.name}</Link> | |||
</h4> | |||
</th> | |||
<th aria-label={translate('project')} /> | |||
{channels.map(channel => ( | |||
<th className="text-center" key={channel}> | |||
<h4>{translate('notification.channel', channel)}</h4> | |||
@@ -79,16 +70,17 @@ export default class ProjectNotifications extends React.PureComponent<Props> { | |||
))} | |||
</tr> | |||
</thead> | |||
<NotificationsList | |||
channels={this.props.channels} | |||
checkboxId={this.getCheckboxId} | |||
notifications={this.props.notifications} | |||
onAdd={this.handleAddNotification} | |||
onRemove={this.handleRemoveNotification} | |||
channels={props.channels} | |||
checkboxId={getCheckboxId} | |||
notifications={props.notifications} | |||
onAdd={handleAddNotification} | |||
onRemove={handleRemoveNotification} | |||
project={true} | |||
types={this.props.types} | |||
types={props.types} | |||
/> | |||
</table> | |||
); | |||
} | |||
</BoxedGroupAccordion> | |||
); | |||
} |
@@ -17,130 +17,153 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { differenceWith } from 'lodash'; | |||
import { groupBy, sortBy, uniqBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { AsyncSelect } from 'sonar-ui-common/components/controls/Select'; | |||
import { Button } from 'sonar-ui-common/components/controls/buttons'; | |||
import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import { getSuggestions } from '../../../api/components'; | |||
import Organization from '../../../components/shared/Organization'; | |||
import ProjectModal from './ProjectModal'; | |||
import ProjectNotifications from './ProjectNotifications'; | |||
import { NotificationProject } from './types'; | |||
export interface Props { | |||
addNotification: (n: T.Notification) => void; | |||
channels: string[]; | |||
notificationsByProject: T.Dict<T.Notification[]>; | |||
projects: NotificationProject[]; | |||
initialProjectNotificationsCount: number; | |||
notifications: T.Notification[]; | |||
removeNotification: (n: T.Notification) => void; | |||
types: string[]; | |||
} | |||
const THRESHOLD_COLLAPSED = 3; | |||
interface State { | |||
addedProjects: NotificationProject[]; | |||
addedProjects: T.NotificationProject[]; | |||
search: string; | |||
showModal: boolean; | |||
} | |||
function isNotificationProject(project: { | |||
project?: string; | |||
projectName?: string; | |||
}): project is T.NotificationProject { | |||
return project.project !== undefined && project.projectName !== undefined; | |||
} | |||
export default class Projects extends React.PureComponent<Props, State> { | |||
state: State = { addedProjects: [] }; | |||
componentWillReceiveProps(nextProps: Props) { | |||
// remove all projects from `this.state.addedProjects` | |||
// that already exist in `nextProps.projects` | |||
this.setState(state => ({ | |||
addedProjects: differenceWith( | |||
state.addedProjects, | |||
Object.keys(nextProps.projects), | |||
(stateProject, propsProjectKey) => stateProject.key !== propsProjectKey | |||
) | |||
})); | |||
} | |||
state: State = { | |||
addedProjects: [], | |||
search: '', | |||
showModal: false | |||
}; | |||
filterSearch = (project: T.NotificationProject, search: string) => { | |||
return project.projectName && project.projectName.toLowerCase().includes(search); | |||
}; | |||
loadOptions = (query: string) => { | |||
if (query.length < 2) { | |||
return Promise.resolve({ options: [] }); | |||
handleAddProject = (project: T.NotificationProject) => { | |||
this.setState(state => { | |||
return { | |||
addedProjects: [...state.addedProjects, project] | |||
}; | |||
}); | |||
}; | |||
handleSearch = (search = '') => { | |||
this.setState({ search: search.toLowerCase() }); | |||
}; | |||
handleSubmit = (selectedProject: T.NotificationProject) => { | |||
if (selectedProject) { | |||
this.handleAddProject(selectedProject); | |||
} | |||
return getSuggestions(query) | |||
.then(r => { | |||
const projects = r.results.find(domain => domain.q === 'TRK'); | |||
return projects ? projects.items : []; | |||
}) | |||
.then(projects => { | |||
return projects | |||
.filter( | |||
project => | |||
!this.props.projects.find(p => p.key === project.key) && | |||
!this.state.addedProjects.find(p => p.key === project.key) | |||
) | |||
.map(project => ({ | |||
value: project.key, | |||
label: project.name, | |||
organization: project.organization | |||
})); | |||
}) | |||
.then(options => { | |||
return { options }; | |||
}); | |||
this.closeModal(); | |||
}; | |||
handleAddProject = (selected: { label: string; organization: string; value: string }) => { | |||
const project = { | |||
key: selected.value, | |||
name: selected.label, | |||
organization: selected.organization | |||
}; | |||
this.setState(state => ({ | |||
addedProjects: [...state.addedProjects, project] | |||
})); | |||
closeModal = () => { | |||
this.setState({ showModal: false }); | |||
}; | |||
renderOption = (option: { label: string; organization: string; value: string }) => { | |||
return ( | |||
<span> | |||
<Organization link={false} organizationKey={option.organization} /> | |||
<strong>{option.label}</strong> | |||
</span> | |||
); | |||
openModal = () => { | |||
this.setState({ showModal: true }); | |||
}; | |||
removeNotification = (removed: T.Notification, allProjects: T.NotificationProject[]) => { | |||
const projectToRemove = allProjects.find(p => p.project === removed.project); | |||
if (projectToRemove) { | |||
this.handleAddProject(projectToRemove); | |||
} | |||
this.props.removeNotification(removed); | |||
}; | |||
render() { | |||
const allProjects = [...this.props.projects, ...this.state.addedProjects]; | |||
const { initialProjectNotificationsCount, notifications } = this.props; | |||
const { addedProjects, search } = this.state; | |||
const projects = uniqBy(notifications, project => project.project).filter( | |||
isNotificationProject | |||
) as T.NotificationProject[]; | |||
const notificationsByProject = groupBy(notifications, n => n.project); | |||
const allProjects = uniqBy([...addedProjects, ...projects], project => project.project); | |||
const filteredProjects = sortBy(allProjects, 'projectName').filter(p => | |||
this.filterSearch(p, search) | |||
); | |||
const shouldBeCollapsed = initialProjectNotificationsCount > THRESHOLD_COLLAPSED; | |||
return ( | |||
<section className="boxed-group"> | |||
<h2>{translate('my_profile.per_project_notifications.title')}</h2> | |||
<section className="boxed-group" data-test="account__project-notifications"> | |||
<div className="boxed-group-inner"> | |||
<div className="page-actions"> | |||
<Button onClick={this.openModal}> | |||
<span data-test="account__add-project-notification"> | |||
{translate('my_profile.per_project_notifications.add')} | |||
</span> | |||
</Button> | |||
</div> | |||
<h2>{translate('my_profile.per_project_notifications.title')}</h2> | |||
</div> | |||
{this.state.showModal && ( | |||
<ProjectModal | |||
addedProjects={allProjects} | |||
closeModal={this.closeModal} | |||
onSubmit={this.handleSubmit} | |||
/> | |||
)} | |||
<div className="boxed-group-inner"> | |||
{allProjects.length === 0 && ( | |||
<div className="note">{translate('my_account.no_project_notifications')}</div> | |||
)} | |||
{allProjects.map(project => ( | |||
<ProjectNotifications | |||
addNotification={this.props.addNotification} | |||
channels={this.props.channels} | |||
key={project.key} | |||
notifications={this.props.notificationsByProject[project.key] || []} | |||
project={project} | |||
removeNotification={this.props.removeNotification} | |||
types={this.props.types} | |||
/> | |||
))} | |||
<div className="spacer-top panel bg-muted"> | |||
<span className="text-middle spacer-right"> | |||
{translate('my_account.set_notifications_for')}: | |||
</span> | |||
<AsyncSelect | |||
autoload={false} | |||
cache={false} | |||
className="input-super-large" | |||
loadOptions={this.loadOptions} | |||
name="new_project" | |||
onChange={this.handleAddProject} | |||
optionRenderer={this.renderOption} | |||
placeholder={translate('my_account.search_project')} | |||
/> | |||
</div> | |||
{allProjects.length > 0 && ( | |||
<div className="big-spacer-bottom"> | |||
<SearchBox | |||
onChange={this.handleSearch} | |||
placeholder={translate('search.search_for_projects')} | |||
/> | |||
</div> | |||
)} | |||
{filteredProjects.map(project => { | |||
const collapsed = addedProjects.find(p => p.project === project.project) | |||
? false | |||
: shouldBeCollapsed; | |||
return ( | |||
<ProjectNotifications | |||
addNotification={this.props.addNotification} | |||
channels={this.props.channels} | |||
collapsed={collapsed} | |||
key={project.project} | |||
notifications={notificationsByProject[project.project] || []} | |||
project={project} | |||
removeNotification={n => this.removeNotification(n, allProjects)} | |||
types={this.props.types} | |||
/> | |||
); | |||
})} | |||
</div> | |||
</section> | |||
); |
@@ -21,7 +21,7 @@ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; | |||
import { Notifications } from '../Notifications'; | |||
import Notifications from '../Notifications'; | |||
jest.mock('../../../../api/notifications', () => ({ | |||
addNotification: jest.fn(() => Promise.resolve()), | |||
@@ -30,14 +30,26 @@ jest.mock('../../../../api/notifications', () => ({ | |||
channels: ['channel1', 'channel2'], | |||
globalTypes: ['type-global', 'type-common'], | |||
notifications: [ | |||
{ channel: 'channel1', type: 'type-global' }, | |||
{ channel: 'channel1', type: 'type-common' }, | |||
{ | |||
channel: 'channel2', | |||
type: 'type-common', | |||
channel: 'channel1', | |||
type: 'type-global', | |||
project: 'foo', | |||
projectName: 'Foo', | |||
organization: 'org' | |||
}, | |||
{ | |||
channel: 'channel1', | |||
type: 'type-common', | |||
project: 'bar', | |||
projectName: 'Bar', | |||
organization: 'org' | |||
}, | |||
{ | |||
channel: 'channel2', | |||
type: 'type-common', | |||
project: 'qux', | |||
projectName: 'Qux', | |||
organization: 'org' | |||
} | |||
], | |||
perProjectTypes: ['type-common'] | |||
@@ -74,12 +86,16 @@ it('should add global notification', async () => { | |||
}); | |||
it('should remove project notification', async () => { | |||
const notification = { channel: 'channel2', project: 'foo', type: 'type-common' }; | |||
const notification = { | |||
channel: 'channel2', | |||
type: 'type-common', | |||
project: 'qux' | |||
}; | |||
const wrapper = await shallowRender(); | |||
expect(wrapper.state('notifications')).toContainEqual({ | |||
...notification, | |||
organization: 'org', | |||
projectName: 'Foo' | |||
projectName: 'Qux' | |||
}); | |||
wrapper.find('Projects').prop<Function>('removeNotification')(notification); | |||
// `state` must be immediately updated | |||
@@ -87,28 +103,8 @@ it('should remove project notification', async () => { | |||
expect(removeNotification).toBeCalledWith(notification); | |||
}); | |||
it('should NOT fetch organizations', async () => { | |||
const fetchOrganizations = jest.fn(); | |||
await shallowRender({ fetchOrganizations }); | |||
expect(getNotifications).toBeCalled(); | |||
expect(fetchOrganizations).not.toBeCalled(); | |||
}); | |||
it('should fetch organizations', async () => { | |||
const fetchOrganizations = jest.fn(); | |||
await shallowRender({ appState: { organizationsEnabled: true }, fetchOrganizations }); | |||
expect(getNotifications).toBeCalled(); | |||
expect(fetchOrganizations).toBeCalledWith(['org']); | |||
}); | |||
async function shallowRender(props?: Partial<Notifications['props']>) { | |||
const wrapper = shallow( | |||
<Notifications | |||
appState={{ organizationsEnabled: false }} | |||
fetchOrganizations={jest.fn()} | |||
{...props} | |||
/> | |||
); | |||
async function shallowRender(props: Partial<Notifications['props']> = {}) { | |||
const wrapper = shallow<Notifications>(<Notifications {...props} />); | |||
await waitAndUpdate(wrapper); | |||
return wrapper; | |||
} |
@@ -0,0 +1,133 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { change, elementKeydown, submit, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; | |||
import { getSuggestions } from '../../../../api/components'; | |||
import ProjectModal from '../ProjectModal'; | |||
jest.mock('../../../../api/components', () => ({ | |||
getSuggestions: jest.fn().mockResolvedValue({ | |||
organizations: [{ key: 'org', name: 'Org' }], | |||
results: [ | |||
{ | |||
q: 'TRK', | |||
items: [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }] | |||
}, | |||
// this file should be ignored | |||
{ q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js' }] } | |||
] | |||
}) | |||
})); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
}); | |||
it('should render correctly', () => { | |||
expect(shallowRender().dive()).toMatchSnapshot(); | |||
}); | |||
it('should trigger a search correctly', async () => { | |||
const wrapper = shallowRender(); | |||
change(wrapper.dive().find('SearchBox'), 'foo'); | |||
expect(getSuggestions).toBeCalledWith('foo'); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.dive().find('.notifications-add-project-search-results')).toMatchSnapshot(); | |||
}); | |||
it('should return an empty list when I search non-existent elements', async () => { | |||
(getSuggestions as jest.Mock<any>).mockResolvedValue({ | |||
results: [ | |||
{ q: 'FIL', items: [], more: 0 }, | |||
{ q: 'TRK', items: [], more: 0 }, | |||
{ q: 'UTS', items: [], more: 0 } | |||
], | |||
organizations: [], | |||
projects: [] | |||
}); | |||
const wrapper = shallowRender(); | |||
change(wrapper.dive().find('SearchBox'), 'Supercalifragilisticexpialidocious'); | |||
await waitAndUpdate(wrapper); | |||
expect( | |||
wrapper | |||
.dive() | |||
.find('.notifications-add-project-no-search-results') | |||
.exists() | |||
).toBe(true); | |||
}); | |||
it('should handle submit', async () => { | |||
const selectedProject = { | |||
projectName: 'Foo', | |||
project: 'foo' | |||
}; | |||
const onSubmit = jest.fn(); | |||
const wrapper = shallowRender({ onSubmit }); | |||
wrapper.setState({ | |||
selectedProject | |||
}); | |||
submit(wrapper.dive().find('form')); | |||
await waitAndUpdate(wrapper); | |||
expect(onSubmit).toHaveBeenCalledWith(selectedProject); | |||
}); | |||
it('should handle up and down keys', async () => { | |||
const foo = { project: 'foo', projectName: 'Foo' }; | |||
const bar = { project: 'bar', projectName: 'Bar' }; | |||
const onSubmit = jest.fn(); | |||
const wrapper = shallowRender({ onSubmit }); | |||
wrapper.setState({ | |||
open: true, | |||
suggestions: [foo, bar] | |||
}); | |||
await waitAndUpdate(wrapper); | |||
// Down. | |||
elementKeydown(wrapper.dive().find('SearchBox'), 40); | |||
expect(wrapper.state('highlighted')).toEqual(foo); | |||
elementKeydown(wrapper.dive().find('SearchBox'), 40); | |||
expect(wrapper.state('highlighted')).toEqual(bar); | |||
elementKeydown(wrapper.dive().find('SearchBox'), 40); | |||
expect(wrapper.state('highlighted')).toEqual(foo); | |||
// Up. | |||
elementKeydown(wrapper.dive().find('SearchBox'), 38); | |||
expect(wrapper.state('highlighted')).toEqual(bar); | |||
elementKeydown(wrapper.dive().find('SearchBox'), 38); | |||
expect(wrapper.state('highlighted')).toEqual(foo); | |||
elementKeydown(wrapper.dive().find('SearchBox'), 38); | |||
expect(wrapper.state('highlighted')).toEqual(bar); | |||
// Enter. | |||
elementKeydown(wrapper.dive().find('SearchBox'), 13); | |||
expect(wrapper.state('selectedProject')).toEqual(bar); | |||
expect(onSubmit).not.toHaveBeenCalled(); | |||
elementKeydown(wrapper.dive().find('SearchBox'), 13); | |||
expect(onSubmit).toHaveBeenCalledWith(bar); | |||
}); | |||
function shallowRender(props = {}) { | |||
return shallow<ProjectModal>( | |||
<ProjectModal addedProjects={[]} closeModal={jest.fn()} onSubmit={jest.fn()} {...props} /> | |||
); | |||
} |
@@ -21,48 +21,20 @@ import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import ProjectNotifications from '../ProjectNotifications'; | |||
const channels = ['channel1', 'channel2']; | |||
const types = ['type1', 'type2']; | |||
const notifications = [ | |||
{ channel: 'channel1', type: 'type1' }, | |||
{ channel: 'channel1', type: 'type2' }, | |||
{ channel: 'channel2', type: 'type2' } | |||
]; | |||
it('should match snapshot', () => { | |||
expect( | |||
shallow( | |||
<ProjectNotifications | |||
addNotification={jest.fn()} | |||
channels={channels} | |||
notifications={notifications} | |||
project={{ key: 'foo', name: 'Foo', organization: 'org' }} | |||
removeNotification={jest.fn()} | |||
types={types} | |||
/> | |||
) | |||
).toMatchSnapshot(); | |||
it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
expect(shallowRender({ collapsed: true })).toMatchSnapshot(); | |||
}); | |||
it('should call `addNotification` and `removeNotification`', () => { | |||
const addNotification = jest.fn(); | |||
const removeNotification = jest.fn(); | |||
const wrapper = shallow( | |||
<ProjectNotifications | |||
addNotification={addNotification} | |||
channels={channels} | |||
notifications={notifications} | |||
project={{ key: 'foo', name: 'Foo', organization: 'org' }} | |||
removeNotification={removeNotification} | |||
types={types} | |||
/> | |||
); | |||
const wrapper = shallowRender({ addNotification, removeNotification }); | |||
const notificationsList = wrapper.find('NotificationsList'); | |||
notificationsList.prop<Function>('onAdd')({ channel: 'channel2', type: 'type1' }); | |||
expect(addNotification).toHaveBeenCalledWith({ | |||
channel: 'channel2', | |||
organization: 'org', | |||
project: 'foo', | |||
projectName: 'Foo', | |||
type: 'type1' | |||
@@ -73,7 +45,28 @@ it('should call `addNotification` and `removeNotification`', () => { | |||
notificationsList.prop<Function>('onRemove')({ channel: 'channel1', type: 'type1' }); | |||
expect(removeNotification).toHaveBeenCalledWith({ | |||
channel: 'channel1', | |||
type: 'type1', | |||
project: 'foo' | |||
project: 'foo', | |||
projectName: 'Foo', | |||
type: 'type1' | |||
}); | |||
}); | |||
function shallowRender(props = {}) { | |||
const project = { project: 'foo', projectName: 'Foo' }; | |||
return shallow( | |||
<ProjectNotifications | |||
addNotification={jest.fn()} | |||
channels={['channel1', 'channel2']} | |||
collapsed={false} | |||
notifications={[ | |||
{ ...project, channel: 'channel1', type: 'type1' }, | |||
{ ...project, channel: 'channel1', type: 'type2' }, | |||
{ ...project, channel: 'channel2', type: 'type2' } | |||
]} | |||
project={project} | |||
removeNotification={jest.fn()} | |||
types={['type1', 'type2']} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -19,113 +19,99 @@ | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import Projects, { Props } from '../Projects'; | |||
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; | |||
import Projects from '../Projects'; | |||
jest.mock('../../../../api/components', () => ({ | |||
getSuggestions: jest.fn(() => | |||
Promise.resolve({ | |||
results: [ | |||
{ | |||
q: 'TRK', | |||
items: [ | |||
{ key: 'foo', name: 'Foo', organization: 'org' }, | |||
{ key: 'bar', name: 'Bar', organization: 'org' } | |||
] | |||
}, | |||
// this file should be ignored | |||
{ q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js', organization: 'org' }] } | |||
] | |||
}) | |||
) | |||
getSuggestions: jest.fn().mockResolvedValue({ | |||
results: [ | |||
{ | |||
q: 'TRK', | |||
items: [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }] | |||
}, | |||
// this file should be ignored | |||
{ q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js' }] } | |||
] | |||
}) | |||
})); | |||
const channels = ['channel1', 'channel2']; | |||
const types = ['type1', 'type2']; | |||
const projectFoo = { key: 'foo', name: 'Foo', organization: 'org' }; | |||
const projectBar = { key: 'bar', name: 'Bar', organization: 'org' }; | |||
const projects = [projectFoo, projectBar]; | |||
const newProject = { key: 'qux', name: 'Qux', organization: 'org' }; | |||
const projectFoo = { project: 'foo', projectName: 'Foo' }; | |||
const projectBar = { project: 'bar', projectName: 'Bar' }; | |||
const extraProps = { | |||
channel: 'channel1', | |||
type: 'type2' | |||
}; | |||
const projects = [{ ...projectFoo, ...extraProps }, { ...projectBar, ...extraProps }]; | |||
it('should render projects', () => { | |||
const wrapper = shallowRender({ | |||
notificationsByProject: { | |||
foo: [ | |||
{ | |||
channel: 'channel1', | |||
organization: 'org', | |||
project: 'foo', | |||
projectName: 'Foo', | |||
type: 'type1' | |||
}, | |||
{ | |||
channel: 'channel1', | |||
organization: 'org', | |||
project: 'foo', | |||
projectName: 'Foo', | |||
type: 'type2' | |||
} | |||
] | |||
}, | |||
projects | |||
notifications: projects | |||
}); | |||
expect(wrapper).toMatchSnapshot(); | |||
// let's add a new project | |||
wrapper.setState({ addedProjects: [newProject] }); | |||
expect(wrapper).toMatchSnapshot(); | |||
// let's say we saved it, so it's passed back in `props` | |||
wrapper.setProps({ projects: [...projects, newProject] }); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.state()).toMatchSnapshot(); | |||
}); | |||
it('should search projects', () => { | |||
const wrapper = shallowRender({ projects: [projectBar] }); | |||
const loadOptions = wrapper.find('AsyncSelect').prop<Function>('loadOptions'); | |||
expect(loadOptions('')).resolves.toEqual({ options: [] }); | |||
// should not contain `projectBar` | |||
expect(loadOptions('more than two symbols')).resolves.toEqual({ | |||
options: [{ label: 'Foo', organization: 'org', value: 'foo' }] | |||
}); | |||
}); | |||
it('should add project', () => { | |||
it('should handle project addition', () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper.state('addedProjects')).toEqual([]); | |||
wrapper.find('AsyncSelect').prop<Function>('onChange')({ | |||
label: 'Qwe', | |||
organization: 'org', | |||
value: 'qwe' | |||
}); | |||
const { handleAddProject } = wrapper.instance(); | |||
handleAddProject(projectFoo); | |||
expect(wrapper.state('addedProjects')).toEqual([ | |||
{ key: 'qwe', name: 'Qwe', organization: 'org' } | |||
{ | |||
project: 'foo', | |||
projectName: 'Foo' | |||
} | |||
]); | |||
}); | |||
it('should render option', () => { | |||
it('should handle search', () => { | |||
const wrapper = shallowRender(); | |||
const optionRenderer = wrapper.find('AsyncSelect').prop<Function>('optionRenderer'); | |||
expect( | |||
shallow( | |||
optionRenderer({ | |||
label: 'Qwe', | |||
organization: 'org', | |||
value: 'qwe' | |||
}) | |||
) | |||
).toMatchSnapshot(); | |||
const { handleAddProject, handleSearch } = wrapper.instance(); | |||
handleAddProject(projectFoo); | |||
handleAddProject(projectBar); | |||
handleSearch('Bar'); | |||
expect(wrapper.state('search')).toBe('bar'); | |||
expect(wrapper.find('ProjectNotifications')).toHaveLength(1); | |||
}); | |||
it('should handle submit from modal', async () => { | |||
const wrapper = shallowRender(); | |||
wrapper.instance().handleAddProject = jest.fn(); | |||
const { handleAddProject, handleSubmit } = wrapper.instance(); | |||
handleSubmit(projectFoo); | |||
await waitAndUpdate(wrapper); | |||
expect(handleAddProject).toHaveBeenCalledWith(projectFoo); | |||
}); | |||
it('should toggle modal', () => { | |||
const wrapper = shallowRender(); | |||
const { closeModal, openModal } = wrapper.instance(); | |||
expect(wrapper.state('showModal')).toBe(false); | |||
openModal(); | |||
expect(wrapper.state('showModal')).toBe(true); | |||
closeModal(); | |||
expect(wrapper.state('showModal')).toBe(false); | |||
}); | |||
function shallowRender(props?: Partial<Props>) { | |||
return shallow( | |||
function shallowRender(props?: Partial<Projects['props']>) { | |||
return shallow<Projects>( | |||
<Projects | |||
addNotification={jest.fn()} | |||
channels={channels} | |||
notificationsByProject={{}} | |||
projects={[]} | |||
initialProjectNotificationsCount={0} | |||
notifications={[]} | |||
removeNotification={jest.fn()} | |||
types={types} | |||
{...props} |
@@ -26,18 +26,7 @@ exports[`should fetch notifications and render 1`] = ` | |||
"channel2", | |||
] | |||
} | |||
notifications={ | |||
Array [ | |||
Object { | |||
"channel": "channel1", | |||
"type": "type-global", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"type": "type-common", | |||
}, | |||
] | |||
} | |||
notifications={Array []} | |||
removeNotification={[Function]} | |||
types={ | |||
Array [ | |||
@@ -54,25 +43,29 @@ exports[`should fetch notifications and render 1`] = ` | |||
"channel2", | |||
] | |||
} | |||
notificationsByProject={ | |||
Object { | |||
"foo": Array [ | |||
Object { | |||
"channel": "channel2", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type-common", | |||
}, | |||
], | |||
} | |||
} | |||
projects={ | |||
initialProjectNotificationsCount={3} | |||
notifications={ | |||
Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type-global", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "bar", | |||
"projectName": "Bar", | |||
"type": "type-common", | |||
}, | |||
Object { | |||
"channel": "channel2", | |||
"organization": "org", | |||
"project": "qux", | |||
"projectName": "Qux", | |||
"type": "type-common", | |||
}, | |||
] | |||
} |
@@ -0,0 +1,74 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<Modal | |||
contentLabel="my_account.set_notifications_for.title" | |||
onRequestClose={[MockFunction]} | |||
> | |||
<form | |||
onSubmit={[Function]} | |||
> | |||
<header | |||
className="modal-head" | |||
> | |||
<h2> | |||
my_account.set_notifications_for.title | |||
</h2> | |||
</header> | |||
<div | |||
className="modal-body" | |||
> | |||
<div | |||
className="modal-field abs-width-400" | |||
> | |||
<label> | |||
my_account.set_notifications_for | |||
</label> | |||
<SearchBox | |||
autoFocus={true} | |||
onChange={[Function]} | |||
onKeyDown={[Function]} | |||
placeholder="search.placeholder" | |||
/> | |||
</div> | |||
</div> | |||
<footer | |||
className="modal-foot" | |||
> | |||
<div> | |||
<SubmitButton | |||
disabled={true} | |||
> | |||
add_verb | |||
</SubmitButton> | |||
<ResetButtonLink | |||
onClick={[Function]} | |||
> | |||
cancel | |||
</ResetButtonLink> | |||
</div> | |||
</footer> | |||
</form> | |||
</Modal> | |||
`; | |||
exports[`should trigger a search correctly 1`] = ` | |||
<ul | |||
className="notifications-add-project-search-results" | |||
> | |||
<li | |||
className="" | |||
key="foo" | |||
onClick={[Function]} | |||
> | |||
Foo | |||
</li> | |||
<li | |||
className="" | |||
key="bar" | |||
onClick={[Function]} | |||
> | |||
Bar | |||
</li> | |||
</ul> | |||
`; |
@@ -1,91 +1,167 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should match snapshot 1`] = ` | |||
<table | |||
className="form big-spacer-bottom" | |||
key="foo" | |||
exports[`should render correctly 1`] = ` | |||
<BoxedGroupAccordion | |||
onClick={[Function]} | |||
open={true} | |||
title={ | |||
<h4 | |||
className="display-inline-block" | |||
> | |||
Foo | |||
</h4> | |||
} | |||
> | |||
<thead> | |||
<tr> | |||
<th> | |||
<span | |||
className="text-normal" | |||
<table | |||
className="data zebra notifications-table" | |||
key="foo" | |||
> | |||
<thead> | |||
<tr> | |||
<th | |||
aria-label="project" | |||
/> | |||
<th | |||
className="text-center" | |||
key="channel1" | |||
> | |||
<Connect(Organization) | |||
organizationKey="org" | |||
/> | |||
</span> | |||
<h4 | |||
className="display-inline-block" | |||
<h4> | |||
notification.channel.channel1 | |||
</h4> | |||
</th> | |||
<th | |||
className="text-center" | |||
key="channel2" | |||
> | |||
<Link | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"branch": undefined, | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
Foo | |||
</Link> | |||
</h4> | |||
</th> | |||
<th | |||
className="text-center" | |||
key="channel1" | |||
> | |||
<h4> | |||
notification.channel.channel1 | |||
</h4> | |||
</th> | |||
<th | |||
className="text-center" | |||
key="channel2" | |||
> | |||
<h4> | |||
notification.channel.channel2 | |||
</h4> | |||
</th> | |||
</tr> | |||
</thead> | |||
<NotificationsList | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
checkboxId={[Function]} | |||
notifications={ | |||
Array [ | |||
Object { | |||
"channel": "channel1", | |||
"type": "type1", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"type": "type2", | |||
}, | |||
Object { | |||
"channel": "channel2", | |||
"type": "type2", | |||
}, | |||
] | |||
} | |||
onAdd={[Function]} | |||
onRemove={[Function]} | |||
project={true} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
</table> | |||
<h4> | |||
notification.channel.channel2 | |||
</h4> | |||
</th> | |||
</tr> | |||
</thead> | |||
<NotificationsList | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
checkboxId={[Function]} | |||
notifications={ | |||
Array [ | |||
Object { | |||
"channel": "channel1", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type1", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type2", | |||
}, | |||
Object { | |||
"channel": "channel2", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type2", | |||
}, | |||
] | |||
} | |||
onAdd={[Function]} | |||
onRemove={[Function]} | |||
project={true} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
</table> | |||
</BoxedGroupAccordion> | |||
`; | |||
exports[`should render correctly 2`] = ` | |||
<BoxedGroupAccordion | |||
onClick={[Function]} | |||
open={false} | |||
title={ | |||
<h4 | |||
className="display-inline-block" | |||
> | |||
Foo | |||
</h4> | |||
} | |||
> | |||
<table | |||
className="data zebra notifications-table" | |||
key="foo" | |||
> | |||
<thead> | |||
<tr> | |||
<th | |||
aria-label="project" | |||
/> | |||
<th | |||
className="text-center" | |||
key="channel1" | |||
> | |||
<h4> | |||
notification.channel.channel1 | |||
</h4> | |||
</th> | |||
<th | |||
className="text-center" | |||
key="channel2" | |||
> | |||
<h4> | |||
notification.channel.channel2 | |||
</h4> | |||
</th> | |||
</tr> | |||
</thead> | |||
<NotificationsList | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
checkboxId={[Function]} | |||
notifications={ | |||
Array [ | |||
Object { | |||
"channel": "channel1", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type1", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type2", | |||
}, | |||
Object { | |||
"channel": "channel2", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type2", | |||
}, | |||
] | |||
} | |||
onAdd={[Function]} | |||
onRemove={[Function]} | |||
project={true} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
</table> | |||
</BoxedGroupAccordion> | |||
`; |
@@ -1,128 +1,41 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render option 1`] = ` | |||
<span> | |||
<Connect(Organization) | |||
link={false} | |||
organizationKey="org" | |||
/> | |||
<strong> | |||
Qwe | |||
</strong> | |||
</span> | |||
`; | |||
exports[`should render projects 1`] = ` | |||
<section | |||
className="boxed-group" | |||
data-test="account__project-notifications" | |||
> | |||
<h2> | |||
my_profile.per_project_notifications.title | |||
</h2> | |||
<div | |||
className="boxed-group-inner" | |||
> | |||
<ProjectNotifications | |||
addNotification={[MockFunction]} | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
key="foo" | |||
notifications={ | |||
Array [ | |||
Object { | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type1", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type2", | |||
}, | |||
] | |||
} | |||
project={ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"organization": "org", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
<ProjectNotifications | |||
addNotification={[MockFunction]} | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
key="bar" | |||
notifications={Array []} | |||
project={ | |||
Object { | |||
"key": "bar", | |||
"name": "Bar", | |||
"organization": "org", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
<div | |||
className="spacer-top panel bg-muted" | |||
className="page-actions" | |||
> | |||
<span | |||
className="text-middle spacer-right" | |||
<Button | |||
onClick={[Function]} | |||
> | |||
my_account.set_notifications_for | |||
: | |||
</span> | |||
<AsyncSelect | |||
autoload={false} | |||
cache={false} | |||
className="input-super-large" | |||
loadOptions={[Function]} | |||
name="new_project" | |||
onChange={[Function]} | |||
optionRenderer={[Function]} | |||
placeholder="my_account.search_project" | |||
/> | |||
<span | |||
data-test="account__add-project-notification" | |||
> | |||
my_profile.per_project_notifications.add | |||
</span> | |||
</Button> | |||
</div> | |||
<h2> | |||
my_profile.per_project_notifications.title | |||
</h2> | |||
</div> | |||
</section> | |||
`; | |||
exports[`should render projects 2`] = ` | |||
<section | |||
className="boxed-group" | |||
> | |||
<h2> | |||
my_profile.per_project_notifications.title | |||
</h2> | |||
<div | |||
className="boxed-group-inner" | |||
> | |||
<div | |||
className="big-spacer-bottom" | |||
> | |||
<SearchBox | |||
onChange={[Function]} | |||
placeholder="search.search_for_projects" | |||
/> | |||
</div> | |||
<ProjectNotifications | |||
addNotification={[MockFunction]} | |||
channels={ | |||
@@ -131,58 +44,27 @@ exports[`should render projects 2`] = ` | |||
"channel2", | |||
] | |||
} | |||
key="foo" | |||
collapsed={false} | |||
key="bar" | |||
notifications={ | |||
Array [ | |||
Object { | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type1", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"project": "bar", | |||
"projectName": "Bar", | |||
"type": "type2", | |||
}, | |||
] | |||
} | |||
project={ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"organization": "org", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
<ProjectNotifications | |||
addNotification={[MockFunction]} | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
key="bar" | |||
notifications={Array []} | |||
project={ | |||
Object { | |||
"key": "bar", | |||
"name": "Bar", | |||
"organization": "org", | |||
"channel": "channel1", | |||
"project": "bar", | |||
"projectName": "Bar", | |||
"type": "type2", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
removeNotification={[Function]} | |||
types={ | |||
Array [ | |||
"type1", | |||
@@ -198,78 +80,12 @@ exports[`should render projects 2`] = ` | |||
"channel2", | |||
] | |||
} | |||
key="qux" | |||
notifications={Array []} | |||
project={ | |||
Object { | |||
"key": "qux", | |||
"name": "Qux", | |||
"organization": "org", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
<div | |||
className="spacer-top panel bg-muted" | |||
> | |||
<span | |||
className="text-middle spacer-right" | |||
> | |||
my_account.set_notifications_for | |||
: | |||
</span> | |||
<AsyncSelect | |||
autoload={false} | |||
cache={false} | |||
className="input-super-large" | |||
loadOptions={[Function]} | |||
name="new_project" | |||
onChange={[Function]} | |||
optionRenderer={[Function]} | |||
placeholder="my_account.search_project" | |||
/> | |||
</div> | |||
</div> | |||
</section> | |||
`; | |||
exports[`should render projects 3`] = ` | |||
<section | |||
className="boxed-group" | |||
> | |||
<h2> | |||
my_profile.per_project_notifications.title | |||
</h2> | |||
<div | |||
className="boxed-group-inner" | |||
> | |||
<ProjectNotifications | |||
addNotification={[MockFunction]} | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
collapsed={false} | |||
key="foo" | |||
notifications={ | |||
Array [ | |||
Object { | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type1", | |||
}, | |||
Object { | |||
"channel": "channel1", | |||
"organization": "org", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type2", | |||
@@ -278,12 +94,13 @@ exports[`should render projects 3`] = ` | |||
} | |||
project={ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"organization": "org", | |||
"channel": "channel1", | |||
"project": "foo", | |||
"projectName": "Foo", | |||
"type": "type2", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
removeNotification={[Function]} | |||
types={ | |||
Array [ | |||
"type1", | |||
@@ -291,82 +108,14 @@ exports[`should render projects 3`] = ` | |||
] | |||
} | |||
/> | |||
<ProjectNotifications | |||
addNotification={[MockFunction]} | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
key="bar" | |||
notifications={Array []} | |||
project={ | |||
Object { | |||
"key": "bar", | |||
"name": "Bar", | |||
"organization": "org", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
<ProjectNotifications | |||
addNotification={[MockFunction]} | |||
channels={ | |||
Array [ | |||
"channel1", | |||
"channel2", | |||
] | |||
} | |||
key="qux" | |||
notifications={Array []} | |||
project={ | |||
Object { | |||
"key": "qux", | |||
"name": "Qux", | |||
"organization": "org", | |||
} | |||
} | |||
removeNotification={[MockFunction]} | |||
types={ | |||
Array [ | |||
"type1", | |||
"type2", | |||
] | |||
} | |||
/> | |||
<div | |||
className="spacer-top panel bg-muted" | |||
> | |||
<span | |||
className="text-middle spacer-right" | |||
> | |||
my_account.set_notifications_for | |||
: | |||
</span> | |||
<AsyncSelect | |||
autoload={false} | |||
cache={false} | |||
className="input-super-large" | |||
loadOptions={[Function]} | |||
name="new_project" | |||
onChange={[Function]} | |||
optionRenderer={[Function]} | |||
placeholder="my_account.search_project" | |||
/> | |||
</div> | |||
</div> | |||
</section> | |||
`; | |||
exports[`should render projects 4`] = ` | |||
exports[`should render projects 2`] = ` | |||
Object { | |||
"addedProjects": Array [], | |||
"search": "", | |||
"showModal": false, | |||
} | |||
`; |
@@ -1,24 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 NotificationProject { | |||
key: string; | |||
name: string; | |||
organization: string; | |||
} |
@@ -36,7 +36,7 @@ const routes = [ | |||
}, | |||
{ | |||
path: 'notifications', | |||
component: lazyLoad(() => import('./notifications/NotificationsContainer')) | |||
component: lazyLoad(() => import('./notifications/Notifications')) | |||
}, | |||
{ | |||
path: 'organizations', |
@@ -1539,6 +1539,7 @@ my_profile.overall_notifications.title=Overall notifications | |||
my_profile.sonarcloud_feature_notifications.title=SonarCloud new feature notifications | |||
my_profile.sonarcloud_feature_notifications.description=Display a notification in the header when new features are deployed | |||
my_profile.per_project_notifications.title=Notifications per project | |||
my_profile.per_project_notifications.add=Add a project | |||
my_profile.warning_message=This is a definitive action. No account recovery will be possible. | |||
my_account.page=My Account | |||
@@ -1558,7 +1559,8 @@ my_account.organizations.description=Those organizations are the ones you are me | |||
my_account.organizations.no_results=You are not a member of any organizations yet. | |||
my_account.create_organization=Create Organization | |||
my_account.search_project=Search Project | |||
my_account.set_notifications_for=Set notifications for | |||
my_account.set_notifications_for=Search a project by name | |||
my_account.set_notifications_for.title=Add a project | |||
my_account.create_new_portfolio_application=Create new portfolio / application | |||
my_account.create_new.TRK=Create new project | |||
my_account.create_new.VW=Create new portfolio |