Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/projects/mr-projects-page/helpers.js b/static_src/elements/projects/mr-projects-page/helpers.js
new file mode 100644
index 0000000..5c12ae8
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/helpers.js
@@ -0,0 +1,30 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {projectMemberToProjectName} from 'shared/converters.js';
+
+// TODO(crbug.com/monorail/7910): Dedupe this with the similar "projectRoles"
+// constant in <mr-header>.
+const projectRoles = Object.freeze({
+  PROJECT_ROLE_UNSPECIFIED: '',
+  OWNER: 'Owner',
+  COMMITTER: 'Committer',
+  CONTRIBUTOR: 'Contributor',
+});
+
+/**
+ * Creates a mapping of project names to the user's role in that project.
+ * @param {Array<ProjectMember>} projectMembers Project memebrships
+ *   for a given user.
+ * @return {Object<ProjectName, string>} Mapping of a user's roles,
+ *   by project name.
+ */
+export function computeRoleByProjectName(projectMembers) {
+  const mapping = {};
+  if (!projectMembers) return mapping;
+  projectMembers.forEach(({name, role}) => {
+    mapping[projectMemberToProjectName(name)] = projectRoles[role];
+  });
+  return mapping;
+}
diff --git a/static_src/elements/projects/mr-projects-page/helpers.test.js b/static_src/elements/projects/mr-projects-page/helpers.test.js
new file mode 100644
index 0000000..9e3c5a2
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/helpers.test.js
@@ -0,0 +1,24 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {computeRoleByProjectName} from './helpers.js';
+
+describe('computeRoleByProjectName', () => {
+  it('handles empty project memberships', () => {
+    assert.deepEqual(computeRoleByProjectName(undefined), {});
+    assert.deepEqual(computeRoleByProjectName([]), {});
+  });
+
+  it('creates mapping', () => {
+    const projectMembers = [
+      {role: 'OWNER', name: 'projects/project-name/members/1234'},
+      {role: 'COMMITTER', name: 'projects/test/members/1234'},
+    ];
+    assert.deepEqual(computeRoleByProjectName(projectMembers), {
+      'projects/project-name': 'Owner',
+      'projects/test': 'Committer',
+    });
+  });
+});
diff --git a/static_src/elements/projects/mr-projects-page/mr-projects-page.js b/static_src/elements/projects/mr-projects-page/mr-projects-page.js
new file mode 100644
index 0000000..1124ef0
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/mr-projects-page.js
@@ -0,0 +1,297 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import {store, connectStore} from 'reducers/base.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'elements/framework/mr-star/mr-project-star.js';
+import 'shared/typedef.js';
+import 'elements/chops/chops-chip/chops-chip.js';
+
+import * as projects from 'reducers/projects.js';
+import {users} from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {computeRoleByProjectName} from './helpers.js';
+
+
+/**
+ * `<mr-projects-page>`
+ *
+ * Displays list of all projects.
+ *
+ */
+export class MrProjectsPage extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          box-sizing: border-box;
+          display: block;
+          padding: 1em 8px;
+          padding-left: 40px; /** 32px + 8px */
+          margin: auto;
+          max-width: 1280px;
+          width: 100%;
+        }
+        :host::after {
+          content: "";
+          background-image: url('/static/images/chromium.svg');
+          background-repeat: no-repeat;
+          background-position: right -100px bottom -150px;
+          background-size: 700px;
+          opacity: 0.09;
+          width: 100%;
+          height: 100%;
+          bottom: 0;
+          right: 0;
+          position: fixed;
+          z-index: -1;
+        }
+        h2 {
+          font-size: 20px;
+          letter-spacing: 0.1px;
+          font-weight: 500;
+          margin-top: 1em;
+        }
+        .project-header {
+          display: flex;
+          align-items: flex-start;
+          flex-direction: row;
+          justify-content: space-between;
+          font-size: 16px;
+          line-height: 24px;
+          margin: 0;
+          margin-bottom: 16px;
+          padding-top: 0.1em;
+          padding-bottom: 16px;
+          letter-spacing: 0.1px;
+          font-weight: 500;
+          width: 100%;
+          border-bottom: var(--chops-normal-border);
+          border-color: var(--chops-gray-400);
+        }
+        .project-title {
+          display: flex;
+          flex-direction: column;
+        }
+        h3 {
+          margin: 0;
+          padding: 0;
+          font-weight: inherit;
+          font-size: inherit;
+          transition: color var(--chops-transition-time) ease-in-out;
+        }
+        h3:hover {
+          color: var(--chops-link-color);
+        }
+        .subtitle {
+          color: var(--chops-gray-700);
+          font-size: var(--chops-main-font-size);
+          line-height: 100%;
+          font-weight: normal;
+        }
+        .project-container {
+          display: flex;
+          align-items: stretch;
+          flex-wrap: wrap;
+          width: 100%;
+          padding: 0.5em 0;
+          margin-bottom: 3em;
+        }
+        .project {
+          background: var(--chops-white);
+          width: 220px;
+          margin-right: 32px;
+          margin-bottom: 32px;
+          display: flex;
+          flex-direction: column;
+          align-items: flex-start;
+          justify-content: flex-start;
+          border-radius: 4px;
+          border: var(--chops-normal-border);
+          padding: 16px;
+          color: var(--chops-primary-font-color);
+          font-weight: normal;
+          line-height: 16px;
+          transition: all var(--chops-transition-time) ease-in-out;
+        }
+        .project:hover {
+          text-decoration: none;
+          cursor: pointer;
+          box-shadow: 0 2px 6px hsla(0,0%,0%,0.12),
+            0 1px 3px hsla(0,0%,0%,0.24);
+        }
+        .project > p {
+          margin: 0;
+          margin-bottom: 32px;
+          flex-grow: 1;
+        }
+        .view-project-link {
+          text-transform: uppercase;
+          margin: 0;
+          font-weight: 600;
+          flex-grow: 0;
+        }
+        .view-project-link:hover {
+          text-decoration: underline;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const myProjects = this.myProjects;
+    const otherProjects = this.otherProjects;
+    const noProjects = !myProjects.length && !otherProjects.length;
+
+    if (this._isFetchingProjects && noProjects) {
+      return html`Loading...`;
+    }
+
+    if (noProjects) {
+      return html`No projects found.`;
+    }
+
+    if (!myProjects.length) {
+      // Skip sorting projects into different sections if the user
+      // has no projects.
+      return html`
+        <h2>All projects</h2>
+        <div class="project-container all-projects">
+          ${otherProjects.map((project) => this._renderProject(project))}
+        </div>
+      `;
+    }
+
+    const myProjectsTemplate = myProjects.map((project) => this._renderProject(
+        project, this._roleByProjectName[project.name]));
+
+    return html`
+      <h2>My projects</h2>
+      <div class="project-container my-projects">
+        ${myProjectsTemplate}
+      </div>
+
+      <h2>Other projects</h2>
+      <div class="project-container other-projects">
+        ${otherProjects.map((project) => this._renderProject(project))}
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      _projects: {type: Array},
+      _isFetchingProjects: {type: Boolean},
+      _currentUser: {type: String},
+      _roleByProjectName: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /**
+     * @type {Array<Project>}
+     */
+    this._projects = [];
+    /**
+     * @type {boolean}
+     */
+    this._isFetchingProjects = false;
+    /**
+     * @type {string}
+     */
+    this._currentUser = undefined;
+    /**
+     * @type {Object<ProjectName, string>}
+     */
+    this._roleByProjectName = {};
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    store.dispatch(projects.list());
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('_currentUser') && this._currentUser) {
+      const userName = this._currentUser;
+      store.dispatch(users.gatherProjectMemberships(userName));
+      store.dispatch(stars.listProjects(userName));
+    }
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._projects = projects.all(state);
+    this._isFetchingProjects = projects.requests(state).list.requesting;
+    this._currentUser = users.currentUserName(state);
+    const allProjectMemberships = users.projectMemberships(state);
+    this._roleByProjectName = computeRoleByProjectName(
+        allProjectMemberships[this._currentUser]);
+  }
+
+  /**
+   * @param {Project} project
+   * @param {string=} role
+   * @return {TemplateResult}
+   */
+  _renderProject(project, role) {
+    return html`
+      <a href="/p/${project.displayName}/issues/list" class="project">
+        <div class="project-header">
+          <span class="project-title">
+            <h3>${project.displayName}</h3>
+            <span class="subtitle" ?hidden=${!role} title="My role: ${role}">
+              Role: ${role}
+            </span>
+          </span>
+
+          <mr-project-star .name=${project.name}></mr-project-star>
+        </div>
+        <p>
+          ${project.summary}
+        </p>
+        <button class="view-project-link linkify">
+          View project
+        </button>
+      </a>
+    `;
+  }
+
+  /**
+   * Projects the currently logged in user is a member of.
+   * @return {Array<Project>}
+   */
+  get myProjects() {
+    return this._projects.filter(
+        ({name}) => this._userIsMemberOfProject(name));
+  }
+
+  /**
+   * Projects the currently logged in user is not a member of.
+   * @return {Array<Project>}
+   */
+  get otherProjects() {
+    return this._projects.filter(
+        ({name}) => !this._userIsMemberOfProject(name));
+  }
+
+  /**
+   * Helper to check if a user is a member of a project.
+   * @param {ProjectName} project Resource name of a project.
+   * @return {boolean} Whether the user a member of the given project.
+   */
+  _userIsMemberOfProject(project) {
+    return project in this._roleByProjectName;
+  }
+}
+customElements.define('mr-projects-page', MrProjectsPage);
diff --git a/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js b/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js
new file mode 100644
index 0000000..1a9a1e4
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js
@@ -0,0 +1,248 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {prpcClient} from 'prpc-client-instance.js';
+import {stateUpdated} from 'reducers/base.js';
+import {users} from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {MrProjectsPage} from './mr-projects-page.js';
+
+let element;
+
+describe('mr-projects-page', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-projects-page');
+    document.body.appendChild(element);
+
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrProjectsPage);
+  });
+
+  it('renders loading', async () => {
+    element._isFetchingProjects = true;
+
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.textContent.trim(), 'Loading...');
+  });
+
+  it('renders projects when refetching projects', async () => {
+    element._isFetchingProjects = true;
+    element._projects = [
+      {name: 'projects/chromium', displayName: 'chromium',
+        summary: 'Best project ever'},
+    ];
+
+    await element.updateComplete;
+
+    const headers = element.shadowRoot.querySelectorAll('h2');
+
+    assert.equal(headers.length, 1);
+    assert.equal(headers[0].textContent.trim(), 'All projects');
+
+    const projects = element.shadowRoot.querySelectorAll(
+        '.all-projects > .project');
+    assert.equal(projects.length, 1);
+
+    assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+    assert.include(projects[0].textContent, 'Best project ever');
+  });
+
+  it('renders all projects when no user projects', async () => {
+    element._isFetchingProjects = false;
+    element._projects = [
+      {name: 'projects/chromium', displayName: 'chromium',
+        summary: 'Best project ever'},
+      {name: 'projects/infra', displayName: 'infra',
+        summary: 'Make it work'},
+    ];
+
+    await element.updateComplete;
+
+    const headers = element.shadowRoot.querySelectorAll('h2');
+
+    assert.equal(headers.length, 1);
+    assert.equal(headers[0].textContent.trim(), 'All projects');
+
+    const projects = element.shadowRoot.querySelectorAll(
+        '.all-projects > .project');
+    assert.equal(projects.length, 2);
+
+    assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+    assert.include(projects[0].textContent, 'Best project ever');
+
+    assert.include(projects[1].querySelector('h3').textContent, 'infra');
+    assert.include(projects[1].textContent, 'Make it work');
+  });
+
+  it('renders no projects found', async () => {
+    element._isFetchingProjects = false;
+    sinon.stub(element, 'myProjects').get(() => []);
+    sinon.stub(element, 'otherProjects').get(() => []);
+
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.textContent.trim(), 'No projects found.');
+  });
+
+  describe('project grouping', () => {
+    beforeEach(() => {
+      element._projects = [
+        {name: 'projects/chromium', displayName: 'chromium',
+          summary: 'Best project ever'},
+        {name: 'projects/infra', displayName: 'infra',
+          summary: 'Make it work'},
+        {name: 'projects/test', displayName: 'test',
+          summary: 'Hmm'},
+        {name: 'projects/a-project', displayName: 'a-project',
+          summary: 'I am Monkeyrail'},
+      ];
+      element._roleByProjectName = {
+        'projects/chromium': 'Owner',
+        'projects/infra': 'Committer',
+      };
+      element._isFetchingProjects = false;
+    });
+
+    it('myProjects filters out non-member projects', () => {
+      assert.deepEqual(element.myProjects, [
+        {name: 'projects/chromium', displayName: 'chromium',
+          summary: 'Best project ever'},
+        {name: 'projects/infra', displayName: 'infra',
+          summary: 'Make it work'},
+      ]);
+    });
+
+    it('otherProjects filters out member projects', () => {
+      assert.deepEqual(element.otherProjects, [
+        {name: 'projects/test', displayName: 'test',
+          summary: 'Hmm'},
+        {name: 'projects/a-project', displayName: 'a-project',
+          summary: 'I am Monkeyrail'},
+      ]);
+    });
+
+    it('renders user projects', async () => {
+      await element.updateComplete;
+
+      const projects = element.shadowRoot.querySelectorAll(
+          '.my-projects > .project');
+
+      assert.equal(projects.length, 2);
+      assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+      assert.include(projects[0].textContent, 'Best project ever');
+      assert.include(projects[0].querySelector('.subtitle').textContent,
+          'Owner');
+
+      assert.include(projects[1].querySelector('h3').textContent, 'infra');
+      assert.include(projects[1].textContent, 'Make it work');
+      assert.include(projects[1].querySelector('.subtitle').textContent,
+          'Committer');
+    });
+
+    it('renders other projects', async () => {
+      await element.updateComplete;
+
+      const projects = element.shadowRoot.querySelectorAll(
+          '.other-projects > .project');
+
+      assert.equal(projects.length, 2);
+      assert.include(projects[0].querySelector('h3').textContent, 'test');
+      assert.include(projects[0].textContent, 'Hmm');
+
+      assert.include(projects[1].querySelector('h3').textContent, 'a-project');
+      assert.include(projects[1].textContent, 'I am Monkeyrail');
+    });
+  });
+});
+
+describe('mr-projects-page (connected)', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    sinon.spy(users, 'gatherProjectMemberships');
+    sinon.spy(stars, 'listProjects');
+
+    element = document.createElement('mr-projects-page');
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+
+    prpcClient.call.restore();
+    users.gatherProjectMemberships.restore();
+    stars.listProjects.restore();
+  });
+
+  it('fetches projects when connected', async () => {
+    const promise = Promise.resolve({
+      projects: [{name: 'projects/proj', displayName: 'proj',
+        summary: 'test'}],
+    });
+    prpcClient.call.returns(promise);
+
+    assert.isFalse(element._isFetchingProjects);
+    sinon.assert.notCalled(prpcClient.call);
+
+    // Trigger connectedCallback().
+    document.body.appendChild(element);
+    await stateUpdated, element.updateComplete;
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.v3.Projects',
+        'ListProjects', {});
+
+    assert.isFalse(element._isFetchingProjects);
+    assert.deepEqual(element._projects,
+        [{name: 'projects/proj', displayName: 'proj',
+          summary: 'test'}]);
+  });
+
+  it('does not gather projects when user is logged out', async () => {
+    document.body.appendChild(element);
+    element._currentUser = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(users.gatherProjectMemberships);
+  });
+
+  it('gathers user projects when user is logged in', async () => {
+    document.body.appendChild(element);
+    element._currentUser = 'users/1234';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(users.gatherProjectMemberships);
+    sinon.assert.calledWith(users.gatherProjectMemberships, 'users/1234');
+  });
+
+  it('does not fetch stars user is logged out', async () => {
+    document.body.appendChild(element);
+    element._currentUser = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(stars.listProjects);
+  });
+
+  it('fetches stars when user is logged in', async () => {
+    document.body.appendChild(element);
+    element._currentUser = 'users/1234';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(stars.listProjects);
+    sinon.assert.calledWith(stars.listProjects, 'users/1234');
+  });
+});