aboutsummaryrefslogtreecommitdiffstats
path: root/server
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
parentf1a7bed7bc06dfcf1cba32eccfebfa448095c0c6 (diff)
downloadsonarqube-d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b.tar.gz
sonarqube-d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b.zip
SONAR-17436 Improve SL connection UX
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/public/images/SonarLint-connection-ok.pngbin0 -> 6308 bytes
-rw-r--r--server/sonar-web/public/images/SonarLint-connection-request.pngbin0 -> 6605 bytes
-rw-r--r--server/sonar-web/public/images/check.svg3
-rw-r--r--server/sonar-web/public/images/cross.svg3
-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
8 files changed, 199 insertions, 41 deletions
diff --git a/server/sonar-web/public/images/SonarLint-connection-ok.png b/server/sonar-web/public/images/SonarLint-connection-ok.png
new file mode 100644
index 00000000000..e4071d0e2f0
--- /dev/null
+++ b/server/sonar-web/public/images/SonarLint-connection-ok.png
Binary files differ
diff --git a/server/sonar-web/public/images/SonarLint-connection-request.png b/server/sonar-web/public/images/SonarLint-connection-request.png
new file mode 100644
index 00000000000..421a79baa81
--- /dev/null
+++ b/server/sonar-web/public/images/SonarLint-connection-request.png
Binary files differ
diff --git a/server/sonar-web/public/images/check.svg b/server/sonar-web/public/images/check.svg
new file mode 100644
index 00000000000..30051d62119
--- /dev/null
+++ b/server/sonar-web/public/images/check.svg
@@ -0,0 +1,3 @@
+<svg width="43" height="31" viewBox="0 0 43 31" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M41.6187 0.881256C42.7906 2.05313 42.7906 3.95626 41.6187 5.12813L17.6187 29.1281C16.4468 30.3 14.5437 30.3 13.3718 29.1281L1.37183 17.1281C0.199951 15.9563 0.199951 14.0531 1.37183 12.8813C2.5437 11.7094 4.44683 11.7094 5.6187 12.8813L15.5 22.7531L37.3812 0.881256C38.5531 -0.290619 40.4562 -0.290619 41.6281 0.881256H41.6187Z" fill="#008A25"/>
+</svg>
diff --git a/server/sonar-web/public/images/cross.svg b/server/sonar-web/public/images/cross.svg
new file mode 100644
index 00000000000..9ca7781bec9
--- /dev/null
+++ b/server/sonar-web/public/images/cross.svg
@@ -0,0 +1,3 @@
+<svg width="39" height="39" viewBox="0 0 39 39" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M35.0487 27.464L26.5823 19.0057L35.04 10.54C35.0402 10.5398 35.0404 10.5395 35.0407 10.5393C36.9929 8.58635 36.9927 5.41739 35.04 3.46469C33.0871 1.51177 29.9176 1.51177 27.9647 3.46469L27.964 3.46536L19.5057 11.9317L11.04 3.47407C11.0399 3.47396 11.0398 3.47385 11.0397 3.47374C11.0396 3.47363 11.0394 3.47351 11.0393 3.4734C9.08634 1.52114 5.91739 1.52137 3.96469 3.47407C2.01177 5.42699 2.01177 8.59645 3.96469 10.5494L3.96536 10.55L12.4317 19.0084L3.97407 27.4741C3.97398 27.4741 3.9739 27.4742 3.97382 27.4743C3.97368 27.4745 3.97354 27.4746 3.9734 27.4747C2.02114 29.4277 2.02137 32.5967 3.97407 34.5494C5.92699 36.5023 9.09645 36.5023 11.0494 34.5494L11.05 34.5487L19.5084 26.0823L27.9741 34.54C27.9743 34.5402 27.9745 34.5404 27.9747 34.5407C29.9277 36.4929 33.0967 36.4927 35.0494 34.54C37.0023 32.5871 37.0023 29.4176 35.0494 27.4647L35.0487 27.464Z" fill="#D02F3A" stroke="white" stroke-width="4"/>
+</svg>
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', () => {