Migrate admin/blink.html to SPA [5/5] (#2743)

* Migrate admin/blink.html to SPA

Add implementations of the three new API routes
Remove old code for templated admin/blink.html

* Remove old handler and template

* start of tests

* fix tests

* temp

* separate into smaller elements

* add more python tests

* fix lint for optional values

* add tests

* fix lint error

* update model names

* use global

* fix test

* fix jsdoc from renaming

* address feedback
This commit is contained in:
James C Scott III 2023-03-24 18:25:18 -04:00 коммит произвёл GitHub
Родитель 42817a703b
Коммит f33576a5f3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
17 изменённых файлов: 1121 добавлений и 623 удалений

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

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Copyright 2023 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 user_models
class ComponentUsersAPI(basehandlers.APIHandler):
def __update_subscribers_list(
self, add=True, user_id=None, blink_component_id=None, primary=False):
if not user_id or not blink_component_id:
return False
user = user_models.FeatureOwner.get_by_id(int(user_id))
if not user:
return True
if primary:
if add:
user.add_as_component_owner(blink_component_id)
else:
user.remove_as_component_owner(blink_component_id)
else:
if add:
user.add_to_component_subscribers(blink_component_id)
else:
user.remove_from_component_subscribers(blink_component_id)
return True
def do_get(self, **kwargs):
"""In the future, this could be implemented."""
self.abort(405, valid_methods=['PUT', 'DELETE'])
@permissions.require_admin_site
def do_put(self, **kwargs) -> tuple[dict, int]:
params = self.request.get_json(force=True)
self.__update_subscribers_list(True, user_id=kwargs.get('user_id', None),
blink_component_id=kwargs.get('component_id', None),
primary=params.get('owner'))
return {}, 200
@permissions.require_admin_site
def do_delete(self, **kwargs) -> tuple[dict, int]:
params = self.request.get_json(force=True)
self.__update_subscribers_list(False, user_id=kwargs.get('user_id', None),
blink_component_id=kwargs.get('component_id', None),
primary=params.get('owner'))
return {}, 200

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

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# Copyright 2023 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 datetime
import flask
from google.cloud import ndb # type: ignore
from internals import user_models
from chromestatus_openapi.models import ComponentUsersRequest
from api import component_users
test_app = flask.Flask(__name__)
class ComponentUsersAPITest(testing_config.CustomTestCase):
def setUp(self):
self.handler = component_users.ComponentUsersAPI()
self.app_admin = user_models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
created = datetime.datetime(2022, 10, 28, 0, 0, 0)
self.component_1 = user_models.BlinkComponent(name='Blink', created=created, updated=created)
self.component_1.key = ndb.Key('BlinkComponent', 123)
self.component_1.put()
self.component_2 = user_models.BlinkComponent(name='Blink>Accessibility', created=created, updated=created)
self.component_2.key = ndb.Key('BlinkComponent', 234)
self.component_2.put()
self.component_owner_1 = user_models.FeatureOwner(
name='owner_1', email='owner_1@example.com',
primary_blink_components=[self.component_1.key, self.component_2.key],
blink_components=[self.component_1.key, self.component_2.key]
)
self.component_owner_1.key = ndb.Key('FeatureOwner', 111)
self.component_owner_1.put()
self.component_owner_2 = user_models.FeatureOwner(
name='owner_2', email='owner_2@example.com',
primary_blink_components=[self.component_1.key, self.component_2.key],
blink_components=[self.component_1.key, self.component_2.key]
)
self.component_owner_2.key = ndb.Key('FeatureOwner', 999)
self.component_owner_2.put()
self.watcher_1 = user_models.FeatureOwner(
name='watcher_1', email='watcher_1@example.com',
blink_components=[self.component_1.key, self.component_2.key],
watching_all_features=True)
self.watcher_1.key = ndb.Key('FeatureOwner', 222)
self.watcher_1.put()
self.no_body = user_models.FeatureOwner(
name='no_body', email='no_body@example.com',
watching_all_features=True)
self.no_body.key = ndb.Key('FeatureOwner', 444)
self.no_body.put()
def tearDown(self):
self.no_body.key.delete()
self.watcher_1.key.delete()
self.component_owner_1.key.delete()
self.component_owner_2.key.delete()
self.component_1.key.delete()
self.component_2.key.delete()
testing_config.sign_out()
self.app_admin.key.delete()
def test_do_put(self):
request_path = f'/api/v0/components/{self.component_2.key.integer_id()}/users/{self.no_body.key.integer_id()}'
user = user_models.FeatureOwner.get_by_id(self.no_body.key.integer_id())
self.assertEqual(user.blink_components, [])
self.assertEqual(user.primary_blink_components, [])
# Add user to component
testing_config.sign_in('admin@example.com', 123567890)
with test_app.test_request_context(request_path, json=ComponentUsersRequest().to_dict()):
response = self.handler.do_put(
component_id=self.component_2.key.integer_id(),
user_id=self.no_body.key.integer_id())
self.assertEqual(({}, 200), response)
user = user_models.FeatureOwner.get_by_id(self.no_body.key.integer_id())
self.assertEqual(user.blink_components, [self.component_2.key])
self.assertEqual(user.primary_blink_components, [])
# Add owner to an existing component user
with test_app.test_request_context(request_path, json=ComponentUsersRequest(owner=True).to_dict()):
response = self.handler.do_put(
component_id=self.component_2.key.integer_id(),
user_id=self.no_body.key.integer_id())
self.assertEqual(({}, 200), response)
user = user_models.FeatureOwner.get_by_id(self.no_body.key.integer_id())
self.assertEqual(user.blink_components, [self.component_2.key])
self.assertEqual(user.primary_blink_components, [self.component_2.key])
def test_do_delete(self):
request_path = f'/api/v0/components/{self.component_2.key.integer_id()}/users/{self.watcher_1.key.integer_id()}'
user = user_models.FeatureOwner.get_by_id(self.watcher_1.key.integer_id())
self.assertEqual(user.blink_components, [self.component_1.key, self.component_2.key])
self.assertEqual(user.primary_blink_components, [])
# Remove user from component
testing_config.sign_in('admin@example.com', 123567890)
with test_app.test_request_context(request_path, json=ComponentUsersRequest().to_dict()):
response = self.handler.do_delete(
component_id=self.component_2.key.integer_id(),
user_id=self.watcher_1.key.integer_id())
self.assertEqual(({}, 200), response)
user = user_models.FeatureOwner.get_by_id(self.watcher_1.key.integer_id())
self.assertEqual(user.blink_components, [self.component_1.key])
self.assertEqual(user.primary_blink_components, [])
request_path = f'/api/v0/components/{self.component_2.key.integer_id()}/users/{self.component_owner_1.key.integer_id()}'
user = user_models.FeatureOwner.get_by_id(self.component_owner_1.key.integer_id())
self.assertEqual(user.blink_components, [self.component_1.key, self.component_2.key])
self.assertEqual(user.primary_blink_components, [self.component_1.key, self.component_2.key])
# Remove only the owner from component but keep it as a subscriber
testing_config.sign_in('admin@example.com', 123567890)
with test_app.test_request_context(request_path, json=ComponentUsersRequest(owner=True).to_dict()):
response = self.handler.do_delete(
component_id=self.component_2.key.integer_id(),
user_id=self.component_owner_1.key.integer_id())
self.assertEqual(({}, 200), response)
user = user_models.FeatureOwner.get_by_id(self.component_owner_1.key.integer_id())
self.assertEqual(user.blink_components, [self.component_1.key, self.component_2.key])
self.assertEqual(user.primary_blink_components, [self.component_1.key])
request_path = f'/api/v0/components/{self.component_2.key.integer_id()}/users/{self.component_owner_2.key.integer_id()}'
user = user_models.FeatureOwner.get_by_id(self.component_owner_2.key.integer_id())
self.assertEqual(user.blink_components, [self.component_1.key, self.component_2.key])
self.assertEqual(user.primary_blink_components, [self.component_1.key, self.component_2.key])
# Remove the user as both and owner and a user from component
testing_config.sign_in('admin@example.com', 123567890)
with test_app.test_request_context(request_path, json=ComponentUsersRequest().to_dict()):
response = self.handler.do_delete(
component_id=self.component_2.key.integer_id(),
user_id=self.component_owner_2.key.integer_id())
self.assertEqual(({}, 200), response)
user = user_models.FeatureOwner.get_by_id(self.component_owner_2.key.integer_id())
self.assertEqual(user.blink_components, [self.component_1.key])
self.assertEqual(user.primary_blink_components, [self.component_1.key])

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

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2023 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 chromestatus_openapi.models import (
ComponentsUsersResponse,
OwnersAndSubscribersOfComponent,
ComponentsUser)
from google.cloud import ndb
from framework import basehandlers
from framework import permissions
from internals import user_models
class ComponentsUsersAPI(basehandlers.APIHandler):
"""The list of owners and subscribers for each component."""
@permissions.require_admin_site
def do_get(self, **kwargs) -> dict:
"""Returns a dict with 1) subscribers for each component and 2) each component."""
components: list[user_models.BlinkComponent] = user_models.BlinkComponent.query().order(
user_models.BlinkComponent.name).fetch(None)
possible_subscribers: list[user_models.FeatureOwner] = user_models.FeatureOwner.query().order(
user_models.FeatureOwner.name).fetch(None)
users = [
ComponentsUser(
id=fo.key.integer_id(), email=fo.email, name=fo.name)
for fo in possible_subscribers]
component_to_subscribers: dict[ndb.Key, list[int]] = {
c.key: [] for c in components}
component_to_owners: dict[ndb.Key, list[int]] = {
c.key: [] for c in components}
for ps in possible_subscribers:
for subed_component_key in ps.blink_components:
component_to_subscribers[subed_component_key].append(ps.key.integer_id())
for owned_component_key in ps.primary_blink_components:
component_to_owners[owned_component_key].append(ps.key.integer_id())
returned_components: list[OwnersAndSubscribersOfComponent] = []
for c in components:
returned_components.append(
OwnersAndSubscribersOfComponent(
id=c.key.integer_id(),
name=c.name,
subscriber_ids=component_to_subscribers[c.key],
owner_ids=component_to_owners[c.key]))
return ComponentsUsersResponse(
users=users,
components=returned_components[1:], # ditch generic "Blink" component
).to_dict()

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

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# Copyright 2023 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 datetime
import flask
from google.cloud import ndb # type: ignore
from internals import user_models
from api import components_users
test_app = flask.Flask(__name__)
class ComponentsUsersAPITest(testing_config.CustomTestCase):
def setUp(self):
self.handler = components_users.ComponentsUsersAPI()
self.app_admin = user_models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
created = datetime.datetime(2022, 10, 28, 0, 0, 0)
self.component_1 = user_models.BlinkComponent(name='Blink', created=created, updated=created)
self.component_1.key = ndb.Key('BlinkComponent', 123)
self.component_1.put()
self.component_2 = user_models.BlinkComponent(name='Blink>Accessibility', created=created, updated=created)
self.component_2.key = ndb.Key('BlinkComponent', 234)
self.component_2.put()
self.component_owner_1 = user_models.FeatureOwner(
name='owner_1', email='owner_1@example.com',
primary_blink_components=[self.component_1.key, self.component_2.key],
blink_components=[self.component_1.key, self.component_2.key]
)
self.component_owner_1.key = ndb.Key('FeatureOwner', 111)
self.component_owner_1.put()
self.watcher_1 = user_models.FeatureOwner(
name='watcher_1', email='watcher_1@example.com',
blink_components=[self.component_1.key, self.component_2.key],
watching_all_features=True)
self.watcher_1.key = ndb.Key('FeatureOwner', 222)
self.watcher_1.put()
self.no_body = user_models.FeatureOwner(
name='no_body', email='no_body@example.com',
watching_all_features=True)
self.no_body.key = ndb.Key('FeatureOwner', 444)
self.no_body.put()
self.request_path = '/api/v0/componentsusers'
def tearDown(self):
self.no_body.key.delete()
self.watcher_1.key.delete()
self.component_owner_1.key.delete()
self.component_1.key.delete()
self.component_2.key.delete()
testing_config.sign_out()
self.app_admin.key.delete()
def test_do_get(self):
testing_config.sign_in('admin@example.com', 123567890)
with test_app.test_request_context(self.request_path):
response = self.handler.do_get()
expected = {
# Should not see the generic Blink
'components': [
{'id': 234,'name': 'Blink>Accessibility', 'owner_ids': [111], 'subscriber_ids': [111, 222]}],
'users': [{'email': 'no_body@example.com', 'id': 444, 'name': 'no_body'},
{'email': 'owner_1@example.com', 'id': 111, 'name': 'owner_1'},
{'email': 'watcher_1@example.com', 'id': 222, 'name': 'watcher_1'}]}
self.assertEqual(expected, response)

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

@ -41,6 +41,8 @@ registerIconLibrary('material', {
// chromedash components
import './elements/icons';
import './elements/chromedash-admin-blink-component-listing';
import './elements/chromedash-admin-blink-page';
import './elements/chromedash-all-features-page';
import './elements/chromedash-activity-log';
import './elements/chromedash-app';

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

@ -0,0 +1,252 @@
import {html, css, LitElement} from 'lit';
import {SHARED_STYLES} from '../sass/shared-css.js';
import {VARS} from '../sass/_vars-css.js';
import {LAYOUT_CSS} from '../sass/_layout-css.js';
export class ChromedashAdminBlinkComponentListing extends LitElement {
static get styles() {
return [
SHARED_STYLES,
VARS,
LAYOUT_CSS,
css`
:host {
display: flex;
}
:host([editing]) .owners_list_add_remove {
opacity: 1;
pointer-events: all;
}
:host([editing]) .owners_list select[multiple] {
background-color: #fff;
border-color: rgba(0, 0, 0, 0);
}
:host([editing]) .owners_list select[multiple] option {
padding-left: 4px;
}
.component_name {
flex: 1 0 130px;
margin-right: var(--content-padding);
}
.component_name h3 {
color: initial;
padding-top: 4px;
}
.column_header {
margin-bottom: calc(var(--content-padding) / 2);
}
.owners_list {
flex: 1 0 auto;
}
.component_owner {
font-weight: 600;
}
.owners_list_add_remove {
margin-left: calc(var(--content-padding) / 2);
opacity: 0;
transition: 200ms opacity cubic-bezier(0, 0, 0.2, 1);
pointer-events: none;
}
.owners_list_add_remove button[disabled] {
pointer-events: none;
opacity: .5;
}
.remove_owner_button {
color: darkred;
}
select[multiple] {
min-width: 275px;
background-color: #eee;
border: none;
transition: 200ms background-color cubic-bezier(0, 0, 0.2, 1);
font-size: inherit;
}
select[multiple]:disabled option {
color: initial;
padding: 4px 0;
}`];
}
/** @type {import('chromestatus-openapi').DefaultApiInterface} */
_client;
static get properties() {
return {
_client: {attribute: false},
editing: {type: Boolean, reflect: true},
component: {type: Object},
index: {type: Number},
usersMap: {type: Object},
id: {type: Number},
name: {type: String},
subscriberIds: {type: Array},
ownerIds: {type: Array},
};
}
constructor() {
super();
this._client = window.csOpenApiClient;
}
_getOptionsElement() {
return this.shadowRoot.querySelector('.owner_candidates');
}
_findSelectedOptionElement() {
return this._getOptionsElement().selectedOptions[0];
}
_isOwnerCheckboxChecked() {
return this.shadowRoot.querySelector('.is_primary_checkbox').checked;
}
/**
* @param {int} userId
* @return {boolean}
*/
_isUserInOwnerList(userId) {
const ownersList = this.shadowRoot.querySelector(`#owner_list_${this.index}`);
return Array.from(
ownersList.options).find(option => parseInt(option.value) === userId);
}
_addUser() {
const toggleAsOwner = this._isOwnerCheckboxChecked();
const selectedCandidate = this._findSelectedOptionElement();
const userId = parseInt(selectedCandidate.value);
if (selectedCandidate.disabled) {
alert('Please select a user before trying to add');
return;
}
// Don't try to add user if they're already in the list, and we're not
// modifying their owner state.
if (this._isUserInOwnerList(userId) && !toggleAsOwner) {
return;
}
let isError = false;
this._client.addUserToComponent({
componentId: this.id,
userId: userId,
componentUsersRequest: {owner: toggleAsOwner},
})
.then(() => {})
.catch(()=> {
isError = true;
})
.finally(() => {
this.dispatchEvent(new CustomEvent('adminAddComponentUser', {
detail: {
userId: userId,
toggleAsOwner: toggleAsOwner,
index: this.index,
isError: isError,
},
bubbles: true,
composed: true,
}));
});
}
/**
* @param {PointerEvent} e event
*/
_removeUser() {
const toggleAsOwner = this._isOwnerCheckboxChecked();
const selectedCandidate = this._findSelectedOptionElement();
const userId = parseInt(selectedCandidate.value);
if (selectedCandidate.disabled) {
alert('Please select a user before trying to remove');
return;
}
// Don't try to remove user if they do not exist in the list
if (!this._isUserInOwnerList(userId)) {
return;
}
let isError = false;
this._client.removeUserFromComponent({
componentId: this.id,
userId: userId,
componentUsersRequest: {owner: toggleAsOwner},
})
.then(() => {})
.catch(()=> {
isError = true;
})
.finally(() => {
this.dispatchEvent(new CustomEvent('adminRemoveComponentUser', {
detail: {
userId: userId,
toggleAsOwner: toggleAsOwner,
index: this.index,
isError: isError,
},
bubbles: true,
composed: true,
}));
});
}
_printUserDetails(userId) {
return html`${this.usersMap.get(userId).name}: ${this.usersMap.get(userId).email}`;
}
render() {
const userListTemplate = [];
for (const user of this.usersMap.values()) {
userListTemplate.push(
html`<option class="owner_name" value="${user.id}" data-email="${user.email}" data-name="${user.name}">${user.name}: ${user.email}</option>`);
}
return html `
<div class="component_name">
<div class="column_header">Component</div>
<h3>${this.name}</h3>
</div>
<div class="owners_list layout horizontal center">
<div>
<div class="column_header">Receives email updates:</div>
<select multiple disabled id="owner_list_${this.index}" size="${this.subscriberIds.length}">
${this.subscriberIds.map((subscriberId) => this.ownerIds.includes(subscriberId) ?
html `<option class="owner_name component_owner" value="${subscriberId}">${this._printUserDetails(subscriberId)}</option>`:
html `<option class="owner_name" value="${subscriberId}">${this._printUserDetails(subscriberId)}</option>`,
)};
</select>
</div>
<div class="owners_list_add_remove">
<div>
<select class="owner_candidates">
<option selected disabled data-placeholder="true">Select owner to add/remove</option>
${userListTemplate}
</select><br>
<label title="Toggles the user as an owner. If you click 'Remove' ans this is not checked, the user is removed from the component.">Owner? <input type="checkbox" class="is_primary_checkbox"></label>
</div>
<button @click="${this._addUser}" class="add_owner_button"
data-component-name="${this.name}">Add</button>
<button @click="${this._removeUser}" class="remove_owner_button"
data-component-name="${this.name}">Remove</button>
</div>
</div>
`;
}
}
customElements.define(
'chromedash-admin-blink-component-listing', ChromedashAdminBlinkComponentListing);

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

@ -0,0 +1,205 @@
import {ChromedashAdminBlinkComponentListing} from './chromedash-admin-blink-component-listing';
import {html} from 'lit';
import {assert, expect, fixture, oneEvent} from '@open-wc/testing';
import sinon from 'sinon';
import {DefaultApi} from 'chromestatus-openapi';
/** @type {Map<number, import('chromestatus-openapi').ComponentsUser>} */
const testUsersMap = new Map([
[0, {id: 0, email: 'a@b.c', name: '0'}],
[1, {id: 1, email: 'a@b.c', name: '1'}],
[2, {id: 2, email: 'a@b.c', name: '2'}],
[3, {id: 3, email: 'a@b.c', name: '3'}],
[4, {id: 4, email: 'a@b.c', name: '4'}],
[5, {id: 5, email: 'a@b.c', name: '5'}],
]);
describe('chromedash-admin-blink-component-listing', () => {
it('renders with data', async () => {
const element = await fixture(
html`<chromedash-admin-blink-component-listing
.id=${1}
.name=${'foo'}
.subscriberIds=${[0, 1]}
.ownerIds=${[0]}
.index=${0}
.usersMap=${testUsersMap}
?editing=${false}
></chromedash-admin-blink-component-listing>`,
);
assert.exists(element);
assert.instanceOf(element, ChromedashAdminBlinkComponentListing);
});
describe('interactions on the element call certain functions', async () => {
let element;
beforeEach(async () => {
element = await fixture(
html`<chromedash-admin-blink-component-listing
.id=${1}
.name=${'foo'}
.subscriberIds=${[0, 1]}
.ownerIds=${[0]}
.index=${0}
.usersMap=${testUsersMap}
?editing=${true}
></chromedash-admin-blink-component-listing>`,
);
});
it('calls addUser when Add button is clicked', async () => {
// Stub the function and re-render
// https://open-wc.org/guides/knowledge/testing/stubs/
const addUserFn = sinon.stub(element, '_addUser');
element.requestUpdate();
await element.updateComplete;
expect(addUserFn).to.have.callCount(0);
const addBtn = element.shadowRoot.querySelector('.add_owner_button');
addBtn.click();
expect(addUserFn).to.have.callCount(1);
});
it('calls removeUser when Remove button is clicked', async () => {
// Stub the function and re-render
// https://open-wc.org/guides/knowledge/testing/stubs/
const removeUserFn = sinon.stub(element, '_removeUser');
element.requestUpdate();
await element.updateComplete;
expect(removeUserFn).to.have.callCount(0);
const removeBtn = element.shadowRoot.querySelector('.remove_owner_button');
removeBtn.click();
expect(removeUserFn).to.have.callCount(1);
});
});
describe('_addUser', async () => {
const eventListeners = {add: function() {}, remove: function() {}};
let sandbox;
let element;
/** @type {import('sinon').SinonStubbedInstance<DefaultApi>} */
let client;
beforeEach(async () => {
sandbox = sinon.createSandbox();
/** @type {import('sinon').SinonStubbedInstance<DefaultApi>} */
client = sandbox.createStubInstance(DefaultApi);
sandbox.stub(eventListeners, 'add');
sandbox.stub(eventListeners, 'remove');
window.csOpenApiClient = client;
element = await fixture(
html`<chromedash-admin-blink-component-listing
.id=${1}
.name=${'foo'}
.subscriberIds=${[0, 1]}
.ownerIds=${[0]}
.index=${0}
.usersMap=${testUsersMap}
?editing=${true}
@adminRemoveComponentUser=${eventListeners.remove}
@adminAddComponentUser=${eventListeners.add}
></chromedash-admin-blink-component-listing>`,
);
});
afterEach(async () => {
sandbox.restore();
});
it('should generate an alert if nothing is selected', async () => {
const alertStub = sandbox.stub(window, 'alert');
const el = element._findSelectedOptionElement();
// The placeholder is selected.
expect(el.dataset.placeholder).to.equal('true');
expect(alertStub).to.have.callCount(0);
element._addUser();
expect(alertStub).to.have.callCount(1);
});
it('should do nothing for a user already subscribed', async () => {
const alertStub = sandbox.stub(window, 'alert');
// Must select user currently a subscriber.
element._getOptionsElement().options[1].selected = true;
client.addUserToComponent.resolves({});
element._addUser();
// Should timeout
expect(oneEvent(element, 'adminAddComponentUser')).to.throw;
expect(alertStub).to.have.callCount(0);
sandbox.assert.callCount(eventListeners.add, 0);
sandbox.assert.callCount(eventListeners.remove, 0);
});
it('should make successful adminAddComponentUser event if addUserToComponent OK', async () => {
const alertStub = sandbox.stub(window, 'alert');
// Must select user not currently a subscriber.
element._getOptionsElement().options[5].selected = true;
client.addUserToComponent.resolves({});
element._addUser();
const ev = await oneEvent(element, 'adminAddComponentUser');
expect(ev).to.exist;
expect(alertStub).to.have.callCount(0);
sandbox.assert.callCount(eventListeners.add, 1);
sandbox.assert.callCount(eventListeners.remove, 0);
});
});
describe('_removeUser', async () => {
const eventListeners = {add: function() {}, remove: function() {}};
let sandbox;
let element;
/** @type {import('sinon').SinonStubbedInstance<DefaultApi>} */
let client;
beforeEach(async () => {
sandbox = sinon.createSandbox();
client = sandbox.createStubInstance(DefaultApi);
sandbox.stub(eventListeners, 'add');
sandbox.stub(eventListeners, 'remove');
window.csOpenApiClient = client;
element = await fixture(
html`<chromedash-admin-blink-component-listing
.id=${1}
.name=${'foo'}
.subscriberIds=${[0, 1]}
.ownerIds=${[0]}
.index=${0}
.usersMap=${testUsersMap}
?editing=${true}
@adminRemoveComponentUser=${eventListeners.remove}
@adminAddComponentUser=${eventListeners.add}
></chromedash-admin-blink-component-listing>`,
);
});
afterEach(async () => {
sandbox.restore();
});
it('should generate an alert if nothing is selected', async () => {
const alertStub = sandbox.stub(window, 'alert');
const el = element._findSelectedOptionElement();
// The placeholder is selected.
expect(el.dataset.placeholder).to.equal('true');
expect(alertStub).to.have.callCount(0);
element._removeUser();
expect(alertStub).to.have.callCount(1);
});
it('should do nothing for a user not already subscribed', async () => {
const alertStub = sandbox.stub(window, 'alert');
// Must select user currently a subscriber.
element._getOptionsElement().options[5].selected = true;
client.removeUserFromComponent.resolves({});
element._removeUser();
// Should timeout
expect(oneEvent(element, 'adminRemoveComponentUser')).to.throw;
expect(alertStub).to.have.callCount(0);
sandbox.assert.callCount(eventListeners.add, 0);
sandbox.assert.callCount(eventListeners.remove, 0);
});
// eslint-disable-next-line max-len
it('should make successful adminRemoveComponentUser event if removeUserFromComponent OK', async () => {
const alertStub = sandbox.stub(window, 'alert');
// Must select user is currently a subscriber.
element._getOptionsElement().options[1].selected = true;
client.removeUserFromComponent.resolves({});
element._removeUser();
const ev = await oneEvent(element, 'adminRemoveComponentUser');
expect(ev).to.exist;
expect(alertStub).to.have.callCount(0);
sandbox.assert.callCount(eventListeners.add, 0);
sandbox.assert.callCount(eventListeners.remove, 1);
});
});
});

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

@ -0,0 +1,212 @@
import {LitElement, css, html} from 'lit';
import {showToastMessage} from './utils.js';
import {SHARED_STYLES} from '../sass/shared-css.js';
import {VARS} from '../sass/_vars-css.js';
import {LAYOUT_CSS} from '../sass/_layout-css.js';
import './chromedash-admin-blink-component-listing';
export class ChromedashAdminBlinkPage extends LitElement {
static get styles() {
return [
SHARED_STYLES,
VARS,
LAYOUT_CSS,
css`
body {
scroll-behavior: smooth;
}
#spinner {
display: none !important;
}
#subheader .subheader_toggles {
display: flex !important;
justify-content: flex-end;
flex: 1 0 auto;
}
#subheader ul {
list-style-type: none;
margin-left: var(--content-padding);
}
#subheader ul li {
text-align: center;
border-radius: var(--default-border-radius);
box-shadow: 1px 1px 4px var(--bar-shadow-color);
padding: .5em;
background: var(--chromium-color-dark);
color: #fff;
font-weight: 500;
text-transform: uppercase;
}
#subheader ul li a {
color: inherit;
text-decoration: none;
}
#subheader .view_owners_linke {
margin-left: var(--content-padding);
}
#components_list {
list-style: none;
margin-bottom: calc(var(--content-padding) * 4);
}
#components_list li {
padding: var(--content-padding) 0;
}`];
}
static get properties() {
return {
loading: {type: Boolean},
user: {type: Object},
_client: {attribute: false},
_editMode: {type: Boolean},
components: {type: Array},
usersMap: {type: Object},
};
}
/** @type {import('chromestatus-openapi').DefaultApiInterface} */
_client;
/** @type {Array<import('chromestatus-openapi').OwnersAndSubscribersOfComponent>} */
components;
/** @type {Map<int, import('chromestatus-openapi').ComponentsUser}> */
usersMap;
constructor() {
super();
this.loading = true;
this._editMode = false;
this._client = window.csOpenApiClient;
}
connectedCallback() {
super.connectedCallback();
this.fetchData();
}
fetchData() {
this.loading = true;
this._client.listComponentUsers()
.then((response) => {
this.usersMap = new Map(response.users.map(user => [user.id, user]));
this.components = response.components;
this.loading = false;
}).catch(() => {
showToastMessage('Some errors occurred. Please refresh the page or try again later.');
});
}
_onEditModeToggle() {
this._editMode = !this._editMode;
}
_addComponentUserListener(e) {
const component = Object.assign({}, this.components[e.detail.index]);
if (e.detail.isError) {
showToastMessage(`"Unable to add ${this.usersMap.get(e.detail.userId).name} to ${component.name}".`);
return;
}
// If the user is already a subscriber, we do not want to append it.
// We can get here if we are adding the user the owner list.
if (!component.subscriberIds.includes(e.detail.userId)) {
component.subscriberIds = [...component.subscriberIds, e.detail.userId];
}
if (e.detail.toggleAsOwner) {
component.ownerIds = [...component.ownerIds, e.detail.userId];
}
showToastMessage(`"${this.usersMap.get(e.detail.userId).name} added to ${component.name}".`);
this.components[e.detail.index] = component;
this.requestUpdate();
}
_removeComponentUserListener(e) {
const component = Object.assign({}, this.components[e.detail.index]);
if (e.detail.isError) {
showToastMessage(`"Unable to remove ${this.usersMap.get(e.detail.userId).name} from ${component.name}".`);
return;
}
component.subscriberIds = component.subscriberIds.filter(
(currentUserId) => e.detail.userId !== currentUserId);
if (e.detail.toggleAsOwner) {
component.ownerIds = component.ownerIds.filter(
(currentUserId) => e.detail.userId !== currentUserId);
}
showToastMessage(`"${this.usersMap.get(e.detail.userId).name} removed from ${component.name}".`);
this.components[e.detail.index] = component;
this.requestUpdate();
}
renderSubheader() {
return html`
<div id="subheader">
<div class="layout horizontal center">
<div>
<h2>Blink components</h2>
${this.loading ?
html`<div>loading components</div>` :
html`<div id="component-count">listing ${this.components.length} components</div>`
}
</div>
<a href="/admin/subscribers" class="view_owners_linke">List by owner &amp; their features </a>
</div>
<div class="layout horizontal subheader_toggles">
<!-- <paper-toggle-button> doesn't working here. Related links:
https://github.com/PolymerElements/paper-toggle-button/pull/132 -->
<label><input type="checkbox" class="paper-toggle-button" ?checked="${this._editMode}" @change="${this._onEditModeToggle}">Edit mode</label>
</div>
</div>
`;
}
scrollToPosition() {
if (location.hash) {
const hash = decodeURIComponent(location.hash);
if (hash) {
const el = this.shadowRoot.querySelector(hash);
el.scrollIntoView(true, {behavior: 'smooth'});
}
}
}
renderComponents() {
return html`
<ul id="components_list">
${this.components.map((component, index) => html`
<li class="layout horizontal" id="${component.name}">
<chromedash-admin-blink-component-listing
.id=${component.id}
.name=${component.name}
.subscriberIds=${component.subscriberIds ?? []}
.ownerIds=${component.ownerIds ?? []}
.index=${index}
.usersMap=${this.usersMap}
?editing=${this._editMode}
@adminRemoveComponentUser=${this._removeComponentUserListener}
@adminAddComponentUser=${this._addComponentUserListener}
></chromedash-admin-blink-component-listing>
</li>
`)}
</ul>
`;
}
render() {
return html`
${this.renderSubheader()}
${this.loading ?
html`` :
this.renderComponents()
}
`;
}
}
customElements.define('chromedash-admin-blink-page', ChromedashAdminBlinkPage);

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

@ -0,0 +1,54 @@
import './chromedash-toast';
import {html} from 'lit';
import {ChromedashAdminBlinkPage} from './chromedash-admin-blink-page';
import {assert, fixture} from '@open-wc/testing';
import sinon from 'sinon';
import {DefaultApi} from 'chromestatus-openapi';
describe('chromedash-admin-blink-page', () => {
beforeEach(async () => {
await fixture(html`<chromedash-toast></chromedash-toast>`);
});
it('render with no data', async () => {
window.csOpenApiClient = sinon.createStubInstance(DefaultApi, {
listComponentUsers: sinon.stub().rejects(
new Error('Got error response from server')),
});
const component = await fixture(
html`<chromedash-admin-blink-page></chromedash-admin-blink-page>`,
);
assert.exists(component);
assert.instanceOf(component, ChromedashAdminBlinkPage);
// error response would trigger the toast to show message
const toastEl = document.querySelector('chromedash-toast');
const toastMsgSpan = toastEl.shadowRoot.querySelector('span#msg');
assert.include(toastMsgSpan.innerHTML,
'Some errors occurred. Please refresh the page or try again later.');
});
it('render with fake data', async () => {
/** @type {import('chromestatus-openapi').ComponentsUsersResponse} */
const response = {
users: [
{id: 0, name: 'user0', email: 'user0@example.com'},
],
components: [
{id: 0, name: 'component0', subscriberIds: [0], ownerIds: [0]},
],
};
window.csOpenApiClient = sinon.createStubInstance(DefaultApi, {
listComponentUsers: sinon.stub().resolves(response),
});
const component = await fixture(
html`<chromedash-admin-blink-page></chromedash-admin-blink-page>`,
);
assert.exists(component);
assert.instanceOf(component, ChromedashAdminBlinkPage);
// subheader exists
const subheaderCountEl = component.shadowRoot.querySelector('#component-count');
assert.exists(subheaderCountEl);
});
});

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

@ -377,6 +377,13 @@ class ChromedashApp extends LitElement {
this.currentPage = ctx.path;
this.hideSidebar();
});
page('/admin/blink', (ctx) => {
this.pageComponent = document.createElement('chromedash-admin-blink-page');
this.pageComponent.user = this.user;
this.contextLink = ctx.path;
this.currentPage = ctx.path;
this.hideSidebar();
});
page.start();
}

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

@ -573,6 +573,14 @@ class SPAHandler(FlaskHandler):
self, feature_id)
if redirect_resp:
return redirect_resp
# Validate the user has admin permissions and redirect if needed.
if defaults.get('require_admin_site'):
user = self.get_current_user()
# Should have already done the require_signin check.
# If for reason, we don't let's treat it as the main 403 case.
if (not user
or not permissions.can_admin_site(user)):
self.abort(403, msg='Cannot perform admin actions')
return {} # no handler_data needed to be returned

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

