From: Jeremy Davis Date: Thu, 6 Oct 2022 15:43:42 +0000 (+0200) Subject: SONAR-17436 Improve SL connection UX X-Git-Tag: 9.7.0.61563~96 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=d17f1fa0ac2b38f1e94f2e7e5d72f9a747062c8b;p=sonarqube.git SONAR-17436 Improve SL connection UX --- 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 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 index 00000000000..421a79baa81 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 index 00000000000..30051d62119 --- /dev/null +++ b/server/sonar-web/public/images/check.svg @@ -0,0 +1,3 @@ + + + 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 @@ + + + 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 & 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(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) {
-

{translate('sonarlint-connection.title')}

- {error && ( + {status === Status.request && ( <> -

{translate('sonarlint-connection.error')}

-

{error}

+

+ {translate('sonarlint-connection.request.title')} +

+ +

+ {translateWithParameters('sonarlint-connection.request.description', ideName)} +

+

+ {translate('sonarlint-connection.request.description2')} +

+ + )} - {success && ( -

- {translateWithParameters('sonarlint-connection.success', newTokenName)} -

- )} - {!error && !success && ( + {status === Status.tokenError && ( <> -

- {translateWithParameters('sonarlint-connection.description', ideName)} + +

+ {translate('sonarlint-connection.token-error.title')} +

+

+ {translate('sonarlint-connection.token-error.description')}

- {translate('sonarlint-connection.description2')} + + {translate('sonarlint-connection.token-error.description2.link')} + + ) + }} + />

+ + )} - + {status === Status.connectionError && newToken && ( + <> + +

+ {translate('sonarlint-connection.connection-error.title')} +

+

+ {translate('sonarlint-connection.connection-error.description')} +

+
+ + {translate('sonarlint-connection.connection-error.token-name')} + + {newToken.name} +
+
+
+ + {translate('sonarlint-connection.connection-error.token-value')} + + {newToken.token} + +
+
+ {translate('sonarlint-connection.connection-error.next-steps')} +
+
    +
  1. {translate('sonarlint-connection.connection-error.step1')}
  2. +
  3. {translate('sonarlint-connection.connection-error.step2')}
  4. +
+ + )} + + {status === Status.success && newToken && ( + <> +

+ {translate('sonarlint-connection.success.title')} +

+ +

+ {translateWithParameters('sonarlint-connection.success.description', newToken.name)} +

+
+ {translate('sonarlint-connection.success.last-step')} +
+
+ {translate('sonarlint-connection.success.step')} +
)}
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', () => { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 735c071564a..225ba753801 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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. #------------------------------------------------------------------------------ #