Commit f562ca0a authored by Leonard Grey's avatar Leonard Grey Committed by Commit Bot

Commander: Add initial WebUI implementation

This includes the basic functionality for commander (sans multi-step
commands).

Styling is WIP.

Bug: 1014639
Change-Id: I809d11c851df0b4849bdb3c8ac0930ed37308d76
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2468680Reviewed-by: default avatarAvi Drissman <avi@chromium.org>
Reviewed-by: default avatardpapad <dpapad@chromium.org>
Reviewed-by: default avatarElly Fong-Jones <ellyjones@chromium.org>
Commit-Queue: Leonard Grey <lgrey@chromium.org>
Cr-Commit-Position: refs/heads/master@{#821515}
parent 600fbf32
...@@ -1347,6 +1347,7 @@ group("extra_resources") { ...@@ -1347,6 +1347,7 @@ group("extra_resources") {
if (!is_android) { if (!is_android) {
public_deps += [ public_deps += [
"//chrome/browser/resources:bookmarks_resources", "//chrome/browser/resources:bookmarks_resources",
"//chrome/browser/resources:commander_resources",
"//chrome/browser/resources:component_extension_resources", "//chrome/browser/resources:component_extension_resources",
"//chrome/browser/resources:dev_ui_paks", "//chrome/browser/resources:dev_ui_paks",
"//chrome/browser/resources:downloads_resources", "//chrome/browser/resources:downloads_resources",
......
...@@ -28,6 +28,7 @@ if (enable_js_type_check) { ...@@ -28,6 +28,7 @@ if (enable_js_type_check) {
deps += [ deps += [
"bluetooth_internals:closure_compile", "bluetooth_internals:closure_compile",
"bookmarks:closure_compile", "bookmarks:closure_compile",
"commander:closure_compile",
"discards:closure_compile", "discards:closure_compile",
"download_internals:closure_compile", "download_internals:closure_compile",
"downloads:closure_compile", "downloads:closure_compile",
...@@ -137,6 +138,32 @@ if (!is_android) { ...@@ -137,6 +138,32 @@ if (!is_android) {
output_dir = "$root_gen_dir/chrome" output_dir = "$root_gen_dir/chrome"
} }
grit("commander_resources") {
grit_flags = [
"-E",
"root_gen_dir=" + rebase_path(root_gen_dir, root_build_dir),
"-E",
"root_src_dir=" + rebase_path("//", root_build_dir),
]
defines = chrome_grit_defines
# These arguments are needed since the grd is generated at build time.
enable_input_discovery_for_gn_analyze = false
defines += [ "SHARED_INTERMEDIATE_DIR=" +
rebase_path(root_gen_dir, root_build_dir) ]
commander_gen_dir = "$root_gen_dir/chrome/browser/resources/commander"
source = "$commander_gen_dir/commander_resources.grd"
deps = [ "//chrome/browser/resources/commander:build_grd" ]
outputs = [
"grit/commander_resources.h",
"grit/commander_resources_map.cc",
"grit/commander_resources_map.h",
"commander_resources.pak",
]
output_dir = "$root_gen_dir/chrome"
}
grit("component_extension_resources") { grit("component_extension_resources") {
source = "component_extension_resources.grd" source = "component_extension_resources.grd"
......
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
module.exports = {
'env': {
'browser': true,
'es6': true,
},
'rules': {'eqeqeq': ['error', 'always', {'null': 'ignore'}]},
};
# Copyright 2020 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import("//third_party/closure_compiler/compile_js.gni")
import("//tools/grit/preprocess_grit.gni")
import("//tools/polymer/html_to_js.gni")
import("//ui/webui/resources/tools/generate_grd.gni")
preprocess_folder = "preprocessed"
preprocess_manifest = "preprocessed_manifest.json"
preprocess_gen_manifest = "preprocessed_gen_manifest.json"
generate_grd("build_grd") {
grd_prefix = "commander"
out_grd = "$target_gen_dir/${grd_prefix}_resources.grd"
input_files = [ "commander.html" ]
input_files_base_dir = rebase_path(".", "//")
deps = [
":preprocess",
":preprocess_generated",
]
manifest_files = [
"$target_gen_dir/$preprocess_manifest",
"$target_gen_dir/$preprocess_gen_manifest",
]
}
preprocess_grit("preprocess") {
in_folder = "./"
out_folder = "$target_gen_dir/$preprocess_folder"
out_manifest = "$target_gen_dir/$preprocess_manifest"
in_files = [
"browser_proxy.js",
"types.js",
]
}
preprocess_grit("preprocess_generated") {
deps = [ ":web_components" ]
in_folder = target_gen_dir
out_folder = "$target_gen_dir/$preprocess_folder"
out_manifest = "$target_gen_dir/$preprocess_gen_manifest"
in_files = [
"app.js",
"icons.js",
"option.js",
]
}
js_type_check("closure_compile") {
is_polymer3 = true
deps = [
":app",
":browser_proxy",
":option",
":types",
]
}
js_library("app") {
deps = [
":browser_proxy",
":option",
":types",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
"//ui/webui/resources/js:cr.m",
]
}
js_library("browser_proxy") {
deps = [ "//ui/webui/resources/js:cr.m" ]
}
js_library("option") {
deps = [
":icons",
":types",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
]
}
js_library("icons") {
}
js_library("types") {
}
html_to_js("web_components") {
js_files = [
"app.js",
"option.js",
"icons.js",
]
}
<style>
:host {
background-color: #fff;
overflow: hidden;
}
input {
border: 0;
box-sizing: border-box;
height: 100%;
margin:0;
padding: 1em 1em 1em .5em;
width: 100%;
}
input:focus {
outline: none;
}
#input-row {
align-items: center;
box-sizing: border-box;
display: flex;
height: 48px;
width: 100%;
}
::-webkit-scrollbar {
display: none;
}
iron-icon {
margin-inline-start: 1em;
--iron-icon-fill-color: #999;
}
</style>
<div id="container">
<div id="input-row">
<iron-icon icon="cr:search"></iron-icon>
<input id="input" type="text" on-input="onInput_"
on-keydown="onKeydown_" autofocus></input>
</div>
<template id="options" is="dom-repeat" on-dom-change="onDomChange_"
items="[[options_]]">
<commander-option class$="[[getOptionClass_(index, focusedIndex_)]]"
model="[[item]]" on-click="onOptionClick_"></commander-option>
</template>
</div>
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './option.js';
import 'chrome://resources/cr_elements/icons.m.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import {addWebUIListener} from 'chrome://resources/js/cr.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {BrowserProxy, BrowserProxyImpl} from './browser_proxy.js';
import {CommanderOptionElement} from './option.js';
import {Action, Entity, Option, ViewModel} from './types.js';
export class CommanderAppElement extends PolymerElement {
static get is() {
return 'commander-app';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/** @private {!Array<!Option>} */
options_: Array,
/** @private */
focusedIndex_: Number,
};
}
constructor() {
super();
/** @private {!BrowserProxy} */
this.browserProxy_ = BrowserProxyImpl.getInstance();
/** @type {?number} */
this.resultSetId_ = null;
}
/** @override */
ready() {
super.ready();
addWebUIListener('view-model-updated', this.onViewModelUpdated_.bind(this));
addWebUIListener('initialize', this.initialize_.bind(this));
}
/**
* Resets the UI for a new session.
* @private
*/
initialize_() {
this.options_ = [];
this.$.input.value = '';
this.focusedIndex_ = -1;
this.resultSetId_ = null;
}
/**
* @param {!Event} e
* @private
*/
onKeydown_(e) {
if (e.key === 'Escape') {
this.browserProxy_.dismiss();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.focusedIndex_ = (this.focusedIndex_ + this.options_.length - 1) %
this.options_.length;
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.focusedIndex_ = (this.focusedIndex_ + 1) % this.options_.length;
} else if (e.key === 'Enter') {
if (this.focusedIndex_ >= 0 &&
this.focusedIndex_ < this.options_.length) {
this.notifySelectedAtIndex_(this.focusedIndex_);
}
}
}
/**
* @private
*/
onInput_() {
this.browserProxy_.textChanged(this.$.input.value);
}
/**
* @param {!ViewModel} viewModel
* @private
*/
onViewModelUpdated_(viewModel) {
if (viewModel.action === Action.DISPLAY_RESULTS) {
this.options_ = viewModel.options || [];
this.resultSetId_ = viewModel.resultSetId;
if (this.options_.length > 0) {
this.focusedIndex_ = 0;
}
}
// TODO(lgrey): Handle Action.PROMPT
}
/** @private */
onDomChange_() {
this.browserProxy_.heightChanged(document.body.offsetHeight);
}
/**
* Called when a result option is clicked via mouse.
* @param {!Event} e
* @private
*/
onOptionClick_(e) {
this.notifySelectedAtIndex_(e.model.index);
}
/**
* Informs the browser that the option at |index| was selected.
* @param {number} index
* @private
*/
notifySelectedAtIndex_(index) {
if (this.resultSetId_ !== null) {
this.browserProxy_.optionSelected(
index, /** type {number} */ this.resultSetId_);
}
}
/**
* @return {string}
* @param {number} index
*/
getOptionClass_(index) {
return index === this.focusedIndex_ ? 'focused' : '';
}
}
customElements.define(CommanderAppElement.is, CommanderAppElement);
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {addSingletonGetter} from 'chrome://resources/js/cr.m.js';
/** @interface */
export class BrowserProxy {
/**
* Notifies the backend that user input has changed.
* @param {string} newText The current contents of the user input field.
*/
textChanged(newText) {}
/**
* Notifies the backend that the option at |index| in result set
* |resultSetId| was selected.
* @param {number} index The index of the selected option.
* @param {number} resultSetId The result set this option was presented in.
*/
optionSelected(index, resultSetId) {}
/**
* Notifies the views layer that the inherent height of the UI has changed
* so that the window can grow or shrink.
* @param {number} newHeight The current height of the element.
*/
heightChanged(newHeight) {}
/**
* Notifies the backend that the user wants to dismiss the UI.
*/
dismiss() {}
}
/** @implements {BrowserProxy} */
export class BrowserProxyImpl {
/** @override */
textChanged(newText) {
chrome.send('textChanged', [newText]);
}
/** @override */
optionSelected(index, resultSetId) {
chrome.send('optionSelected', [index, resultSetId]);
}
/** @override */
heightChanged(newHeight) {
chrome.send('heightChanged', [newHeight]);
}
/** @override */
dismiss() {
chrome.send('dismiss');
}
}
addSingletonGetter(BrowserProxyImpl);
<!doctype html> <!doctype html>
<html dir="$i18n{textdirection}"> <html dir="$i18n{textdirection}" lang="$i18n{language}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="stylesheet" href="chrome://resources/css/text_defaults_md.css">
<style>
body {
margin: 0;
}
</style>
<!-- TODO(lgrey): Localize and pass it in. -->
<title>Commander</title>
</head> </head>
<body> <body>
Hello, commander! <commander-app></commander-app>
<script type="module" src="app.js"></script>
</body> </body>
</html> </html>
<iron-iconset-svg name="commander-icons" size="24">
<svg>
<defs>
<!-- from MD icons-->
<g id="chrome"><path d="M12 7.5h8.9C19.3 4.2 15.9 2 12 2 8.9 2 6.1 3.4 4.3 5.6l3.3 5.7c.3-2.1 2.2-3.8 4.4-3.8zm0 9c-1.7 0-3.1-.9-3.9-2.3L3.6 6.5C2.6 8.1 2 10 2 12c0 5 3.6 9.1 8.4 9.9l3.3-5.7c-.6.2-1.1.3-1.7.3zm4.5-4.5c0 .8-.2 1.6-.6 2.2L11.4 22h.6c5.5 0 10-4.5 10-10 0-1.2-.2-2.4-.6-3.5h-6.6c1 .8 1.7 2.1 1.7 3.5z"></path><circle cx="12" cy="12" r="3.5"></circle></g>
<g id="tab"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"></path><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h10v4h8v10z"></path></g>
<g id="window"><path d="M0 0h24v24H0V0z" fill="none"></path><path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 9h-7V4h7v7zm-9-7v7H4V4h7zm-7 9h7v7H4v-7zm9 7v-7h7v7h-7z"></path></g>
</defs>
</svg>
</iron-iconset-svg>
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/polymer/v3_0/iron-iconset-svg/iron-iconset-svg.js';
import {html} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
const template = html`{__html_template__}`;
document.head.appendChild(template.content);
<style>
:host {
align-items: center;
box-sizing: border-box;
cursor: default;
display: flex;
height: 48px;
padding: 1em;
width: 100%;
}
:host(:hover) {
background-color: #eee;
}
:host(.focused) {
background-color: rgba(var(--google-grey-900-rgb), 6%);
}
.annotation {
color: var(--google-blue-600);
margin-inline-start: auto;
}
span {
white-space: pre;
}
.match {
font-weight: 700;
text-decoration: underline;
}
iron-icon {
margin-inline-end: .5em;
--iron-icon-fill-color: --google-grey-800;
}
</style>
<iron-icon icon="[[computeIcon_(model.entity)]]"></iron-icon>
<template is="dom-repeat"
items="[[computeMatchSpans_(model.title, model.matchedRanges)]]"
as="span">
<span class$="[[getClassForMatch(span.isMatch)]]">[[span.text]]</span>
</template>
<span class="annotation">[[model.annotation]]</span>
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './icons.js';
import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import {assertNotReached} from 'chrome://resources/js/assert.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {Entity, Option} from './types.js';
/**
* Represents a substring of the option title, annotated with whether it's part
* of a match or not.
* @typedef {{
* text : string,
* isMatch : boolean,
* }}
*/
export let MatchSpan;
export class CommanderOptionElement extends PolymerElement {
static get is() {
return 'commander-option';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/** @type {!Option} */
model: Object,
};
}
/**
* @return {string} Appropriate iron-icon 'icon' value for this.model.entity
* @private
*/
computeIcon_() {
switch (this.model.entity) {
case Entity.COMMAND:
// TODO(lgrey): Need a vector of this. Using generic for now.
case Entity.BOOKMARK:
return 'commander-icons:chrome';
case Entity.TAB:
return 'commander-icons:tab';
case Entity.WINDOW:
return 'commander-icons:window';
}
assertNotReached();
return '';
}
/**
* Splits this.model.title into a list of substrings, each marked with
* whether they should be displayed as a match or not.
* @return !{Array<!MatchSpan>}
* @private
*/
computeMatchSpans_() {
/** @type {!Array<!MatchSpan>} */
const result = [];
let firstNonmatch = 0;
for (const r of this.model.matchedRanges) {
const [start, end] = r;
if (start !== 0) {
result.push({
text: this.model.title.substring(firstNonmatch, start),
isMatch: false
});
}
result.push(
{text: this.model.title.substring(start, end), isMatch: true});
firstNonmatch = end;
}
if (firstNonmatch < this.model.title.length) {
result.push(
{text: this.model.title.substring(firstNonmatch), isMatch: false});
}
return result;
}
/**
* @param {boolean} isMatch
* @return {string}
* @private
*/
getClassForMatch(isMatch) {
return isMatch ? 'match' : '';
}
}
customElements.define(CommanderOptionElement.is, CommanderOptionElement);
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @enum {number}
* The type of entity a given command represents. Must stay in sync with
* commander::CommandItem::Entity.
*/
export const Entity = {
COMMAND: 0,
BOOKMARK: 1,
TAB: 2,
WINDOW: 3,
};
/**
* @enum {number}
* The action that should be taken when the view model is received by the view.
* Must stay in sync with commander::CommanderViewModel::Action. "CLOSE" is
* included for completeness, but should be handled before the view model
* reaches the WebUI layer.
*/
export const Action = {
DISPLAY_RESULTS: 0,
CLOSE: 1,
PROMPT: 2,
};
/**
* View model for a result option.
* Corresponds to commander::CommandItemViewModel.
* @typedef {{
* title : string,
* annotation : (string|undefined),
* entity : Entity,
* matchedRanges : !Array<!Array<number>>,
* }}
*/
export let Option;
/**
* View model for a result set.
* Corresponds to commander::CommanderViewModel.
* @typedef {{
* action : Action,
* resultSetId : number,
* options : ?Array<Option>,
* }}
*/
export let ViewModel;
lgrey@chromium.org
ellyjones@chromium.org
# COMPONENT: UI>Browser
...@@ -19,11 +19,11 @@ constexpr char kViewModelUpdatedEvent[] = "view-model-updated"; ...@@ -19,11 +19,11 @@ constexpr char kViewModelUpdatedEvent[] = "view-model-updated";
constexpr char kInitializeEvent[] = "initialize"; constexpr char kInitializeEvent[] = "initialize";
// View model dictionary keys // View model dictionary keys
constexpr char kActionKey[] = "action"; constexpr char kActionKey[] = "action";
constexpr char kResultSetIdKey[] = "result_set_id"; constexpr char kResultSetIdKey[] = "resultSetId";
constexpr char kTitleKey[] = "title"; constexpr char kTitleKey[] = "title";
constexpr char kEntityKey[] = "entity"; constexpr char kEntityKey[] = "entity";
constexpr char kAnnotationKey[] = "annotation"; constexpr char kAnnotationKey[] = "annotation";
constexpr char kMatchedRangesKey[] = "matched_ranges"; constexpr char kMatchedRangesKey[] = "matchedRanges";
constexpr char kOptionsKey[] = "options"; constexpr char kOptionsKey[] = "options";
} // namespace } // namespace
......
...@@ -8,8 +8,11 @@ ...@@ -8,8 +8,11 @@
#include "chrome/browser/profiles/profile.h" #include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/webui/commander/commander_handler.h" #include "chrome/browser/ui/webui/commander/commander_handler.h"
#include "chrome/browser/ui/webui/webui_util.h"
#include "chrome/common/webui_url_constants.h" #include "chrome/common/webui_url_constants.h"
#include "chrome/grit/browser_resources.h" #include "chrome/grit/browser_resources.h"
#include "chrome/grit/commander_resources.h"
#include "chrome/grit/commander_resources_map.h"
#include "content/public/browser/web_ui_data_source.h" #include "content/public/browser/web_ui_data_source.h"
CommanderUI::CommanderUI(content::WebUI* web_ui) CommanderUI::CommanderUI(content::WebUI* web_ui)
...@@ -20,8 +23,9 @@ CommanderUI::CommanderUI(content::WebUI* web_ui) ...@@ -20,8 +23,9 @@ CommanderUI::CommanderUI(content::WebUI* web_ui)
content::WebUIDataSource* source = content::WebUIDataSource* source =
content::WebUIDataSource::Create(chrome::kChromeUICommanderHost); content::WebUIDataSource::Create(chrome::kChromeUICommanderHost);
source->AddResourcePath("index.html", IDR_COMMANDER_HTML); webui::SetupWebUIDataSource(
source->SetDefaultResource(IDR_COMMANDER_HTML); source, base::make_span(kCommanderResources, kCommanderResourcesSize), "",
IDR_COMMANDER_COMMANDER_HTML);
Profile* profile = Profile::FromWebUI(web_ui); Profile* profile = Profile::FromWebUI(web_ui);
content::WebUIDataSource::Add(profile, source); content::WebUIDataSource::Add(profile, source);
......
...@@ -143,17 +143,17 @@ TEST(CommanderHandlerTest, ViewModelPassed) { ...@@ -143,17 +143,17 @@ TEST(CommanderHandlerTest, ViewModelPassed) {
arg->FindPath("options")->GetList()[0].FindPath("title")->GetString()); arg->FindPath("options")->GetList()[0].FindPath("title")->GetString());
EXPECT_EQ(0, arg->FindPath("options") EXPECT_EQ(0, arg->FindPath("options")
->GetList()[0] ->GetList()[0]
.FindPath("matched_ranges") .FindPath("matchedRanges")
->GetList()[0] ->GetList()[0]
.GetList()[0] .GetList()[0]
.GetInt()); .GetInt());
EXPECT_EQ(4, arg->FindPath("options") EXPECT_EQ(4, arg->FindPath("options")
->GetList()[0] ->GetList()[0]
.FindPath("matched_ranges") .FindPath("matchedRanges")
->GetList()[0] ->GetList()[0]
.GetList()[1] .GetList()[1]
.GetInt()); .GetInt());
EXPECT_EQ(42, arg->FindPath("result_set_id")->GetInt()); EXPECT_EQ(42, arg->FindPath("resultSetId")->GetInt());
} }
TEST(CommanderHandlerTest, Initialize) { TEST(CommanderHandlerTest, Initialize) {
......
...@@ -132,6 +132,7 @@ template("chrome_extra_paks") { ...@@ -132,6 +132,7 @@ template("chrome_extra_paks") {
sources += [ sources += [
"$root_gen_dir/chrome/bookmarks_resources.pak", "$root_gen_dir/chrome/bookmarks_resources.pak",
"$root_gen_dir/chrome/browser/media/kaleidoscope/kaleidoscope_resources.pak", "$root_gen_dir/chrome/browser/media/kaleidoscope/kaleidoscope_resources.pak",
"$root_gen_dir/chrome/commander_resources.pak",
"$root_gen_dir/chrome/component_extension_resources.pak", "$root_gen_dir/chrome/component_extension_resources.pak",
"$root_gen_dir/chrome/dev_ui_resources.pak", "$root_gen_dir/chrome/dev_ui_resources.pak",
"$root_gen_dir/chrome/downloads_resources.pak", "$root_gen_dir/chrome/downloads_resources.pak",
...@@ -148,6 +149,7 @@ template("chrome_extra_paks") { ...@@ -148,6 +149,7 @@ template("chrome_extra_paks") {
deps += [ deps += [
"//chrome/browser/media/kaleidoscope:kaleidoscope_resources", "//chrome/browser/media/kaleidoscope:kaleidoscope_resources",
"//chrome/browser/resources:bookmarks_resources", "//chrome/browser/resources:bookmarks_resources",
"//chrome/browser/resources:commander_resources",
"//chrome/browser/resources:component_extension_resources", "//chrome/browser/resources:component_extension_resources",
"//chrome/browser/resources:dev_ui_paks", "//chrome/browser/resources:dev_ui_paks",
"//chrome/browser/resources:downloads_resources", "//chrome/browser/resources:downloads_resources",
......
...@@ -185,6 +185,10 @@ if (include_js_tests) { ...@@ -185,6 +185,10 @@ if (include_js_tests) {
if (enable_webui_tab_strip) { if (enable_webui_tab_strip) {
sources += [ "tab_strip/tab_strip_browsertest.js" ] sources += [ "tab_strip/tab_strip_browsertest.js" ]
} }
if (!is_android) {
sources += [ "commander/commander_browsertest.js" ]
}
deps = [ deps = [
":modulize", ":modulize",
"//build:branding_buildflags", "//build:branding_buildflags",
...@@ -505,6 +509,9 @@ group("closure_compile") { ...@@ -505,6 +509,9 @@ group("closure_compile") {
if (enable_tab_search) { if (enable_tab_search) {
deps += [ "tab_search_merge:closure_compile" ] deps += [ "tab_search_merge:closure_compile" ]
} }
if (!is_android) {
deps += [ "commander:closure_compile" ]
}
} }
js_type_check("closure_compile_local") { js_type_check("closure_compile_local") {
......
# Copyright 2020 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import("//third_party/closure_compiler/compile_js.gni")
js_type_check("closure_compile") {
is_polymer3 = true
closure_flags = default_closure_args + [
"browser_resolver_prefix_replacements=\"chrome://commander/=../../chrome/browser/resources/commander/\"",
"js_module_root=../../chrome/test/data/webui/",
"js_module_root=./gen/chrome/test/data/webui/",
]
deps = [
":commander_app_test",
":test_commander_browser_proxy",
]
}
js_library("commander_app_test") {
deps = [
":test_commander_browser_proxy",
"..:chai_assert",
"//chrome/browser/resources/commander:app",
]
externs_list = [ "$externs_path/mocha-2.5.js" ]
}
js_library("test_commander_browser_proxy") {
deps = [
"..:test_browser_proxy.m",
"//chrome/browser/resources/commander:browser_proxy",
]
}
file://chrome/browser/ui/webui/commander/OWNERS
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://commander/app.js';
import {BrowserProxyImpl} from 'chrome://commander/browser_proxy.js';
import {Action, Entity, ViewModel} from 'chrome://commander/types.js';
import {webUIListenerCallback} from 'chrome://resources/js/cr.m.js';
import {keyDownOn} from 'chrome://resources/polymer/v3_0/iron-test-helpers/mock-interactions.js';
import {assertDeepEquals, assertEquals, assertGT} from '../chai_assert.js';
import {flushTasks} from '../test_util.m.js';
import {TestCommanderBrowserProxy} from './test_commander_browser_proxy.js';
suite('CommanderWebUIBrowserTest', () => {
let app;
let testProxy;
/**
* Creates a basic view model skeleton from the provided data.
* Populated with action = DISPLAY_RESULTS, and an option per each title
* in `titles` with entity = COMMAND and `matchedRanges` = [[0, 1]].
* @param {number} resultSetId The value of `resultSetId` in the view model.
* @param {!Array<string>} titles A list of option titles.
* @returns {!ViewModel}
*/
function createStubViewModel(resultSetId, titles) {
const options = titles.map(title => ({
title: title,
entity: Entity.COMMAND,
matchedRanges: [[0, 1]],
}));
return {
resultSetId: resultSetId,
options: options,
action: Action.DISPLAY_RESULTS,
};
}
/**
* Asserts that of the elements in `elements`, only the element at
* `focusedIndex` has class 'focused'.
* @param {!NodeList<!HTMLElement>} elements An ordered list of elements.
* @param {number} focusedIndex The index at which the focused element is
* expected to appear.
*/
function assertFocused(elements, focusedIndex) {
assertGT(elements.length, 0);
Array.from(elements).forEach((element, index) => {
const isFocused = element.classList.contains('focused');
assertEquals(index === focusedIndex, isFocused);
});
}
setup(async () => {
testProxy = new TestCommanderBrowserProxy();
BrowserProxyImpl.instance_ = testProxy;
document.body.innerHTML = '';
app = document.createElement('commander-app');
document.body.appendChild(app);
await flushTasks();
});
test('esc dismisses', () => {
assertEquals(0, testProxy.getCallCount('dismiss'));
const input = app.$.input;
keyDownOn(input, 0, [], 'Escape');
assertEquals(1, testProxy.getCallCount('dismiss'));
});
test('typing sends textChanged', async () => {
const expectedText = 'orange';
const input = app.$.input;
input.value = expectedText;
input.dispatchEvent(new Event('input'));
const actualText = await testProxy.whenCalled('textChanged');
assertEquals(expectedText, actualText);
});
test('view model change renders options', async () => {
const titles = ['William of Orange', 'Orangutan', 'Orange Juice'];
webUIListenerCallback(
'view-model-updated', createStubViewModel(42, titles));
await flushTasks();
const optionElements = app.shadowRoot.querySelectorAll('commander-option');
assertEquals(titles.length, optionElements.length);
const actualTitles = Array.from(optionElements).map(el => {
return Array.from(el.shadowRoot.querySelectorAll('span'))
.map(span => span.innerText)
.join('');
});
assertDeepEquals(titles, actualTitles);
});
test('view model change sends heightChanged', async () => {
webUIListenerCallback('view-model-updated', createStubViewModel(42, [
'William of Orange', 'Orangutan', 'Orange Juice'
]));
await flushTasks();
const height = await testProxy.whenCalled('heightChanged');
assertEquals(document.body.offsetHeight, height);
});
test('clicking option sends optionSelected', async () => {
const expectedResultSetId = 42;
webUIListenerCallback(
'view-model-updated', createStubViewModel(expectedResultSetId, [
'William of Orange', 'Orangutan', 'Orange Juice'
]));
await flushTasks();
const optionElements = app.shadowRoot.querySelectorAll('commander-option');
optionElements[1].click();
const [optionIndex, resultID] =
await testProxy.whenCalled('optionSelected');
assertEquals(1, optionIndex);
assertEquals(expectedResultSetId, resultID);
});
test('first option selected by default', async () => {
webUIListenerCallback('view-model-updated', createStubViewModel(42, [
'William of Orange', 'Orangutan', 'Orange Juice'
]));
await flushTasks();
const optionElements = app.shadowRoot.querySelectorAll('commander-option');
assertFocused(optionElements, 0);
});
test('arrow keys change selection', async () => {
const input = app.$.input;
webUIListenerCallback('view-model-updated', createStubViewModel(42, [
'William of Orange', 'Orangutan', 'Orange Juice'
]));
await flushTasks();
const optionElements = app.shadowRoot.querySelectorAll('commander-option');
keyDownOn(input, 0, [], 'ArrowDown');
assertFocused(optionElements, 1);
keyDownOn(input, 0, [], 'ArrowDown');
assertFocused(optionElements, 2);
keyDownOn(input, 0, [], 'ArrowDown');
assertFocused(optionElements, 0);
keyDownOn(input, 0, [], 'ArrowUp');
assertFocused(optionElements, 2);
keyDownOn(input, 0, [], 'ArrowUp');
assertFocused(optionElements, 1);
keyDownOn(input, 0, [], 'ArrowUp');
assertFocused(optionElements, 0);
});
test('return sends optionSelected', async () => {
const input = app.$.input;
const expectedResultSetId = 42;
webUIListenerCallback(
'view-model-updated', createStubViewModel(expectedResultSetId, [
'William of Orange', 'Orangutan', 'Orange Juice'
]));
await flushTasks();
keyDownOn(input, 0, [], 'Enter');
const [optionIndex, resultID] =
await testProxy.whenCalled('optionSelected');
assertEquals(0, optionIndex);
assertEquals(expectedResultSetId, resultID);
});
});
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/** @fileoverview Test suite for the Commander WebUI interface. */
GEN_INCLUDE(['//chrome/test/data/webui/polymer_browser_test_base.js']);
GEN('#include "content/public/test/browser_test.h"');
// eslint-disable-next-line no-var
var CommanderWebUIBrowserTest = class extends PolymerTest {
/** @override */
get browsePreload() {
return 'chrome://commander/test_loader.html?module=commander/commander_app_test.js';
}
/** @override */
get extraLibraries() {
return [
// Even though PolymerTest includes this, we need to override it to
// avoid double-importing cr.m.js
'//third_party/mocha/mocha.js',
'//chrome/test/data/webui/mocha_adapter.js',
];
}
};
TEST_F('CommanderWebUIBrowserTest', 'All', function() {
mocha.run();
});
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {BrowserProxy} from 'chrome://commander/browser_proxy.js';
import {TestBrowserProxy} from '../test_browser_proxy.m.js';
/** @implements {BrowserProxy} */
export class TestCommanderBrowserProxy extends TestBrowserProxy {
constructor() {
super([
'textChanged',
'optionSelected',
'heightChanged',
'dismiss',
]);
}
/** @override */
textChanged(newText) {
this.methodCalled('textChanged', newText);
}
/** @override */
optionSelected(index, resultSetId) {
this.methodCalled('optionSelected', [index, resultSetId]);
}
/** @override */
heightChanged(newHeight) {
this.methodCalled('heightChanged', newHeight);
}
/** @override */
dismiss() {
this.methodCalled('dismiss');
}
}
...@@ -120,6 +120,10 @@ ...@@ -120,6 +120,10 @@
"chrome/browser/resources/chromeos/multidevice_setup/multidevice_setup_resources.grd": { "chrome/browser/resources/chromeos/multidevice_setup/multidevice_setup_resources.grd": {
"structures": [1400], "structures": [1400],
}, },
"<(SHARED_INTERMEDIATE_DIR)/chrome/browser/resources/commander/commander_resources.grd": {
"META": {"sizes": {"includes": [15]}},
"includes": [1405],
},
"chrome/browser/resources/component_extension_resources.grd": { "chrome/browser/resources/component_extension_resources.grd": {
"includes": [1420], "includes": [1420],
"structures": [1440], "structures": [1440],
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment