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.`);
+ });
+ });
+});