diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2022-10-06 17:43:42 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-10-10 20:03:09 +0000 |
commit | d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b (patch) | |
tree | 6806d2f0bcfc19719cd6e6bfdff05a6dd7a6d526 /server/sonar-web/src | |
parent | f1a7bed7bc06dfcf1cba32eccfebfa448095c0c6 (diff) | |
download | sonarqube-d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b.tar.gz sonarqube-d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b.zip |
SONAR-17436 Improve SL connection UX
Diffstat (limited to 'server/sonar-web/src')
4 files changed, 193 insertions, 41 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts b/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts index 9a10d6e29e0..cd21d0fc443 100644 --- a/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts @@ -40,6 +40,7 @@ const defaultTokens = [ export default class UserTokensMock { tokens: Array<Partial<NewUserToken> & UserToken>; + failGeneration = false; constructor() { this.tokens = cloneDeep(defaultTokens); @@ -66,6 +67,11 @@ export default class UserTokensMock { projectKey: string; expirationDate?: string; }) => { + if (this.failGeneration) { + this.failGeneration = false; + return Promise.reject('x_x'); + } + const token = { name, login, @@ -96,6 +102,10 @@ export default class UserTokensMock { return Promise.resolve(); }; + failNextTokenGeneration = () => { + this.failGeneration = true; + }; + getTokens = () => { return cloneDeep(this.tokens); }; diff --git a/server/sonar-web/src/main/js/app/components/SonarLintConnection.css b/server/sonar-web/src/main/js/app/components/SonarLintConnection.css index 8ff95e9993c..75072217d93 100644 --- a/server/sonar-web/src/main/js/app/components/SonarLintConnection.css +++ b/server/sonar-web/src/main/js/app/components/SonarLintConnection.css @@ -29,7 +29,25 @@ } .sonarlint-connection-content { - min-width: 500px; + min-width: 600px; width: 40%; margin: 0 auto; } + +.sonarlint-connection-page ol { + list-style: inside decimal; +} + +.sonarlint-connection-page .field-label { + display: inline-block; + width: 150px; + color: var(--secondFontColor); + text-align: start; + flex-shrink: 0; +} + +.sonarlint-connection-page .sonarlint-token-value { + background-color: var(--codeBackground); + border: 1px solid var(--barBorderColor); + padding: calc(var(--gridSize) / 2) var(--gridSize); +} diff --git a/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx b/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx index 6483aa50fd8..77cb25dc997 100644 --- a/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx +++ b/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx @@ -18,10 +18,14 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { generateToken, getTokens } from '../../api/user-tokens'; +import Link from '../../components/common/Link'; import { Button } from '../../components/controls/buttons'; +import { ClipboardButton } from '../../components/controls/clipboard'; import { whenLoggedIn } from '../../components/hoc/whenLoggedIn'; +import CheckIcon from '../../components/icons/CheckIcon'; import { translate, translateWithParameters } from '../../helpers/l10n'; import { portIsValid, sendUserToken } from '../../helpers/sonarlint'; import { @@ -29,13 +33,23 @@ import { getAvailableExpirationOptions, getNextTokenName } from '../../helpers/tokens'; +import { NewUserToken, TokenExpiration } from '../../types/token'; import { LoggedInUser } from '../../types/users'; import './SonarLintConnection.css'; +enum Status { + request, + tokenError, + connectionError, + success +} + interface Props { currentUser: LoggedInUser; } +const TOKEN_PREFIX = 'SonarLint'; + const getNextAvailableTokenName = async (login: string, tokenNameBase: string) => { const tokens = await getTokens(login); @@ -46,15 +60,14 @@ async function computeExpirationDate() { const options = await getAvailableExpirationOptions(); const maxOption = options[options.length - 1]; - return maxOption.value ? computeTokenExpirationDate(maxOption.value) : undefined; + return computeTokenExpirationDate(maxOption.value || TokenExpiration.OneYear); } export function SonarLintConnection({ currentUser }: Props) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [success, setSuccess] = React.useState(false); - const [error, setError] = React.useState(''); - const [newTokenName, setNewTokenName] = React.useState(''); + const [status, setStatus] = React.useState(Status.request); + const [newToken, setNewToken] = React.useState<NewUserToken | undefined>(undefined); const port = parseInt(searchParams.get('port') ?? '0', 10); const ideName = searchParams.get('ideName') ?? translate('sonarlint-connection.unspecified-ide'); @@ -69,20 +82,24 @@ export function SonarLintConnection({ currentUser }: Props) { const { login } = currentUser; const authorize = React.useCallback(async () => { - const newTokenName = await getNextAvailableTokenName(login, `sonarlint-${ideName}`); + const newTokenName = await getNextAvailableTokenName(login, `${TOKEN_PREFIX}-${ideName}`); const expirationDate = await computeExpirationDate(); - const token = await generateToken({ name: newTokenName, login, expirationDate }); + const token = await generateToken({ name: newTokenName, login, expirationDate }).catch( + () => undefined + ); + + if (!token) { + setStatus(Status.tokenError); + return; + } + + setNewToken(token); try { await sendUserToken(port, token); - setSuccess(true); - setNewTokenName(newTokenName); - } catch (error) { - if (error instanceof Error) { - setError(error.message); - } else { - setError('-'); - } + setStatus(Status.success); + } catch (_) { + setStatus(Status.connectionError); } }, [port, ideName, login]); @@ -90,31 +107,119 @@ export function SonarLintConnection({ currentUser }: Props) { <div className="sonarlint-connection-page"> <div className="sonarlint-connection-content boxed-group"> <div className="boxed-group-inner text-center"> - <h1 className="big-spacer-bottom">{translate('sonarlint-connection.title')}</h1> - {error && ( + {status === Status.request && ( <> - <p className="big big-spacer-bottom">{translate('sonarlint-connection.error')}</p> - <p className="monospaced huge-spacer-bottom">{error}</p> + <h1 className="big-spacer-top big-spacer-bottom"> + {translate('sonarlint-connection.request.title')} + </h1> + <img + alt="" + aria-hidden={true} + className="big-spacer-top big-spacer-bottom" + src="/images/SonarLint-connection-request.png" + /> + <p className="big big-spacer-top big-spacer-bottom"> + {translateWithParameters('sonarlint-connection.request.description', ideName)} + </p> + <p className="big huge-spacer-bottom"> + {translate('sonarlint-connection.request.description2')} + </p> + + <Button className="big-spacer-bottom" onClick={authorize}> + <CheckIcon className="spacer-right" /> + {translate('sonarlint-connection.request.action')} + </Button> </> )} - {success && ( - <p className="big"> - {translateWithParameters('sonarlint-connection.success', newTokenName)} - </p> - )} - {!error && !success && ( + {status === Status.tokenError && ( <> - <p className="big big-spacer-bottom"> - {translateWithParameters('sonarlint-connection.description', ideName)} + <img + alt="" + aria-hidden={true} + className="big-spacer-top big-spacer-bottom padded-top" + src="/images/cross.svg" + /> + <h1 className="big-spacer-bottom"> + {translate('sonarlint-connection.token-error.title')} + </h1> + <p className="big big-spacer-top big-spacer-bottom"> + {translate('sonarlint-connection.token-error.description')} </p> <p className="big huge-spacer-bottom"> - {translate('sonarlint-connection.description2')} + <FormattedMessage + id="sonarlint-connection.token-error.description2" + defaultMessage={translate('sonarlint-connection.token-error.description2')} + values={{ + link: ( + <Link to="/account/security"> + {translate('sonarlint-connection.token-error.description2.link')} + </Link> + ) + }} + /> </p> + </> + )} - <Button className="big-spacer-bottom" onClick={authorize}> - {translate('sonarlint-connection.action')} - </Button> + {status === Status.connectionError && newToken && ( + <> + <img + alt="" + aria-hidden={true} + className="big-spacer-top big-spacer-bottom padded-top" + src="/images/check.svg" + /> + <h1 className="big-spacer-bottom"> + {translate('sonarlint-connection.connection-error.title')} + </h1> + <p className="big big-spacer-top big-spacer-bottom"> + {translate('sonarlint-connection.connection-error.description')} + </p> + <div className="display-flex-center"> + <span className="field-label"> + {translate('sonarlint-connection.connection-error.token-name')} + </span> + {newToken.name} + </div> + <hr /> + <div className="display-flex-center"> + <span className="field-label"> + {translate('sonarlint-connection.connection-error.token-value')} + </span> + <span className="sonarlint-token-value">{newToken.token}</span> + <ClipboardButton className="big-spacer-left" copyValue={newToken.token} /> + </div> + <div className="big-spacer-top"> + <strong>{translate('sonarlint-connection.connection-error.next-steps')}</strong> + </div> + <ol className="big-spacer-top big-spacer-bottom"> + <li>{translate('sonarlint-connection.connection-error.step1')}</li> + <li>{translate('sonarlint-connection.connection-error.step2')}</li> + </ol> + </> + )} + + {status === Status.success && newToken && ( + <> + <h1 className="big-spacer-top big-spacer-bottom"> + {translate('sonarlint-connection.success.title')} + </h1> + <img + alt="" + aria-hidden={true} + className="big-spacer-bottom" + src="/images/SonarLint-connection-ok.png" + /> + <p className="big big-spacer-top big-spacer-bottom"> + {translateWithParameters('sonarlint-connection.success.description', newToken.name)} + </p> + <div className="big-spacer-top"> + <strong>{translate('sonarlint-connection.success.last-step')}</strong> + </div> + <div className="big-spacer-top big-spacer-bottom"> + {translate('sonarlint-connection.success.step')} + </div> </> )} </div> diff --git a/server/sonar-web/src/main/js/app/components/__tests__/SonarLintConnection-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/SonarLintConnection-test.tsx index 0cec3892d3c..a023d6d4896 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/SonarLintConnection-test.tsx +++ b/server/sonar-web/src/main/js/app/components/__tests__/SonarLintConnection-test.tsx @@ -74,28 +74,47 @@ it('should allow the user to accept the binding request', async () => { renderSonarLintConnection(); expect( - await screen.findByRole('heading', { name: 'sonarlint-connection.title' }) + await screen.findByRole('heading', { name: 'sonarlint-connection.request.title' }) ).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'sonarlint-connection.action' })); + await user.click(screen.getByRole('button', { name: 'sonarlint-connection.request.action' })); expect( - await screen.findByText( - 'sonarlint-connection.success.sonarlint-sonarlint-connection.unspecified-ide' - ) + await screen.findByText('sonarlint-connection.success.description', { exact: false }) ).toBeInTheDocument(); }); -it('should handle errors on binding', async () => { - (sendUserToken as jest.Mock).mockRejectedValueOnce(new Error('error message')); +it('should handle token generation errors', async () => { + tokenMock.failNextTokenGeneration(); const user = userEvent.setup(); renderSonarLintConnection(); - await user.click(await screen.findByRole('button', { name: 'sonarlint-connection.action' })); + await user.click( + await screen.findByRole('button', { name: 'sonarlint-connection.request.action' }) + ); - expect(await screen.findByText('sonarlint-connection.error')).toBeInTheDocument(); - expect(await screen.findByText('error message')).toBeInTheDocument(); + expect( + await screen.findByText('sonarlint-connection.token-error.description') + ).toBeInTheDocument(); +}); + +it('should handle connection errors', async () => { + (sendUserToken as jest.Mock).mockRejectedValueOnce(new Error('')); + + const user = userEvent.setup(); + renderSonarLintConnection(); + + await user.click( + await screen.findByRole('button', { name: 'sonarlint-connection.request.action' }) + ); + + expect( + await screen.findByText('sonarlint-connection.connection-error.description') + ).toBeInTheDocument(); + + const tokenValue = tokenMock.getLastToken()?.token ?? ''; + expect(await screen.findByText(tokenValue)).toBeInTheDocument(); }); it('should require authentication if user is not logged in', () => { |