aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/apps/projectLinks
diff options
context:
space:
mode:
authorStas Vilchik <stas.vilchik@sonarsource.com>2018-08-22 13:33:15 +0200
committerSonarTech <sonartech@sonarsource.com>2018-08-22 20:21:22 +0200
commit4835b94eb4214cbd9be4b1e0c4c7209253c3f0fb (patch)
tree007eda8fdff522d37694f13e0e134a9fa69380c5 /server/sonar-web/src/main/js/apps/projectLinks
parentaf00018774baa18f3539560de991e747cba74848 (diff)
downloadsonarqube-4835b94eb4214cbd9be4b1e0c4c7209253c3f0fb.tar.gz
sonarqube-4835b94eb4214cbd9be4b1e0c4c7209253c3f0fb.zip
drop project links from redux store (#632)
Diffstat (limited to 'server/sonar-web/src/main/js/apps/projectLinks')
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/App.tsx104
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/CreationModal.tsx111
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/Header.tsx73
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/LinkRow.tsx102
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/Table.tsx65
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/App-test.tsx78
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/CreationModal-test.tsx37
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/Header-test.tsx41
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/LinkRow-test.tsx44
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/Table-test.tsx36
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/App-test.tsx.snap121
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/CreationModal-test.tsx.snap92
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Header-test.tsx.snap30
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/LinkRow-test.tsx.snap99
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Table-test.tsx.snap88
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/__tests__/utils-test.ts40
-rw-r--r--server/sonar-web/src/main/js/apps/projectLinks/utils.ts41
17 files changed, 1202 insertions, 0 deletions
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/App.tsx b/server/sonar-web/src/main/js/apps/projectLinks/App.tsx
new file mode 100644
index 00000000000..5e9d9a2b94e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/App.tsx
@@ -0,0 +1,104 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 Helmet from 'react-helmet';
+import Header from './Header';
+import Table from './Table';
+import { getProjectLinks, createLink, deleteLink } from '../../api/projectLinks';
+import { ProjectLink, Component } from '../../app/types';
+import { translate } from '../../helpers/l10n';
+import DeferredSpinner from '../../components/common/DeferredSpinner';
+
+interface Props {
+ component: Pick<Component, 'key'>;
+}
+
+interface State {
+ links?: ProjectLink[];
+ loading: boolean;
+}
+
+export default class App extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = { loading: true };
+
+ componentDidMount() {
+ this.mounted = true;
+ this.fetchLinks();
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.component.key !== this.props.component.key) {
+ this.fetchLinks();
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ fetchLinks = () => {
+ this.setState({ loading: true });
+ getProjectLinks(this.props.component.key).then(
+ links => {
+ if (this.mounted) {
+ this.setState({ links, loading: false });
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ }
+ );
+ };
+
+ handleCreateLink = (name: string, url: string) => {
+ return createLink({ name, projectKey: this.props.component.key, url }).then(link => {
+ if (this.mounted) {
+ this.setState(({ links = [] }) => ({
+ links: [...links, link]
+ }));
+ }
+ });
+ };
+
+ handleDeleteLink = (linkId: string) => {
+ return deleteLink(linkId).then(() => {
+ if (this.mounted) {
+ this.setState(({ links = [] }) => ({
+ links: links.filter(link => link.id !== linkId)
+ }));
+ }
+ });
+ };
+
+ render() {
+ return (
+ <div className="page page-limited">
+ <Helmet title={translate('project_links.page')} />
+ <Header onCreate={this.handleCreateLink} />
+ <DeferredSpinner loading={this.state.loading}>
+ {this.state.links && <Table links={this.state.links} onDelete={this.handleDeleteLink} />}
+ </DeferredSpinner>
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/CreationModal.tsx b/server/sonar-web/src/main/js/apps/projectLinks/CreationModal.tsx
new file mode 100644
index 00000000000..81a1361725a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/CreationModal.tsx
@@ -0,0 +1,111 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 DeferredSpinner from '../../components/common/DeferredSpinner';
+import SimpleModal from '../../components/controls/SimpleModal';
+import { SubmitButton, ResetButtonLink } from '../../components/ui/buttons';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+ onClose: () => void;
+ onSubmit: (name: string, url: string) => Promise<void>;
+}
+
+interface State {
+ name: string;
+ url: string;
+}
+
+export default class CreationModal extends React.PureComponent<Props, State> {
+ state: State = { name: '', url: '' };
+
+ handleSubmit = () => {
+ return this.props.onSubmit(this.state.name, this.state.url).then(this.props.onClose);
+ };
+
+ handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ this.setState({ name: event.currentTarget.value });
+ };
+
+ handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ this.setState({ url: event.currentTarget.value });
+ };
+
+ render() {
+ const header = translate('project_links.create_new_project_link');
+
+ return (
+ <SimpleModal header={header} onClose={this.props.onClose} onSubmit={this.handleSubmit}>
+ {({ onCloseClick, onFormSubmit, submitting }) => (
+ <form onSubmit={onFormSubmit}>
+ <header className="modal-head">
+ <h2>{header}</h2>
+ </header>
+
+ <div className="modal-body">
+ <div className="modal-field">
+ <label htmlFor="create-link-name">
+ {translate('project_links.name')}
+ <em className="mandatory">*</em>
+ </label>
+ <input
+ autoFocus={true}
+ id="create-link-name"
+ maxLength={128}
+ name="name"
+ onChange={this.handleNameChange}
+ required={true}
+ type="text"
+ value={this.state.name}
+ />
+ </div>
+
+ <div className="modal-field">
+ <label htmlFor="create-link-url">
+ {translate('project_links.url')}
+ <em className="mandatory">*</em>
+ </label>
+ <input
+ id="create-link-url"
+ maxLength={128}
+ name="url"
+ onChange={this.handleUrlChange}
+ required={true}
+ type="text"
+ value={this.state.url}
+ />
+ </div>
+ </div>
+
+ <footer className="modal-foot">
+ <DeferredSpinner className="spacer-right" loading={submitting} />
+ <SubmitButton disabled={submitting} id="create-link-confirm">
+ {translate('create')}
+ </SubmitButton>
+ <ResetButtonLink disabled={submitting} onClick={onCloseClick}>
+ {translate('cancel')}
+ </ResetButtonLink>
+ </footer>
+ </form>
+ )}
+ </SimpleModal>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/Header.tsx b/server/sonar-web/src/main/js/apps/projectLinks/Header.tsx
new file mode 100644
index 00000000000..9cc71970886
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/Header.tsx
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 CreationModal from './CreationModal';
+import { Button } from '../../components/ui/buttons';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+ onCreate: (name: string, url: string) => Promise<void>;
+}
+
+interface State {
+ creationModal: boolean;
+}
+
+export default class Header extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = { creationModal: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleCreateClick = () => {
+ this.setState({ creationModal: true });
+ };
+
+ handleCreationModalClose = () => {
+ if (this.mounted) {
+ this.setState({ creationModal: false });
+ }
+ };
+
+ render() {
+ return (
+ <>
+ <header className="page-header">
+ <h1 className="page-title">{translate('project_links.page')}</h1>
+ <div className="page-actions">
+ <Button id="create-project-link" onClick={this.handleCreateClick}>
+ {translate('create')}
+ </Button>
+ </div>
+ <div className="page-description">{translate('project_links.page.description')}</div>
+ </header>
+ {this.state.creationModal && (
+ <CreationModal onClose={this.handleCreationModalClose} onSubmit={this.props.onCreate} />
+ )}
+ </>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/LinkRow.tsx b/server/sonar-web/src/main/js/apps/projectLinks/LinkRow.tsx
new file mode 100644
index 00000000000..7d2630cba14
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/LinkRow.tsx
@@ -0,0 +1,102 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { isProvided, getLinkName } from './utils';
+import { ProjectLink } from '../../app/types';
+import ConfirmButton from '../../components/controls/ConfirmButton';
+import ProjectLinkIcon from '../../components/icons-components/ProjectLinkIcon';
+import { Button } from '../../components/ui/buttons';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+
+interface Props {
+ link: ProjectLink;
+ onDelete: (linkId: string) => Promise<void>;
+}
+
+export default class LinkRow extends React.PureComponent<Props> {
+ renderNameForProvided = (link: ProjectLink) => {
+ return (
+ <div className="display-inline-block text-top">
+ <div>
+ <span className="js-name">{getLinkName(link)}</span>
+ </div>
+ <div className="note little-spacer-top">
+ <span className="js-type">{`sonar.links.${link.type}`}</span>
+ </div>
+ </div>
+ );
+ };
+
+ renderName = (link: ProjectLink) => {
+ return (
+ <div>
+ <ProjectLinkIcon className="little-spacer-right" type={link.type} />
+ {isProvided(link) ? (
+ this.renderNameForProvided(link)
+ ) : (
+ <div className="display-inline-block text-top">
+ <span className="js-name">{link.name}</span>
+ </div>
+ )}
+ </div>
+ );
+ };
+
+ renderDeleteButton = (link: ProjectLink) => {
+ if (isProvided(link)) {
+ return null;
+ }
+
+ return (
+ <ConfirmButton
+ confirmButtonText={translate('delete')}
+ confirmData={link.id}
+ isDestructive={true}
+ modalBody={translateWithParameters(
+ 'project_links.are_you_sure_to_delete_x_link',
+ link.name!
+ )}
+ modalHeader={translate('project_links.delete_project_link')}
+ onConfirm={this.props.onDelete}>
+ {({ onClick }) => (
+ <Button className="button-red js-delete-button" onClick={onClick}>
+ {translate('delete')}
+ </Button>
+ )}
+ </ConfirmButton>
+ );
+ };
+
+ render() {
+ const { link } = this.props;
+
+ return (
+ <tr data-name={link.name}>
+ <td className="nowrap">{this.renderName(link)}</td>
+ <td className="nowrap js-url">
+ <a href={link.url} rel="nofollow" target="_blank">
+ {link.url}
+ </a>
+ </td>
+ <td className="thin nowrap">{this.renderDeleteButton(link)}</td>
+ </tr>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/Table.tsx b/server/sonar-web/src/main/js/apps/projectLinks/Table.tsx
new file mode 100644
index 00000000000..41a21af6d92
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/Table.tsx
@@ -0,0 +1,65 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 LinkRow from './LinkRow';
+import { orderLinks } from './utils';
+import { ProjectLink } from '../../app/types';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+ links: ProjectLink[];
+ onDelete: (linkId: string) => Promise<void>;
+}
+
+export default class Table extends React.PureComponent<Props> {
+ renderHeader() {
+ // keep empty cell for actions
+ return (
+ <thead>
+ <tr>
+ <th className="nowrap">{translate('project_links.name')}</th>
+ <th className="nowrap width-100">{translate('project_links.url')}</th>
+ <th className="thin">&nbsp;</th>
+ </tr>
+ </thead>
+ );
+ }
+
+ render() {
+ if (!this.props.links.length) {
+ return <div className="note">{translate('no_results')}</div>;
+ }
+
+ const orderedLinks = orderLinks(this.props.links);
+
+ const linkRows = orderedLinks.map(link => (
+ <LinkRow key={link.id} link={link} onDelete={this.props.onDelete} />
+ ));
+
+ return (
+ <div className="boxed-group boxed-group-inner">
+ <table className="data zebra" id="project-links">
+ {this.renderHeader()}
+ <tbody>{linkRows}</tbody>
+ </table>
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/App-test.tsx
new file mode 100644
index 00000000000..c6fcd752a5f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/App-test.tsx
@@ -0,0 +1,78 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 App from '../App';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+import { createLink, deleteLink, getProjectLinks } from '../../../api/projectLinks';
+
+// import { getProjectLinks, createLink, deleteLink } from '../../api/projectLinks';
+jest.mock('../../../api/projectLinks', () => ({
+ getProjectLinks: jest
+ .fn()
+ .mockResolvedValue([
+ { id: '1', type: 'homepage', url: 'http://example.com' },
+ { id: '2', name: 'foo', type: 'foo', url: 'http://example.com/foo' }
+ ]),
+ createLink: jest
+ .fn()
+ .mockResolvedValue({ id: '3', name: 'bar', type: 'bar', url: 'http://example.com/bar' }),
+ deleteLink: jest.fn().mockResolvedValue(undefined)
+}));
+
+it('should fetch links and render', async () => {
+ const wrapper = shallow(<App component={{ key: 'comp' }} />);
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+ expect(getProjectLinks).toBeCalledWith('comp');
+});
+
+it('should fetch links when component changes', async () => {
+ const wrapper = shallow(<App component={{ key: 'comp' }} />);
+ await waitAndUpdate(wrapper);
+ expect(getProjectLinks).lastCalledWith('comp');
+
+ wrapper.setProps({ component: { key: 'another' } });
+ expect(getProjectLinks).lastCalledWith('another');
+});
+
+it('should create link', async () => {
+ const wrapper = shallow(<App component={{ key: 'comp' }} />);
+ await waitAndUpdate(wrapper);
+
+ wrapper.find('Header').prop<Function>('onCreate')('bar', 'http://example.com/bar');
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+ expect(createLink).toBeCalledWith({
+ name: 'bar',
+ projectKey: 'comp',
+ url: 'http://example.com/bar'
+ });
+});
+
+it('should delete link', async () => {
+ const wrapper = shallow(<App component={{ key: 'comp' }} />);
+ await waitAndUpdate(wrapper);
+
+ wrapper.find('Table').prop<Function>('onDelete')('foo');
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+ expect(deleteLink).toBeCalledWith('foo');
+});
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/CreationModal-test.tsx b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/CreationModal-test.tsx
new file mode 100644
index 00000000000..99160f81695
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/CreationModal-test.tsx
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 CreationModal from '../CreationModal';
+import { change, submit } from '../../../helpers/testUtils';
+
+it('should create link', () => {
+ const onClose = jest.fn();
+ const onSubmit = jest.fn().mockResolvedValue(undefined);
+ const wrapper = shallow(<CreationModal onClose={onClose} onSubmit={onSubmit} />);
+ const form = wrapper.dive();
+
+ change(form.find('#create-link-name'), 'foo');
+ change(form.find('#create-link-url'), 'http://example.com/foo');
+ expect(form).toMatchSnapshot();
+
+ submit(wrapper);
+ expect(onSubmit).toBeCalledWith('foo', 'http://example.com/foo');
+});
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/Header-test.tsx
new file mode 100644
index 00000000000..7b798e15084
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/Header-test.tsx
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 Header from '../Header';
+import { click } from '../../../helpers/testUtils';
+
+it('should render', () => {
+ expect(shallow(<Header onCreate={jest.fn()} />)).toMatchSnapshot();
+});
+
+it('should open creation modal', () => {
+ const onCreate = jest.fn();
+ const wrapper = shallow(<Header onCreate={onCreate} />);
+ click(wrapper.find('Button'));
+ expect(wrapper.find('CreationModal').exists()).toBe(true);
+
+ wrapper.find('CreationModal').prop<Function>('onSubmit')('foo', 'http://example.com/foo');
+ expect(onCreate).toBeCalledWith('foo', 'http://example.com/foo');
+
+ wrapper.find('CreationModal').prop<Function>('onClose')();
+ wrapper.update();
+ expect(wrapper.find('CreationModal').exists()).toBe(false);
+});
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/LinkRow-test.tsx b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/LinkRow-test.tsx
new file mode 100644
index 00000000000..19cff23dd62
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/LinkRow-test.tsx
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 LinkRow from '../LinkRow';
+
+it('should render provided link', () => {
+ expect(
+ shallow(
+ <LinkRow
+ link={{ id: '12', type: 'homepage', url: 'http://example.com' }}
+ onDelete={jest.fn()}
+ />
+ )
+ ).toMatchSnapshot();
+});
+
+it('should render custom link', () => {
+ expect(
+ shallow(
+ <LinkRow
+ link={{ id: '12', name: 'foo', type: 'foo', url: 'http://example.com' }}
+ onDelete={jest.fn()}
+ />
+ )
+ ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/Table-test.tsx b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/Table-test.tsx
new file mode 100644
index 00000000000..aeddcd78524
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/Table-test.tsx
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 Table from '../Table';
+
+it('should render', () => {
+ const links = [
+ { id: '1', type: 'homepage', url: 'http://example.com/homepage' },
+ { id: '2', type: 'issue', url: 'http://example.com/issue' },
+ { id: '3', name: 'foo', type: 'foo', url: 'http://example.com/foo' },
+ { id: '4', name: 'bar', type: 'bar', url: 'http://example.com/bar' }
+ ];
+ expect(shallow(<Table links={links} onDelete={jest.fn()} />)).toMatchSnapshot();
+});
+
+it('should render empty', () => {
+ expect(shallow(<Table links={[]} onDelete={jest.fn()} />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/App-test.tsx.snap
new file mode 100644
index 00000000000..b817f3fbcfd
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/App-test.tsx.snap
@@ -0,0 +1,121 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should create link 1`] = `
+<div
+ className="page page-limited"
+>
+ <HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="project_links.page"
+ />
+ <Header
+ onCreate={[Function]}
+ />
+ <DeferredSpinner
+ loading={false}
+ timeout={100}
+ >
+ <Table
+ links={
+ Array [
+ Object {
+ "id": "1",
+ "type": "homepage",
+ "url": "http://example.com",
+ },
+ Object {
+ "id": "2",
+ "name": "foo",
+ "type": "foo",
+ "url": "http://example.com/foo",
+ },
+ Object {
+ "id": "3",
+ "name": "bar",
+ "type": "bar",
+ "url": "http://example.com/bar",
+ },
+ ]
+ }
+ onDelete={[Function]}
+ />
+ </DeferredSpinner>
+</div>
+`;
+
+exports[`should delete link 1`] = `
+<div
+ className="page page-limited"
+>
+ <HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="project_links.page"
+ />
+ <Header
+ onCreate={[Function]}
+ />
+ <DeferredSpinner
+ loading={false}
+ timeout={100}
+ >
+ <Table
+ links={
+ Array [
+ Object {
+ "id": "1",
+ "type": "homepage",
+ "url": "http://example.com",
+ },
+ Object {
+ "id": "2",
+ "name": "foo",
+ "type": "foo",
+ "url": "http://example.com/foo",
+ },
+ ]
+ }
+ onDelete={[Function]}
+ />
+ </DeferredSpinner>
+</div>
+`;
+
+exports[`should fetch links and render 1`] = `
+<div
+ className="page page-limited"
+>
+ <HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="project_links.page"
+ />
+ <Header
+ onCreate={[Function]}
+ />
+ <DeferredSpinner
+ loading={false}
+ timeout={100}
+ >
+ <Table
+ links={
+ Array [
+ Object {
+ "id": "1",
+ "type": "homepage",
+ "url": "http://example.com",
+ },
+ Object {
+ "id": "2",
+ "name": "foo",
+ "type": "foo",
+ "url": "http://example.com/foo",
+ },
+ ]
+ }
+ onDelete={[Function]}
+ />
+ </DeferredSpinner>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/CreationModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/CreationModal-test.tsx.snap
new file mode 100644
index 00000000000..b56b44cef77
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/CreationModal-test.tsx.snap
@@ -0,0 +1,92 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should create link 1`] = `
+<Modal
+ contentLabel="project_links.create_new_project_link"
+ onRequestClose={[MockFunction]}
+>
+ <form
+ onSubmit={[Function]}
+ >
+ <header
+ className="modal-head"
+ >
+ <h2>
+ project_links.create_new_project_link
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-link-name"
+ >
+ project_links.name
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ autoFocus={true}
+ id="create-link-name"
+ maxLength={128}
+ name="name"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value=""
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-link-url"
+ >
+ project_links.url
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ id="create-link-url"
+ maxLength={128}
+ name="url"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value=""
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <DeferredSpinner
+ className="spacer-right"
+ loading={false}
+ timeout={100}
+ />
+ <SubmitButton
+ disabled={false}
+ id="create-link-confirm"
+ >
+ create
+ </SubmitButton>
+ <ResetButtonLink
+ disabled={false}
+ onClick={[Function]}
+ >
+ cancel
+ </ResetButtonLink>
+ </footer>
+ </form>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Header-test.tsx.snap
new file mode 100644
index 00000000000..073d2a845a1
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Header-test.tsx.snap
@@ -0,0 +1,30 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<React.Fragment>
+ <header
+ className="page-header"
+ >
+ <h1
+ className="page-title"
+ >
+ project_links.page
+ </h1>
+ <div
+ className="page-actions"
+ >
+ <Button
+ id="create-project-link"
+ onClick={[Function]}
+ >
+ create
+ </Button>
+ </div>
+ <div
+ className="page-description"
+ >
+ project_links.page.description
+ </div>
+ </header>
+</React.Fragment>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/LinkRow-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/LinkRow-test.tsx.snap
new file mode 100644
index 00000000000..cea1ad2384f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/LinkRow-test.tsx.snap
@@ -0,0 +1,99 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render custom link 1`] = `
+<tr
+ data-name="foo"
+>
+ <td
+ className="nowrap"
+ >
+ <div>
+ <ProjectLinkIcon
+ className="little-spacer-right"
+ type="foo"
+ />
+ <div
+ className="display-inline-block text-top"
+ >
+ <span
+ className="js-name"
+ >
+ foo
+ </span>
+ </div>
+ </div>
+ </td>
+ <td
+ className="nowrap js-url"
+ >
+ <a
+ href="http://example.com"
+ rel="nofollow"
+ target="_blank"
+ >
+ http://example.com
+ </a>
+ </td>
+ <td
+ className="thin nowrap"
+ >
+ <ConfirmButton
+ confirmButtonText="delete"
+ confirmData="12"
+ isDestructive={true}
+ modalBody="project_links.are_you_sure_to_delete_x_link.foo"
+ modalHeader="project_links.delete_project_link"
+ onConfirm={[MockFunction]}
+ />
+ </td>
+</tr>
+`;
+
+exports[`should render provided link 1`] = `
+<tr>
+ <td
+ className="nowrap"
+ >
+ <div>
+ <ProjectLinkIcon
+ className="little-spacer-right"
+ type="homepage"
+ />
+ <div
+ className="display-inline-block text-top"
+ >
+ <div>
+ <span
+ className="js-name"
+ >
+ project_links.homepage
+ </span>
+ </div>
+ <div
+ className="note little-spacer-top"
+ >
+ <span
+ className="js-type"
+ >
+ sonar.links.homepage
+ </span>
+ </div>
+ </div>
+ </div>
+ </td>
+ <td
+ className="nowrap js-url"
+ >
+ <a
+ href="http://example.com"
+ rel="nofollow"
+ target="_blank"
+ >
+ http://example.com
+ </a>
+ </td>
+ <td
+ className="thin nowrap"
+ />
+</tr>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Table-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Table-test.tsx.snap
new file mode 100644
index 00000000000..c043f6a5f56
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/__snapshots__/Table-test.tsx.snap
@@ -0,0 +1,88 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<div
+ className="boxed-group boxed-group-inner"
+>
+ <table
+ className="data zebra"
+ id="project-links"
+ >
+ <thead>
+ <tr>
+ <th
+ className="nowrap"
+ >
+ project_links.name
+ </th>
+ <th
+ className="nowrap width-100"
+ >
+ project_links.url
+ </th>
+ <th
+ className="thin"
+ >
+  
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <LinkRow
+ key="1"
+ link={
+ Object {
+ "id": "1",
+ "type": "homepage",
+ "url": "http://example.com/homepage",
+ }
+ }
+ onDelete={[MockFunction]}
+ />
+ <LinkRow
+ key="2"
+ link={
+ Object {
+ "id": "2",
+ "type": "issue",
+ "url": "http://example.com/issue",
+ }
+ }
+ onDelete={[MockFunction]}
+ />
+ <LinkRow
+ key="4"
+ link={
+ Object {
+ "id": "4",
+ "name": "bar",
+ "type": "bar",
+ "url": "http://example.com/bar",
+ }
+ }
+ onDelete={[MockFunction]}
+ />
+ <LinkRow
+ key="3"
+ link={
+ Object {
+ "id": "3",
+ "name": "foo",
+ "type": "foo",
+ "url": "http://example.com/foo",
+ }
+ }
+ onDelete={[MockFunction]}
+ />
+ </tbody>
+ </table>
+</div>
+`;
+
+exports[`should render empty 1`] = `
+<div
+ className="note"
+>
+ no_results
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/utils-test.ts
new file mode 100644
index 00000000000..882d0064c16
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/__tests__/utils-test.ts
@@ -0,0 +1,40 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 utils from '../utils';
+
+it('#isProvided', () => {
+ expect(utils.isProvided({ type: 'homepage' })).toBe(true);
+ expect(utils.isProvided({ type: 'custom' })).toBe(false);
+});
+
+it('#orderLinks', () => {
+ const homepage = { type: 'homepage' };
+ const issues = { type: 'issue' };
+ const foo = { name: 'foo', type: 'foo' };
+ const bar = { name: 'bar', type: 'bar' };
+ expect(utils.orderLinks([foo, homepage, issues, bar])).toEqual([homepage, issues, bar, foo]);
+ expect(utils.orderLinks([foo, bar])).toEqual([bar, foo]);
+ expect(utils.orderLinks([issues, homepage])).toEqual([homepage, issues]);
+});
+
+it('#getLinkName', () => {
+ expect(utils.getLinkName({ type: 'homepage' })).toBe('project_links.homepage');
+ expect(utils.getLinkName({ name: 'foo', type: 'custom' })).toBe('foo');
+});
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/utils.ts b/server/sonar-web/src/main/js/apps/projectLinks/utils.ts
new file mode 100644
index 00000000000..2bb1bd70c25
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectLinks/utils.ts
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { partition, sortBy } from 'lodash';
+import { ProjectLink } from '../../app/types';
+import { translate } from '../../helpers/l10n';
+
+const PROVIDED_TYPES = ['homepage', 'ci', 'issue', 'scm', 'scm_dev'];
+type NameAndType = Pick<ProjectLink, 'name' | 'type'>;
+
+export function isProvided(link: Pick<ProjectLink, 'type'>) {
+ return PROVIDED_TYPES.includes(link.type);
+}
+
+export function orderLinks<T extends NameAndType>(links: T[]) {
+ const [provided, unknown] = partition<T>(links, isProvided);
+ return [
+ ...sortBy(provided, link => PROVIDED_TYPES.indexOf(link.type)),
+ ...sortBy(unknown, link => link.name!.toLowerCase())
+ ];
+}
+
+export function getLinkName(link: NameAndType) {
+ return isProvided(link) ? translate('project_links', link.type) : link.name;
+}