diff options
author | Wouter Admiraal <45544358+wouter-admiraal-sonarsource@users.noreply.github.com> | 2019-09-17 11:56:13 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-09-18 09:51:48 +0200 |
commit | 708344c0051c6d0c0f93b644dc92892a13d7cb89 (patch) | |
tree | 6ab3636a1cb0d9e390785ef99b37badedadb6996 | |
parent | 4f0bd4cb6dcd98f45f140c45cf376686898d4dc3 (diff) | |
download | sonarqube-708344c0051c6d0c0f93b644dc92892a13d7cb89.tar.gz sonarqube-708344c0051c6d0c0f93b644dc92892a13d7cb89.zip |
SONAR-10030 Improve project notifications management
19 files changed, 979 insertions, 766 deletions
diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index 7a424f4ee38..31650acaf14 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -49,6 +49,10 @@ th.hide-overflow { margin-top: -1px; } +.nudged-down { + margin-top: 1px; +} + .spacer { margin: 8px !important; } diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts index 8bfd5c55adc..47e5d1f6100 100644 --- a/server/sonar-web/src/main/js/app/types.d.ts +++ b/server/sonar-web/src/main/js/app/types.d.ts @@ -507,6 +507,11 @@ declare namespace T { type: string; } + export interface NotificationProject { + project: string; + projectName: string; + } + export interface OrganizationActions { admin?: boolean; delete?: boolean; diff --git a/server/sonar-web/src/main/js/apps/account/account.css b/server/sonar-web/src/main/js/apps/account/account.css index 02d8c556796..eab942f9e5e 100644 --- a/server/sonar-web/src/main/js/apps/account/account.css +++ b/server/sonar-web/src/main/js/apps/account/account.css @@ -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); +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx index f903ee91942..a2d26135ada 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx @@ -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; } diff --git a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx deleted file mode 100644 index 1d50e89dde2..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx +++ /dev/null @@ -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); diff --git a/server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx b/server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx new file mode 100644 index 00000000000..01b1d06f023 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx index 16d6b5e9ea8..d2ab10811fd 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx @@ -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> + ); } diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx index 663ad924912..57eebfa2a89 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx @@ -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> ); diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx index 0aebefa4d3d..82d02d028f6 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx @@ -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; } diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx new file mode 100644 index 00000000000..26931f215e7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx @@ -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} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx index 936160c18f6..e2a8ebca561 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx @@ -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} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx index e0234bd92d9..e397e80f845 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx @@ -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} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap index a24b8456f1d..5b6472500bf 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap @@ -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", }, ] } diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap new file mode 100644 index 00000000000..e9f2bb6a718 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap index 11b25e6dd9d..07ae17de22b 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap @@ -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> `; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap index 72fd77b67c3..529f67f2132 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap @@ -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, } `; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/types.ts b/server/sonar-web/src/main/js/apps/account/notifications/types.ts deleted file mode 100644 index 26d312e9360..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/types.ts +++ /dev/null @@ -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; -} diff --git a/server/sonar-web/src/main/js/apps/account/routes.ts b/server/sonar-web/src/main/js/apps/account/routes.ts index 9ecf83909af..a6bcf8ed638 100644 --- a/server/sonar-web/src/main/js/apps/account/routes.ts +++ b/server/sonar-web/src/main/js/apps/account/routes.ts @@ -36,7 +36,7 @@ const routes = [ }, { path: 'notifications', - component: lazyLoad(() => import('./notifications/NotificationsContainer')) + component: lazyLoad(() => import('./notifications/Notifications')) }, { path: 'organizations', diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 9bb347d45f2..bd95abe8e39 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 |