]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7920 Rewrite Links project page (#1127)
authorStas Vilchik <vilchiks@gmail.com>
Fri, 29 Jul 2016 14:08:36 +0000 (16:08 +0200)
committerGitHub <noreply@github.com>
Fri, 29 Jul 2016 14:08:36 +0000 (16:08 +0200)
24 files changed:
it/it-tests/src/test/java/it/Category1Suite.java
it/it-tests/src/test/java/it/projectAdministration/ProjectLinksPageTest.java [new file with mode: 0644]
it/it-tests/src/test/java/pageobjects/LoginPage.java
it/it-tests/src/test/java/pageobjects/Navigation.java
it/it-tests/src/test/java/pageobjects/ProjectLinkItem.java [new file with mode: 0644]
it/it-tests/src/test/java/pageobjects/ProjectLinksPage.java [new file with mode: 0644]
server/sonar-web/src/main/js/api/projectLinks.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/app.js
server/sonar-web/src/main/js/apps/project-admin/links/Header.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/LinkRow.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/Links.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/Table.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/utils.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/views/CreationModal.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/views/CreationModalTemplate.hbs [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/views/DeletionModal.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/links/views/DeletionModalTemplate.hbs [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/store/actions.js
server/sonar-web/src/main/js/apps/project-admin/store/links.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/store/linksByProject.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/project-admin/store/rootReducer.js
server/sonar-web/src/main/webapp/WEB-INF/app/controllers/project_controller.rb
server/sonar-web/src/main/webapp/WEB-INF/app/views/project/links.html.erb
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 65ff384d431bce8a257382c2c6965a1ca867144e..b613cc3ec2b85dd4b2ce71a0f6e33b8e74ecfe77 100644 (file)
@@ -43,6 +43,7 @@ import it.measureHistory.TimeMachineTest;
 import it.projectAdministration.BackgroundTasksTest;
 import it.projectAdministration.BulkDeletionTest;
 import it.projectAdministration.ProjectAdministrationTest;
+import it.projectAdministration.ProjectLinksPageTest;
 import it.qualityGate.QualityGateNotificationTest;
 import it.qualityGate.QualityGateTest;
 import it.qualityGate.QualityGateUiTest;
@@ -66,6 +67,7 @@ import static util.ItUtils.xooPlugin;
   // project administration
   BulkDeletionTest.class,
   ProjectAdministrationTest.class,
+  ProjectLinksPageTest.class,
   BackgroundTasksTest.class,
   // settings
   PropertySetsTest.class,
diff --git a/it/it-tests/src/test/java/it/projectAdministration/ProjectLinksPageTest.java b/it/it-tests/src/test/java/it/projectAdministration/ProjectLinksPageTest.java
new file mode 100644 (file)
index 0000000..6faea27
--- /dev/null
@@ -0,0 +1,153 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+package it.projectAdministration;
+
+import com.codeborne.selenide.Condition;
+import com.sonar.orchestrator.Orchestrator;
+import com.sonar.orchestrator.build.SonarScanner;
+import it.Category1Suite;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonarqube.ws.WsProjectLinks.CreateWsResponse;
+import org.sonarqube.ws.client.WsClient;
+import org.sonarqube.ws.client.projectlinks.CreateWsRequest;
+import org.sonarqube.ws.client.projectlinks.DeleteWsRequest;
+import pageobjects.Navigation;
+import pageobjects.ProjectLinkItem;
+import pageobjects.ProjectLinksPage;
+
+import static com.codeborne.selenide.Condition.hasText;
+import static com.codeborne.selenide.Selenide.$;
+import static util.ItUtils.newAdminWsClient;
+import static util.ItUtils.projectDir;
+
+public class ProjectLinksPageTest {
+
+  @ClassRule
+  public static Orchestrator ORCHESTRATOR = Category1Suite.ORCHESTRATOR;
+
+  @Rule
+  public Navigation nav = Navigation.get(ORCHESTRATOR);
+
+  private static WsClient wsClient;
+  private long customLinkId;
+
+  @BeforeClass
+  public static void setUp() {
+    wsClient = newAdminWsClient(ORCHESTRATOR);
+
+    ORCHESTRATOR.resetData();
+    ORCHESTRATOR.executeBuild(
+      SonarScanner.create(projectDir("shared/xoo-sample"))
+        .setProperty("sonar.links.homepage", "http://example.com"));
+  }
+
+  @Before
+  public void prepare() {
+    customLinkId = Long.parseLong(createCustomLink().getLink().getId());
+  }
+
+  @After
+  public void clean() {
+    deleteLink(customLinkId);
+  }
+
+  @Test
+  public void should_list_links() {
+    ProjectLinksPage page = openPage();
+
+    page.getLinks().shouldHaveSize(2);
+
+    List<ProjectLinkItem> links = page.getLinksAsItems();
+    ProjectLinkItem homepageLink = links.get(0);
+    ProjectLinkItem customLink = links.get(1);
+
+    homepageLink.getName().should(hasText("Home"));
+    homepageLink.getType().should(hasText("sonar.links.homepage"));
+    homepageLink.getUrl().should(hasText("http://example.com"));
+    homepageLink.getDeleteButton().shouldNot(Condition.present);
+
+    customLink.getName().should(hasText("Custom"));
+    customLink.getType().shouldNot(Condition.present);
+    customLink.getUrl().should(hasText("http://example.org/custom"));
+    customLink.getDeleteButton().shouldBe(Condition.visible);
+  }
+
+  @Test
+  public void should_create_link() {
+    ProjectLinksPage page = openPage();
+
+    page.getLinks().shouldHaveSize(2);
+
+    $("#create-project-link").click();
+    $("#create-link-name").setValue("Test");
+    $("#create-link-url").setValue("http://example.com/test");
+    $("#create-link-confirm").click();
+
+    page.getLinks().shouldHaveSize(3);
+
+    ProjectLinkItem testLink = page.getLinksAsItems().get(2);
+
+    testLink.getName().should(hasText("Test"));
+    testLink.getType().shouldNot(Condition.present);
+    testLink.getUrl().should(hasText("http://example.com/test"));
+    testLink.getDeleteButton().shouldBe(Condition.visible);
+  }
+
+  @Test
+  public void should_delete_link() {
+    ProjectLinksPage page = openPage();
+
+    page.getLinks().shouldHaveSize(2);
+
+    List<ProjectLinkItem> links = page.getLinksAsItems();
+    ProjectLinkItem customLink = links.get(1);
+
+    customLink.getDeleteButton().click();
+    $("#delete-link-confirm").click();
+
+    page.getLinks().shouldHaveSize(1);
+  }
+
+  private CreateWsResponse createCustomLink() {
+    return wsClient.projectLinks().create(new CreateWsRequest()
+      .setProjectKey("sample")
+      .setName("Custom")
+      .setUrl("http://example.org/custom"));
+  }
+
+  private void deleteLink(long id) {
+    try {
+      wsClient.projectLinks().delete(new DeleteWsRequest().setId(id));
+    } catch (Exception e) {
+      // fail silently
+    }
+  }
+
+  private ProjectLinksPage openPage() {
+    nav.logIn().submitCredentials("admin", "admin");
+    return nav.openProjectLinks("sample");
+  }
+}
index bf946b27afabcbe08a12b574ba2bee93441fdc88..2aea164f0cb451848b26489926290de3bcbbdb2f 100644 (file)
@@ -37,7 +37,10 @@ public class LoginPage {
   }
 
   public LoginPage submitWrongCredentials(String login, String password) {
-    return submitCredentials(login, password, LoginPage.class);
+    $("#login").val(login);
+    $("#password").val(password);
+    $(By.name("commit")).click();
+    return page(LoginPage.class);
   }
 
   public SelenideElement getErrorMessage() {
@@ -48,6 +51,7 @@ public class LoginPage {
     $("#login").val(login);
     $("#password").val(password);
     $(By.name("commit")).click();
+    $("#login").should(Condition.disappear);
     return page(expectedResultPage);
   }
 }
index 395509c5fad08b4d4f020f857100d4631961e056..46ee57ca42586b267dd8d5a9b11e6207465be30a 100644 (file)
@@ -50,6 +50,12 @@ public class Navigation extends ExternalResource {
     return open("/", Navigation.class);
   }
 
+  public ProjectLinksPage openProjectLinks(String projectKey) {
+    // TODO encode projectKey
+    String url = "/project/links?id=" + projectKey;
+    return open(url, ProjectLinksPage.class);
+  }
+
   public void open(String relativeUrl) {
     Selenide.open(relativeUrl);
   }
diff --git a/it/it-tests/src/test/java/pageobjects/ProjectLinkItem.java b/it/it-tests/src/test/java/pageobjects/ProjectLinkItem.java
new file mode 100644 (file)
index 0000000..cf54620
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+package pageobjects;
+
+import com.codeborne.selenide.SelenideElement;
+import org.openqa.selenium.NoSuchElementException;
+
+public class ProjectLinkItem {
+
+  private final SelenideElement elt;
+
+  public ProjectLinkItem(SelenideElement elt) {
+    this.elt = elt;
+  }
+
+  public SelenideElement getName() {
+    return elt.$(".js-name");
+  }
+
+  public SelenideElement getType() {
+    try {
+      return elt.$(".js-type");
+    } catch (NoSuchElementException e) {
+      return null;
+    }
+  }
+
+  public SelenideElement getUrl() {
+    return elt.$(".js-url");
+  }
+
+  public SelenideElement getDeleteButton() {
+    return elt.$(".js-delete-button");
+  }
+}
diff --git a/it/it-tests/src/test/java/pageobjects/ProjectLinksPage.java b/it/it-tests/src/test/java/pageobjects/ProjectLinksPage.java
new file mode 100644 (file)
index 0000000..fb143e5
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+package pageobjects;
+
+import com.codeborne.selenide.Condition;
+import com.codeborne.selenide.ElementsCollection;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static com.codeborne.selenide.Selenide.$;
+import static com.codeborne.selenide.Selenide.$$;
+
+public class ProjectLinksPage {
+
+  public ProjectLinksPage() {
+    $("#project-links").should(Condition.exist);
+  }
+
+  public ElementsCollection getLinks() {
+    return $$("#project-links tr[data-name]");
+  }
+
+  public List<ProjectLinkItem> getLinksAsItems() {
+    return getLinks()
+      .stream()
+      .map(ProjectLinkItem::new)
+      .collect(Collectors.toList());
+  }
+}
diff --git a/server/sonar-web/src/main/js/api/projectLinks.js b/server/sonar-web/src/main/js/api/projectLinks.js
new file mode 100644 (file)
index 0000000..06e1eaa
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 { getJSON, post, postJSON } from '../helpers/request';
+
+export function getProjectLinks (projectKey) {
+  const url = '/api/project_links/search';
+  const data = { projectKey };
+  return getJSON(url, data).then(r => r.links);
+}
+
+export function deleteLink (linkId) {
+  const url = '/api/project_links/delete';
+  const data = { id: linkId };
+  return post(url, data);
+}
+
+export function createLink (projectKey, name, url) {
+  const apiURL = '/api/project_links/create';
+  const data = { projectKey, name, url };
+  return postJSON(apiURL, data).then(r => r.link);
+}
index 7bcb91f9e1c13723abfe1a75836563bcfdbd21ca..85fb770962f1be9184341e49b5ede0de269c38a0 100644 (file)
@@ -24,6 +24,7 @@ import { Router, Route, useRouterHistory } from 'react-router';
 import { createHistory } from 'history';
 import Deletion from './deletion/Deletion';
 import QualityProfiles from './quality-profiles/QualityProfiles';
+import Links from './links/Links';
 import rootReducer from './store/rootReducer';
 import configureStore from '../../components/store/configureStore';
 
@@ -48,6 +49,9 @@ window.sonarqube.appStarted.then(options => {
           <Route
               path="/quality_profiles"
               component={withComponent(QualityProfiles)}/>
+          <Route
+              path="/links"
+              component={withComponent(Links)}/>
         </Router>
       </Provider>
   ), el);
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/Header.js b/server/sonar-web/src/main/js/apps/project-admin/links/Header.js
new file mode 100644 (file)
index 0000000..e6471ca
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 React from 'react';
+import CreationModal from './views/CreationModal';
+import { translate } from '../../../helpers/l10n';
+
+export default class Header extends React.Component {
+  static propTypes = {
+    onCreate: React.PropTypes.func.isRequired
+  };
+
+  handleCreateClick (e) {
+    e.preventDefault();
+    e.target.blur();
+    new CreationModal({
+      onCreate: this.props.onCreate
+    }).render();
+  }
+
+  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.bind(this)}>
+              {translate('create')}
+            </button>
+          </div>
+          <div className="page-description">
+            {translate('project_links.page.description')}
+          </div>
+        </header>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/LinkRow.js b/server/sonar-web/src/main/js/apps/project-admin/links/LinkRow.js
new file mode 100644 (file)
index 0000000..e55651d
--- /dev/null
@@ -0,0 +1,113 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 React from 'react';
+import shallowCompare from 'react-addons-shallow-compare';
+import { isProvided, isClickable } from './utils';
+import { translate } from '../../../helpers/l10n';
+
+export default class LinkRow extends React.Component {
+  static propTypes = {
+    link: React.PropTypes.object.isRequired,
+    onDelete: React.PropTypes.func.isRequired
+  };
+
+  shouldComponentUpdate (nextProps, nextState) {
+    return shallowCompare(this, nextProps, nextState);
+  }
+
+  handleDeleteClick (e) {
+    e.preventDefault();
+    e.target.blur();
+    this.props.onDelete();
+  }
+
+  renderIcon (iconClassName) {
+    return (
+        <div className="display-inline-block text-top spacer-right">
+          <i className={iconClassName}/>
+        </div>
+    );
+  }
+
+  renderNameForProvided (link) {
+    return (
+        <div>
+          {this.renderIcon(`icon-${link.type}`)}
+          <div className="display-inline-block text-top">
+            <div>
+              <span className="js-name">{link.name}</span>
+            </div>
+            <div className="note little-spacer-top">
+              <span className="js-type">{`sonar.links.${link.type}`}</span>
+            </div>
+          </div>
+        </div>
+    );
+  }
+
+  renderName (link) {
+    if (isProvided(link)) {
+      return this.renderNameForProvided(link);
+    }
+
+    return (
+        <div>
+          {this.renderIcon('icon-detach')}
+          <div className="display-inline-block text-top">
+            <span className="js-name">{link.name}</span>
+          </div>
+        </div>
+    );
+  }
+
+  renderUrl (link) {
+    if (isClickable(link)) {
+      return <a href={link.url} target="_blank">{link.url}</a>;
+    }
+
+    return link.url;
+  }
+
+  renderDeleteButton (link) {
+    if (isProvided(link)) {
+      return null;
+    }
+
+    return (
+        <button
+            className="button-red js-delete-button"
+            onClick={this.handleDeleteClick.bind(this)}>
+          {translate('delete')}
+        </button>
+    );
+  }
+
+  render () {
+    const { link } = this.props;
+
+    return (
+        <tr data-name={link.name}>
+          <td className="nowrap">{this.renderName(link)}</td>
+          <td className="nowrap js-url">{this.renderUrl(link)}</td>
+          <td className="thin nowrap">{this.renderDeleteButton(link)}</td>
+        </tr>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/Links.js b/server/sonar-web/src/main/js/apps/project-admin/links/Links.js
new file mode 100644 (file)
index 0000000..8860253
--- /dev/null
@@ -0,0 +1,82 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 React from 'react';
+import shallowCompare from 'react-addons-shallow-compare';
+import { connect } from 'react-redux';
+import Header from './Header';
+import Table from './Table';
+import DeletionModal from './views/DeletionModal';
+import { getProjectLinks } from '../store/rootReducer';
+import {
+    fetchProjectLinks,
+    deleteProjectLink,
+    createProjectLink
+} from '../store/actions';
+
+class Links extends React.Component {
+  static propTypes = {
+    component: React.PropTypes.object.isRequired,
+    links: React.PropTypes.array
+  };
+
+  componentWillMount () {
+    this.handleCreateLink = this.handleCreateLink.bind(this);
+    this.handleDeleteLink = this.handleDeleteLink.bind(this);
+  }
+
+  componentDidMount () {
+    this.props.fetchProjectLinks(this.props.component.key);
+  }
+
+  shouldComponentUpdate (nextProps, nextState) {
+    return shallowCompare(this, nextProps, nextState);
+  }
+
+  handleCreateLink (name, url) {
+    return this.props.createProjectLink(this.props.component.key, name, url);
+  }
+
+  handleDeleteLink (link) {
+    new DeletionModal({ link }).on('done', () => {
+      this.props.deleteProjectLink(this.props.component.key, link.id);
+    }).render();
+  }
+
+  render () {
+    return (
+        <div className="page page-limited">
+          <Header
+              onCreate={this.handleCreateLink}/>
+          <Table
+              links={this.props.links}
+              onDelete={this.handleDeleteLink}/>
+        </div>
+    );
+  }
+}
+
+const mapStateToProps = (state, ownProps) => ({
+  links: getProjectLinks(state, ownProps.component.key)
+});
+
+export default connect(
+    mapStateToProps,
+    { fetchProjectLinks, createProjectLink, deleteProjectLink }
+)(Links);
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/Table.js b/server/sonar-web/src/main/js/apps/project-admin/links/Table.js
new file mode 100644 (file)
index 0000000..95f37f6
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 React from 'react';
+import shallowCompare from 'react-addons-shallow-compare';
+import LinkRow from './LinkRow';
+import { orderLinks } from './utils';
+import { translate } from '../../../helpers/l10n';
+
+export default class Table extends React.Component {
+  static propTypes = {
+    links: React.PropTypes.array.isRequired,
+    onDelete: React.PropTypes.func.isRequired
+  };
+
+  shouldComponentUpdate (nextProps, nextState) {
+    return shallowCompare(this, nextProps, nextState);
+  }
+
+  handleDeleteLink (link) {
+    this.props.onDelete(link);
+  }
+
+  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 () {
+    const orderedLinks = orderLinks(this.props.links);
+
+    const linkRows = orderedLinks.map(link => (
+        <LinkRow
+            key={link.id}
+            link={link}
+            onDelete={this.handleDeleteLink.bind(this, link)}/>
+    ));
+
+    return (
+        <table id="project-links" className="data zebra">
+          {this.renderHeader()}
+          <tbody>{linkRows}</tbody>
+        </table>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/utils.js b/server/sonar-web/src/main/js/apps/project-admin/links/utils.js
new file mode 100644 (file)
index 0000000..9a34509
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 from 'lodash/partition';
+import sortBy from 'lodash/sortBy';
+
+const PROVIDED_TYPES = [
+  'homepage',
+  'ci',
+  'issue',
+  'scm',
+  'scm_dev'
+];
+
+export function isProvided (link) {
+  return PROVIDED_TYPES.includes(link.type);
+}
+
+export function orderLinks (links) {
+  const [provided, unknown] = partition(links, isProvided);
+  return [
+    ...sortBy(provided, link => PROVIDED_TYPES.indexOf(link.type)),
+    ...sortBy(unknown, link => link.name)
+  ];
+}
+
+export function isClickable (link) {
+  // stupid simple check
+  return link.url.indexOf('http') === 0;
+}
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/views/CreationModal.js b/server/sonar-web/src/main/js/apps/project-admin/links/views/CreationModal.js
new file mode 100644 (file)
index 0000000..a2fa0e3
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 ModalForm from '../../../../components/common/modal-form';
+import Template from './CreationModalTemplate.hbs';
+
+export default ModalForm.extend({
+  template: Template,
+
+  onFormSubmit () {
+    ModalForm.prototype.onFormSubmit.apply(this, arguments);
+    this.disableForm();
+
+    const name = this.$('#create-link-name').val();
+    const url = this.$('#create-link-url').val();
+
+    this.options.onCreate(name, url)
+        .then(() => {
+          this.destroy();
+        })
+        .catch(function (e) {
+          e.response.json().then(r => {
+            this.showErrors(r.errors, r.warnings);
+            this.enableForm();
+          });
+        });
+  }
+});
+
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/views/CreationModalTemplate.hbs b/server/sonar-web/src/main/js/apps/project-admin/links/views/CreationModalTemplate.hbs
new file mode 100644 (file)
index 0000000..7405f30
--- /dev/null
@@ -0,0 +1,22 @@
+<form>
+  <div class="modal-head">
+    <h2>{{t 'project_links.create_new_project_link'}}</h2>
+  </div>
+  <div class="modal-body">
+    <div class="js-modal-messages"></div>
+
+    <div class="modal-field">
+      <label for="create-link-name">{{t 'project_links.name'}}<em class="mandatory">*</em></label>
+      <input id="create-link-name" name="name" type="text" required>
+    </div>
+
+    <div class="modal-field">
+      <label for="create-link-url">{{t 'project_links.url'}}<em class="mandatory">*</em></label>
+      <input id="create-link-url" name="url" type="text" required>
+    </div>
+  </div>
+  <div class="modal-foot">
+    <button id="create-link-confirm">{{t 'create'}}</button>
+    <a href="#" class="js-modal-close">{{t 'cancel'}}</a>
+  </div>
+</form>
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/views/DeletionModal.js b/server/sonar-web/src/main/js/apps/project-admin/links/views/DeletionModal.js
new file mode 100644 (file)
index 0000000..a1064df
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 ModalForm from '../../../../components/common/modal-form';
+import Template from './DeletionModalTemplate.hbs';
+import { deleteLink } from '../../../../api/projectLinks';
+
+export default ModalForm.extend({
+  template: Template,
+
+  onFormSubmit () {
+    ModalForm.prototype.onFormSubmit.apply(this, arguments);
+    this.disableForm();
+
+    deleteLink(this.options.link.id)
+        .then(() => {
+          this.trigger('done');
+          this.destroy();
+        })
+        .catch(function (e) {
+          e.response.json().then(r => {
+            this.showErrors(r.errors, r.warnings);
+            this.enableForm();
+          });
+        });
+  },
+
+  serializeData () {
+    return { link: this.options.link };
+  }
+});
+
diff --git a/server/sonar-web/src/main/js/apps/project-admin/links/views/DeletionModalTemplate.hbs b/server/sonar-web/src/main/js/apps/project-admin/links/views/DeletionModalTemplate.hbs
new file mode 100644 (file)
index 0000000..b8d744c
--- /dev/null
@@ -0,0 +1,13 @@
+<form>
+  <div class="modal-head">
+    <h2>{{t 'project_links.delete_project_link'}}</h2>
+  </div>
+  <div class="modal-body">
+    <div class="js-modal-messages"></div>
+    {{tp 'project_links.are_you_sure_to_delete_x_link' link.name}}
+  </div>
+  <div class="modal-foot">
+    <button id="delete-link-confirm" class="button-red">{{t 'delete'}}</button>
+    <a href="#" class="js-modal-close">{{t 'cancel'}}</a>
+  </div>
+</form>
index cad25a70e7a42f958cf387c615bf9ac43b609f74..083a28bfa92a7d3797428403ca1ca754a6b6731d 100644 (file)
@@ -23,6 +23,7 @@ import {
     dissociateProject
 } from '../../../api/quality-profiles';
 import { getProfileByKey } from './rootReducer';
+import { getProjectLinks, createLink } from '../../../api/projectLinks';
 
 export const RECEIVE_PROFILES = 'RECEIVE_PROFILES';
 export const receiveProfiles = profiles => ({
@@ -68,3 +69,36 @@ export const setProjectProfile = (projectKey, oldKey, newKey) =>
         dispatch(setProjectProfileAction(projectKey, oldKey, newKey));
       });
     };
+
+export const RECEIVE_PROJECT_LINKS = 'RECEIVE_PROJECT_LINKS';
+export const receiveProjectLinks = (projectKey, links) => ({
+  type: RECEIVE_PROJECT_LINKS,
+  projectKey,
+  links
+});
+
+export const fetchProjectLinks = projectKey => dispatch => {
+  getProjectLinks(projectKey).then(links => {
+    dispatch(receiveProjectLinks(projectKey, links));
+  });
+};
+
+export const ADD_PROJECT_LINK = 'ADD_PROJECT_LINK';
+const addProjectLink = (projectKey, link) => ({
+  type: ADD_PROJECT_LINK,
+  projectKey,
+  link
+});
+
+export const createProjectLink = (projectKey, name, url) => dispatch => {
+  return createLink(projectKey, name, url).then(link => {
+    dispatch(addProjectLink(projectKey, link));
+  });
+};
+
+export const DELETE_PROJECT_LINK = 'DELETE_PROJECT_LINK';
+export const deleteProjectLink = (projectKey, linkId) => ({
+  type: DELETE_PROJECT_LINK,
+  projectKey,
+  linkId
+});
diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/links.js b/server/sonar-web/src/main/js/apps/project-admin/store/links.js
new file mode 100644 (file)
index 0000000..9a79d3d
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 keyBy from 'lodash/keyBy';
+import omit from 'lodash/omit';
+import { RECEIVE_PROJECT_LINKS, DELETE_PROJECT_LINK } from './actions';
+import { ADD_PROJECT_LINK } from './actions';
+
+const links = (state = {}, action = {}) => {
+  if (action.type === RECEIVE_PROJECT_LINKS) {
+    const newLinksById = keyBy(action.links, 'id');
+    return { ...state, ...newLinksById };
+  }
+
+  if (action.type === ADD_PROJECT_LINK) {
+    return { ...state, [action.link.id]: action.link };
+  }
+
+  if (action.type === DELETE_PROJECT_LINK) {
+    return omit(state, action.linkId);
+  }
+
+  return state;
+};
+
+export default links;
+
+export const getLink = (state, id) =>
+    state[id];
diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/linksByProject.js b/server/sonar-web/src/main/js/apps/project-admin/store/linksByProject.js
new file mode 100644 (file)
index 0000000..dd48ff4
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 without from 'lodash/without';
+import {
+    RECEIVE_PROJECT_LINKS,
+    DELETE_PROJECT_LINK,
+    ADD_PROJECT_LINK
+} from './actions';
+
+const linksByProject = (state = {}, action = {}) => {
+  if (action.type === RECEIVE_PROJECT_LINKS) {
+    const linkIds = action.links.map(link => link.id);
+    return { ...state, [action.projectKey]: linkIds };
+  }
+
+  if (action.type === ADD_PROJECT_LINK) {
+    const byProject = state[action.projectKey] || [];
+    const ids = [...byProject, action.link.id];
+    return { ...state, [action.projectKey]: ids };
+  }
+
+  if (action.type === DELETE_PROJECT_LINK) {
+    const ids = without(state[action.projectKey], action.linkId);
+    return { ...state, [action.projectKey]: ids };
+  }
+
+  return state;
+};
+
+export default linksByProject;
+
+export const getLinks = (state, projectKey) => state[projectKey] || [];
index 5f5dc3d78997e45d3d9f781a905cac30b88b374f..e8174f53aa06d387ffba03dd6202d3e779506e74 100644 (file)
@@ -23,10 +23,14 @@ import profiles, {
     getAllProfiles as nextGetAllProfiles
 } from './profiles';
 import profilesByProject, { getProfiles } from './profilesByProject';
+import links, { getLink } from './links';
+import linksByProject, { getLinks } from './linksByProject';
 
 const rootReducer = combineReducers({
   profiles,
-  profilesByProject
+  profilesByProject,
+  links,
+  linksByProject
 });
 
 export default rootReducer;
@@ -40,3 +44,10 @@ export const getAllProfiles = state =>
 export const getProjectProfiles = (state, projectKey) =>
     getProfiles(state.profilesByProject, projectKey)
         .map(profileKey => getProfileByKey(state, profileKey));
+
+export const getLinkById = (state, linkId) =>
+    getLink(state.links, linkId);
+
+export const getProjectLinks = (state, projectKey) =>
+    getLinks(state.linksByProject, projectKey)
+        .map(linkId => getLinkById(state, linkId));
index 5754eccd08647b04eaa111380af9357f73768208..dbd5d6a434e52b1bc3c04a8df7d83eb39b380f42 100644 (file)
@@ -44,6 +44,14 @@ class ProjectController < ApplicationController
     redirect_to(url_for({:action => 'quality_profiles'}) + '?id=' + url_encode(params[:id]))
   end
 
+  def background_tasks
+    @project = get_current_project(params[:id])
+  end
+
+  def links
+    @project = get_current_project(params[:id])
+  end
+
   # GET /project/qualitygate?id=<project id>
   def qualitygate
     require_parameters :id
@@ -154,44 +162,6 @@ class ProjectController < ApplicationController
                               :include => 'events', :order => 'snapshots.created_at DESC')
   end
 
-  def background_tasks
-    @project = get_current_project(params[:id])
-  end
-
-  def links
-    @project = get_current_project(params[:id])
-
-    if !@project.project?
-      redirect_to :action => 'index', :id => params[:id]
-    end
-    @snapshot = @project.last_snapshot
-  end
-
-  def set_links
-    project = get_current_project(params[:project_id])
-
-    project.custom_links.each { |link| link.delete }
-
-    params.each_pair do |param_key, value|
-      if (param_key.starts_with?('name_'))
-        id = param_key[5..-1]
-        name=value
-        url=params["url_#{id}"]
-        key=params["key_#{id}"]
-        if key.blank?
-          key=ProjectLink.name_to_key(name)
-        end
-        unless key.blank? || name.blank? || url.blank?
-          project.links.create(:href => url, :name => name, :link_type => key)
-        end
-      end
-    end
-    project.save!
-
-    flash[:notice] = 'Links updated.'
-    redirect_to :action => 'links', :id => project.id
-  end
-
   def settings
     @resource = get_current_project(params[:id])
 
index 0c64f689e8f03facccd744df079ca5e2ac039318..e9dd9ae341023b9b8daca7528d9cb6c8edd3ea46 100644 (file)
@@ -1,135 +1,3 @@
-<div class="page">
-  <header class="page-header">
-    <h1 class="page-title"><%= message('project_links.page') -%></h1>
-    <p class="page-description"><%= message('project_links.page.description') -%></p>
-  </header>
-
-  <style>
-  #widget_links td, #widget_links th {
-    white-space: nowrap;
-    vertical-align: top;
-    text-align: left;
-    padding-left: 20px;
-    padding-right: 20px;
-  }
-  </style>
-
-  <div class="yui-g widget" id="widget_links">
-
-  <% form_for( 'set_links', :url => { :action => 'set_links', :project_id => @project.id } ) do |form|
-       links_by_key={}
-       @project.links.each do |link|
-         links_by_key[link.link_type]=link
-       end
-  %>
-   <div class="yui-u first">
-     <table class="data">
-       <thead><tr><th >Title</th><th width="100%">URL</th></tr></thead>
-
-       <tr class="even">
-        <td>
-          <%= image_tag("links/homepage.png") -%> <%= message('project_links.homepage') -%>
-          <br/>
-          <span class="note" style="margin-left: 20px">sonar.links.homepage</span>
-        </td>
-          <td>
-            <%
-              link = links_by_key['homepage'] ? links_by_key['homepage'].href : ''
-              if link.starts_with?("http")
-                link = "<a href=\"#{h link}\">#{h link}</a>"
-              end
-            %>
-            <%= link -%>
-          </td>
-       </tr>
-       <tr class="odd">
-        <td>
-          <%= image_tag("links/ci.png") -%> <%= message('project_links.ci') -%>
-          <br/>
-          <span class="note" style="margin-left: 20px">sonar.links.ci</span>
-        </td>
-          <td>
-            <%
-              link = links_by_key['ci'] ? links_by_key['ci'].href : ''
-              if link.starts_with?("http")
-                link = "<a href=\"#{h link}\">#{h link}</a>"
-              end
-            %>
-            <%= link -%>
-          </td>
-       </tr>
-       <tr class="even">
-        <td>
-          <%= image_tag("links/issue.png") -%> <%= message('project_links.issue') -%>
-          <br/>
-          <span class="note" style="margin-left: 20px">sonar.links.issue</span>
-        </td>
-          <td>
-            <%
-              link = links_by_key['issue'] ? links_by_key['issue'].href : ''
-              if link.starts_with?("http")
-                link = "<a href=\"#{h link}\">#{h link}</a>"
-              end
-            %>
-            <%= link -%>
-          </td>
-       </tr>
-       <tr class="odd">
-        <td>
-          <%= image_tag("links/scm.png") -%> <%= message('project_links.scm') -%>
-          <br/>
-          <span class="note" style="margin-left: 20px">sonar.links.scm</span>
-        </td>
-          <td>
-            <%
-              link = links_by_key['scm'] ? links_by_key['scm'].href : ''
-              if link.starts_with?("http")
-                link = "<a href=\"#{h link}\">#{h link}</a>"
-              end
-            %>
-            <%= link -%>
-          </td>
-       </tr>
-       <tr class="even">
-        <td>
-          <%= image_tag("links/scm_dev.png") -%> <%= message('project_links.scm_dev') -%>
-          <br/>
-          <span class="note" style="margin-left: 20px">sonar.links.scm_dev</span>
-        </td>
-          <td>
-            <%
-              link = links_by_key['scm_dev'] ? links_by_key['scm_dev'].href : ''
-              if link.starts_with?("http")
-                link = "<a href=\"#{h link}\">#{h link}</a>"
-              end
-            %>
-            <%= link -%>
-          </td>
-       </tr>
-
-         <% index = 0
-           @project.custom_links.each do |custom_link|
-             index += 1 %>
-          <tr class="<%= cycle('odd','even') -%>">
-            <td align="left">
-              <%= image_tag("links/external.png") -%> <%= text_field_tag( "name_#{index}", h(custom_link.name), :size => 15 ) -%>
-            </td>
-            <td>
-              <%= text_field_tag( "url_#{index}", custom_link.href, :size => 30 ) -%>
-            </td>
-          </tr>
-        <% end %>
-        <% index += 1
-           for var in index..5 %>
-          <tr class="<%= cycle('odd','even') -%>">
-              <td align="left"><%= image_tag("links/external.png") -%>  <%= text_field_tag( "name_#{var}", '', :size => 15) %></td>
-              <td><%= text_field_tag( "url_#{var}", '', :size => 30 ) %></td>
-          </tr>
-        <% end %>
-     </table>
-     <br/>
-     <%= submit_tag( "Save links" ) %>
-   </div>
-  <% end %>
-  </div>
-</div>
+<% content_for :extra_script do %>
+  <script src="<%= ApplicationController.root_context -%>/js/bundles/project-admin.js?v=<%= sonar_version -%>"></script>
+<% end %>
index 0a86b12052e3f8f32ace8d6349b2e746c458cae4..7878ece812908b77d545a68529ce624e97bc741a 100644 (file)
@@ -419,6 +419,12 @@ project_links.scm=Sources
 project_links.scm_ro=Read-only connection
 project_links.scm_dev=Developer connection
 
+project_links.create_new_project_link=Create New Project Link
+project_links.delete_project_link=Delete Project Link
+project_links.are_you_sure_to_delete_x_link=Are you sure you want to delete the "{0}" link?
+project_links.name=Name
+project_links.url=URL
+
 
 #------------------------------------------------------------------------------
 #