Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.js b/static_src/elements/framework/mr-star/mr-issue-star.js
new file mode 100644
index 0000000..5255820
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.js
@@ -0,0 +1,110 @@
+// 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 {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import {MrStar} from './mr-star.js';
+
+
+/**
+ * `<mr-issue-star>`
+ *
+ * A button for starring an issue.
+ *
+ */
+export class MrIssueStar extends connectStore(MrStar) {
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * A reference to the issue that the star button interacts with.
+       */
+      issueRef: {type: Object},
+      /**
+       * Whether the issue is starred (used for accessing easily).
+       */
+      _starredIssues: {type: Set},
+      /**
+       * Whether the issue's star state is being fetched. This is taken from
+       * the component's parent, which is expected to handle fetching initial
+       * star state for an issue.
+       */
+      _fetchingIsStarred: {type: Boolean},
+      /**
+       * A Map of all issues currently being starred.
+       */
+      _starringIssues: {type: Object},
+      /**
+       * The currently logged in user. Required to determine if the user can
+       * star.
+       */
+      _currentUserName: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /**
+     * @type {IssueRef}
+     */
+    this.issueRef = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._currentUserName = users.currentUserName(state);
+
+    // TODO(crbug.com/monorail/7374): Remove references to issueV0 in
+    // <mr-star>.
+    this._starringIssues = issueV0.starringIssues(state);
+    this._starredIssues = issueV0.starredIssues(state);
+    this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+  }
+
+  /** @override */
+  get type() {
+    return 'issue';
+  }
+
+  /**
+   * @return {boolean} Whether there's an in-flight star request.
+   */
+  get _isStarring() {
+    const requestKey = issueRefToString(this.issueRef);
+    if (this._starringIssues.has(requestKey)) {
+      return this._starringIssues.get(requestKey).requesting;
+    }
+    return false;
+  }
+
+  /** @override */
+  get isLoggedIn() {
+    return !!this._currentUserName;
+  }
+
+  /** @override */
+  get requesting() {
+    return this._fetchingIsStarred || this._isStarring;
+  }
+
+  /** @override */
+  get isStarred() {
+    return this._starredIssues.has(issueRefToString(this.issueRef));
+  }
+
+  /** @override */
+  star() {
+    store.dispatch(issueV0.star(this.issueRef, true));
+  }
+
+  /** @override */
+  unstar() {
+    store.dispatch(issueV0.star(this.issueRef, false));
+  }
+}
+
+customElements.define('mr-issue-star', MrIssueStar);
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.test.js b/static_src/elements/framework/mr-star/mr-issue-star.test.js
new file mode 100644
index 0000000..bb618f7
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.test.js
@@ -0,0 +1,85 @@
+// 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 {MrIssueStar} from './mr-issue-star.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import sinon from 'sinon';
+
+
+let element;
+
+describe('mr-issue-star', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-star');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueStar);
+  });
+
+  it('starring logins user when user is not logged in', async () => {
+    element._currentUserName = undefined;
+    sinon.stub(element, 'login');
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    star.click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  it('_isStarring true only when issue ref is being starred', async () => {
+    element._starringIssues = new Map([['chromium:22', {requesting: true}]]);
+    element.issueRef = {projectName: 'chromium', localId: 5};
+
+    assert.isFalse(element._isStarring);
+
+    element.issueRef = {projectName: 'chromium', localId: 22};
+
+    assert.isTrue(element._isStarring);
+
+    element._starringIssues = new Map([['chromium:22', {requesting: false}]]);
+
+    assert.isFalse(element._isStarring);
+  });
+
+  it('starring is disabled when _isStarring true', () => {
+    element._currentUserName = 'users/1234';
+    sinon.stub(element, '_isStarring').get(() => true);
+
+    assert.isFalse(element._starringEnabled);
+  });
+
+  it('starring is disabled when _fetchingIsStarred true', () => {
+    element._currentUserName = 'users/1234';
+    element._fetchingIsStarred = true;
+
+    assert.isFalse(element._starringEnabled);
+  });
+
+  it('_starredIssues changes displayed icon', async () => {
+    element.issueRef = {projectName: 'proj', localId: 1};
+
+    element._starredIssues = new Set([issueRefToString(element.issueRef)]);
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+    assert.equal(star.textContent.trim(), 'star');
+
+    element._starredIssues = new Set();
+
+    await element.updateComplete;
+
+    assert.equal(star.textContent.trim(), 'star_border');
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-project-star.js b/static_src/elements/framework/mr-star/mr-project-star.js
new file mode 100644
index 0000000..14b2c73
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.js
@@ -0,0 +1,148 @@
+// 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 {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {projectAndUserToStarName} from 'shared/converters.js';
+import {MrStar} from './mr-star.js';
+import 'shared/typedef.js';
+
+
+/**
+ * `<mr-project-star>`
+ *
+ * A button for starring a project.
+ *
+ */
+export class MrProjectStar extends connectStore(MrStar) {
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Resource name of the project being starred.
+       */
+      name: {type: String},
+      /**
+       * List of all stars, indexed by star name.
+       */
+      _stars: {type: Object},
+      /**
+       * Whether project stars are currently being fetched.
+       */
+      _fetchingStars: {type: Boolean},
+      /**
+       * Request data for projects currently being starred.
+       */
+      _starringProjects: {type: Object},
+      /**
+       * Request data for projects currently being unstarred.
+       */
+      _unstarringProjects: {type: Object},
+      /**
+       * The currently logged in user. Required to determine if the user can
+       * star.
+       */
+      _currentUserName: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {string} */
+    this.name = undefined;
+
+    /** @type {boolean} */
+    this._fetchingStars = false;
+
+    /** @type {Object<ProjectStarName, ReduxRequestState>} */
+    this._starringProjects = {};
+
+    /** @type {Object<ProjectStarName, ReduxRequestState>} */
+    this._unstarringProjects = {};
+
+    /** @type {Object<StarName, Star>} */
+    this._stars = {};
+
+    /** @type {string} */
+    this._currentUserName = undefined;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._currentUserName = users.currentUserName(state);
+
+    this._stars = stars.byName(state);
+
+    const requests = stars.requests(state);
+    this._fetchingStars = requests.listProjects.requesting;
+    this._starringProjects = requests.starProject;
+    this._unstarringProjects = requests.unstarProject;
+  }
+
+  /** @override */
+  get type() {
+    return 'project';
+  }
+
+  /**
+   * @return {string} The resource name of the ProjectStar.
+   */
+  get _starName() {
+    return projectAndUserToStarName(this.name, this._currentUserName);
+  }
+
+  /**
+   * @return {ProjectStar} The ProjectStar object for the referenced project,
+   *   if one exists.
+   */
+  get _projectStar() {
+    const name = this._starName;
+    if (!(name in this._stars)) return {};
+    return this._stars[name];
+  }
+
+  /**
+   * @return {boolean} Whether there's an in-flight star request.
+   */
+  get _isStarring() {
+    const requestKey = this._starName;
+    if (requestKey in this._starringProjects &&
+        this._starringProjects[requestKey].requesting) {
+      return true;
+    }
+    if (requestKey in this._unstarringProjects &&
+        this._unstarringProjects[requestKey].requesting) {
+      return true;
+    }
+    return false;
+  }
+
+  /** @override */
+  get isLoggedIn() {
+    return !!this._currentUserName;
+  }
+
+  /** @override */
+  get requesting() {
+    return this._fetchingStars || this._isStarring;
+  }
+
+  /** @override */
+  get isStarred() {
+    return !!(this._projectStar && this._projectStar.name);
+  }
+
+  /** @override */
+  star() {
+    store.dispatch(stars.starProject(this.name, this._currentUserName));
+  }
+
+  /** @override */
+  unstar() {
+    store.dispatch(stars.unstarProject(this.name, this._currentUserName));
+  }
+}
+
+customElements.define('mr-project-star', MrProjectStar);
diff --git a/static_src/elements/framework/mr-star/mr-project-star.test.js b/static_src/elements/framework/mr-star/mr-project-star.test.js
new file mode 100644
index 0000000..6afd982
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.test.js
@@ -0,0 +1,181 @@
+// 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 sinon from 'sinon';
+import {MrProjectStar} from './mr-project-star.js';
+import {stars} from 'reducers/stars.js';
+
+let element;
+
+describe('mr-project-star (disconnected)', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-project-star');
+    document.body.appendChild(element);
+
+    sinon.stub(element, 'stateChanged');
+    sinon.spy(stars, 'starProject');
+    sinon.spy(stars, 'unstarProject');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    stars.starProject.restore();
+    stars.unstarProject.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrProjectStar);
+  });
+
+  it('clicking on star when logged out logs in user', async () => {
+    element._currentUserName = undefined;
+    sinon.stub(element, 'login');
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    star.click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  it('star dispatches star request', () => {
+    element._currentUserName = 'users/1234';
+    element.name = 'projects/monorail';
+
+    element.star();
+
+    sinon.assert.calledWith(stars.starProject,
+        'projects/monorail', 'users/1234');
+  });
+
+  it('unstar dispatches unstar request', () => {
+    element._currentUserName = 'users/1234';
+    element.name = 'projects/monorail';
+
+    element.unstar();
+
+    sinon.assert.calledWith(stars.unstarProject,
+        'projects/monorail', 'users/1234');
+  });
+
+  describe('isStarred', () => {
+    beforeEach(() => {
+      element._stars = {
+        'users/1234/projectStars/monorail':
+            {name: 'users/1234/projectStars/monorail'},
+        'users/5678/projectStars/chromium':
+            {name: 'users/5678/projectStars/chromium'},
+      };
+    });
+
+    it('false when no data', () => {
+      element._stars = {};
+      assert.isFalse(element.isStarred);
+    });
+
+    it('false when user is not logged in', () => {
+      element._currentUserName = '';
+      element.name = 'projects/monorail';
+
+      assert.isFalse(element.isStarred);
+    });
+
+    it('false when project is not starred', () => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/chromium';
+
+      assert.isFalse(element.isStarred);
+
+      element._currentUserName = 'users/5678';
+      element.name = 'projects/monorail';
+
+      assert.isFalse(element.isStarred);
+    });
+
+    it('true when user has starred project', () => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/monorail';
+
+      assert.isTrue(element.isStarred);
+
+      element._currentUserName = 'users/5678';
+      element.name = 'projects/chromium';
+
+      assert.isTrue(element.isStarred);
+    });
+  });
+
+  describe('_starringEnabled', () => {
+    beforeEach(() => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/monorail';
+    });
+
+    it('disabled when user is not logged in', () => {
+      element._currentUserName = '';
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when stars are being fetched', () => {
+      element._fetchingStars = true;
+      element._starringProjects = {};
+      element._unstarringProjects = {};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user is starring project', () => {
+      element._fetchingStars = false;
+      element._starringProjects =
+          {'users/1234/projectStars/monorail': {requesting: true}};
+      element._unstarringProjects = {};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user is unstarring project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects =
+          {'users/1234/projectStars/monorail': {requesting: true}};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('enabled when user is starring an unrelated project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {
+        'users/1234/projectStars/chromium': {requesting: true},
+        'users/1234/projectStars/monorail': {requesting: false},
+      };
+      element._unstarringProjects = {};
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('enabled when user is unstarring an unrelated project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects = {
+        'users/1234/projectStars/chromium': {requesting: true},
+        'users/1234/projectStars/monorail': {requesting: false},
+      };
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('enabled when no in-flight requests', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects = {};
+
+      assert.isTrue(element._starringEnabled);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-star.js b/static_src/elements/framework/mr-star/mr-star.js
new file mode 100644
index 0000000..fe509be
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.js
@@ -0,0 +1,235 @@
+// 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';
+
+/**
+ * `<mr-star>`
+ *
+ * A button for starring a resource. Does not directly integrate with app
+ * state. Subclasses by <mr-issue-star> and <mr-project-star>, which add
+ * resource-specific logic for state management.
+ *
+ */
+export class MrStar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        --mr-star-size: var(--chops-icon-font-size);
+      }
+      button {
+        background: none;
+        border: none;
+        cursor: pointer;
+        padding: 0;
+        margin: 0;
+        display: flex;
+        align-items: center;
+      }
+      /* TODO(crbug.com/monorail/8008): Add nicer looking loading style. */
+      button.loading {
+        opacity: 0.5;
+        cursor: default;
+      }
+      i.material-icons {
+        font-size: var(--mr-star-size);
+        color: var(--chops-primary-icon-color);
+      }
+      i.material-icons.starred {
+        color: var(--chops-primary-accent-color);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    const {isStarred} = this;
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <button class="star-button"
+        @click=${this._loginOrStar}
+        title=${this._starToolTip}
+        role="checkbox"
+        aria-checked=${isStarred ? 'true' : 'false'}
+        class=${this.requesting ? 'loading' : ''}
+      >
+        ${isStarred ? html`
+          <i class="material-icons starred" role="presentation">
+            star
+          </i>
+        `: html`
+          <i class="material-icons" role="presentation">
+            star_border
+          </i>
+        `}
+      </button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Note: In order for re-renders to happen based on the getters defined
+       * in this class, those getters must have values based on properties.
+       * Subclasses of <mr-star> are not expected to inherit <mr-star>'s
+       * properties, but they should make sure their getter implementations
+       * are also backed by properties.
+       */
+      _isStarred: {type: Boolean},
+      _isLoggedIn: {type: Boolean},
+      _canStar: {type: Boolean},
+      _requesting: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /**
+     * @type {boolean} Whether the user has starred the resource or not.
+     */
+    this._isStarred = false;
+
+    /**
+     * @type {boolean} If the user is logged in.
+     */
+    this._isLoggedIn = false;
+
+    /**
+     * @return {boolean} Whether the user has permission to star the star.
+     */
+    this._canStar = true;
+
+    /**
+     * @return {boolean} Whether there's an in-flight request to star
+     * the resource.
+     */
+    this._requesting = false;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Prevent clicks on this element from causing navigation if the element
+    // is embedded inside a link.
+    this.addEventListener('click', (e) => e.preventDefault());
+  }
+
+  /**
+   * @return {boolean} If the user is logged in.
+   */
+  get isLoggedIn() {
+    return this._isLoggedIn;
+  }
+
+  /**
+   * @return {boolean} If there's an in-flight request that might affect the
+   *   star's data.
+   */
+  get requesting() {
+    return this._requesting;
+  }
+
+  /**
+   * @return {boolean} Whether the resource is starred or not.
+   */
+  get isStarred() {
+    return this._isStarred;
+  }
+
+  /**
+   * @return {boolean} If the user has permission to star.
+   */
+  get canStar() {
+    return this._canStar;
+  }
+
+  /**
+   * @return {boolean}
+   */
+  get _starringEnabled() {
+    return this.isLoggedIn && this.canStar && !this.requesting;
+  }
+
+  /**
+   * @return {string} The name of the resource kind being starred.
+   * ie: issue, project, etc.
+   */
+  get type() {
+    return 'resource';
+  }
+
+  /**
+   * @return {string} the title to display on the star button.
+   */
+  get _starToolTip() {
+    if (!this.isLoggedIn) {
+      return `Login to star this ${this.type}.`;
+    }
+    if (!this.canStar) {
+      return `You don't have permission to star this ${this.type}.`;
+    }
+    if (this.requesting) {
+      return `Loading star state for this ${this.type}.`;
+    }
+    return `${this.isStarred ? 'Unstar' : 'Star'} this ${this.type}.`;
+  }
+
+  /**
+   * Logins the user if they're not logged in. Otherwise, stars or
+   * unstars the resource based on star state.
+   */
+  _loginOrStar() {
+    if (!this.isLoggedIn) {
+      this.login();
+    } else {
+      this.toggleStar();
+    }
+  }
+
+  /**
+   * Logs in the user.
+   */
+  login() {
+    // TODO(crbug.com/monorail/6073): Replace this logic with a function call
+    // when moving authentication to frontend.
+    // HACK: In our current login implementation, login URLs can only be
+    // generated by the backend which makes piping a login URL into a component
+    // a <mr-star> complex. To get around this, we're using the
+    // legacy window.CS_env infrastructure.
+    window.location.href = window.CS_env.login_url;
+  }
+
+  /**
+   * Stars or unstars the resource based on the user's interaction.
+   */
+  toggleStar() {
+    if (!this._starringEnabled) return;
+    if (this.isStarred) {
+      this.unstar();
+    } else {
+      this.star();
+    }
+  }
+
+  /**
+   * Stars the given resource. To be implemented by a subclass.
+   */
+  star() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * Unstars the given resource. To be implemented by a subclass.
+   */
+  unstar() {
+    throw new Error('Method not implemented.');
+  }
+}
+
+customElements.define('mr-star', MrStar);
diff --git a/static_src/elements/framework/mr-star/mr-star.test.js b/static_src/elements/framework/mr-star/mr-star.test.js
new file mode 100644
index 0000000..4db7877
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.test.js
@@ -0,0 +1,302 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+
+import {MrStar} from './mr-star.js';
+
+let element;
+
+describe('mr-star', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-star');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrStar);
+  });
+
+  it('unimplemented methods throw errors', () => {
+    assert.throws(element.star, 'Method not implemented.');
+    assert.throws(element.unstar, 'Method not implemented.');
+  });
+
+  describe('clicking star toggles star state', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+      element._isLoggedIn = true;
+      element._canStar = true;
+    });
+
+    it('unstarred star', async () => {
+      element._isStarred = false;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.notCalled(element.unstar);
+
+      element.shadowRoot.querySelector('button').click();
+
+      sinon.assert.calledOnce(element.star);
+      sinon.assert.notCalled(element.unstar);
+    });
+
+    it('starred star', async () => {
+      element._isStarred = true;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.notCalled(element.unstar);
+
+      element.shadowRoot.querySelector('button').click();
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.calledOnce(element.unstar);
+    });
+  });
+
+  it('clicking while logged out logs you in', async () => {
+    sinon.stub(element, 'login');
+    element._isLoggedIn = false;
+    element._canStar = true;
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.login);
+
+    element.shadowRoot.querySelector('button').click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  describe('toggleStar', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+    });
+
+    it('stars when unstarred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = false;
+
+      element.toggleStar();
+
+      sinon.assert.calledOnce(element.star);
+      sinon.assert.notCalled(element.unstar);
+    });
+
+    it('unstars when starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.calledOnce(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when user is not logged in', () => {
+      element._isLoggedIn = false;
+      element._canStar = true;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when user does not have permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when stars are being fetched', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+  });
+
+  describe('_starringEnabled', () => {
+    it('enabled when user is logged in and has permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = false;
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('disabled when user is logged out', () => {
+      element._isLoggedIn = false;
+      element._canStar = false;
+      element._isStarred = false;
+      element._requesting = false;
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user has no permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      element._isStarred = true;
+      element._requesting = false;
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when requesting star', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = true;
+
+      assert.isFalse(element._starringEnabled);
+    });
+  });
+
+  it('loading state shown when requesting', async () => {
+    element._requesting = true;
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    assert.isTrue(star.classList.contains('loading'));
+
+    element._requesting = false;
+    await element.updateComplete;
+
+    assert.isFalse(star.classList.contains('loading'));
+  });
+
+  it('isStarred changes displayed icon', async () => {
+    element._isStarred = true;
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+    assert.equal(star.textContent.trim(), 'star');
+
+    element._isStarred = false;
+    await element.updateComplete;
+
+    assert.equal(star.textContent.trim(), 'star_border');
+  });
+
+  describe('mr-star nested inside a link', () => {
+    let parent;
+    let oldHash;
+
+    beforeEach(() => {
+      parent = document.createElement('a');
+      parent.setAttribute('href', '#test-hash');
+      parent.appendChild(element);
+
+      oldHash = window.location.hash;
+
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+    });
+
+    afterEach(() => {
+      window.location.hash = oldHash;
+    });
+
+    it('clicking to star does not cause navigation', async () => {
+      sinon.spy(element, 'toggleStar');
+      element._isLoggedIn = true;
+      element._canStar = true;
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('button').click();
+
+      assert.notEqual(window.location.hash, '#test-hash');
+      sinon.assert.calledOnce(element.toggleStar);
+    });
+
+    it('clicking on disabled star does not cause navigation', async () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('button').click();
+
+      assert.notEqual(window.location.hash, '#test-hash');
+    });
+
+    it('clicking on link still navigates', async () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      await element.updateComplete;
+
+      parent.click();
+
+      assert.equal(window.location.hash, '#test-hash');
+    });
+  });
+
+  describe('_starToolTip', () => {
+    it('not logged in', () => {
+      element._isLoggedIn = false;
+      element._canStar = false;
+      assert.equal(element._starToolTip,
+          `Login to star this resource.`);
+    });
+
+    it('no permission to star', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      assert.equal(element._starToolTip,
+          `You don't have permission to star this resource.`);
+    });
+
+    it('star is loading', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._requesting = true;
+      assert.equal(element._starToolTip,
+          `Loading star state for this resource.`);
+    });
+
+    it('issue is not starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = false;
+      assert.equal(element._starToolTip,
+          `Star this resource.`);
+    });
+
+    it('issue is starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      assert.equal(element._starToolTip,
+          `Unstar this resource.`);
+    });
+  });
+});