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