@@ -1,68 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 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 React from 'react'; | |||
import { Popup, PopupPlacement } from '../ui/popups'; | |||
import ScreenPositionFixer from './ScreenPositionFixer'; | |||
interface OverlayProps { | |||
className?: string; | |||
children: React.ReactNode; | |||
noPadding?: boolean; | |||
placement?: PopupPlacement; | |||
useEventBoundary?: boolean; | |||
} | |||
export class DropdownOverlay extends React.Component<OverlayProps> { | |||
get placement() { | |||
return this.props.placement || PopupPlacement.Bottom; | |||
} | |||
renderPopup = (leftFix?: number, topFix?: number) => ( | |||
<Popup | |||
arrowStyle={ | |||
leftFix !== undefined && topFix !== undefined | |||
? { transform: `translate(${-leftFix}px, ${-topFix}px)` } | |||
: undefined | |||
} | |||
className={this.props.className} | |||
noPadding={this.props.noPadding} | |||
placement={this.placement} | |||
style={ | |||
leftFix !== undefined && topFix !== undefined | |||
? { marginLeft: `calc(50% + ${leftFix}px)` } | |||
: undefined | |||
} | |||
useEventBoundary={this.props.useEventBoundary} | |||
> | |||
{this.props.children} | |||
</Popup> | |||
); | |||
render() { | |||
if (this.placement === PopupPlacement.Bottom) { | |||
return ( | |||
<ScreenPositionFixer> | |||
{({ leftFix, topFix }) => this.renderPopup(leftFix, topFix)} | |||
</ScreenPositionFixer> | |||
); | |||
} | |||
return this.renderPopup(); | |||
} | |||
} |
@@ -1,89 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 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 React from 'react'; | |||
import DocumentClickHandler from './DocumentClickHandler'; | |||
import EscKeydownHandler from './EscKeydownHandler'; | |||
import FocusOutHandler from './FocusOutHandler'; | |||
import OutsideClickHandler from './OutsideClickHandler'; | |||
import UpDownKeyboardHanlder from './UpDownKeyboardHandler'; | |||
interface Props { | |||
children?: React.ReactNode; | |||
closeOnClick?: boolean; | |||
closeOnClickOutside?: boolean; | |||
closeOnEscape?: boolean; | |||
closeOnFocusOut?: boolean; | |||
navigateWithKeyboard?: boolean; | |||
onRequestClose: () => void; | |||
open: boolean; | |||
overlay: React.ReactNode; | |||
} | |||
export default class Toggler extends React.Component<Props> { | |||
renderOverlay() { | |||
const { | |||
closeOnClick = false, | |||
closeOnClickOutside = true, | |||
closeOnEscape = true, | |||
closeOnFocusOut = true, | |||
navigateWithKeyboard = true, | |||
onRequestClose, | |||
overlay, | |||
} = this.props; | |||
let renderedOverlay = overlay; | |||
if (navigateWithKeyboard) { | |||
renderedOverlay = <UpDownKeyboardHanlder>{renderedOverlay}</UpDownKeyboardHanlder>; | |||
} | |||
if (closeOnFocusOut) { | |||
renderedOverlay = ( | |||
<FocusOutHandler onFocusOut={onRequestClose}>{renderedOverlay}</FocusOutHandler> | |||
); | |||
} | |||
if (closeOnEscape) { | |||
renderedOverlay = ( | |||
<EscKeydownHandler onKeydown={onRequestClose}>{renderedOverlay}</EscKeydownHandler> | |||
); | |||
} | |||
if (closeOnClick) { | |||
return ( | |||
<DocumentClickHandler onClick={onRequestClose}>{renderedOverlay}</DocumentClickHandler> | |||
); | |||
} else if (closeOnClickOutside) { | |||
return ( | |||
<OutsideClickHandler onClickOutside={onRequestClose}>{renderedOverlay}</OutsideClickHandler> | |||
); | |||
} | |||
return renderedOverlay; | |||
} | |||
render() { | |||
return ( | |||
<> | |||
{this.props.children} | |||
{this.props.open && this.renderOverlay()} | |||
</> | |||
); | |||
} | |||
} |
@@ -1,237 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 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 { render } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; | |||
import * as React from 'react'; | |||
import { byRole } from '../../../helpers/testSelector'; | |||
import Toggler from '../Toggler'; | |||
const ui = { | |||
toggleButton: byRole('button', { name: 'toggle' }), | |||
outButton: byRole('button', { name: 'out' }), | |||
overlayButton: byRole('button', { name: 'overlay' }), | |||
nextOverlayButton: byRole('button', { name: 'next overlay' }), | |||
overlayTextarea: byRole('textbox'), | |||
overlayLatButton: byRole('button', { name: 'last' }), | |||
}; | |||
async function openToggler(user: UserEvent) { | |||
await user.click(ui.toggleButton.get()); | |||
expect(ui.overlayButton.get()).toBeInTheDocument(); | |||
} | |||
async function focusOut() { | |||
await userEvent.click(ui.outButton.get()); | |||
} | |||
it('should handle key up/down', async () => { | |||
const user = userEvent.setup({ delay: null }); | |||
const rerender = renderToggler( | |||
{}, | |||
<> | |||
<textarea name="test-area" /> | |||
<button type="button">last</button> | |||
</>, | |||
); | |||
await openToggler(user); | |||
await user.keyboard('{ArrowUp}'); | |||
expect(ui.overlayLatButton.get()).toHaveFocus(); | |||
await user.keyboard('{ArrowUp}'); | |||
expect(ui.overlayTextarea.get()).toHaveFocus(); | |||
// Focus does not escape multiline input | |||
await user.keyboard('{ArrowDown}'); | |||
expect(ui.overlayTextarea.get()).toHaveFocus(); | |||
await user.keyboard('{ArrowUp}'); | |||
expect(ui.overlayTextarea.get()).toHaveFocus(); | |||
// Escapt textarea | |||
await user.keyboard('{Tab}'); | |||
// No focus change when using shortcut | |||
await user.keyboard('{Control>}{ArrowUp}{/Control}'); | |||
expect(ui.overlayLatButton.get()).toHaveFocus(); | |||
await user.keyboard('{ArrowDown}'); | |||
expect(ui.overlayButton.get()).toHaveFocus(); | |||
await user.keyboard('{ArrowDown}'); | |||
expect(ui.nextOverlayButton.get()).toHaveFocus(); | |||
rerender(); | |||
await openToggler(user); | |||
await user.keyboard('{ArrowDown}'); | |||
expect(ui.overlayButton.get()).toHaveFocus(); | |||
}); | |||
it('should handle escape correclty', async () => { | |||
const user = userEvent.setup({ delay: null }); | |||
const rerender = renderToggler({ | |||
closeOnEscape: true, | |||
closeOnClick: false, | |||
closeOnClickOutside: false, | |||
closeOnFocusOut: false, | |||
}); | |||
await openToggler(user); | |||
await user.keyboard('{Escape}'); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
rerender({ closeOnEscape: false }); | |||
await openToggler(user); | |||
await user.keyboard('{Escape}'); | |||
expect(ui.overlayButton.get()).toBeInTheDocument(); | |||
}); | |||
it('should handle focus correctly', async () => { | |||
const user = userEvent.setup({ delay: null }); | |||
const rerender = renderToggler({ | |||
closeOnEscape: false, | |||
closeOnClick: false, | |||
closeOnClickOutside: false, | |||
closeOnFocusOut: true, | |||
}); | |||
await openToggler(user); | |||
await focusOut(); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
rerender({ closeOnFocusOut: false }); | |||
await openToggler(user); | |||
await focusOut(); | |||
expect(ui.overlayButton.get()).toBeInTheDocument(); | |||
}); | |||
it('should handle click correctly', async () => { | |||
const user = userEvent.setup({ delay: null }); | |||
const rerender = renderToggler({ | |||
closeOnEscape: false, | |||
closeOnClick: true, | |||
closeOnClickOutside: false, | |||
closeOnFocusOut: false, | |||
}); | |||
await openToggler(user); | |||
await user.click(ui.outButton.get()); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
await openToggler(user); | |||
await user.click(ui.overlayButton.get()); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
rerender({ closeOnClick: false }); | |||
await openToggler(user); | |||
await user.click(ui.outButton.get()); | |||
expect(ui.overlayButton.get()).toBeInTheDocument(); | |||
}); | |||
it('should handle click outside correctly', async () => { | |||
const user = userEvent.setup({ delay: null }); | |||
const rerender = renderToggler({ | |||
closeOnEscape: false, | |||
closeOnClick: false, | |||
closeOnClickOutside: true, | |||
closeOnFocusOut: false, | |||
}); | |||
await openToggler(user); | |||
await user.click(ui.overlayButton.get()); | |||
expect(await ui.overlayButton.find()).toBeInTheDocument(); | |||
await user.click(ui.outButton.get()); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
rerender({ closeOnClickOutside: false }); | |||
await openToggler(user); | |||
await user.click(ui.outButton.get()); | |||
expect(ui.overlayButton.get()).toBeInTheDocument(); | |||
}); | |||
it('should open/close correctly when default props is applied', async () => { | |||
const user = userEvent.setup({ delay: null }); | |||
renderToggler(); | |||
await openToggler(user); | |||
// Should not close when on overlay | |||
await user.click(ui.overlayButton.get()); | |||
expect(await ui.overlayButton.find()).toBeInTheDocument(); | |||
// Focus out should close | |||
await focusOut(); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
await openToggler(user); | |||
// Escape should close | |||
await user.keyboard('{Escape}'); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
await openToggler(user); | |||
// Click should close (focus out is trigger first) | |||
await user.click(ui.outButton.get()); | |||
expect(ui.overlayButton.query()).not.toBeInTheDocument(); | |||
}); | |||
function renderToggler(override?: Partial<Toggler['props']>, additionalOverlay?: React.ReactNode) { | |||
function App(props: Partial<Toggler['props']>) { | |||
const [open, setOpen] = React.useState(false); | |||
return ( | |||
<> | |||
<Toggler | |||
onRequestClose={() => setOpen(false)} | |||
open={open} | |||
overlay={ | |||
<div className="popup"> | |||
<button type="button">overlay</button> | |||
<button type="button">next overlay</button> | |||
{additionalOverlay} | |||
</div> | |||
} | |||
{...props} | |||
> | |||
<button onClick={() => setOpen(true)} type="button"> | |||
toggle | |||
</button> | |||
</Toggler> | |||
<button type="button">out</button> | |||
</> | |||
); | |||
} | |||
const { rerender } = render(<App {...override} />); | |||
return function (reoverride?: Partial<Toggler['props']>) { | |||
return rerender(<App {...override} {...reoverride} />); | |||
}; | |||
} |
@@ -22,7 +22,6 @@ import * as React from 'react'; | |||
import { IssueActions } from '../../../types/issues'; | |||
import { Issue } from '../../../types/types'; | |||
import IssueAssign from './IssueAssign'; | |||
import IssueCommentAction from './IssueCommentAction'; | |||
import IssueTags from './IssueTags'; | |||
import IssueTransition from './IssueTransition'; | |||
import SonarLintBadge from './SonarLintBadge'; | |||
@@ -50,16 +49,7 @@ export default function IssueActionsBar(props: Readonly<Props>) { | |||
canSetTags, | |||
} = props; | |||
const [commentPlaceholder, setCommentPlaceholder] = React.useState(''); | |||
const toggleComment = (open: boolean, placeholder = '') => { | |||
setCommentPlaceholder(placeholder); | |||
togglePopup('comment', open); | |||
}; | |||
const canAssign = issue.actions.includes(IssueActions.Assign); | |||
const canComment = issue.actions.includes(IssueActions.Comment); | |||
const tagsPopupOpen = currentPopup === 'edit-tags' && canSetTags; | |||
return ( | |||
@@ -107,16 +97,6 @@ export default function IssueActionsBar(props: Readonly<Props>) { | |||
</li> | |||
)} | |||
</ul> | |||
{canComment && ( | |||
<IssueCommentAction | |||
commentPlaceholder={commentPlaceholder} | |||
currentPopup={currentPopup === 'comment'} | |||
issueKey={issue.key} | |||
onChange={onChange} | |||
toggleComment={toggleComment} | |||
/> | |||
)} | |||
</div> | |||
); | |||
} |
@@ -1,71 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 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 React from 'react'; | |||
import { addIssueComment, deleteIssueComment, editIssueComment } from '../../../api/issues'; | |||
import Toggler from '../../../components/controls/Toggler'; | |||
import { Issue } from '../../../types/types'; | |||
import { updateIssue } from '../actions'; | |||
import CommentPopup from '../popups/CommentPopup'; | |||
interface Props { | |||
commentPlaceholder: string; | |||
currentPopup?: boolean; | |||
issueKey: string; | |||
onChange: (issue: Issue) => void; | |||
toggleComment: (open: boolean, placeholder?: string, autoTriggered?: boolean) => void; | |||
} | |||
export default class IssueCommentAction extends React.PureComponent<Props> { | |||
addComment = (text: string) => { | |||
updateIssue(this.props.onChange, addIssueComment({ issue: this.props.issueKey, text })); | |||
this.handleClose(); | |||
}; | |||
handleEditComment = (comment: string, text: string) => { | |||
updateIssue(this.props.onChange, editIssueComment({ comment, text })); | |||
}; | |||
handleDeleteComment = (comment: string) => { | |||
updateIssue(this.props.onChange, deleteIssueComment({ comment })); | |||
}; | |||
handleClose = () => { | |||
this.props.toggleComment(false); | |||
}; | |||
render() { | |||
return ( | |||
<div className="issue-meta dropdown"> | |||
<Toggler | |||
closeOnClickOutside={false} | |||
onRequestClose={this.handleClose} | |||
open={!!this.props.currentPopup} | |||
overlay={ | |||
<CommentPopup | |||
onComment={this.addComment} | |||
placeholder={this.props.commentPlaceholder} | |||
toggleComment={this.props.toggleComment} | |||
/> | |||
} | |||
/> | |||
</div> | |||
); | |||
} | |||
} |
@@ -1,95 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 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 React from 'react'; | |||
import { KeyboardKeys } from '../../../helpers/keycodes'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import FormattingTips from '../../common/FormattingTips'; | |||
import { Button, ResetButtonLink } from '../../controls/buttons'; | |||
export interface CommentFormProps { | |||
comment?: string; | |||
onCancel: () => void; | |||
onSaveComment: (comment: string) => void; | |||
placeholder?: string; | |||
showFormatHelp: boolean; | |||
autoTriggered?: boolean; | |||
} | |||
export default function CommentForm(props: CommentFormProps) { | |||
const { comment, placeholder, showFormatHelp, autoTriggered } = props; | |||
const [editComment, setEditComment] = React.useState(comment || ''); | |||
return ( | |||
<> | |||
<div className="issue-comment-form-text"> | |||
<textarea | |||
autoFocus | |||
className="sw-w-full" | |||
style={{ resize: 'vertical' }} | |||
placeholder={placeholder} | |||
aria-label={translate('issue.comment.enter_comment')} | |||
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => { | |||
setEditComment(event.target.value); | |||
}} | |||
onKeyDown={(event: React.KeyboardEvent) => { | |||
if (event.nativeEvent.key === KeyboardKeys.Enter && (event.metaKey || event.ctrlKey)) { | |||
props.onSaveComment(editComment); | |||
setEditComment(''); | |||
} | |||
}} | |||
rows={2} | |||
value={editComment} | |||
/> | |||
</div> | |||
<div className="sw-flex sw-justify-between issue-comment-form-footer"> | |||
{showFormatHelp && ( | |||
<div className="issue-comment-form-tips"> | |||
<FormattingTips /> | |||
</div> | |||
)} | |||
<div> | |||
<div className="issue-comment-form-actions"> | |||
<Button | |||
className="js-issue-comment-submit" | |||
disabled={editComment.trim().length < 1} | |||
onClick={() => { | |||
props.onSaveComment(editComment); | |||
setEditComment(''); | |||
}} | |||
> | |||
{comment ? translate('save') : translate('issue.comment.formlink')} | |||
</Button> | |||
<ResetButtonLink | |||
className="js-issue-comment-cancel little-spacer-left" | |||
aria-label={ | |||
comment | |||
? translate('issue.comment.edit.cancel') | |||
: translate('issue.comment.add_comment.cancel') | |||
} | |||
onClick={props.onCancel} | |||
> | |||
{autoTriggered ? translate('skip') : translate('cancel')} | |||
</ResetButtonLink> | |||
</div> | |||
</div> | |||
</div> | |||
</> | |||
); | |||
} |
@@ -1,56 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 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 React from 'react'; | |||
import { DropdownOverlay } from '../../../components/controls/Dropdown'; | |||
import { PopupPlacement } from '../../../components/ui/popups'; | |||
import { IssueComment } from '../../../types/types'; | |||
import CommentForm from './CommentForm'; | |||
export interface CommentPopupProps { | |||
comment?: Pick<IssueComment, 'markdown'>; | |||
onComment: (text: string) => void; | |||
toggleComment: (visible: boolean) => void; | |||
placeholder: string; | |||
placement?: PopupPlacement; | |||
} | |||
export default class CommentPopup extends React.PureComponent<CommentPopupProps> { | |||
handleCancelClick = () => { | |||
this.props.toggleComment(false); | |||
}; | |||
render() { | |||
const { comment } = this.props; | |||
return ( | |||
<DropdownOverlay placement={this.props.placement}> | |||
<div className="sw-min-w-abs-500 issue-comment-bubble-popup"> | |||
<CommentForm | |||
placeholder={this.props.placeholder} | |||
onCancel={this.handleCancelClick} | |||
onSaveComment={this.props.onComment} | |||
showFormatHelp | |||
comment={comment?.markdown} | |||
/> | |||
</div> | |||
</DropdownOverlay> | |||
); | |||
} | |||
} |