]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17436 Improve SL connection UX
authorJeremy Davis <jeremy.davis@sonarsource.com>
Thu, 6 Oct 2022 15:43:42 +0000 (17:43 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 10 Oct 2022 20:03:09 +0000 (20:03 +0000)
server/sonar-web/public/images/SonarLint-connection-ok.png [new file with mode: 0644]
server/sonar-web/public/images/SonarLint-connection-request.png [new file with mode: 0644]
server/sonar-web/public/images/check.svg [new file with mode: 0644]
server/sonar-web/public/images/cross.svg [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts
server/sonar-web/src/main/js/app/components/SonarLintConnection.css
server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx
server/sonar-web/src/main/js/app/components/__tests__/SonarLintConnection-test.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

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 (file)
index 0000000..e4071d0
Binary files /dev/null and b/server/sonar-web/public/images/SonarLint-connection-ok.png 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 (file)
index 0000000..421a79b
Binary files /dev/null and b/server/sonar-web/public/images/SonarLint-connection-request.png differ
diff --git a/server/sonar-web/public/images/check.svg b/server/sonar-web/public/images/check.svg
new file mode 100644 (file)
index 0000000..30051d6
--- /dev/null
@@ -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 (file)
index 0000000..9ca7781
--- /dev/null
@@ -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>
index 9a10d6e29e02dbaeb733ba47adc02319656f7800..cd21d0fc4437044f984fd0dc23222ce97d982644 100644 (file)
@@ -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);
   };
index 8ff95e9993cacd4ccc67459614994b3634962a67..75072217d93f4de1e73a8884e7099ab5578528e5 100644 (file)
 }
 
 .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);
+}
index 6483aa50fd86a74b8f84def8951e444744db4850..77cb25dc9975bb48e40884842691851772b407b6 100644 (file)
  * 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>
index 0cec3892d3c7c25ded42769019519fa98685148e..a023d6d48965c391d62ff7aff792fe070c4bac94 100644 (file)
@@ -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', () => {
index 735c071564ada7de350ffd59cbafce9239e624c1..225ba7538015148d505ebf0d689060b984d71233 100644 (file)
@@ -2759,13 +2759,30 @@ promotion.sonarlint.content=Take advantage of the whole ecosystem by using Sonar
 #
 #------------------------------------------------------------------------------
 
-sonarlint-connection.title=SonarLint binding
-sonarlint-connection.description=An instance of SonarLint for {0} requests access to this SonarQube instance.
+sonarlint-connection.request.title=Allow SonarLint connection?
+sonarlint-connection.request.description=SonarLint for {0} is requesting access to SonarQube.
+sonarlint-connection.request.description2=Do you allow SonarLint to connect? This will create a token and share it with SonarLint.
+sonarlint-connection.request.action=Allow connection
+
+sonarlint-connection.token-error.title=Token generation failed
+sonarlint-connection.token-error.description=SonarQube was not able to generate a token.
+sonarlint-connection.token-error.description2=Go back to your IDE and start again, or go to the {link} of your SonarQube account to create a new user token manually.
+sonarlint-connection.token-error.description2.link=Security section
+
+sonarlint-connection.connection-error.title=Token created
+sonarlint-connection.connection-error.description=The following token was created:
+sonarlint-connection.connection-error.token-name=Token name
+sonarlint-connection.connection-error.token-value=Token value
+sonarlint-connection.connection-error.next-steps=Next steps
+sonarlint-connection.connection-error.step1=Copy the above token.
+sonarlint-connection.connection-error.step2=Go back to your IDE and paste the token in SonarLint.
+
+sonarlint-connection.success.title=SonarLint connection is almost ready!
+sonarlint-connection.success.description=A new '{0}' token was created and sent to SonarLint in your IDE.
+sonarlint-connection.success.last-step=Last step
+sonarlint-connection.success.step=Go back to your IDE to complete the setup.
+
 sonarlint-connection.unspecified-ide=an unspecified IDE
-sonarlint-connection.description2=Click on the button below to allow it to connect to this SonarQube instance. This will create a token and share it with SonarLint.
-sonarlint-connection.action=Allow SonarLint to connect to this instance
-sonarlint-connection.error=Something went wrong. Make sure your IDE is still running and try again.
-sonarlint-connection.success=A new '{0}' token was created and successfully sent to SonarLint in your IDE.
 
 #------------------------------------------------------------------------------
 #