@ -152,9 +152,9 @@ class FeatureOwner(ndb.Model):
primary_blink_components = ndb.KeyProperty(repeated=True)
watching_all_features = ndb.BooleanProperty(default=False)
def add_to_component_subscribers(self, component_name):
def add_to_component_subscribers(self, component_id):
"""Adds the user to the list of Blink component subscribers."""
c = BlinkComponent.get_by_name(component_name)
c = BlinkComponent.get_by_id(component_id)
if c:
# Add the user if they're not already in the list.
if not len(list_with_component(self.blink_components, c)):
@ -163,11 +163,11 @@ class FeatureOwner(ndb.Model):
return None
def remove_from_component_subscribers(
self, component_name, remove_as_owner=False):
self, component_id, remove_as_owner=False):
"""Removes the user from the list of Blink component subscribers or as
the owner of the component.
"""
c = BlinkComponent.get_by_name(component_name)
c = BlinkComponent.get_by_id(component_id)
if c:
if remove_as_owner:
self.primary_blink_components = (
@ -179,21 +179,21 @@ class FeatureOwner(ndb.Model):
return self.put()
return None
def add_as_component_owner(self, component_name):
def add_as_component_owner(self, component_id):
"""Adds the user as the Blink component owner."""
c = BlinkComponent.get_by_name(component_name)
c = BlinkComponent.get_by_id(component_id)
if c:
# Update both the primary list and blink components subscribers if the
# user is not already in them.
self.add_to_component_subscribers(component_name)
self.add_to_component_subscribers(component_id)
if not len(list_with_component(self.primary_blink_components, c)):
self.primary_blink_components.append(c.key)
return self.put()
return None
def remove_as_component_owner(self, component_name):
def remove_as_component_owner(self, component_id):
return self.remove_from_component_subscribers(
component_name, remove_as_owner=True)
component_id, remove_as_owner=True)
class BlinkComponent(ndb.Model):

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

