Parcourir la source

SONAR-17436 Improve SL connection UX

tags/9.7.0.61563
Jeremy Davis il y a 1 an
Parent
révision
d17f1fa0ac

BIN
server/sonar-web/public/images/SonarLint-connection-ok.png Voir le fichier


BIN
server/sonar-web/public/images/SonarLint-connection-request.png Voir le fichier


+ 3
- 0
server/sonar-web/public/images/check.svg Voir le fichier

@@ -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>

+ 3
- 0
server/sonar-web/public/images/cross.svg Voir le fichier

@@ -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>

+ 10
- 0
server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts Voir le fichier

@@ -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);
};

+ 19
- 1
server/sonar-web/src/main/js/app/components/SonarLintConnection.css Voir le fichier

@@ -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);
}

+ 135
- 30
server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx Voir le fichier

@@ -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>

+ 29
- 10
server/sonar-web/src/main/js/app/components/__tests__/SonarLintConnection-test.tsx Voir le fichier

@@ -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', () => {

+ 23
- 6
sonar-core/src/main/resources/org/sonar/l10n/core.properties Voir le fichier

@@ -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.

#------------------------------------------------------------------------------
#

Chargement…
Annuler
Enregistrer