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:
Родитель
42817a703b
Коммит
f33576a5f3
|
@ -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
|
|
@ -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])
|
|
@ -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 & 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):
|
||||
|
|
9
main.py
9
main.py
|
@ -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&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 & 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>Accessibility">
|
||||
<div class="component_name">
|
||||
<div class="column_header">Component</div>
|
||||
<h3>Blink>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>Accessibility">Add</button>
|
||||
<button class="remove_owner_button" data-index="1"
|
||||
data-component-name="Blink>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 & 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 %}
|
Загрузка…
Ссылка в новой задаче