@ -19,6 +19,8 @@ from typing import Any, Type
from api import accounts_api, dev_api
from api import approvals_api
from api import blink_components_api
from api import component_users
from api import components_users
from api import channels_api
from api import comments_api
from api import cues_api
@ -126,6 +128,10 @@ api_routes: list[Route] = [
Route(f'{API_BASE}/blinkcomponents',
blink_components_api.BlinkComponentsAPI),
Route(f'{API_BASE}/componentsusers',
components_users.ComponentsUsersAPI),
Route(f'{API_BASE}/components/<int:component_id>/users/<int:user_id>',
component_users.ComponentUsersAPI),
Route(f'{API_BASE}/login', login_api.LoginAPI),
Route(f'{API_BASE}/logout', logout_api.LogoutAPI),
@ -183,6 +189,8 @@ spa_page_routes = [
Route('/metrics/feature/timeline/popularity/<int:bucket_id>'),
Route('/settings', defaults={'require_signin': True}),
Route('/enterprise'),
# Admin pages
Route('/admin/blink', defaults={'require_admin_site': True, 'require_signin': True}),
]
spa_page_post_routes: list[Route] = [
@ -199,7 +207,6 @@ spa_page_post_routes: list[Route] = [
mpa_page_routes: list[Route] = [
Route('/admin/subscribers', blink_handler.SubscribersHandler),
Route('/admin/blink', blink_handler.BlinkHandler),
Route('/admin/users/new', users.UserListHandler),
Route('/admin/features/launch/<int:feature_id>',

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

@ -21,82 +21,6 @@ from internals import legacy_helpers
from internals import user_models
from api.channels_api import construct_chrome_channels_details
class BlinkHandler(basehandlers.FlaskHandler):
TEMPLATE_PATH = 'admin/blink.html'
def __update_subscribers_list(
self, add=True, user_id=None, blink_component=None, primary=False):
if not user_id or not blink_component:
return False
user = user_models.FeatureOwner.get_by_id(int(user_id))
if not user:
return True
if primary:
if add:
user.add_as_component_owner(blink_component)
else:
user.remove_as_component_owner(blink_component)
else:
if add:
user.add_to_component_subscribers(blink_component)
else:
user.remove_from_component_subscribers(blink_component)
return True
@permissions.require_admin_site
def get_template_data(self, **kwargs):
components = user_models.BlinkComponent.query().order(
user_models.BlinkComponent.name).fetch(None)
possible_subscribers = user_models.FeatureOwner.query().order(
user_models.FeatureOwner.name).fetch(None)
# Format for template
possible_subscriber_dicts = [
{'id': fo.key.integer_id(), 'email': fo.email}
for fo in possible_subscribers]
component_to_subscribers = {c.key: [] for c in components}
component_to_owners = {c.key: [] for c in components}
for ps in possible_subscribers:
for subed_component_key in ps.blink_components:
component_to_subscribers[subed_component_key].append(ps)
for owned_component_key in ps.primary_blink_components:
component_to_owners[owned_component_key].append(ps.name)
for c in components:
c.computed_subscribers = component_to_subscribers[c.key]
c.computed_owners = component_to_owners[c.key]
template_data = {
'possible_subscribers': possible_subscriber_dicts,
'components': components[1:] # ditch generic "Blink" component
}
return template_data
# Remove user from component subscribers.
@permissions.require_admin_site
def put(self):
params = self.request.get_json(force=True)
self.__update_subscribers_list(False, user_id=params.get('userId'),
blink_component=params.get('componentName'),
primary=params.get('primary'))
return {'done': True}
# Add user to component subscribers.
@permissions.require_admin_site
def process_post_data(self, **kwargs):
params = self.request.get_json(force=True)
self.__update_subscribers_list(True, user_id=params.get('userId'),
blink_component=params.get('componentName'),
primary=params.get('primary'))
return params
class SubscribersHandler(basehandlers.FlaskHandler):
TEMPLATE_PATH = 'admin/subscribers.html'

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

@ -31,65 +31,6 @@ test_app = flask.Flask(__name__,
# Load testdata to be used across all of the CustomTestCases
TESTDATA = testing_config.Testdata(__file__)
class BlinkTemplateTest(testing_config.CustomTestCase):
HANDLER_CLASS = blink_handler.BlinkHandler
def setUp(self):
self.request_path = self.HANDLER_CLASS.TEMPLATE_PATH
self.handler = self.HANDLER_CLASS()
self.app_admin = user_models.AppUser(email='admin@example.com')
self.app_admin.is_admin = True
self.app_admin.put()
testing_config.sign_in('admin@example.com', 123567890)
self.component_1 = user_models.BlinkComponent(name='Blink')
self.component_1.put()
self.component_2 = user_models.BlinkComponent(name='Blink>Accessibility')
self.component_2.put()
self.component_owner_1 = user_models.FeatureOwner(
name='owner_1', email='owner_1@example.com',
primary_blink_components=[self.component_1.key, self.component_2.key])
self.component_owner_1.key = ndb.Key('FeatureOwner', 111)
self.component_owner_1.put()
self.watcher_1 = user_models.FeatureOwner(
name='watcher_1', email='watcher_1@example.com',
watching_all_features=True)
self.watcher_1.key = ndb.Key('FeatureOwner', 222)
self.watcher_1.put()
with test_app.test_request_context(self.request_path):
self.template_data = self.handler.get_template_data()
self.template_data.update(self.handler.get_common_data())
self.template_data['nonce'] = 'fake nonce'
self.template_data['xsrf_token'] = ''
self.template_data['xsrf_token_expires'] = 0
self.full_template_path = self.handler.get_template_path(self.template_data)
self.maxDiff = None
def tearDown(self):
self.watcher_1.key.delete()
self.component_owner_1.key.delete()
self.component_1.key.delete()
self.component_2.key.delete()
testing_config.sign_out()
self.app_admin.key.delete()
def test_html_rendering(self):
"""We can render the template with valid html."""
with test_app.app_context():
template_text = self.handler.render(
self.template_data, self.full_template_path)
parser = html5lib.HTMLParser(strict=True)
document = parser.parse(template_text)
# TESTDATA.make_golden(template_text, 'BlinkTemplateTest_test_html_rendering.html')
self.assertMultiLineEqual(
TESTDATA['BlinkTemplateTest_test_html_rendering.html'], template_text)
class SubscribersTemplateTest(testing_config.CustomTestCase):
HANDLER_CLASS = blink_handler.SubscribersHandler

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

@ -1,286 +0,0 @@
<!DOCTYPE html>
<!--
Copyright 2016 Google Inc. All Rights Reserved.
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.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Local testing</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<meta name="theme-color" content="#366597">
<link rel="stylesheet" href="/static/css/base.css?v=Undeployed" />
<link rel="icon" sizes="192x192" href="/static/img/crstatus_192.png">
<!-- iOS: run in full-screen mode and display upper status bar as translucent -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/static/img/crstatus_128.png">
<link rel="apple-touch-icon-precomposed" href="/static/img/crstatus_128.png">
<link rel="shortcut icon" href="/static/img/crstatus_128.png">
<link rel="preconnect" href="https://www.google-analytics.com" crossorigin>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/main.css?v=Undeployed">
<link rel="stylesheet" href="/static/css/blink.css?v=Undeployed">
<script src="https://accounts.google.com/gsi/client" async defer nonce="fake nonce"></script>
<script nonce="fake nonce">
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("loginStatus") == 'False') {
alert('Please log in.');
}
</script>
<script nonce="fake nonce" src="/static/js/metric.min.js?v=Undeployed"></script>
<script nonce="fake nonce" src="/static/js/cs-client.min.js?v=Undeployed"></script>
<script nonce="fake nonce">
window.csClient = new ChromeStatusClient(
'', 0);
</script>
<script type="module" nonce="fake nonce" defer
src="/static/dist/components.js?v=Undeployed"></script>
</head>
<body class="loading" data-path="/admin/blink.html?">
<div id="app-content-container">
<div>
<div class="main-toolbar">
<div class="toolbar-content">
<chromedash-header
appTitle="Local testing"
currentPage="/admin/blink.html?"
googleSignInClientId="914217904764-enfcea61q4hqe7ak8kkuteglrbhk8el1.apps.googleusercontent.com">
</chromedash-header>
</div>
</div>
<div id="content">
<div id="spinner">
<img src="/static/img/ring.svg">
</div>
<chromedash-banner
message=""
timestamp="None">
</chromedash-banner>
<div id="rollout">
<a href="/newfeatures">Try out our new features page</a>
</div>
<div id="content-flex-wrapper">
<div id="content-component-wrapper">
<div id="subheader">
<div class="layout horizontal center">
<div>
<h2>Blink components</h2>
<div>listing 1 components</div>
</div>
<a href="/admin/subscribers" class="view_owners_linke">List by owner &amp; their features →</a>
</div>
<div class="layout horizontal subheader_toggles">
<!-- <paper-toggle-button> doesn't working here. Related links:
https://github.com/PolymerElements/paper-toggle-button/pull/132 -->
<label><input type="checkbox" class="paper-toggle-button">Edit mode</label>
</div>
</div>
<section>
<ul id="components_list">
<li class="layout horizontal" id="Blink&gt;Accessibility">
<div class="component_name">
<div class="column_header">Component</div>
<h3>Blink&gt;Accessibility</h3>
<!---->
</div>
<div class="owners_list layout horizontal center">
<div>
<div class="column_header">Receives email updates:</div>
<select multiple disabled id="owner_list_1" size="0">
</select>
</div>
<div class="owners_list_add_remove">
<div>
<select class="owner_candidates">
<option selected disabled>Select owner to add/remove</option>
<option class="owner_name" value="111" data-email="owner_1@example.com"></option>
<option class="owner_name" value="222" data-email="watcher_1@example.com"></option>
</select><br>
<label title="Toggles the user as an owner. If you click 'Remove' ans this is not checked, the user is removed from the component.">Owner? <input type="checkbox" class="is_primary_checkbox"></label>
</div>
<button class="add_owner_button" data-index="1"
data-component-name="Blink&gt;Accessibility">Add</button>
<button class="remove_owner_button" data-index="1"
data-component-name="Blink&gt;Accessibility">Remove</button>
</div>
</div>
</li>
</ul>
</section>
</div>
</div>
</div>
</div>
<chromedash-footer></chromedash-footer>
</div>
<chromedash-toast msg="Welcome to chromestatus.com!"></chromedash-toast>
<script nonce="fake nonce">
(function() {
'use strict';
document.querySelector('.paper-toggle-button').addEventListener('change', e => {
e.stopPropagation();
document.querySelector('#components_list').classList.toggle('editing', e.target.checked);
});
document.querySelector('#components_list').addEventListener('click', function(e) {
const addUser = e.target.classList.contains('add_owner_button');
const removeUser = e.target.classList.contains('remove_owner_button');
if (!(addUser || removeUser)) {
return;
}
const candidates = e.target.parentElement.querySelector('.owner_candidates');
const primaryCheckbox = e.target.parentElement.querySelector('.is_primary_checkbox');
const idx = e.target.dataset.index;
const componentName = e.target.dataset.componentName;
const userId = candidates.value;
const selectedCandidate = candidates.selectedOptions[0];
const username = selectedCandidate.textContent;
const email = selectedCandidate.dataset.email;
const toggleAsOwner = primaryCheckbox.checked;
if (selectedCandidate.disabled) {
alert('Please select a user');
return;
}
// Add new item to owners list.
const ownersList = this.querySelector(`#owner_list_${idx}`);
const optionText = `${username}: ${email}`;
const foundName = Array.from(ownersList.options).find(option => option.textContent === optionText);
if (addUser) {
// Don't try to add user if they're already in the list, and we're not
// modifying their owner state.
if (foundName && !toggleAsOwner) {
return;
}
const option = document.createElement('option');
option.value = userId;
option.textContent = optionText;
if (!foundName) {
ownersList.appendChild(option);
}
if (toggleAsOwner) {
const el = foundName || option;
el.classList.add('component_owner');
}
} else if (removeUser && foundName) {
if (toggleAsOwner) {
foundName.classList.remove('component_owner');
} else {
foundName.remove(); // remove existing name.
}
}
fetch('/admin/blink', {
method: removeUser ? 'PUT' : 'POST',
credentials: 'include',
headers: {
'X-Xsrf-Token': window.csClient.token,
},
body: JSON.stringify({
userId,
componentName,
primary: toggleAsOwner
})
})
.then(resp => resp.json())
.then(json => {
const Toast = document.querySelector('chromedash-toast');
Toast.showMessage(`"${componentName}" updated.`);
ownersList.size = ownersList.options.length;
primaryCheckbox.checked = false;
});
});
window.addEventListener('DOMContentLoaded', function(e) {
if (location.hash) {
setTimeout(function() {
const el = document.getElementById(location.hash.slice(1));
document.querySelector('app-header').scroll(0, el.offsetTop);
}, 500);
}
});
document.body.classList.remove('loading');
})();
</script>
<script src="https://www.googletagmanager.com/gtag/js?id=UA-179341418-1"
async nonce="fake nonce"></script>
<script type="module" nonce="fake nonce" src="/static/js/shared.min.js?v=Undeployed"></script>
</body>
</html>

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

@ -1,192 +0,0 @@
{% extends "_base.html" %}
{% block css %}
<link rel="stylesheet" href="/static/css/blink.css?v={{app_version}}">
{% endblock %}
{% block drawer %}
{% endblock %}
{% block subheader %}
<div id="subheader">
<div class="layout horizontal center">
<div>
<h2>Blink components</h2>
<div>listing {{components|length}} components</div>
</div>
<a href="/admin/subscribers" class="view_owners_linke">List by owner &amp; their features →</a>
</div>
<div class="layout horizontal subheader_toggles">
<!-- <paper-toggle-button> doesn't working here. Related links:
https://github.com/PolymerElements/paper-toggle-button/pull/132 -->
<label><input type="checkbox" class="paper-toggle-button">Edit mode</label>
</div>
</div>
{% endblock %}
{% block content %}
<section>
{#
<!--<p>Start typing a component name:</p>
<input list="components" name="components" placeholder="{{components.0}}>Animation"></label>
<datalist id="components">
{% for c in components %}
<option value="{{c.name}}">{{c.name}}</option>
{% endfor %}
</datalist>-->
<!--<template id="tmpl_owners_list">
<select>
{% for owner in owners %}
<option class="owner_name" value="{{owner.id}}">{{owner.name}}</option>
{% endfor %}
</select>
</template>-->
#}
<ul id="components_list">
{% for c in components %}
<li class="layout horizontal" id="{{c.name}}">
<div class="component_name">
<div class="column_header">Component</div>
<h3>{{c.name}}</h3>
<!--{% for url in c.wf_urls %}
<li>{{url.url}} (last updated: {{url.updatedOn}}</li>
{% endfor %}-->
</div>
<div class="owners_list layout horizontal center">
<div>
<div class="column_header">Receives email updates:</div>
<select multiple disabled id="owner_list_{{loop.index}}" size="{{c.computed_subscribers|length}}">
{% for s in c.computed_subscribers %}
<option class="owner_name {% if s.name in c.computed_owners %}component_owner{% endif %}"
value="{{s.id}}">{{s.name}}: {{s.email}}</option>
{% endfor %}
</select>
</div>
<div class="owners_list_add_remove">
<div>
<select class="owner_candidates">
<option selected disabled>Select owner to add/remove</option>
{% for s in possible_subscribers %}
<option class="owner_name" value="{{s.id}}" data-email="{{s.email}}">{{s.name}}</option>
{% endfor %}
</select><br>
<label title="Toggles the user as an owner. If you click 'Remove' ans this is not checked, the user is removed from the component.">Owner? <input type="checkbox" class="is_primary_checkbox"></label>
</div>
<button class="add_owner_button" data-index="{{loop.index}}"
data-component-name="{{c.name}}">Add</button>
<button class="remove_owner_button" data-index="{{loop.index}}"
data-component-name="{{c.name}}">Remove</button>
</div>
</div>
</li>
{% endfor %}
</ul>
</section>
{% endblock %}
{% block js %}
<script nonce="{{nonce}}">
(function() {
'use strict';
document.querySelector('.paper-toggle-button').addEventListener('change', e => {
e.stopPropagation();
document.querySelector('#components_list').classList.toggle('editing', e.target.checked);
});
document.querySelector('#components_list').addEventListener('click', function(e) {
const addUser = e.target.classList.contains('add_owner_button');
const removeUser = e.target.classList.contains('remove_owner_button');
if (!(addUser || removeUser)) {
return;
}
const candidates = e.target.parentElement.querySelector('.owner_candidates');
const primaryCheckbox = e.target.parentElement.querySelector('.is_primary_checkbox');
const idx = e.target.dataset.index;
const componentName = e.target.dataset.componentName;
const userId = candidates.value;
const selectedCandidate = candidates.selectedOptions[0];
const username = selectedCandidate.textContent;
const email = selectedCandidate.dataset.email;
const toggleAsOwner = primaryCheckbox.checked;
if (selectedCandidate.disabled) {
alert('Please select a user');
return;
}
// Add new item to owners list.
const ownersList = this.querySelector(`#owner_list_${idx}`);
const optionText = `${username}: ${email}`;
const foundName = Array.from(ownersList.options).find(option => option.textContent === optionText);
if (addUser) {
// Don't try to add user if they're already in the list, and we're not
// modifying their owner state.
if (foundName && !toggleAsOwner) {
return;
}
const option = document.createElement('option');
option.value = userId;
option.textContent = optionText;
if (!foundName) {
ownersList.appendChild(option);
}
if (toggleAsOwner) {
const el = foundName || option;
el.classList.add('component_owner');
}
} else if (removeUser && foundName) {
if (toggleAsOwner) {
foundName.classList.remove('component_owner');
} else {
foundName.remove(); // remove existing name.
}
}
fetch('/admin/blink', {
method: removeUser ? 'PUT' : 'POST',
credentials: 'include',
headers: {
'X-Xsrf-Token': window.csClient.token,
},
body: JSON.stringify({
userId,
componentName,
primary: toggleAsOwner
})
})
.then(resp => resp.json())
.then(json => {
const Toast = document.querySelector('chromedash-toast');
Toast.showMessage(`"${componentName}" updated.`);
ownersList.size = ownersList.options.length;
primaryCheckbox.checked = false;
});
});
window.addEventListener('DOMContentLoaded', function(e) {
if (location.hash) {
setTimeout(function() {
const el = document.getElementById(location.hash.slice(1));
document.querySelector('app-header').scroll(0, el.offsetTop);
}, 500);
}
});
document.body.classList.remove('loading');
})();
</script>
{% endblock %}