Browse Source

SONAR-10030 Improve project notifications management

tags/8.0
Wouter Admiraal 4 years ago
parent
commit
708344c005
19 changed files with 979 additions and 766 deletions
  1. 4
    0
      server/sonar-web/src/main/js/app/styles/init/misc.css
  2. 5
    0
      server/sonar-web/src/main/js/app/types.d.ts
  3. 18
    0
      server/sonar-web/src/main/js/apps/account/account.css
  4. 25
    34
      server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
  5. 0
    29
      server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx
  6. 231
    0
      server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx
  7. 32
    40
      server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx
  8. 114
    91
      server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx
  9. 25
    29
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx
  10. 133
    0
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx
  11. 27
    34
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx
  12. 66
    80
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx
  13. 21
    28
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap
  14. 74
    0
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap
  15. 161
    85
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap
  16. 39
    290
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap
  17. 0
    24
      server/sonar-web/src/main/js/apps/account/notifications/types.ts
  18. 1
    1
      server/sonar-web/src/main/js/apps/account/routes.ts
  19. 3
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 4
- 0
server/sonar-web/src/main/js/app/styles/init/misc.css View File

@@ -49,6 +49,10 @@ th.hide-overflow {
margin-top: -1px;
}

.nudged-down {
margin-top: 1px;
}

.spacer {
margin: 8px !important;
}

+ 5
- 0
server/sonar-web/src/main/js/app/types.d.ts View File

@@ -507,6 +507,11 @@ declare namespace T {
type: string;
}

export interface NotificationProject {
project: string;
projectName: string;
}

export interface OrganizationActions {
admin?: boolean;
delete?: boolean;

+ 18
- 0
server/sonar-web/src/main/js/apps/account/account.css View File

@@ -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);
}

+ 25
- 34
server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx View File

@@ -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;
}

+ 0
- 29
server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx View File

@@ -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);

+ 231
- 0
server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx View File

@@ -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>
);
}
}

+ 32
- 40
server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx View File

@@ -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>
);
}

+ 114
- 91
server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx View File

@@ -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>
);

+ 25
- 29
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx View File

@@ -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;
}

+ 133
- 0
server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx View File

@@ -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} />
);
}

+ 27
- 34
server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx View File

@@ -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}
/>
);
}

+ 66
- 80
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx View File

@@ -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}

+ 21
- 28
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap View File

@@ -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",
},
]
}

+ 74
- 0
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap View File

@@ -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>
`;

+ 161
- 85
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap View File

@@ -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>
`;

+ 39
- 290
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap View File

@@ -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,
}
`;

+ 0
- 24
server/sonar-web/src/main/js/apps/account/notifications/types.ts View File

@@ -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;
}

+ 1
- 1
server/sonar-web/src/main/js/apps/account/routes.ts View File

@@ -36,7 +36,7 @@ const routes = [
},
{
path: 'notifications',
component: lazyLoad(() => import('./notifications/NotificationsContainer'))
component: lazyLoad(() => import('./notifications/Notifications'))
},
{
path: 'organizations',

+ 3
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -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

Loading…
Cancel
Save