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:
Kuan-Hsuan (Kevin) Shen 2022-06-25 10:54:02 -04:00 коммит произвёл GitHub
Родитель ae669d7159
Коммит 40f7323144
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 335 добавлений и 272 удалений

Просмотреть файл

@ -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)

46
api/permissions_api.py Normal file
Просмотреть файл

@ -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:

Просмотреть файл

@ -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 %}