* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
-import { translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
import { assignSecurityHotspot } from '../../../../api/security-hotspots';
import addGlobalSuccessMessage from '../../../../app/utils/addGlobalSuccessMessage';
import { withCurrentUser } from '../../../../components/hoc/withCurrentUser';
this.setState({ editing: false });
};
- handleAssign = (newAssignee?: T.UserActive) => {
- if (newAssignee && newAssignee.login) {
- this.setState({ loading: true });
- assignSecurityHotspot(this.props.hotspot.key, {
- assignee: newAssignee.login
+ handleAssign = (newAssignee: T.UserActive) => {
+ this.setState({ loading: true });
+ assignSecurityHotspot(this.props.hotspot.key, {
+ assignee: newAssignee?.login
+ })
+ .then(() => {
+ if (this.mounted) {
+ this.setState({ editing: false, loading: false });
+ this.props.onAssigneeChange();
+ }
})
- .then(() => {
- if (this.mounted) {
- this.setState({ editing: false, loading: false });
- this.props.onAssigneeChange();
- }
- })
- .then(() =>
- addGlobalSuccessMessage(
- translateWithParameters('hotspots.assign.success', newAssignee.name)
- )
+ .then(() =>
+ addGlobalSuccessMessage(
+ newAssignee.login
+ ? translateWithParameters('hotspots.assign.success', newAssignee.name)
+ : translate('hotspots.assign.unassign.success')
)
- .catch(() => this.setState({ loading: false }));
- }
+ )
+ .catch(() => this.setState({ loading: false }));
};
render() {
assignee?: T.UserBase;
loggedInUser?: T.LoggedInUser;
- onAssign: (user?: T.UserActive) => void;
+ onAssign: (user: T.UserActive) => void;
onEnterEditionMode: () => void;
onExitEditionMode: () => void;
}
import { debounce } from 'lodash';
import * as React from 'react';
import { KeyCodes } from 'sonar-ui-common/helpers/keycodes';
+import { translate } from 'sonar-ui-common/helpers/l10n';
import { searchUsers } from '../../../../api/users';
import { isUserActive } from '../../../../helpers/users';
import AssigneeSelectionRenderer from './AssigneeSelectionRenderer';
interface Props {
allowCurrentUserSelection: boolean;
loggedInUser: T.LoggedInUser;
- onSelect: (user?: T.UserActive) => void;
+ onSelect: (user: T.UserActive) => void;
}
interface State {
highlighted?: T.UserActive;
loading: boolean;
- open: boolean;
query?: string;
suggestedUsers: T.UserActive[];
}
+const UNASSIGNED: T.UserActive = { login: '', name: translate('unassigned') };
+
export default class AssigneeSelection extends React.PureComponent<Props, State> {
mounted = false;
this.state = {
loading: false,
- open: props.allowCurrentUserSelection,
- suggestedUsers: props.allowCurrentUserSelection ? [props.loggedInUser] : []
+ suggestedUsers: props.allowCurrentUserSelection
+ ? [props.loggedInUser, UNASSIGNED]
+ : [UNASSIGNED]
};
this.handleSearch = debounce(this.handleSearch, 250);
this.setState({
loading: false,
- open: allowCurrentUserSelection,
query,
- suggestedUsers: allowCurrentUserSelection ? [loggedInUser] : []
+ suggestedUsers: allowCurrentUserSelection ? [loggedInUser, UNASSIGNED] : [UNASSIGNED]
});
};
this.setState({
loading: false,
query,
- open: true,
- suggestedUsers: result.users.filter(isUserActive) as T.UserActive[]
+ suggestedUsers: (result.users.filter(isUserActive) as T.UserActive[]).concat(UNASSIGNED)
});
}
})
};
render() {
- const { highlighted, loading, open, query, suggestedUsers } = this.state;
+ const { highlighted, loading, query, suggestedUsers } = this.state;
return (
<AssigneeSelectionRenderer
onKeyDown={this.handleKeyDown}
onSearch={this.handleSearch}
onSelect={this.props.onSelect}
- open={open}
query={query}
suggestedUsers={suggestedUsers}
/>
loading: boolean;
onKeyDown: (event: React.KeyboardEvent) => void;
onSearch: (query: string) => void;
- onSelect: (user: T.UserActive) => void;
- open: boolean;
+ onSelect: (user?: T.UserActive) => void;
query?: string;
suggestedUsers?: T.UserActive[];
}
export default function AssigneeSelectionRenderer(props: HotspotAssigneeSelectRendererProps) {
- const { highlighted, loading, open, query, suggestedUsers } = props;
+ const { highlighted, loading, query, suggestedUsers } = props;
+
return (
<>
<div className="display-flex-center">
placeholder={translate('hotspots.assignee.select_user')}
value={query}
/>
-
{loading && <DeferredSpinner className="spacer-left" />}
</div>
- {!loading && open && (
+ {!loading && (
<div className="position-relative">
<DropdownOverlay noPadding={true} placement={PopupPlacement.BottomLeft}>
- {suggestedUsers && suggestedUsers.length > 0 ? (
- <ul className="hotspot-assignee-search-results">
- {suggestedUsers.map(suggestion => (
+ <ul className="hotspot-assignee-search-results">
+ {suggestedUsers &&
+ suggestedUsers.map(suggestion => (
<li
className={classNames('padded', {
active: highlighted && highlighted.login === suggestion.login
})}
key={suggestion.login}
onClick={() => props.onSelect(suggestion)}>
- <Avatar
- className="spacer-right"
- hash={suggestion.avatar}
- name={suggestion.name}
- size={16}
- />
+ {suggestion.login && (
+ <Avatar
+ className="spacer-right"
+ hash={suggestion.avatar}
+ name={suggestion.name}
+ size={16}
+ />
+ )}
{suggestion.name}
</li>
))}
- </ul>
- ) : (
- <div className="padded">{translate('no_results')}</div>
- )}
+ </ul>
</DropdownOverlay>
</div>
)}
expect(wrapper.state().editing).toBe(false);
});
-it('should handle assign event correctly', async () => {
+it.each([
+ ['assign to user', mockUser() as T.UserActive],
+ ['unassign', { login: '', name: 'unassigned' } as T.UserActive]
+])('should handle %s event', async (_, user: T.UserActive) => {
const hotspot = mockHotspot();
const onAssigneeChange = jest.fn();
- const user = mockUser() as T.UserActive;
const wrapper = shallowRender({ hotspot, onAssigneeChange });
.onAssign(user);
expect(wrapper.state().loading).toBe(true);
- expect(assignSecurityHotspot).toHaveBeenCalledWith(hotspot.key, { assignee: user.login });
+ expect(assignSecurityHotspot).toHaveBeenCalledWith(hotspot.key, { assignee: user?.login });
await waitAndUpdate(wrapper);
const wrapper = shallowRender({ onSelect });
wrapper.instance().handleKeyDown(mockEvent(KeyCodes.UpArrow) as any);
- expect(wrapper.state().highlighted).toBeUndefined();
+ expect(wrapper.state().highlighted).toEqual({ login: '', name: 'unassigned' });
wrapper.setState({ suggestedUsers });
const onSelect = jest.fn();
const wrapper = shallowRender({ onSelect });
- expect(wrapper.state().suggestedUsers.length).toBe(0);
+ expect(wrapper.state().suggestedUsers.length).toBe(1);
wrapper.instance().handleSearch('j');
expect(searchUsers).not.toBeCalled();
- expect(wrapper.state().open).toBe(false);
wrapper.instance().handleSearch('jo');
expect(wrapper.state().loading).toBe(true);
await waitAndUpdate(wrapper);
expect(wrapper.state().loading).toBe(false);
- expect(wrapper.state().open).toBe(true);
- expect(wrapper.state().suggestedUsers).toHaveLength(3);
+ expect(wrapper.state().suggestedUsers).toHaveLength(4);
jest.clearAllMocks();
wrapper.instance().handleSearch('');
expect(searchUsers).not.toBeCalled();
- expect(wrapper.state().suggestedUsers.length).toBe(0);
+ expect(wrapper.state().suggestedUsers.length).toBe(1);
});
it('should allow current user selection', async () => {
wrapper.instance().handleSearch('jo');
await waitAndUpdate(wrapper);
- expect(wrapper.state().suggestedUsers).toHaveLength(3);
+ expect(wrapper.state().suggestedUsers).toHaveLength(4);
wrapper.instance().handleSearch('');
expect(wrapper.state().suggestedUsers[0]).toBe(loggedInUser);
it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot();
expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
- expect(shallowRender({ open: true })).toMatchSnapshot('open');
const highlightedUser = mockUser({ login: 'highlighted' }) as T.UserActive;
expect(
shallowRender({
highlighted: highlightedUser,
- open: true,
suggestedUsers: [mockUser() as T.UserActive, highlightedUser]
})
).toMatchSnapshot('open with results');
const user = mockUser() as T.UserActive;
const onSelect = jest.fn();
const wrapper = shallowRender({
- open: true,
onSelect,
suggestedUsers: [user]
});
onKeyDown={jest.fn()}
onSearch={jest.fn()}
onSelect={jest.fn()}
- open={false}
{...props}
/>
);
onKeyDown={[Function]}
onSearch={[Function]}
onSelect={[MockFunction]}
- open={false}
- suggestedUsers={Array []}
+ suggestedUsers={
+ Array [
+ Object {
+ "login": "",
+ "name": "unassigned",
+ },
+ ]
+ }
/>
`;
placeholder="hotspots.assignee.select_user"
/>
</div>
+ <div
+ className="position-relative"
+ >
+ <DropdownOverlay
+ noPadding={true}
+ placement="bottom-left"
+ >
+ <ul
+ className="hotspot-assignee-search-results"
+ />
+ </DropdownOverlay>
+ </div>
</Fragment>
`;
</Fragment>
`;
-exports[`should render correctly: open 1`] = `
-<Fragment>
- <div
- className="display-flex-center"
- >
- <SearchBox
- autoFocus={true}
- onChange={[MockFunction]}
- onKeyDown={[MockFunction]}
- placeholder="hotspots.assignee.select_user"
- />
- </div>
- <div
- className="position-relative"
- >
- <DropdownOverlay
- noPadding={true}
- placement="bottom-left"
- >
- <div
- className="padded"
- >
- no_results
- </div>
- </DropdownOverlay>
- </div>
-</Fragment>
-`;
-
exports[`should render correctly: open with results 1`] = `
<Fragment>
<div
}
export interface HotspotAssignRequest {
- assignee: string;
+ assignee?: string;
comment?: string;
}
package org.sonar.server.hotspot.ws;
import javax.annotation.Nullable;
+import org.sonar.api.server.ws.Change;
import org.sonar.api.server.ws.Request;
import org.sonar.api.server.ws.Response;
import org.sonar.api.server.ws.WebService;
import org.sonar.server.issue.IssueFieldsSetter;
import org.sonar.server.issue.ws.IssueUpdater;
+import static com.google.common.base.Strings.isNullOrEmpty;
import static org.sonar.api.issue.Issue.STATUS_TO_REVIEW;
import static org.sonar.server.exceptions.NotFoundException.checkFound;
import static org.sonar.server.exceptions.NotFoundException.checkFoundWithOptional;
.setSince("8.2")
.setHandler(this)
.setInternal(true)
- .setPost(true);
+ .setPost(true)
+ .setChangelog(
+ new Change("8.9", "Parameter 'assignee' is no longer mandatory"));
action.createParam(PARAM_HOTSPOT_KEY)
.setDescription("Hotspot key")
action.createParam(PARAM_ASSIGNEE)
.setDescription("Login of the assignee with 'Browse' project permission")
- .setRequired(true)
.setExampleValue("admin");
action.createParam(PARAM_COMMENT)
@Override
public void handle(Request request, Response response) throws Exception {
- String assignee = request.mandatoryParam(PARAM_ASSIGNEE);
+ String assignee = request.param(PARAM_ASSIGNEE);
String key = request.mandatoryParam(PARAM_HOTSPOT_KEY);
String comment = request.param(PARAM_COMMENT);
checkIfHotspotToReview(hotspotDto);
hotspotWsSupport.loadAndCheckProject(dbSession, hotspotDto, UserRole.USER);
- UserDto assignee = getAssignee(dbSession, login);
+ UserDto assignee = isNullOrEmpty(login) ? null :getAssignee(dbSession, login);
IssueChangeContext context = hotspotWsSupport.newIssueChangeContext();
issueFieldsSetter.addComment(defaultIssue, comment, context);
}
- checkAssigneeProjectPermission(dbSession, assignee, hotspotDto.getProjectUuid());
+ if (assignee != null) {
+ checkAssigneeProjectPermission(dbSession, assignee, hotspotDto.getProjectUuid());
+ }
if (issueFieldsSetter.assign(defaultIssue, assignee, context)) {
issueUpdater.saveIssueAndPreloadSearchResponseData(dbSession, defaultIssue, context, false);
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.sonar.api.rules.RuleType;
+import org.sonar.api.server.ws.Change;
import org.sonar.api.server.ws.WebService;
import org.sonar.api.utils.System2;
import org.sonar.api.web.UserRole;
import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
assertThat(hotspotParam.isRequired()).isTrue();
WebService.Param assigneeParam = wsDefinition.param("assignee");
assertThat(assigneeParam).isNotNull();
- assertThat(assigneeParam.isRequired()).isTrue();
+ assertThat(assigneeParam.isRequired()).isFalse();
WebService.Param commentParam = wsDefinition.param("comment");
assertThat(commentParam).isNotNull();
assertThat(commentParam.isRequired()).isFalse();
assertThat(wsDefinition.since()).isEqualTo("8.2");
+ assertThat(wsDefinition.changelog())
+ .extracting(Change::getVersion, Change::getDescription)
+ .contains(tuple("8.9", "Parameter 'assignee' is no longer mandatory"));
}
@Test
verifyFieldSetters(assignee, null);
}
+ @Test
+ public void unassign_hotspot_for_public_project() {
+ ComponentDto project = dbTester.components().insertPublicProject();
+ ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+ UserDto assignee = insertUser(randomAlphanumeric(15));
+
+ IssueDto hotspot = dbTester.issues().insertHotspot(project, file, h -> h.setAssigneeUuid(assignee.getUuid()));
+
+ UserDto userDto = insertUser(randomAlphanumeric(10));
+ userSessionRule.logIn(userDto).registerComponents(project);
+ when(issueFieldsSetter.assign(eq(hotspot.toDefaultIssue()), isNull(), any(IssueChangeContext.class))).thenReturn(true);
+
+ executeRequest(hotspot, null, null);
+
+ verifyFieldSetters(null, null);
+ }
+
@Test
public void assign_hotspot_to_me_for_public_project() {
ComponentDto project = dbTester.components().insertPublicProject();
verifyFieldSetters(me, null);
}
+ @Test
+ public void unassign_hotspot_myself_for_public_project() {
+ ComponentDto project = dbTester.components().insertPublicProject();
+ ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+ UserDto me = insertUser(randomAlphanumeric(10));
+ userSessionRule.logIn(me).registerComponents(project);
+ IssueDto hotspot = dbTester.issues().insertHotspot(project, file, h -> h.setAssigneeUuid(me.getUuid()));
+
+ when(issueFieldsSetter.assign(eq(hotspot.toDefaultIssue()), isNull(), any(IssueChangeContext.class))).thenReturn(true);
+
+ executeRequest(hotspot, null, null);
+
+ verifyFieldSetters(null, null);
+ }
+
@Test
public void assign_hotspot_to_someone_for_private_project() {
ComponentDto project = dbTester.components().insertPrivateProject();
verifyFieldSetters(assignee, null);
}
+ @Test
+ public void unassign_hotspot_for_private_project() {
+ ComponentDto project = dbTester.components().insertPrivateProject();
+ ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+ UserDto assignee = insertUser(randomAlphanumeric(15));
+ IssueDto hotspot = dbTester.issues().insertHotspot(project, file, h -> h.setAssigneeUuid(assignee.getUuid()));
+
+ insertAndLoginAsUserWithProjectUserPermission(randomAlphanumeric(10), project, UserRole.USER);
+
+ when(issueFieldsSetter.assign(eq(hotspot.toDefaultIssue()), isNull(), any(IssueChangeContext.class))).thenReturn(true);
+
+ executeRequest(hotspot, null, null);
+
+ verifyFieldSetters(null, null);
+ }
+
@Test
public void assign_hotspot_to_someone_for_private_project_branch() {
ComponentDto project = dbTester.components().insertPrivateProject();
}
private static UserDto userMatcher(UserDto user) {
- return argThat(argument -> argument.getLogin().equals(user.getLogin()) &&
- argument.getUuid().equals(user.getUuid()));
+ if (user == null) {
+ return isNull();
+ } else {
+ return argThat(argument -> argument.getLogin().equals(user.getLogin()) &&
+ argument.getUuid().equals(user.getUuid()));
+ }
}
}
hotspots.review_hotspot=Review Hotspot
hotspots.assign.success=Security Hotspot was successfully assigned to {0}
+hotspots.assign.unassign.success=Security Hotspot was successfully unassigned
hotspots.update.success=Security Hotspot status was successfully changed to {0}
#------------------------------------------------------------------------------