Browse Source

SONAR-21482 User notifications page adopts the new UI

tags/10.4.0.87286
David Cho-Lerat 4 months ago
parent
commit
c283ec2e52

+ 20
- 14
server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx View File

@@ -178,10 +178,15 @@ it('should render the top menu', () => {

expect(screen.getByText(name)).toBeInTheDocument();

expect(screen.getByText('my_account.profile')).toBeInTheDocument();
expect(screen.getByText('my_account.security')).toBeInTheDocument();
expect(screen.getByText('my_account.notifications')).toBeInTheDocument();
expect(screen.getByText('my_account.projects')).toBeInTheDocument();
const topMenuNavigationItems = [
'my_account.profile',
'my_account.security',
'my_account.notifications',
'my_account.projects',
];
topMenuNavigationItems.forEach((itemName) => {
expect(byRole('navigation').byRole('link', { name: itemName }).get()).toBeInTheDocument();
});
});

describe('profile page', () => {
@@ -477,10 +482,10 @@ describe('notifications page', () => {
addButton: byRole('button', { name: 'my_profile.per_project_notifications.add' }),
addModalButton: byRole('button', { name: 'add_verb' }),
searchInput: byRole('searchbox', { name: 'search.placeholder' }),
sonarQubeProject: byRole('heading', { name: 'SonarQube' }),
sonarQubeProject: byRole('link', { name: 'SonarQube' }),
checkbox: (type: NotificationProjectType) =>
byRole('checkbox', {
name: `notification.dispatcher.descrption_x.notification.dispatcher.${type}.project`,
name: `notification.dispatcher.description_x.notification.dispatcher.${type}.project`,
}),
};

@@ -489,7 +494,7 @@ describe('notifications page', () => {
noNotificationForProject: byText('my_account.no_project_notifications'),
checkbox: (type: NotificationGlobalType) =>
byRole('checkbox', {
name: `notification.dispatcher.descrption_x.notification.dispatcher.${type}`,
name: `notification.dispatcher.description_x.notification.dispatcher.${type}`,
}),
};

@@ -536,7 +541,8 @@ describe('notifications page', () => {

await user.click(await projectUI.addButton.find());
expect(projectUI.addModalButton.get()).toBeDisabled();
await user.type(projectUI.searchInput.get(), 'sonar');

await user.keyboard('sonar');
// navigate within the two results, choose the first:
await user.keyboard('[ArrowDown][ArrowDown][ArrowUp][Enter]');
await user.click(projectUI.addModalButton.get());
@@ -558,10 +564,10 @@ describe('notifications page', () => {

renderAccountApp(mockLoggedInUser(), notificationsPagePath);

await user.click(
await screen.findByRole('button', { name: 'my_profile.per_project_notifications.add' }),
);
expect(screen.getByLabelText('search.placeholder', { selector: 'input' })).toBeInTheDocument();
await user.click(await projectUI.addButton.find());
expect(screen.getByLabelText('my_account.set_notifications_for.title')).toBeInTheDocument();
await user.keyboard('sonarqube');

await user.click(screen.getByText('SonarQube'));
@@ -574,11 +580,11 @@ describe('notifications page', () => {
await user.click(screen.getByRole('searchbox'));
await user.keyboard('bla');

expect(screen.queryByRole('heading', { name: 'SonarQube' })).not.toBeInTheDocument();
expect(projectUI.sonarQubeProject.query()).not.toBeInTheDocument();

await user.keyboard('[Backspace>3/]');

expect(await screen.findByRole('heading', { name: 'SonarQube' })).toBeInTheDocument();
expect(await projectUI.sonarQubeProject.find()).toBeInTheDocument();
});
});


+ 20
- 26
server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.tsx View File

@@ -17,6 +17,8 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { PageTitle, Table } from 'design-system';
import * as React from 'react';
import { translate } from '../../../helpers/l10n';
import { Notification, NotificationGlobalType } from '../../../types/notifications';
@@ -25,40 +27,32 @@ import NotificationsList from './NotificationsList';
interface Props {
addNotification: (n: Notification) => void;
channels: string[];
header?: React.JSX.Element;
notifications: Notification[];
removeNotification: (n: Notification) => void;
types: NotificationGlobalType[];
}

export default function GlobalNotifications(props: Props) {
export default function GlobalNotifications(props: Readonly<Props>) {
return (
<section className="boxed-group">
<h2>{translate('my_profile.overall_notifications.title')}</h2>
<>
<PageTitle className="sw-mb-4" text={translate('my_profile.overall_notifications.title')} />

<div className="boxed-group-inner">
<table className="data zebra">
<thead>
<tr>
<th>{translate('notification.notification')}</th>
{props.channels.map((channel) => (
<th className="text-center" key={channel}>
<h4>{translate('notification.channel', channel)}</h4>
</th>
))}
</tr>
</thead>
{!props.header && (
<div className="sw-body-sm-highlight sw-mb-2">{translate('notifications.send_email')}</div>
)}

<NotificationsList
channels={props.channels}
checkboxId={getCheckboxId}
notifications={props.notifications}
onAdd={props.addNotification}
onRemove={props.removeNotification}
types={props.types}
/>
</table>
</div>
</section>
<Table className="sw-w-full" columnCount={2} header={props.header ?? null}>
<NotificationsList
channels={props.channels}
checkboxId={getCheckboxId}
notifications={props.notifications}
onAdd={props.addNotification}
onRemove={props.removeNotification}
types={props.types}
/>
</Table>
</>
);
}


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

@@ -17,49 +17,74 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { FlagMessage, GreySeparator, Spinner, Title } from 'design-system';
import { partition } from 'lodash';
import * as React from 'react';
import { Helmet } from 'react-helmet-async';
import {
withNotifications,
WithNotificationsProps,
withNotifications,
} from '../../../components/hoc/withNotifications';
import { Alert } from '../../../components/ui/Alert';
import Spinner from '../../../components/ui/Spinner';
import { translate } from '../../../helpers/l10n';
import GlobalNotifications from './GlobalNotifications';
import Projects from './Projects';

export function Notifications(props: WithNotificationsProps) {
const {
addNotification,
channels,
globalTypes,
loading,
notifications,
perProjectTypes,
removeNotification,
} = props;

export function Notifications({
addNotification,
channels,
globalTypes,
loading,
notifications,
perProjectTypes,
removeNotification,
}: Readonly<WithNotificationsProps>) {
const [globalNotifications, projectNotifications] = partition(notifications, (n) => !n.project);

const emailOnly = channels.length === 1 && channels[0] === 'EmailNotificationChannel';

const header = emailOnly ? undefined : (
<tr>
<th className="sw-body-sm-highlight">{translate('events')}</th>

{channels.map((channel) => (
<th className="sw-body-sm-highlight sw-text-right" key={channel}>
{translate('notification.channel', channel)}
</th>
))}
</tr>
);

return (
<div className="account-body account-container">
<div className="it__account-body">
<Helmet defer={false} title={translate('my_account.notifications')} />
<Alert variant="info">{translate('notification.dispatcher.information')}</Alert>

<Title>{translate('my_account.notifications')}</Title>

<FlagMessage className="sw-my-2" variant="info">
{translate('notification.dispatcher.information')}
</FlagMessage>

<Spinner loading={loading}>
{notifications && (
<>
<GreySeparator className="sw-mb-4 sw-mt-6" />

<GlobalNotifications
addNotification={addNotification}
channels={channels}
header={header}
notifications={globalNotifications}
removeNotification={removeNotification}
types={globalTypes}
/>

<GreySeparator className="sw-mb-4 sw-mt-6" />

<Projects
addNotification={addNotification}
channels={channels}
header={header}
notifications={projectNotifications}
removeNotification={removeNotification}
types={perProjectTypes}

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

@@ -17,8 +17,9 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { CellComponent, Checkbox, TableRowInteractive } from 'design-system';
import * as React from 'react';
import Checkbox from '../../../components/controls/Checkbox';
import { hasMessage, translate, translateWithParameters } from '../../../helpers/l10n';
import {
Notification,
@@ -27,63 +28,66 @@ import {
} from '../../../types/notifications';

interface Props {
onAdd: (n: Notification) => void;
onRemove: (n: Notification) => void;
channels: string[];
checkboxId: (type: string, channel: string) => string;
notifications: Notification[];
onAdd: (n: Notification) => void;
onRemove: (n: Notification) => void;
project?: boolean;
types: (NotificationGlobalType | NotificationProjectType)[];
notifications: Notification[];
}

export default class NotificationsList extends React.PureComponent<Props> {
isEnabled(type: string, channel: string) {
return !!this.props.notifications.find(
export default function NotificationsList({
channels,
checkboxId,
notifications,
onAdd,
onRemove,
project,
types,
}: Readonly<Props>) {
const isEnabled = (type: string, channel: string) =>
!!notifications.find(
(notification) => notification.type === type && notification.channel === channel,
);
}

handleCheck(type: string, channel: string, checked: boolean) {
const handleCheck = (type: string, channel: string, checked: boolean) => {
if (checked) {
this.props.onAdd({ type, channel });
onAdd({ type, channel });
} else {
this.props.onRemove({ type, channel });
onRemove({ type, channel });
}
}
};

getDispatcherLabel(dispatcher: string) {
const getDispatcherLabel = (dispatcher: string) => {
const globalMessageKey = ['notification.dispatcher', dispatcher];
const projectMessageKey = [...globalMessageKey, 'project'];
const shouldUseProjectMessage = this.props.project && hasMessage(...projectMessageKey);
const shouldUseProjectMessage = project && hasMessage(...projectMessageKey);

return shouldUseProjectMessage
? translate(...projectMessageKey)
: translate(...globalMessageKey);
}
};

render() {
const { channels, checkboxId, types } = this.props;
return types.map((type) => (
<TableRowInteractive className="sw-h-9" key={type}>
<CellComponent className="sw-py-0 sw-border-0">{getDispatcherLabel(type)}</CellComponent>

return (
<tbody>
{types.map((type) => (
<tr key={type}>
<td>{this.getDispatcherLabel(type)}</td>
{channels.map((channel) => (
<td className="text-center" key={channel}>
<Checkbox
label={translateWithParameters(
'notification.dispatcher.descrption_x',
this.getDispatcherLabel(type),
)}
checked={this.isEnabled(type, channel)}
id={checkboxId(type, channel)}
onCheck={(checked) => this.handleCheck(type, channel, checked)}
/>
</td>
))}
</tr>
))}
</tbody>
);
}
{channels.map((channel) => (
<CellComponent className="sw-py-0 sw-border-0" key={channel}>
<div className="sw-justify-end sw-flex sw-items-center">
<Checkbox
checked={isEnabled(type, channel)}
id={checkboxId(type, channel)}
label={translateWithParameters(
'notification.dispatcher.description_x',
getDispatcherLabel(type),
)}
onCheck={(checked) => handleCheck(type, channel, checked)}
/>
</div>
</CellComponent>
))}
</TableRowInteractive>
));
}

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

@@ -17,15 +17,23 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';

import {
ButtonPrimary,
DropdownMenu,
InputSearch,
ItemButton,
Modal,
Popup,
PopupPlacement,
PopupZLevel,
Spinner,
} from 'design-system';
import * as React from 'react';
import { getSuggestions } from '../../../api/components';
import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
import { DropdownOverlay } from '../../../components/controls/Dropdown';
import SearchBox from '../../../components/controls/SearchBox';
import SimpleModal from '../../../components/controls/SimpleModal';
import { KeyboardKeys } from '../../../helpers/keycodes';
import { translate } from '../../../helpers/l10n';
import { ComponentQualifier } from '../../../types/component';
import { NotificationProject } from '../../../types/notifications';

interface Props {
@@ -38,7 +46,6 @@ interface State {
highlighted?: NotificationProject;
loading?: boolean;
query?: string;
open?: boolean;
selectedProject?: NotificationProject;
suggestions?: NotificationProject[];
}
@@ -66,10 +73,12 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
event.preventDefault();
this.handleSelectHighlighted();
break;

case KeyboardKeys.UpArrow:
event.preventDefault();
this.handleHighlightPrevious();
break;

case KeyboardKeys.DownArrow:
event.preventDefault();
this.handleHighlightNext();
@@ -79,6 +88,7 @@ export default class ProjectModal extends React.PureComponent<Props, State> {

getCurrentIndex = () => {
const { highlighted, suggestions } = this.state;

return highlighted && suggestions
? suggestions.findIndex((suggestion) => suggestion.project === highlighted.project)
: -1;
@@ -86,12 +96,14 @@ export default class ProjectModal extends React.PureComponent<Props, State> {

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],
});
@@ -107,13 +119,10 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
};

handleSelectHighlighted = () => {
const { highlighted, selectedProject } = this.state;
const { highlighted } = this.state;

if (highlighted !== undefined) {
if (selectedProject !== undefined && highlighted.project === selectedProject.project) {
this.handleSubmit();
} else {
this.handleSelect(highlighted);
}
this.handleSelect(highlighted);
}
};

@@ -121,16 +130,20 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
const { addedProjects } = this.props;

if (query.length < 2) {
this.setState({ open: false, query });
this.setState({ query, selectedProject: undefined, suggestions: undefined });

return;
}

this.setState({ loading: true, query });
getSuggestions(query).then(
this.setState({ loading: true, query, selectedProject: undefined });

getSuggestions(query, undefined, ComponentQualifier.Project).then(
(r) => {
if (this.mounted) {
let suggestions = undefined;
const projects = r.results.find((domain) => domain.q === 'TRK');

const projects = r.results.find((domain) => domain.q === ComponentQualifier.Project);

if (projects && projects.items.length > 0) {
suggestions = projects.items
.filter((item) => !addedProjects.find((p) => p.project === item.key))
@@ -139,12 +152,13 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
projectName: item.name,
}));
}
this.setState({ loading: false, open: true, suggestions });

this.setState({ loading: false, suggestions });
}
},
() => {
if (this.mounted) {
this.setState({ loading: false, open: false });
this.setState({ loading: false });
}
},
);
@@ -152,14 +166,15 @@ export default class ProjectModal extends React.PureComponent<Props, State> {

handleSelect = (selectedProject: NotificationProject) => {
this.setState({
open: false,
query: selectedProject.projectName,
selectedProject,
suggestions: undefined,
});
};

handleSubmit = () => {
const { selectedProject } = this.state;

if (selectedProject) {
this.props.onSubmit(selectedProject);
}
@@ -167,66 +182,82 @@ export default class ProjectModal extends React.PureComponent<Props, State> {

render() {
const { closeModal } = this.props;
const { highlighted, loading, query, open, selectedProject, suggestions } = this.state;
const header = translate('my_account.set_notifications_for.title');
const { highlighted, loading, query, selectedProject, suggestions } = this.state;

const projectSuggestion = (suggestion: NotificationProject) => (
<ItemButton
className="sw-my-1"
key={suggestion.project}
onClick={() => this.handleSelect(suggestion)}
selected={
highlighted?.project === suggestion.project ||
selectedProject?.project === suggestion.project
}
>
{suggestion.projectName}
</ItemButton>
);

const isSearching = query?.length && !selectedProject;

const noResults = isSearching ? (
<div className="sw-mx-5 sw-my-3">{translate('no_results')}</div>
) : undefined;

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
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>
<Modal
body={
<form id="project-notifications-modal-form" onSubmit={this.handleSubmit}>
<Popup
allowResizing
overlay={
isSearching ? (
<DropdownMenu
className="sw-overflow-x-hidden sw-min-w-abs-350"
maxHeight="38rem"
size="auto"
>
<Spinner className="sw-mx-5 sw-my-3" loading={!!loading}>
{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 className="sw-py-2">
{suggestions.map((suggestion) => projectSuggestion(suggestion))}
</ul>
) : (
<div className="notifications-add-project-no-search-results">
{translate('no_results')}
</div>
noResults
)}
</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>
</Spinner>
</DropdownMenu>
) : undefined
}
placement={PopupPlacement.BottomLeft}
zLevel={PopupZLevel.Global}
>
<InputSearch
autoFocus
className="sw-my-2"
onChange={this.handleSearch}
onKeyDown={this.handleKeyDown}
placeholder={translate('my_account.set_notifications_for')}
searchInputAriaLabel={translate('search_verb')}
size="full"
value={query}
/>
</Popup>
</form>
)}
</SimpleModal>
}
headerTitle={translate('my_account.set_notifications_for.title')}
onClose={closeModal}
primaryButton={
<ButtonPrimary
disabled={selectedProject === undefined}
form="project-notifications-modal-form"
type="submit"
>
{translate('add_verb')}
</ButtonPrimary>
}
secondaryButtonLabel={translate('cancel')}
/>
);
}
}

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

@@ -17,9 +17,11 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { Link, Table } from 'design-system';
import * as React from 'react';
import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion';
import { translate } from '../../../helpers/l10n';
import { getProjectUrl } from '../../../helpers/urls';
import {
Notification,
NotificationProject,
@@ -30,63 +32,62 @@ import NotificationsList from './NotificationsList';
interface Props {
addNotification: (n: Notification) => void;
channels: string[];
collapsed: boolean;
header?: React.JSX.Element;
notifications: Notification[];
project: NotificationProject;
removeNotification: (n: Notification) => void;
types: NotificationProjectType[];
}

export default function ProjectNotifications(props: Props) {
const { collapsed, project, channels } = props;
const [isCollapsed, setCollapsed] = React.useState<boolean>(collapsed);

export default function ProjectNotifications({
addNotification,
channels,
header,
notifications,
project,
removeNotification,
types,
}: Readonly<Props>) {
const getCheckboxId = (type: string, channel: string) => {
return `project-notification-${props.project.project}-${type}-${channel}`;
return `project-notification-${project.project}-${type}-${channel}`;
};

const handleAddNotification = ({ channel, type }: { channel: string; type: string }) => {
props.addNotification({ ...props.project, channel, type });
addNotification({ ...project, channel, type });
};

const handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => {
props.removeNotification({
...props.project,
removeNotification({
...project,
channel,
type,
});
};

const toggleExpanded = () => setCollapsed(!isCollapsed);

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 aria-label={translate('project')} />
{channels.map((channel) => (
<th className="text-center" key={channel}>
<h4>{translate('notification.channel', channel)}</h4>
</th>
))}
</tr>
</thead>
<div className="sw-my-6">
<div className="sw-mb-4">
<Link to={getProjectUrl(project.project)}>{project.projectName}</Link>
</div>
{!header && (
<div className="sw-body-sm-highlight sw-mb-2">{translate('notifications.send_email')}</div>
)}

<Table
className={classNames('sw-w-full', { 'sw-mt-4': header })}
columnCount={2}
header={header ?? null}
>
<NotificationsList
channels={props.channels}
channels={channels}
checkboxId={getCheckboxId}
notifications={props.notifications}
notifications={notifications}
onAdd={handleAddNotification}
onRemove={handleRemoveNotification}
project
types={props.types}
types={types}
/>
</table>
</BoxedGroupAccordion>
</Table>
</div>
);
}

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

@@ -17,10 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { ButtonPrimary, InputSearch, Note } from 'design-system';
import { groupBy, sortBy, uniqBy } from 'lodash';
import * as React from 'react';
import { Button } from '../../../components/controls/buttons';
import SearchBox from '../../../components/controls/SearchBox';
import { translate } from '../../../helpers/l10n';
import {
Notification,
@@ -33,13 +33,12 @@ import ProjectNotifications from './ProjectNotifications';
export interface Props {
addNotification: (n: Notification) => void;
channels: string[];
header?: React.JSX.Element;
notifications: Notification[];
removeNotification: (n: Notification) => void;
types: NotificationProjectType[];
}

const THRESHOLD_COLLAPSED = 3;

interface State {
addedProjects: NotificationProject[];
search: string;
@@ -61,7 +60,7 @@ export default class Projects extends React.PureComponent<Props, State> {
};

filterSearch = (project: NotificationProject, search: string) => {
return project.projectName && project.projectName.toLowerCase().includes(search);
return project.projectName?.toLowerCase().includes(search);
};

handleAddProject = (project: NotificationProject) => {
@@ -94,6 +93,7 @@ export default class Projects extends React.PureComponent<Props, State> {

removeNotification = (removed: Notification, allProjects: NotificationProject[]) => {
const projectToRemove = allProjects.find((p) => p.project === removed.project);

if (projectToRemove) {
this.handleAddProject(projectToRemove);
}
@@ -108,25 +108,26 @@ export default class Projects extends React.PureComponent<Props, State> {
const projects = uniqBy(notifications, ({ project }) => project).filter(
isNotificationProject,
) as 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 = Object.keys(notificationsByProject).length > THRESHOLD_COLLAPSED;

return (
<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>
<section data-test="account__project-notifications">
<div className="sw-flex sw-justify-between">
<h2 className="sw-body-md-highlight sw-mb-4">
{translate('my_profile.per_project_notifications.title')}
</h2>
<ButtonPrimary onClick={this.openModal}>
<span data-test="account__add-project-notification">
{translate('my_profile.per_project_notifications.add')}
</span>
</ButtonPrimary>
</div>

{this.state.showModal && (
@@ -137,37 +138,32 @@ export default class Projects extends React.PureComponent<Props, State> {
/>
)}

<div className="boxed-group-inner">
<div>
{allProjects.length === 0 && (
<div className="note">{translate('my_account.no_project_notifications')}</div>
<Note>{translate('my_account.no_project_notifications')}</Note>
)}

{allProjects.length > 0 && (
<div className="big-spacer-bottom">
<SearchBox
<div className="sw-mb-4">
<InputSearch
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}
/>
);
})}
{filteredProjects.map((project) => (
<ProjectNotifications
addNotification={this.props.addNotification}
channels={this.props.channels}
header={this.props.header}
key={project.project}
notifications={notificationsByProject[project.project] || []}
project={project}
removeNotification={(n) => this.removeNotification(n, allProjects)}
types={this.props.types}
/>
))}
</div>
</section>
);

+ 3
- 3
server/sonar-web/src/main/js/apps/account/security/Security.tsx View File

@@ -18,7 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { PageTitle, SubHeading } from 'design-system';
import { PageTitle } from 'design-system';
import * as React from 'react';
import { Helmet } from 'react-helmet-async';
import { useCurrentLoginUser } from '../../../app/components/current-user/CurrentUserContext';
@@ -35,14 +35,14 @@ export default function Security() {
<Tokens login={currentUser.login} />

{currentUser.local && (
<SubHeading as="section">
<>
<PageTitle
className="sw-heading-md sw-my-6"
text={translate('my_profile.password.title')}
/>

<ResetPasswordForm user={currentUser} />
</SubHeading>
</>
)}
</>
);

+ 2
- 2
server/sonar-web/src/main/js/apps/projectInformation/notifications/ProjectNotifications.tsx View File

@@ -80,7 +80,7 @@ export function ProjectNotifications(props: WithNotificationsProps & Props) {

<Spinner className="sw-mt-6" loading={loading}>
<h3 id="notifications-update-title" className="sw-mt-6">
{translate('project_information.project_notifications.title')}
{translate('notifications.send_email')}
</h3>
<ul className="sw-list-none sw-mt-4 sw-pl-0">
{perProjectTypes.map((type) => (
@@ -89,7 +89,7 @@ export function ProjectNotifications(props: WithNotificationsProps & Props) {
right
className="sw-flex sw-justify-between"
label={translateWithParameters(
'notification.dispatcher.descrption_x',
'notification.dispatcher.description_x',
getDispatcherLabel(type),
)}
checked={isEnabled(type, emailChannel)}

+ 7
- 9
server/sonar-web/src/main/js/apps/projectInformation/notifications/__tests__/ProjectNotifications-test.tsx View File

@@ -49,44 +49,42 @@ it('should render correctly', async () => {
const user = userEvent.setup();
renderProjectNotifications();

expect(
await screen.findByText('project_information.project_notifications.title'),
).toBeInTheDocument();
expect(await screen.findByText('notifications.send_email')).toBeInTheDocument();
expect(
screen.getByLabelText(
'notification.dispatcher.descrption_x.notification.dispatcher.NewAlerts.project',
'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
),
).toBeChecked();

expect(
screen.getByLabelText(
'notification.dispatcher.descrption_x.notification.dispatcher.NewIssues.project',
'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
),
).not.toBeChecked();

// Toggle New Alerts
await user.click(
screen.getByLabelText(
'notification.dispatcher.descrption_x.notification.dispatcher.NewAlerts.project',
'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
),
);

expect(
screen.getByLabelText(
'notification.dispatcher.descrption_x.notification.dispatcher.NewAlerts.project',
'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
),
).not.toBeChecked();

// Toggle New Issues
await user.click(
screen.getByLabelText(
'notification.dispatcher.descrption_x.notification.dispatcher.NewIssues.project',
'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
),
);

expect(
screen.getByLabelText(
'notification.dispatcher.descrption_x.notification.dispatcher.NewIssues.project',
'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
),
).toBeChecked();
});

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

@@ -2595,7 +2595,7 @@ notification.dispatcher.NewFalsePositiveIssue=Issues resolved as false positive
notification.dispatcher.SQ-MyNewIssues=My new issues
notification.dispatcher.CeReportTaskFailure=Background tasks in failure on my administered projects
notification.dispatcher.CeReportTaskFailure.project=Background tasks in failure
notification.dispatcher.descrption_x=Check to receive notification for {0}
notification.dispatcher.description_x=Check to receive notification for {0}

#------------------------------------------------------------------------------
#
@@ -2704,6 +2704,8 @@ my_account.preferences.keyboard_shortcuts.description=Some actions can be perfor
my_account.preferences.keyboard_shortcuts.enabled=Keyboard shortcuts are enabled
my_account.preferences.keyboard_shortcuts.disabled=Keyboard shortcuts are disabled

notifications.send_email=Send me an email for:

#------------------------------------------------------------------------------
#
# PROJECT PROVISIONING
@@ -3787,13 +3789,6 @@ project_dump.failed_import=The last import has failed. Please try once again.
project_dump.import_form_description=A dump has been found on the file system for this project. You can import it by clicking on the button below.
project_dump.import_form_description_disabled=Projects cannot be imported. This feature is only available starting from Enterprise Edition.

#------------------------------------------------------------------------------
#
# Project Information
#
#------------------------------------------------------------------------------
project_information.project_notifications.title=Send me an email when:

#------------------------------------------------------------------------------
#
# SYSTEM

Loading…
Cancel
Save