Add permissions API and move all feature content and js scripts into feature-page component (#1972)
* Add process and fieldDefs APIs and move components * Add processes, cues, fielddefs tests * Fix typos * Add `permissions_api` and clean up `feature.html` * Add permissions_api test and improve feature page test * Change TODOs, rename test response variables
This commit is contained in:
Родитель
ae669d7159
Коммит
40f7323144
|
@ -75,13 +75,12 @@ class CuesAPITest(testing_config.CustomTestCase):
|
|||
"""Anon should always have an empty list of dismissed cues."""
|
||||
testing_config.sign_out()
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get()
|
||||
self.assertEqual([], actual_response)
|
||||
actual = self.handler.do_get()
|
||||
self.assertEqual([], actual)
|
||||
|
||||
def test_get__signed_in(self):
|
||||
"""Signed-in user has dismissed a cue."""
|
||||
testing_config.sign_in('two@example.com', 123567890)
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get()
|
||||
self.assertEqual(['progress-checkmarks'], actual_response)
|
||||
|
||||
actual = self.handler.do_get()
|
||||
self.assertEqual(['progress-checkmarks'], actual)
|
||||
|
|
|
@ -33,12 +33,12 @@ class FieldDefsAPITest(testing_config.CustomTestCase):
|
|||
"""We can get field definitions as an anonymous."""
|
||||
testing_config.sign_out()
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get()
|
||||
self.assertEqual(guideforms.DISPLAY_FIELDS_IN_STAGES, actual_response)
|
||||
actual = self.handler.do_get()
|
||||
self.assertEqual(guideforms.DISPLAY_FIELDS_IN_STAGES, actual)
|
||||
|
||||
def test_get__signed_in(self):
|
||||
"""We can get field definitions as a signed-in user."""
|
||||
testing_config.sign_in('one@example.com', 123567890)
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get()
|
||||
self.assertEqual(guideforms.DISPLAY_FIELDS_IN_STAGES, actual_response)
|
||||
actual = self.handler.do_get()
|
||||
self.assertEqual(guideforms.DISPLAY_FIELDS_IN_STAGES, actual)
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2022 Google Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License")
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
from framework import basehandlers
|
||||
from framework import permissions
|
||||
from internals import approval_defs
|
||||
|
||||
|
||||
class PermissionsAPI(basehandlers.APIHandler):
|
||||
"""Permissions determine whether a user can create, approve,
|
||||
or edit any feature, or admin the site"""
|
||||
|
||||
def do_get(self):
|
||||
"""Return the permissions and the email of the user."""
|
||||
|
||||
# No user data if not signed in
|
||||
user_data = None
|
||||
|
||||
# get user permission data if signed in
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
field_id = approval_defs.ShipApproval.field_id
|
||||
approvers = approval_defs.get_approvers(field_id)
|
||||
user_data = {
|
||||
'can_create_feature': permissions.can_create_feature(user),
|
||||
'can_approve': permissions.can_approve_feature(
|
||||
user, None, approvers),
|
||||
'can_edit': permissions.can_edit_any_feature(user),
|
||||
'is_admin': permissions.can_admin_site(user),
|
||||
'email': user.email(),
|
||||
}
|
||||
|
||||
return {'user': user_data}
|
|
@ -0,0 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2022 Google Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License")
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import testing_config # Must be imported before the module under test.
|
||||
|
||||
import flask
|
||||
|
||||
from api import permissions_api
|
||||
from framework import ramcache
|
||||
|
||||
test_app = flask.Flask(__name__)
|
||||
|
||||
|
||||
class PermissionsAPITest(testing_config.CustomTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.handler = permissions_api.PermissionsAPI()
|
||||
self.request_path = '/api/v0/currentuser/permissions'
|
||||
|
||||
def tearDown(self):
|
||||
testing_config.sign_out()
|
||||
ramcache.flush_all()
|
||||
ramcache.check_for_distributed_invalidation()
|
||||
|
||||
def test_get__anon(self):
|
||||
"""Returns no user object if not signed in"""
|
||||
testing_config.sign_out()
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual = self.handler.do_get()
|
||||
self.assertEqual({'user': None}, actual)
|
||||
|
||||
def test_get__non_googler(self):
|
||||
"""Non-googlers have no permissions by default"""
|
||||
testing_config.sign_in('one@example.com', 12345)
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual = self.handler.do_get()
|
||||
expected = {
|
||||
'user': {
|
||||
'can_create_feature': False,
|
||||
'can_approve': False,
|
||||
'can_edit': False,
|
||||
'is_admin': False,
|
||||
'email': 'one@example.com'
|
||||
}}
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_get__googler(self):
|
||||
"""Googlers have default permissions to create feature and edit."""
|
||||
testing_config.sign_in('one@google.com', 67890)
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual = self.handler.do_get()
|
||||
expected = {
|
||||
'user': {
|
||||
'can_create_feature': True,
|
||||
'can_approve': False,
|
||||
'can_edit': True,
|
||||
'is_admin': False,
|
||||
'email': 'one@google.com'
|
||||
}}
|
||||
self.assertEqual(expected, actual)
|
|
@ -45,38 +45,38 @@ class ProcessesAPITest(testing_config.CustomTestCase):
|
|||
def test_get__default_feature_type(self):
|
||||
"""We can get process for features with the default feature type (New feature incubation)."""
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get(self.feature_id)
|
||||
expected_response = processes.process_to_dict(processes.BLINK_LAUNCH_PROCESS)
|
||||
self.assertEqual(expected_response, actual_response)
|
||||
actual = self.handler.do_get(self.feature_id)
|
||||
expected = processes.process_to_dict(processes.BLINK_LAUNCH_PROCESS)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_get__feature_type_0(self):
|
||||
"""We can get process for features with feature type 0 (New feature incubation)."""
|
||||
self.feature_1.feature_type = 0
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get(self.feature_id)
|
||||
expected_response = processes.process_to_dict(processes.BLINK_LAUNCH_PROCESS)
|
||||
self.assertEqual(expected_response, actual_response)
|
||||
actual = self.handler.do_get(self.feature_id)
|
||||
expected = processes.process_to_dict(processes.BLINK_LAUNCH_PROCESS)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_get__feature_type_1(self):
|
||||
"""We can get process for features with feature type 1 (Existing feature implementation)."""
|
||||
self.feature_1.feature_type = 1
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get(self.feature_id)
|
||||
expected_response = processes.process_to_dict(processes.BLINK_FAST_TRACK_PROCESS)
|
||||
self.assertEqual(expected_response, actual_response)
|
||||
actual = self.handler.do_get(self.feature_id)
|
||||
expected = processes.process_to_dict(processes.BLINK_FAST_TRACK_PROCESS)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_get__feature_type_2(self):
|
||||
"""We can get process for features with feature type 2 (Web developer facing change to existing code)."""
|
||||
self.feature_1.feature_type = 2
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get(self.feature_id)
|
||||
expected_response = processes.process_to_dict(processes.PSA_ONLY_PROCESS)
|
||||
self.assertEqual(expected_response, actual_response)
|
||||
actual = self.handler.do_get(self.feature_id)
|
||||
expected = processes.process_to_dict(processes.PSA_ONLY_PROCESS)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_get__feature_type_3(self):
|
||||
"""We can get process for features with feature type 3 (Feature deprecation)."""
|
||||
self.feature_1.feature_type = 3
|
||||
with test_app.test_request_context(self.request_path):
|
||||
actual_response = self.handler.do_get(self.feature_id)
|
||||
expected_response = processes.process_to_dict(processes.DEPRECATION_PROCESS)
|
||||
self.assertEqual(expected_response, actual_response)
|
||||
actual = self.handler.do_get(self.feature_id)
|
||||
expected = processes.process_to_dict(processes.DEPRECATION_PROCESS)
|
||||
self.assertEqual(expected, actual)
|
||||
|
|
|
@ -182,7 +182,7 @@ class SharedInvalidate(ndb.Model):
|
|||
|
||||
@classmethod
|
||||
def check_for_distributed_invalidation(cls):
|
||||
"""Check if any appengine instance has invlidated the cache."""
|
||||
"""Check if any appengine instance has invalidated the cache."""
|
||||
singleton = None
|
||||
entities = cls.query(ancestor=cls.PARENT_KEY).fetch(1)
|
||||
if entities:
|
||||
|
|
2
main.py
2
main.py
|
@ -26,6 +26,7 @@ from api import fielddefs_api
|
|||
from api import login_api
|
||||
from api import logout_api
|
||||
from api import metricsdata
|
||||
from api import permissions_api
|
||||
from api import processes_api
|
||||
from api import stars_api
|
||||
from api import token_refresh_api
|
||||
|
@ -102,6 +103,7 @@ api_routes = [
|
|||
|
||||
(API_BASE + '/login', login_api.LoginAPI),
|
||||
(API_BASE + '/logout', logout_api.LogoutAPI),
|
||||
(API_BASE + '/currentuser/permissions', permissions_api.PermissionsAPI),
|
||||
(API_BASE + '/currentuser/stars', stars_api.StarsAPI),
|
||||
(API_BASE + '/currentuser/cues', cues_api.CuesAPI),
|
||||
(API_BASE + '/currentuser/token', token_refresh_api.TokenRefreshAPI),
|
||||
|
|
|
@ -107,37 +107,5 @@ class FeatureDetailTemplateTest(TestWithFeature):
|
|||
"""We can render the template."""
|
||||
template_text = self.handler.render(
|
||||
self.template_data, self.full_template_path)
|
||||
self.assertIn('feature one', template_text)
|
||||
self.assertIn('detailed sum', template_text)
|
||||
|
||||
def test_html_rendering(self):
|
||||
"""We can render the template with valid html."""
|
||||
template_text = self.handler.render(
|
||||
self.template_data, self.full_template_path)
|
||||
parser = html5lib.HTMLParser(strict=True)
|
||||
document = parser.parse(template_text)
|
||||
|
||||
def test_links(self):
|
||||
"""We can generate clickable links."""
|
||||
self.template_data['new_crbug_url'] = 'fake crbug link'
|
||||
resources = self.template_data['feature']['resources']
|
||||
resources['samples'] = ['fake sample link one',
|
||||
'fake sample link two']
|
||||
resources['docs'] = ['fake doc link one',
|
||||
'fake doc link two']
|
||||
self.template_data['feature']['standards']['spec'] = 'fake spec link'
|
||||
self.template_data['feature']['tags'] = ['tag_one']
|
||||
|
||||
template_text = self.handler.render(
|
||||
self.template_data, self.full_template_path)
|
||||
|
||||
self.assertIn('href="fake crbug link"', template_text)
|
||||
self.assertIn('href="/features"', template_text)
|
||||
# TODO(kevinshen56714): ultimately remove all the tests here and convert
|
||||
# to js unit tests
|
||||
# self.assertIn('href="fake sample link one"', template_text)
|
||||
# self.assertIn('href="fake sample link two"', template_text)
|
||||
# self.assertIn('href="fake doc link one"', template_text)
|
||||
# self.assertIn('href="fake doc link two"', template_text)
|
||||
# self.assertIn('href="fake spec link"', template_text)
|
||||
# self.assertIn('href="/features#tags:tag_one"', template_text)
|
||||
self.assertIn('feature one', template_text) # still exists at the title section
|
||||
self.assertIn('detailed sum', template_text) # still exists at the meta section
|
||||
|
|
|
@ -58,49 +58,161 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
|
||||
static get properties() {
|
||||
return {
|
||||
user: {type: Object},
|
||||
featureId: {type: Number},
|
||||
feature: {type: Object},
|
||||
process: {type: Object},
|
||||
fieldDefs: {type: Object},
|
||||
dismissedCues: {type: Array},
|
||||
contextLink: {type: String},
|
||||
toastEl: {type: Element},
|
||||
starred: {type: Boolean},
|
||||
loading: {attribute: false},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.user = {};
|
||||
this.featureId = 0;
|
||||
this.feature = {};
|
||||
this.process = {};
|
||||
this.fieldDefs = {};
|
||||
this.dismissedCues = [];
|
||||
this.contextLink = '';
|
||||
this.toastEl = document.querySelector('chromedash-toast');
|
||||
this.starred = false;
|
||||
this.loading = true;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.fetchFeatureData();
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
fetchFeatureData() {
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
Promise.all([
|
||||
window.csClient.getPermissions(),
|
||||
window.csClient.getFeature(this.featureId),
|
||||
window.csClient.getFeatureProcess(this.featureId),
|
||||
window.csClient.getFieldDefs(),
|
||||
window.csClient.getDismissedCues(),
|
||||
]).then(([feature, process, fieldDefs, dismissedCues]) => {
|
||||
window.csClient.getStars(),
|
||||
]).then(([permissionsRes, feature, process, fieldDefs, dismissedCues, subscribedFeatures]) => {
|
||||
this.user = permissionsRes.user;
|
||||
this.feature = feature;
|
||||
this.process = process;
|
||||
this.fieldDefs = fieldDefs;
|
||||
this.dismissedCues = dismissedCues;
|
||||
|
||||
if (subscribedFeatures.includes(this.featureId)) {
|
||||
this.starred = true;
|
||||
}
|
||||
this.loading = false;
|
||||
|
||||
// TODO(kevinshen56714): Remove this once SPA index page is set up.
|
||||
// Has to include this for now to remove the spinner at _base.html.
|
||||
document.body.classList.remove('loading');
|
||||
}).catch(() => {
|
||||
const toastEl = document.querySelector('chromedash-toast');
|
||||
toastEl.showMessage('Some errors occurred. Please refresh the page or try again later.');
|
||||
this.toastEl.showMessage('Some errors occurred. Please refresh the page or try again later.');
|
||||
});
|
||||
}
|
||||
|
||||
handleStarClick(e) {
|
||||
e.preventDefault();
|
||||
window.csClient.setStar(this.featureId, !this.starred).then(() => {
|
||||
this.starred = !this.starred;
|
||||
});
|
||||
}
|
||||
|
||||
handleShareClick(e) {
|
||||
e.preventDefault();
|
||||
if (navigator.share) {
|
||||
const url = '/feature/' + this.featureId;
|
||||
navigator.share({
|
||||
title: this.feature.name,
|
||||
text: this.feature.summary,
|
||||
url: url,
|
||||
}).then(() => {
|
||||
ga('send', 'social',
|
||||
{
|
||||
'socialNetwork': 'web',
|
||||
'socialAction': 'share',
|
||||
'socialTarget': url,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleCopyLinkClick(e) {
|
||||
e.preventDefault();
|
||||
const url = e.currentTarget.href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.toastEl.showMessage('Link copied');
|
||||
});
|
||||
}
|
||||
|
||||
handleApprovalClick(e) {
|
||||
e.preventDefault();
|
||||
const dialog = this.shadowRoot.querySelector('chromedash-approvals-dialog');
|
||||
dialog.openWithFeature(this.featureId);
|
||||
}
|
||||
|
||||
renderSubHeader() {
|
||||
return html`
|
||||
<div id="subheader" style="display:block">
|
||||
<div class="tooltips" style="float:right">
|
||||
${this.user ? html`
|
||||
<span class="tooltip" title="Receive an email notification when there are updates">
|
||||
<a href="#" data-tooltip id="star-when-signed-in" @click=${this.handleStarClick}>
|
||||
<iron-icon icon=${this.starred ? 'chromestatus:star' : 'chromestatus:star-border'} class="pushicon"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
`: nothing}
|
||||
<span class="tooltip" title="File a bug against this feature">
|
||||
<a href=${this.feature.new_crbug_url} class="newbug" data-tooltip target="_blank" rel="noopener">
|
||||
<iron-icon icon="chromestatus:bug-report"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
<span class="tooltip ${navigator.share ? '' : 'no-web-share'}" title="Share this feature">
|
||||
<a href="#" data-tooltip id="share-feature" @click=${this.handleShareClick}>
|
||||
<iron-icon icon="chromestatus:share"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
<span class="tooltip copy-to-clipboard" title="Copy link to clipboard">
|
||||
<a href="/feature/${this.featureId}" data-tooltip id="copy-link" @click=${this.handleCopyLinkClick}>
|
||||
<iron-icon icon="chromestatus:link"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
${this.user && this.user.can_approve ? html`
|
||||
<span class="tooltip" title="Review approvals">
|
||||
<a href="#" id="approvals-icon" data-tooltip @click=${this.handleApprovalClick}>
|
||||
<iron-icon icon="chromestatus:approval"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
`: nothing}
|
||||
${this.user && this.user.can_edit ? html`
|
||||
<span class="tooltip" title="Edit this feature">
|
||||
<a href="/guide/edit/${this.featureId}" class="editfeature" data-tooltip>
|
||||
<iron-icon icon="chromestatus:create"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
`: nothing}
|
||||
</div>
|
||||
<h2 id="breadcrumbs">
|
||||
<a href="${this.contextLink}">
|
||||
<iron-icon icon="chromestatus:arrow-back"></iron-icon>
|
||||
</a>
|
||||
<a href="/feature/${this.featureId}">
|
||||
Feature: ${this.feature.name}
|
||||
</a>
|
||||
(${this.feature.browsers.chrome.status.text})
|
||||
</h2>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFeatureContent() {
|
||||
return html`
|
||||
${this.feature.unlisted ? html`
|
||||
|
@ -267,17 +379,31 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
.dismissedCues=${this.dismissedCues}>
|
||||
</chromedash-feature-detail>
|
||||
</sl-details>
|
||||
|
||||
${this.user && this.user.can_approve ? html`
|
||||
<chromedash-approvals-dialog
|
||||
signedInUser="${this.user.email}">
|
||||
</chromedash-approvals-dialog>
|
||||
`: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
// TODO: Create precomiled main, forms, and guide css files,
|
||||
// and import them instead of inlining them here
|
||||
// TODO: create another element - chromedash-feature-highlights
|
||||
// for all the content of the <div id="feature"> part of the page
|
||||
return html`
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<link rel="stylesheet" href="/static/css/forms.css">
|
||||
<link rel="stylesheet" href="/static/css/guide.css">
|
||||
${this.loading ?
|
||||
html`
|
||||
<div class="loading">
|
||||
<div id="spinner"><img src="/static/img/ring.svg"></div>
|
||||
</div>` :
|
||||
html`
|
||||
${this.renderSubHeader()}
|
||||
<div id="feature">
|
||||
${this.renderFeatureContent()}
|
||||
${this.renderFeatureStatus()}
|
||||
|
|
|
@ -6,6 +6,15 @@ import '../js-src/cs-client';
|
|||
import sinon from 'sinon';
|
||||
|
||||
describe('chromedash-feature-page', () => {
|
||||
const permissionsPromise = Promise.resolve({
|
||||
user: {
|
||||
can_approve: false,
|
||||
can_create_feature: true,
|
||||
can_edit: true,
|
||||
is_admin: false,
|
||||
email: 'example@google.com',
|
||||
},
|
||||
});
|
||||
const processPromise = Promise.resolve({
|
||||
name: 'fake process',
|
||||
stages: [{name: 'fake stage name', outgoing_stage: 1}],
|
||||
|
@ -14,6 +23,7 @@ describe('chromedash-feature-page', () => {
|
|||
1: ['fake field one', 'fake field two', 'fake field three'],
|
||||
});
|
||||
const dismissedCuesPromise = Promise.resolve(['progress-checkmarks']);
|
||||
const starsPromise = Promise.resolve([123456]);
|
||||
|
||||
/* window.csClient and <chromedash-toast> are initialized at _base.html
|
||||
* which are not available here, so we initialize them before each test.
|
||||
|
@ -21,20 +31,26 @@ describe('chromedash-feature-page', () => {
|
|||
beforeEach(async () => {
|
||||
await fixture(html`<chromedash-toast></chromedash-toast>`);
|
||||
window.csClient = new ChromeStatusClient('fake_token', 1);
|
||||
sinon.stub(window.csClient, 'getPermissions');
|
||||
sinon.stub(window.csClient, 'getFeature');
|
||||
sinon.stub(window.csClient, 'getFeatureProcess');
|
||||
sinon.stub(window.csClient, 'getFieldDefs');
|
||||
sinon.stub(window.csClient, 'getDismissedCues');
|
||||
sinon.stub(window.csClient, 'getStars');
|
||||
window.csClient.getPermissions.returns(permissionsPromise);
|
||||
window.csClient.getFeatureProcess.returns(processPromise);
|
||||
window.csClient.getFieldDefs.returns(fieldDefsPromise);
|
||||
window.csClient.getDismissedCues.returns(dismissedCuesPromise);
|
||||
window.csClient.getStars.returns(starsPromise);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.csClient.getPermissions.restore();
|
||||
window.csClient.getFeature.restore();
|
||||
window.csClient.getFeatureProcess.restore();
|
||||
window.csClient.getFieldDefs.restore();
|
||||
window.csClient.getDismissedCues.restore();
|
||||
window.csClient.getStars.restore();
|
||||
});
|
||||
|
||||
it('renders with no data', async () => {
|
||||
|
@ -55,8 +71,12 @@ describe('chromedash-feature-page', () => {
|
|||
|
||||
it('renders with fake data', async () => {
|
||||
const featureId = 123456;
|
||||
const contextLink = '/features';
|
||||
const validFeaturePromise = Promise.resolve({
|
||||
id: 123456,
|
||||
name: 'feature one',
|
||||
summary: 'detailed sum',
|
||||
new_crbug_url: 'fake crbug link',
|
||||
browsers: {
|
||||
chrome: {
|
||||
blink_component: ['Blink'],
|
||||
|
@ -82,25 +102,55 @@ describe('chromedash-feature-page', () => {
|
|||
const component = await fixture(
|
||||
html`<chromedash-feature-page
|
||||
.featureId=${featureId}
|
||||
.contextLink=${contextLink}
|
||||
></chromedash-feature-page>`);
|
||||
assert.exists(component);
|
||||
|
||||
const subheaderDiv = component.shadowRoot.querySelector('div#subheader');
|
||||
assert.exists(subheaderDiv);
|
||||
// crbug link is clickable
|
||||
assert.include(subheaderDiv.innerHTML, 'href="fake crbug link"');
|
||||
// star icon is rendered and the feature is starred
|
||||
assert.include(subheaderDiv.innerHTML, 'icon="chromestatus:star"');
|
||||
// edit icon is rendered (the test user can edit)
|
||||
assert.include(subheaderDiv.innerHTML, 'icon="chromestatus:create"');
|
||||
// approval icon is not rendered (the test user cannot approve)
|
||||
assert.notInclude(subheaderDiv.innerHTML, 'icon="chromestatus:approval"');
|
||||
|
||||
const breadcrumbsH2 = component.shadowRoot.querySelector('h2#breadcrumbs');
|
||||
assert.exists(breadcrumbsH2);
|
||||
// feature name is rendered
|
||||
assert.include(breadcrumbsH2.innerHTML, 'feature one');
|
||||
// context link is clickable
|
||||
assert.include(breadcrumbsH2.innerHTML, 'href="/features"');
|
||||
// feature link is clickable
|
||||
assert.include(breadcrumbsH2.innerHTML, 'href="/feature/123456');
|
||||
|
||||
const summarySection = component.shadowRoot.querySelector('section#summary');
|
||||
assert.exists(summarySection);
|
||||
// feature summary is rendered
|
||||
assert.include(summarySection.innerHTML, 'detailed sum');
|
||||
|
||||
const sampleSection = component.shadowRoot.querySelector('section#demo');
|
||||
assert.exists(sampleSection);
|
||||
// sample links are clickable
|
||||
assert.include(sampleSection.innerHTML, 'href="fake sample link one"');
|
||||
assert.include(sampleSection.innerHTML, 'href="fake sample link two"');
|
||||
|
||||
const docSection = component.shadowRoot.querySelector('section#documentation');
|
||||
assert.exists(docSection);
|
||||
// doc links are clickable
|
||||
assert.include(docSection.innerHTML, 'href="fake doc link one"');
|
||||
assert.include(docSection.innerHTML, 'href="fake doc link two"');
|
||||
|
||||
const specSection = component.shadowRoot.querySelector('section#specification');
|
||||
assert.exists(specSection);
|
||||
// spec link is clickable
|
||||
assert.include(specSection.innerHTML, 'href="fake spec link"');
|
||||
|
||||
const tagSection = component.shadowRoot.querySelector('section#tags');
|
||||
assert.exists(tagSection);
|
||||
// feature tag link is clickable
|
||||
assert.include(tagSection.innerHTML, 'href="/features#tags:tag_one"');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -155,6 +155,11 @@ class ChromeStatusClient {
|
|||
// TODO: catch((error) => { display message }
|
||||
}
|
||||
|
||||
// Permissions API
|
||||
getPermissions() {
|
||||
return this.doGet('/currentuser/permissions');
|
||||
}
|
||||
|
||||
// Star API
|
||||
|
||||
getStars() {
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
(function(exports) {
|
||||
const toastEl = document.querySelector('chromedash-toast');
|
||||
const copyLinkEl = document.querySelector('#copy-link');
|
||||
const approvalsIconEl = document.querySelector('#approvals-icon');
|
||||
|
||||
// Event handler. Used in feature.html template.
|
||||
const subscribeToFeature = (featureId) => {
|
||||
const iconEl = document.querySelector('.pushicon');
|
||||
if (iconEl.icon === 'chromestatus:star') {
|
||||
window.csClient.setStar(featureId, false).then(() => {
|
||||
iconEl.icon = 'chromestatus:star-border';
|
||||
});
|
||||
} else {
|
||||
window.csClient.setStar(featureId, true).then(() => {
|
||||
iconEl.icon = 'chromestatus:star';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Event handler. Used in feature.html template.
|
||||
const shareFeature = () => {
|
||||
if (navigator.share) {
|
||||
const url = '/feature/' + FEATURE_ID;
|
||||
navigator.share({
|
||||
title: FEATURE_NAME,
|
||||
text: FEATUER_SUMMARY,
|
||||
url: url,
|
||||
}).then(() => {
|
||||
ga('send', 'social',
|
||||
{
|
||||
'socialNetwork': 'web',
|
||||
'socialAction': 'share',
|
||||
'socialTarget': url,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function copyURLToClipboard() {
|
||||
const url = copyLinkEl.href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toastEl.showMessage('Link copied');
|
||||
});
|
||||
}
|
||||
|
||||
function openApprovalsDialog() {
|
||||
const dialog = document.querySelector('chromedash-approvals-dialog');
|
||||
dialog.openWithFeature(Number(FEATURE_ID));
|
||||
}
|
||||
|
||||
// Remove loading spinner at page load.
|
||||
document.body.classList.remove('loading');
|
||||
|
||||
// Unhide "Web Share" feature if browser supports it.
|
||||
if (navigator.share) {
|
||||
Array.from(document.querySelectorAll('.no-web-share')).forEach((el) => {
|
||||
el.classList.remove('no-web-share');
|
||||
});
|
||||
}
|
||||
|
||||
const shareFeatureEl = document.querySelector('#share-feature');
|
||||
if (shareFeatureEl) {
|
||||
shareFeatureEl.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
shareFeature();
|
||||
});
|
||||
}
|
||||
|
||||
if (copyLinkEl) {
|
||||
copyLinkEl.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
copyURLToClipboard();
|
||||
});
|
||||
}
|
||||
|
||||
if (approvalsIconEl) {
|
||||
approvalsIconEl.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
openApprovalsDialog();
|
||||
});
|
||||
}
|
||||
|
||||
// Show the star icon if the user has starred this feature.
|
||||
window.csClient.getStars().then((subscribedFeatures) => {
|
||||
const iconEl = document.querySelector('.pushicon');
|
||||
if (subscribedFeatures.includes(Number(FEATURE_ID))) {
|
||||
iconEl.icon = 'chromestatus:star';
|
||||
} else {
|
||||
iconEl.icon = 'chromestatus:star-border';
|
||||
}
|
||||
});
|
||||
|
||||
const starWhenSignedInEl = document.querySelector('#star-when-signed-in');
|
||||
if (starWhenSignedInEl) {
|
||||
starWhenSignedInEl.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
subscribeToFeature(Number(FEATURE_ID));
|
||||
});
|
||||
}
|
||||
|
||||
if (SHOW_TOAST) {
|
||||
setTimeout(() => {
|
||||
toastEl.showMessage('Your feature was saved! It may take a few minutes to ' +
|
||||
'show up in the main list.', null, null, -1);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Open an accordion that was bookmarked open
|
||||
if (location.hash) {
|
||||
const targetId = decodeURIComponent(location.hash.substr(1));
|
||||
const targetEl = document.getElementById(targetId);
|
||||
if (targetEl && targetEl.tagName == 'CHROMEDASH-ACCORDION') {
|
||||
targetEl.opened = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
exports.subscribeToFeature = subscribeToFeature;
|
||||
exports.shareFeature = shareFeature;
|
||||
})(window);
|
|
@ -9,99 +9,14 @@
|
|||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<link rel="stylesheet" href="/static/css/forms.css?v={{app_version}}">
|
||||
<link rel="stylesheet" href="/static/css/guide.css?v={{app_version}}">
|
||||
{% endblock %}
|
||||
|
||||
{% block rss %}
|
||||
<link rel="alternate" type="application/rss+xml" href="https://chromestatus.com/features.xml" title="All features" />
|
||||
{% endblock %}
|
||||
|
||||
{% block subheader %}
|
||||
<div id="subheader" style="display:block">
|
||||
<div class="tooltips" style="float:right">
|
||||
{% if user %}
|
||||
<span class="tooltip" title="Receive an email notification when there are updates">
|
||||
<a href="#" data-tooltip id="star-when-signed-in">
|
||||
<iron-icon icon="chromestatus:star-border"
|
||||
class="pushicon"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="tooltip" title="File a bug against this feature">
|
||||
<a href="{{ new_crbug_url }}" class="newbug" data-tooltip target="_blank" rel="noopener">
|
||||
<iron-icon icon="chromestatus:bug-report"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
<span class="tooltip no-web-share" title="Share this feature">
|
||||
<a href="#" data-tooltip id="share-feature">
|
||||
<iron-icon icon="chromestatus:share"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
<span class="tooltip copy-to-clipboard" title="Copy link to clipboard">
|
||||
<a href="/feature/{{ feature.id }}" data-tooltip id="copy-link">
|
||||
<iron-icon icon="chromestatus:link"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
{% if user.can_approve %}
|
||||
<span class="tooltip" title="Review approvals">
|
||||
<a href="#" id="approvals-icon" data-tooltip>
|
||||
<iron-icon icon="chromestatus:approval"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if user.can_edit %}
|
||||
<span class="tooltip" title="Edit this feature">
|
||||
<a href="/guide/edit/{{ feature.id }}" class="editfeature" data-tooltip>
|
||||
<iron-icon icon="chromestatus:create"></iron-icon>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 id="breadcrumbs">
|
||||
<a href="{{ context_link }}">
|
||||
<iron-icon icon="chromestatus:arrow-back"></iron-icon>
|
||||
</a>
|
||||
<a href="/feature/{{ feature.id }}">
|
||||
Feature: {{ feature.name }}
|
||||
</a>
|
||||
{% if feature.browsers.chrome.status.text == "No longer pursuing" %} (No longer pursuing){% endif %}
|
||||
{% if feature.browsers.chrome.status.text == "Deprecated" %} (deprecated){% endif %}
|
||||
{% if feature.browsers.chrome.status.text == "Removed" %} (removed){% endif %}
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<!-- TODO(kevinshen56714): get feature_id from SPA router -->
|
||||
{# TODO(kevinshen56714): get feature_id and context_link from SPA router #}
|
||||
<chromedash-feature-page
|
||||
featureId='{{ feature_id }}'>
|
||||
featureId='{{ feature_id }}'
|
||||
contextLink='{{ context_link }}'>
|
||||
</chromedash-feature-page>
|
||||
|
||||
{% if user and user.can_approve %}
|
||||
<chromedash-approvals-dialog
|
||||
signedInUser="{{user.email}}">
|
||||
</chromedash-approvals-dialog>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script nonce="{{nonce}}">
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Variables used in feature-page.js
|
||||
const FEATURE_ID = '{{ feature.id }}';
|
||||
const FEATURE_NAME = '{{ feature.name|escapejs }}';
|
||||
const FEATUER_SUMMARY = '{{ feature.summary|escapejs }}';
|
||||
const SHOW_TOAST = {% if was_updated %}true{% else %}false{% endif %};
|
||||
|
||||
{% inline_file "/static/js/feature-page.min.js" %}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
Загрузка…
Ссылка в новой задаче