aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src
diff options
context:
space:
mode:
authorJeremy Davis <jeremy.davis@sonarsource.com>2022-10-06 17:43:42 +0200
committersonartech <sonartech@sonarsource.com>2022-10-10 20:03:09 +0000
commitd17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b (patch)
tree6806d2f0bcfc19719cd6e6bfdff05a6dd7a6d526 /server/sonar-web/src
parentf1a7bed7bc06dfcf1cba32eccfebfa448095c0c6 (diff)
downloadsonarqube-d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b.tar.gz
sonarqube-d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b.zip
SONAR-17436 Improve SL connection UX
Diffstat (limited to 'server/sonar-web/src')
-rw-r--r--server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts10
-rw-r--r--server/sonar-web/src/main/js/app/components/SonarLintConnection.css20
-rw-r--r--server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx165
-rw-r--r--server/sonar-web/src/main/js/app/components/__tests__/SonarLintConnection-test.tsx39
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', () => {