diff options
Diffstat (limited to 'server/sonar-web/src/main')
16 files changed, 591 insertions, 38 deletions
diff --git a/server/sonar-web/src/main/js/api/user-tokens.ts b/server/sonar-web/src/main/js/api/user-tokens.ts index a74c9187d4d..fd446a35684 100644 --- a/server/sonar-web/src/main/js/api/user-tokens.ts +++ b/server/sonar-web/src/main/js/api/user-tokens.ts @@ -20,29 +20,15 @@ import { getJSON, postJSON, post } from '../helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; -export interface UserToken { - name: string; - createdAt: string; -} - /** List tokens for given user login */ -export function getTokens(login: string): Promise<UserToken[]> { +export function getTokens(login: string): Promise<T.UserToken[]> { return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens, throwGlobalError); } -export interface NewToken { - createdAt: string; - login: string; - name: string; - token: string; -} - -/** Generate a user token */ -export function generateToken(data: { name: string; login?: string }): Promise<NewToken> { +export function generateToken(data: { name: string; login?: string }): Promise<T.NewUserToken> { return postJSON('/api/user_tokens/generate', data).catch(throwGlobalError); } -/** Revoke a user token */ -export function revokeToken(data: { name: string; login?: string }): Promise<void | Response> { +export function revokeToken(data: { name: string; login?: string }) { return post('/api/user_tokens/revoke', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts index 52e7ee1fd13..a568d2dbcc1 100644 --- a/server/sonar-web/src/main/js/app/types.d.ts +++ b/server/sonar-web/src/main/js/app/types.d.ts @@ -859,6 +859,7 @@ declare namespace T { externalIdentity?: string; externalProvider?: string; groups?: string[]; + lastConnectionDate?: string; local: boolean; login: string; name: string; @@ -866,6 +867,17 @@ declare namespace T { tokensCount?: number; } + export interface UserToken { + name: string; + createdAt: string; + lastConnectionDate?: string; + } + + export interface NewUserToken extends UserToken { + login: string; + token: string; + } + export type Visibility = 'public' | 'private'; export interface Webhook { diff --git a/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx b/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx index 0eb8a9714c6..6c92ab9c35b 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx @@ -22,10 +22,10 @@ import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router'; import * as classNames from 'classnames'; import Step from './Step'; -import { getTokens, generateToken, revokeToken, UserToken } from '../../../api/user-tokens'; import AlertErrorIcon from '../../../components/icons-components/AlertErrorIcon'; import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon'; import { DeleteButton, SubmitButton, Button } from '../../../components/ui/buttons'; +import { getTokens, generateToken, revokeToken } from '../../../api/user-tokens'; import { translate } from '../../../helpers/l10n'; interface Props { @@ -44,7 +44,7 @@ interface State { selection: string; tokenName?: string; token?: string; - tokens?: UserToken[]; + tokens?: T.UserToken[]; } export default class TokenStep extends React.PureComponent<Props, State> { @@ -85,7 +85,7 @@ export default class TokenStep extends React.PureComponent<Props, State> { getToken = () => this.state.selection === 'generate' ? this.state.token : this.state.existingToken; - getUniqueTokenName = (tokens: UserToken[]) => { + getUniqueTokenName = (tokens: T.UserToken[]) => { const { initialTokenName = '' } = this.props; const hasToken = (name: string) => tokens.find(token => token.name === name) !== undefined; diff --git a/server/sonar-web/src/main/js/apps/users/UsersList.tsx b/server/sonar-web/src/main/js/apps/users/UsersList.tsx index 8c50efcd063..3c437e1212a 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersList.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersList.tsx @@ -46,6 +46,7 @@ export default function UsersList({ <th /> <th className="nowrap" /> <th className="nowrap">{translate('my_profile.scm_accounts')}</th> + <th className="nowrap">{translate('users.last_connection')}</th> {!organizationsEnabled && <th className="nowrap">{translate('my_profile.groups')}</th>} <th className="nowrap">{translate('users.tokens')}</th> <th className="nowrap"> </th> diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap index b3a9562ccdd..f91c28ee600 100644 --- a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap @@ -22,6 +22,11 @@ exports[`should render correctly 1`] = ` <th className="nowrap" > + users.last_connection + </th> + <th + className="nowrap" + > users.tokens </th> <th diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx index fb4dcd102a5..c62c2f253de 100644 --- a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx @@ -20,9 +20,9 @@ import * as React from 'react'; import TokensFormItem from './TokensFormItem'; import TokensFormNewToken from './TokensFormNewToken'; -import { getTokens, generateToken, UserToken } from '../../../api/user-tokens'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import { SubmitButton } from '../../../components/ui/buttons'; +import { getTokens, generateToken } from '../../../api/user-tokens'; import { translate } from '../../../helpers/l10n'; interface Props { @@ -35,7 +35,7 @@ interface State { loading: boolean; newToken?: { name: string; token: string }; newTokenName: string; - tokens: UserToken[]; + tokens: T.UserToken[]; } export default class TokensForm extends React.PureComponent<Props, State> { @@ -103,7 +103,7 @@ export default class TokensForm extends React.PureComponent<Props, State> { } }; - handleRevokeToken = (revokedToken: UserToken) => { + handleRevokeToken = (revokedToken: T.UserToken) => { this.setState( state => ({ tokens: state.tokens.filter(token => token.name !== revokedToken.name) @@ -175,6 +175,7 @@ export default class TokensForm extends React.PureComponent<Props, State> { <thead> <tr> <th>{translate('name')}</th> + <th>{translate('my_account.tokens_last_usage')}</th> <th className="text-right">{translate('created')}</th> <th /> </tr> diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx index 2f39abec632..09673e108dc 100644 --- a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx @@ -18,18 +18,19 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { revokeToken, UserToken } from '../../../api/user-tokens'; +import DateFormatter from '../../../components/intl/DateFormatter'; +import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import Tooltip from '../../../components/controls/Tooltip'; -import DateFormatter from '../../../components/intl/DateFormatter'; import { Button } from '../../../components/ui/buttons'; -import { translate } from '../../../helpers/l10n'; import { limitComponentName } from '../../../helpers/path'; +import { revokeToken } from '../../../api/user-tokens'; +import { translate } from '../../../helpers/l10n'; interface Props { login: string; - onRevokeToken: (token: UserToken) => void; - token: UserToken; + onRevokeToken: (token: T.UserToken) => void; + token: T.UserToken; } interface State { @@ -75,12 +76,15 @@ export default class TokensFormItem extends React.PureComponent<Props, State> { <span>{limitComponentName(token.name)}</span> </Tooltip> </td> + <td className="nowrap"> + <DateFromNowHourPrecision date={token.lastConnectionDate} /> + </td> <td className="thin nowrap text-right"> <DateFormatter date={token.createdAt} long={true} /> </td> <td className="thin nowrap text-right"> <DeferredSpinner loading={loading}> - <i className="spinner-placeholder " /> + <i className="spinner-placeholder" /> </DeferredSpinner> <Button className="button-red input-small spacer-left" diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx index b4a45d85ba3..1c52666dd64 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx @@ -23,8 +23,9 @@ import UserActions from './UserActions'; import UserGroups from './UserGroups'; import UserListItemIdentity from './UserListItemIdentity'; import UserScmAccounts from './UserScmAccounts'; -import BulletListIcon from '../../../components/icons-components/BulletListIcon'; import Avatar from '../../../components/ui/Avatar'; +import BulletListIcon from '../../../components/icons-components/BulletListIcon'; +import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision'; import { ButtonIcon } from '../../../components/ui/buttons'; import { translate } from '../../../helpers/l10n'; @@ -59,6 +60,9 @@ export default class UserListItem extends React.PureComponent<Props, State> { <td> <UserScmAccounts scmAccounts={user.scmAccounts || []} /> </td> + <td> + <DateFromNowHourPrecision date={user.lastConnectionDate} /> + </td> {!organizationsEnabled && ( <td> <UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} /> diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx new file mode 100644 index 00000000000..71d9002127c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx @@ -0,0 +1,83 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import TokensForm from '../TokensForm'; +import { change, submit, waitAndUpdate } from '../../../../helpers/testUtils'; +import { generateToken, getTokens } from '../../../../api/user-tokens'; + +jest.mock('../../../../api/user-tokens', () => ({ + generateToken: jest.fn().mockResolvedValue({ + name: 'baz', + createdAt: '2019-01-21T08:06:00+0100', + login: 'luke', + token: 'token_value' + }), + getTokens: jest.fn().mockResolvedValue([ + { + name: 'foo', + createdAt: '2019-01-15T15:06:33+0100', + lastConnectionDate: '2019-01-18T15:06:33+0100' + }, + { name: 'bar', createdAt: '2019-01-18T15:06:33+0100' } + ]) +})); + +beforeEach(() => { + (generateToken as jest.Mock).mockClear(); + (getTokens as jest.Mock).mockClear(); +}); + +it('should render correctly', async () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); + expect(getTokens).toHaveBeenCalledWith('luke'); + + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); +}); + +it('should create new tokens', async () => { + const wrapper = shallowRender(); + + await waitAndUpdate(wrapper); + expect(wrapper.find('TokensFormItem')).toHaveLength(2); + change(wrapper.find('input'), 'baz'); + submit(wrapper.find('form')); + + await waitAndUpdate(wrapper); + expect(generateToken).toHaveBeenCalledWith({ name: 'baz', login: 'luke' }); + expect(wrapper.find('TokensFormItem')).toHaveLength(3); +}); + +it('should revoke tokens', async () => { + const updateTokensCount = jest.fn(); + const wrapper = shallowRender({ updateTokensCount }); + + await waitAndUpdate(wrapper); + expect(wrapper.find('TokensFormItem')).toHaveLength(2); + wrapper.instance().handleRevokeToken({ createdAt: '2019-01-15T15:06:33+0100', name: 'foo' }); + expect(updateTokensCount).toHaveBeenCalledWith('luke', 1); + expect(wrapper.find('TokensFormItem')).toHaveLength(1); +}); + +function shallowRender(props: Partial<TokensForm['props']> = {}) { + return shallow<TokensForm>(<TokensForm login="luke" updateTokensCount={jest.fn()} {...props} />); +} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx new file mode 100644 index 00000000000..1a65f7c4756 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx @@ -0,0 +1,65 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import TokensFormItem from '../TokensFormItem'; +import { revokeToken } from '../../../../api/user-tokens'; +import { click, waitAndUpdate } from '../../../../helpers/testUtils'; + +jest.mock('../../../../components/intl/DateFormatter'); +jest.mock('../../../../components/intl/DateFromNow'); +jest.mock('../../../../components/intl/DateTimeFormatter'); + +jest.mock('../../../../api/user-tokens', () => ({ + revokeToken: jest.fn().mockResolvedValue(undefined) +})); + +const userToken: T.UserToken = { + name: 'foo', + createdAt: '2019-01-15T15:06:33+0100', + lastConnectionDate: '2019-01-18T15:06:33+0100' +}; + +beforeEach(() => { + (revokeToken as jest.Mock).mockClear(); +}); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should revoke the token', async () => { + const onRevokeToken = jest.fn(); + const wrapper = shallowRender({ onRevokeToken }); + expect(wrapper.find('Button')).toMatchSnapshot(); + click(wrapper.find('Button')); + expect(wrapper.find('Button')).toMatchSnapshot(); + click(wrapper.find('Button')); + expect(wrapper.find('DeferredSpinner').prop('loading')).toBe(true); + await waitAndUpdate(wrapper); + expect(revokeToken).toHaveBeenCalledWith({ login: 'luke', name: 'foo' }); + expect(onRevokeToken).toHaveBeenCalledWith(userToken); +}); + +function shallowRender(props: Partial<TokensFormItem['props']> = {}) { + return shallow( + <TokensFormItem login="luke" onRevokeToken={jest.fn()} token={userToken} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx index 3b040e8a888..a6e895d5840 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx @@ -22,33 +22,41 @@ import { shallow } from 'enzyme'; import { click } from '../../../../helpers/testUtils'; import UserListItem from '../UserListItem'; -const user = { +jest.mock('../../../../components/intl/DateFromNow'); +jest.mock('../../../../components/intl/DateTimeFormatter'); + +const user: T.User = { + active: true, + lastConnectionDate: '2019-01-18T15:06:33+0100', + local: false, login: 'obi', name: 'One', - active: true, - scmAccounts: [], - local: false + scmAccounts: [] }; it('should render correctly', () => { - expect(getWrapper()).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should render correctly without last connection date', () => { + expect(shallowRender({})).toMatchSnapshot(); }); it('should display a change password button', () => { expect( - getWrapper({ organizationsEnabled: true }) + shallowRender({ organizationsEnabled: true }) .find('UserGroups') .exists() ).toBeFalsy(); }); it('should open the correct forms', () => { - const wrapper = getWrapper(); + const wrapper = shallowRender(); click(wrapper.find('.js-user-tokens')); expect(wrapper.find('TokensFormModal').exists()).toBeTruthy(); }); -function getWrapper(props = {}) { +function shallowRender(props: Partial<UserListItem['props']> = {}) { return shallow( <UserListItem isCurrentUser={false} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap new file mode 100644 index 00000000000..2a6ae4b6be1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<Fragment> + <h3 + className="spacer-bottom" + > + users.generate_tokens + </h3> + <form + autoComplete="off" + className="display-flex-center" + id="generate-token-form" + onSubmit={[Function]} + > + <input + className="spacer-right" + maxLength={100} + onChange={[Function]} + placeholder="users.enter_token_name" + required={true} + type="text" + value="" + /> + <SubmitButton + className="js-generate-token" + disabled={true} + > + users.generate + </SubmitButton> + </form> + <table + className="data zebra big-spacer-top " + > + <thead> + <tr> + <th> + name + </th> + <th> + my_account.tokens_last_usage + </th> + <th + className="text-right" + > + created + </th> + <th /> + </tr> + </thead> + <tbody> + <DeferredSpinner + customSpinner={ + <tr> + <td> + <i + className="spinner" + /> + </td> + </tr> + } + loading={true} + timeout={100} + > + <tr> + <td + className="note" + colSpan={3} + > + users.no_tokens + </td> + </tr> + </DeferredSpinner> + </tbody> + </table> +</Fragment> +`; + +exports[`should render correctly 2`] = ` +<Fragment> + <h3 + className="spacer-bottom" + > + users.generate_tokens + </h3> + <form + autoComplete="off" + className="display-flex-center" + id="generate-token-form" + onSubmit={[Function]} + > + <input + className="spacer-right" + maxLength={100} + onChange={[Function]} + placeholder="users.enter_token_name" + required={true} + type="text" + value="" + /> + <SubmitButton + className="js-generate-token" + disabled={true} + > + users.generate + </SubmitButton> + </form> + <table + className="data zebra big-spacer-top " + > + <thead> + <tr> + <th> + name + </th> + <th> + my_account.tokens_last_usage + </th> + <th + className="text-right" + > + created + </th> + <th /> + </tr> + </thead> + <tbody> + <DeferredSpinner + customSpinner={ + <tr> + <td> + <i + className="spinner" + /> + </td> + </tr> + } + loading={false} + timeout={100} + > + <TokensFormItem + key="foo" + login="luke" + onRevokeToken={[Function]} + token={ + Object { + "createdAt": "2019-01-15T15:06:33+0100", + "lastConnectionDate": "2019-01-18T15:06:33+0100", + "name": "foo", + } + } + /> + <TokensFormItem + key="bar" + login="luke" + onRevokeToken={[Function]} + token={ + Object { + "createdAt": "2019-01-18T15:06:33+0100", + "name": "bar", + } + } + /> + </DeferredSpinner> + </tbody> + </table> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap new file mode 100644 index 00000000000..499fed7e30f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<tr> + <td> + <Tooltip + overlay="foo" + > + <span> + foo + </span> + </Tooltip> + </td> + <td + className="nowrap" + > + <DateFromNowHourPrecision + date="2019-01-18T15:06:33+0100" + /> + </td> + <td + className="thin nowrap text-right" + > + <DateFormatter + date="2019-01-15T15:06:33+0100" + long={true} + /> + </td> + <td + className="thin nowrap text-right" + > + <DeferredSpinner + loading={false} + timeout={100} + > + <i + className="spinner-placeholder" + /> + </DeferredSpinner> + <Button + className="button-red input-small spacer-left" + disabled={false} + onClick={[Function]} + > + users.tokens.revoke + </Button> + </td> +</tr> +`; + +exports[`should revoke the token 1`] = ` +<Button + className="button-red input-small spacer-left" + disabled={false} + onClick={[Function]} +> + users.tokens.revoke +</Button> +`; + +exports[`should revoke the token 2`] = ` +<Button + className="button-red input-small spacer-left" + disabled={false} + onClick={[Function]} +> + users.tokens.sure +</Button> +`; diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap index d2c0e157903..8905a4b99af 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap @@ -14,6 +14,7 @@ exports[`should render correctly 1`] = ` user={ Object { "active": true, + "lastConnectionDate": "2019-01-18T15:06:33+0100", "local": false, "login": "obi", "name": "One", @@ -27,12 +28,96 @@ exports[`should render correctly 1`] = ` /> </td> <td> + <DateFromNowHourPrecision + date="2019-01-18T15:06:33+0100" + /> + </td> + <td> + <UserGroups + groups={Array []} + onUpdateUsers={[MockFunction]} + user={ + Object { + "active": true, + "lastConnectionDate": "2019-01-18T15:06:33+0100", + "local": false, + "login": "obi", + "name": "One", + "scmAccounts": Array [], + } + } + /> + </td> + <td> + <ButtonIcon + className="js-user-tokens spacer-left button-small" + onClick={[Function]} + tooltip="users.update_tokens" + > + <BulletListIcon /> + </ButtonIcon> + </td> + <td + className="thin nowrap text-right" + > + <UserActions + isCurrentUser={false} + onUpdateUsers={[MockFunction]} + user={ + Object { + "active": true, + "lastConnectionDate": "2019-01-18T15:06:33+0100", + "local": false, + "login": "obi", + "name": "One", + "scmAccounts": Array [], + } + } + /> + </td> +</tr> +`; + +exports[`should render correctly without last connection date 1`] = ` +<tr> + <td + className="thin nowrap" + > + <Connect(Avatar) + name="One" + size={36} + /> + </td> + <UserListItemIdentity + user={ + Object { + "active": true, + "lastConnectionDate": "2019-01-18T15:06:33+0100", + "local": false, + "login": "obi", + "name": "One", + "scmAccounts": Array [], + } + } + /> + <td> + <UserScmAccounts + scmAccounts={Array []} + /> + </td> + <td> + <DateFromNowHourPrecision + date="2019-01-18T15:06:33+0100" + /> + </td> + <td> <UserGroups groups={Array []} onUpdateUsers={[MockFunction]} user={ Object { "active": true, + "lastConnectionDate": "2019-01-18T15:06:33+0100", "local": false, "login": "obi", "name": "One", @@ -59,6 +144,7 @@ exports[`should render correctly 1`] = ` user={ Object { "active": true, + "lastConnectionDate": "2019-01-18T15:06:33+0100", "local": false, "login": "obi", "name": "One", diff --git a/server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx b/server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx new file mode 100644 index 00000000000..c8cad8cb0fe --- /dev/null +++ b/server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { DateSource } from 'react-intl'; +import DateFromNow from './DateFromNow'; +import DateTimeFormatter from './DateTimeFormatter'; +import Tooltip from '../controls/Tooltip'; +import { differenceInHours } from '../../helpers/dates'; +import { translate } from '../../helpers/l10n'; + +interface Props { + children?: (formattedDate: string) => React.ReactNode; + date?: DateSource; +} + +export default class DateFromNowHourPrecision extends React.PureComponent<Props> { + render() { + const { children, date } = this.props; + + let overrideDate: string | undefined; + if (!date) { + overrideDate = translate('never'); + } else if (differenceInHours(Date.now(), date) < 1) { + overrideDate = translate('less_than_1_hour_ago'); + } + + if (overrideDate) { + return children ? children(overrideDate) : overrideDate; + } + + return ( + <Tooltip overlay={<DateTimeFormatter date={date!} />}> + <span> + <DateFromNow date={date!}>{children}</DateFromNow> + </span> + </Tooltip> + ); + } +} diff --git a/server/sonar-web/src/main/js/helpers/dates.ts b/server/sonar-web/src/main/js/helpers/dates.ts index ee2a3cca7f9..45b3fc648f0 100644 --- a/server/sonar-web/src/main/js/helpers/dates.ts +++ b/server/sonar-web/src/main/js/helpers/dates.ts @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as _differenceInDays from 'date-fns/difference_in_days'; +import * as _differenceInHours from 'date-fns/difference_in_hours'; import * as _differenceInSeconds from 'date-fns/difference_in_seconds'; import * as _differenceInYears from 'date-fns/difference_in_years'; import * as _isSameDay from 'date-fns/is_same_day'; @@ -67,6 +68,10 @@ export function differenceInDays(dateLeft: ParsableDate, dateRight: ParsableDate return _differenceInDays(dateLeft, dateRight); } +export function differenceInHours(dateLeft: ParsableDate, dateRight: ParsableDate): number { + return _differenceInHours(dateLeft, dateRight); +} + export function differenceInSeconds(dateLeft: ParsableDate, dateRight: ParsableDate): number { return _differenceInSeconds(dateLeft, dateRight); } |