diff --git a/tensorboard/components/tf_backend/router.ts b/tensorboard/components/tf_backend/router.ts
index f4e74854078..81cfc33e9fa 100644
--- a/tensorboard/components/tf_backend/router.ts
+++ b/tensorboard/components/tf_backend/router.ts
@@ -20,6 +20,7 @@ export type RunTagUrlFn = (tag: string, run: string) => string;
export interface Router {
logdir: () => string;
runs: () => string;
+ pluginsListing: () => string;
isDemoMode: () => boolean;
pluginRoute: (pluginName: string, route: string) => string;
pluginRunTagRoute: (pluginName: string, route: string) => RunTagUrlFn;
@@ -60,6 +61,7 @@ export function createRouter(dataDir = 'data', demoMode = false): Router {
return {
logdir: () => dataDir + '/logdir',
runs: () => dataDir + '/runs' + (demoMode ? '.json' : ''),
+ pluginsListing: () => dataDir + '/plugins_listing',
isDemoMode: () => demoMode,
pluginRoute,
pluginRunTagRoute,
diff --git a/tensorboard/components/tf_storage/storage.ts b/tensorboard/components/tf_storage/storage.ts
index d2441f83459..58ef52c1a70 100644
--- a/tensorboard/components/tf_storage/storage.ts
+++ b/tensorboard/components/tf_storage/storage.ts
@@ -311,8 +311,8 @@ function _componentToDict(component: string): StringDict {
const tokens = component.split('&');
tokens.forEach((token) => {
const kv = token.split('=');
- // Special backwards compatibility for URI components like #events
- if (kv.length === 1 && _.contains(TABS, kv[0])) {
+ // Special backwards compatibility for URI components like #scalars.
+ if (kv.length === 1) {
items[TAB] = kv[0];
} else if (kv.length === 2) {
items[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]);
diff --git a/tensorboard/components/tf_tensorboard/tf-tensorboard.html b/tensorboard/components/tf_tensorboard/tf-tensorboard.html
index 03c6e4b44b9..4ab6317577d 100644
--- a/tensorboard/components/tf_tensorboard/tf-tensorboard.html
+++ b/tensorboard/components/tf_tensorboard/tf-tensorboard.html
@@ -56,21 +56,57 @@
Settings
TensorBoard
-
-
-
- [[dashboard]]
+
+
+ Loading active dashboards…
+
+
+
+
+
+
+ [[dashboard]]
+
+
+
+
+
+
-
+
Settings
+
+
+
Failed to load the set of active dashboards.
+
+ This can occur if the TensorBoard backend is no longer
+ running. Perhaps this page is cached?
+
+ If you think that you’ve fixed the problem, click the reload
+ button in the top-right.
+
+ We’ll try to reload every [[autoReloadIntervalSecs]] seconds as well.
+
+
Last reload: [[_lastReloadTime]]
+
+ Log directory: [[_logdir]]
+
+
+
+
+
+
No dashboards are active for the current data set.
+
Probable causes:
+
+ - You haven’t written any data to your event files.
+
- TensorBoard can’t find your event files.
+
+ If you’re new to using TensorBoard, and want to find out how
+ to add data and set up your event files, check out the
+
README
+ and perhaps the
TensorBoard tutorial.
+
+ If you think TensorBoard is configured properly, please see
+ the section of the README devoted to missing data problems
+ and consider filing an issue on GitHub.
+
Last reload: [[_lastReloadTime]]
+
+ Log directory: [[_logdir]]
+
+
+
+
+
+
There’s no dashboard by the name of “[[_selectedDashboard]].”
+
+ You can select a dashboard from the list above.
+
+
Last reload: [[_lastReloadTime]]
+
+ Log directory: [[_logdir]]
+
+
+
Settings
text-rendering: optimizeLegibility;
letter-spacing: -0.025em;
font-weight: 500;
- flex-grow: 2;
+ flex-grow: 1;
display: var(--tb-toolbar-title-display, block);
}
+ .toolbar-message {
+ -webkit-font-smoothing: antialiased;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
#tabs {
flex-grow: 1;
text-transform: uppercase;
@@ -139,8 +233,37 @@ Settings
--paper-tabs-selection-bar-color: white;
}
+ paper-dropdown-menu {
+ --paper-input-container-color: rgba(255, 255, 255, 0.8);
+ --paper-input-container-focus-color: white;
+ --paper-input-container-input-color: white;
+ --paper-dropdown-menu-icon: {
+ color: white;
+ }
+ --paper-input-container-input: {
+ -webkit-font-smoothing: antialiased;
+ font-size: 14px;
+ font-weight: 500;
+ text-transform: uppercase;
+ }
+ --paper-input-container-label: {
+ -webkit-font-smoothing: antialiased;
+ font-size: 14px;
+ font-weight: 500;
+ text-transform: uppercase;
+ }
+ }
+
+ #inactive-dashboards-menu {
+ --paper-menu-background-color: var(--tb-toolbar-background-color, --tb-orange-strong);
+ --paper-menu-color: white;
+ --paper-menu: {
+ text-transform: uppercase;
+ }
+ }
+
.global-actions {
- flex-grow: 2;
+ flex-grow: 1;
display: inline-flex; /* Ensure that icons stay aligned */
justify-content: flex-end;
text-align: right;
@@ -168,6 +291,11 @@ Settings
height: 100%;
}
+ .warning-message {
+ max-width: 540px;
+ margin: 80px auto 0 auto;
+ }
+
[disabled] {
opacity: 0.2;
color: white;
@@ -179,9 +307,31 @@ Settings
import {AutoReloadBehavior} from "./autoReloadBehavior.js";
import {TABS, setUseHash} from "../tf-globals/globals.js";
import {getString, setString, TAB} from "../tf-storage/storage.js";
- import {setRouter, createRouter} from "../tf-backend/router.js";
+ import {Canceller} from "../tf-backend/canceller.js";
+ import {RequestManager} from "../tf-backend/requestManager.js";
+ import {getRouter, setRouter, createRouter} from "../tf-backend/router.js";
import {fetchRuns} from "../tf-backend/runsStore.js";
+ // A map from frontend dashboard names to backend plugin names.
+ // TODO(@wchargin): Require that these be the same; update the
+ // projector dashboard's backend name and remove this table.
+ const PLUGIN_NAMES_BY_DASHBOARD = {
+ 'scalars': 'scalars',
+ 'images': 'images',
+ 'audio': 'audio',
+ 'graphs': 'graphs',
+ 'distributions': 'distributions',
+ 'histograms': 'histograms',
+ 'embeddings': 'projector',
+ 'text': 'text',
+ };
+ if (!_.isEqual(Object.keys(PLUGIN_NAMES_BY_DASHBOARD), TABS)) {
+ throw new Error(
+ `Bad set of plugin names: ` +
+ `${Object.values(PLUGIN_NAMES_BY_DASHBOARD)} vs. ${TABS}`);
+ }
+
+ // A map from dashboard name to Polymer component name.
const COMPONENTS = {
'scalars': 'tf-scalar-dashboard',
'images': 'tf-image-dashboard',
@@ -197,6 +347,12 @@ Settings
`Bad set of components: ${Object.keys(COMPONENTS)} vs. ${TABS}`);
}
+ /** @enum {string} */ const ActiveDashboardsLoadState = {
+ NOT_LOADED: 'NOT_LOADED',
+ LOADED: 'LOADED',
+ FAILED: 'FAILED',
+ };
+
Polymer({
is: "tf-tensorboard",
behaviors: [AutoReloadBehavior],
@@ -247,23 +403,70 @@ Settings
},
},
- /** @type {Array} */
+ /**
+ * The set of all possible dashboard names.
+ *
+ * @type {Array!}
+ */
_dashboards: {
type: Array,
readOnly: true,
value: TABS,
- observer: '_dashboardsUpdated',
+ },
+
+ /**
+ * The set of currently active dashboards.
+ * `null` if not yet fetched.
+ *
+ * TODO(@wchargin): Consider templating in an initial value for
+ * this property.
+ *
+ * @type {Array?}
+ */
+ _activeDashboards: {
+ type: Array,
+ value: null,
+ },
+
+ /** @type {ActiveDashboardsLoadState} */
+ _activeDashboardsLoadState: {
+ type: String,
+ value: ActiveDashboardsLoadState.NOT_LOADED,
+ },
+ _activeDashboardsNotLoaded: {
+ type: Boolean,
+ computed:
+ '_computeActiveDashboardsNotLoaded(_activeDashboardsLoadState)',
+ },
+ _activeDashboardsLoaded: {
+ type: Boolean,
+ computed:
+ '_computeActiveDashboardsLoaded(_activeDashboardsLoadState)',
+ },
+ _activeDashboardsFailedToLoad: {
+ type: Boolean,
+ computed:
+ '_computeActiveDashboardsFailedToLoad(_activeDashboardsLoadState)',
+ },
+ _showNoDashboardsMessage: {
+ type: Boolean,
+ computed:
+ '_computeShowNoDashboardsMessage(_activeDashboardsLoaded, _activeDashboards, _selectedDashboard)',
+ },
+ _showNoSuchDashboardMessage: {
+ type: Boolean,
+ computed:
+ '_computeShowNoSuchDashboardMessage(_dashboards, _selectedDashboard)',
},
/**
* The name of the currently selected dashboard, or `null` if no
- * dashboard is selected.
+ * dashboard is selected. A `null` value here is represented by
+ * an empty string in the hash, and vice versa.
*/
_selectedDashboard: {
type: String,
value: null,
- observer: '_updateCurrentDashboard',
- notify: true,
},
/*
@@ -280,19 +483,90 @@ Settings
type: Boolean,
computed: '_computeIsReloadDisabled(_debuggerDataEnabled, _selectedDashboard)',
},
+
+ _lastReloadTime: {
+ type: String,
+ value: "not yet loaded",
+ },
+
+ _logdir: {
+ type: String,
+ value: null,
+ },
+
+ _requestManager: {
+ type: Object,
+ value: () => new RequestManager(),
+ },
+ _canceller: {
+ type: Object,
+ value: () => new Canceller(),
+ },
},
observers: [
- '_ensureSelectedDashboardStamped(_dashboardContainersStamped, _selectedDashboard)',
+ '_updateSelectedDashboard(_selectedDashboard, _activeDashboards)',
+ '_ensureSelectedDashboardStamped(' +
+ '_dashboardContainersStamped, _activeDashboards, _selectedDashboard)',
],
- _dashboardsUpdated(dashboards) {
- if (this._selectedDashboard == null) {
- this._selectedDashboard = dashboards[0];
+ _activeDashboardsUpdated(activeDashboards, selectedDashboard) {
+ },
+
+ /**
+ * @param {string?} disabledDashboards comma-separated
+ * @param {Array?} activeDashboards if null, nothing is active
+ * @param {string} dashboard
+ * @return {boolean}
+ */
+ _isDashboardActive(disabledDashboards, activeDashboards, dashboard) {
+ if ((disabledDashboards || '').split(',').indexOf(dashboard) >= 0) {
+ // Explicitly disabled.
+ return false;
+ }
+ if (!(activeDashboards || []).includes(dashboard)) {
+ // Inactive.
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Determine whether a dashboard is enabled but not active.
+ *
+ * @param {string?} disabledDashboards comma-separated
+ * @param {Array?} activeDashboards if null, nothing is active
+ * @param {string} dashboard
+ * @return {boolean}
+ */
+ _isDashboardInactive(disabledDashboards, activeDashboards, dashboard) {
+ if ((disabledDashboards || '').split(',').indexOf(dashboard) >= 0) {
+ // Disabled dashboards don't appear at all; they're not just
+ // inactive.
+ return false;
+ }
+ if (!(activeDashboards || []).includes(dashboard)) {
+ // Inactive.
+ return true;
}
+ return false;
},
- _isDashboardEnabled(disabledDashboards, dashboard) {
- return (disabledDashboards || '').split(',').indexOf(dashboard) < 0;
+ _inactiveDashboardsExist(dashboards, disabledDashboards, activeDashboards) {
+ if (!activeDashboards) {
+ // Not loaded yet. Show nothing.
+ return false;
+ }
+ const workingSet = new Set();
+ dashboards.forEach(d => {
+ workingSet.add(d);
+ });
+ (disabledDashboards || '').split(',').forEach(d => {
+ workingSet.delete(d);
+ });
+ activeDashboards.forEach(d => {
+ workingSet.delete(d);
+ });
+ return workingSet.size > 0;
},
_getDashboardFromIndex(dashboards, index) {
@@ -304,8 +578,25 @@ Settings
return currentDashboard === candidateDashboard ? 'inherit' : 'none';
},
- _updateCurrentDashboard(currentDashboard) {
- setString(TAB, currentDashboard, /*useLocalStorage=*/false);
+ /**
+ * If no dashboard is selected but dashboards are available,
+ * select the first active dashboard. In any case, synchronize the
+ * state to the hash.
+ */
+ _updateSelectedDashboard(selectedDashboard, activeDashboards) {
+ if (activeDashboards && selectedDashboard == null) {
+ selectedDashboard = activeDashboards[0] || null;
+ if (selectedDashboard != null) {
+ this._selectedDashboard = selectedDashboard;
+ return; // we just triggered that handler; don't need another
+ }
+ }
+ setString(TAB, selectedDashboard || '', /*useLocalStorage=*/false);
+ },
+
+ _updateSelectedDashboardFromHash() {
+ const dashboardName = getString(TAB, /*useLocalStorage=*/false);
+ this.set('_selectedDashboard', dashboardName || null);
},
/**
@@ -317,13 +608,25 @@ Settings
* dashboard until its _container_ is itself stamped. (Containers
* are stamped declaratively by a `` in the HTML
* template.)
+ *
+ * We also wait for the set of active dashboards to be loaded
+ * before we stamp anything. This prevents us from stamping a
+ * dashboard that's not actually enabled (e.g., if the user
+ * navigates to `/#text` when the text plugin is disabled).
+ *
+ * If the currently selected dashboard is not a real dashboard,
+ * this does nothing.
*/
- _ensureSelectedDashboardStamped(containersStamped, dashboard) {
- if (!containersStamped) {
+ _ensureSelectedDashboardStamped(containersStamped, activeDashboards, dashboard) {
+ if (!containersStamped || !activeDashboards || !dashboard) {
return;
}
const container = this.$$(
`.dashboard-container[data-dashboard=${dashboard}]`);
+ if (!container) {
+ // This dashboard doesn't exist. Nothing to do here.
+ return;
+ }
if (container.childNodes.length === 0) {
const component = document.createElement(COMPONENTS[dashboard]);
component.id = 'dashboard'; // used in `_selectedDashboardComponent`
@@ -331,16 +634,26 @@ Settings
};
},
- _computeIsReloadDisabled(debuggerDataEnabled, mode) {
+ _computeIsReloadDisabled(debuggerDataEnabled, selectedDashboard) {
+ if (selectedDashboard == null) {
+ // No dashboards available. Let the user refresh to try to
+ // load more data.
+ return false;
+ }
// TODO(@wchargin): Refactor to remove these explicit plugin names.
const disabledForModes = ['graphs', 'embeddings'];
- return !debuggerDataEnabled && disabledForModes.includes(mode);
+ return !debuggerDataEnabled && disabledForModes.includes(selectedDashboard);
},
/**
* Get the Polymer component corresponding to the currently
* selected dashboard. For instance, the result might be an
* instance of ``.
+ *
+ * If the dashboard does not exist (e.g., the set of active
+ * dashboards has not loaded or has failed to load, or the user
+ * has selected a dashboard for which we have no implementation),
+ * `null` is returned.
*/
_selectedDashboardComponent() {
if (!this._dashboardContainersStamped) {
@@ -350,39 +663,80 @@ Settings
const selectedDashboard = this._selectedDashboard;
var dashboard = this.$$(
`.dashboard-container[data-dashboard=${selectedDashboard}] #dashboard`);
- if (dashboard == null) {
- throw new Error(
- `Unable to find dashboard for mode: ${selectedDashboard}`);
- }
return dashboard;
},
ready() {
setUseHash(this.useHash);
+ this._updateSelectedDashboardFromHash();
+ window.addEventListener('hashchange', () => {
+ this._updateSelectedDashboardFromHash();
+ }, /*useCapture=*/false);
// We have to wait for our dashboard-containers to be stamped
// before we can do anything.
const dashboardsTemplate = this.$$('#dashboards-template');
- dashboardsTemplate.addEventListener('dom-change', () => {
+ const onDomChange = () => {
// This will trigger an observer that kicks off everything.
this._dashboardContainersStamped = true;
+ };
+ dashboardsTemplate.addEventListener(
+ 'dom-change', onDomChange, /*useCapture=*/false);
- this._updateSelectedDashboardFromHash();
- window.addEventListener('hashchange', () => {
- this._updateSelectedDashboardFromHash();
- }, /*useCapture=*/false);
- fetchRuns();
- }, /*useCapture=*/false);
+ fetchRuns();
+ this._fetchLogdir();
+ this._fetchActiveDashboards();
+ this._lastReloadTime = new Date().toString();
},
- _updateSelectedDashboardFromHash() {
- const dashboardName = getString(TAB, /*useLocalStorage=*/false);
- if (this._dashboards.includes(dashboardName)) {
- this.set('_selectedDashboard', dashboardName);
- } else {
- // Select the first dashboard as default.
- this.set('_selectedDashboard', this._dashboards[0]);
- }
+ _fetchLogdir() {
+ const url = getRouter().logdir();
+ return this._requestManager.request(url).then(result => {
+ this._logdir = result.logdir;
+ });
+ },
+
+ _fetchActiveDashboards() {
+ this._canceller.cancelAll();
+ const updateActiveDashboards = this._canceller.cancellable(result => {
+ if (result.cancelled) {
+ return;
+ }
+ const activePlugins = result.value;
+ this._activeDashboards = this._dashboards.filter(
+ dashboard => activePlugins[PLUGIN_NAMES_BY_DASHBOARD[dashboard]]);
+ this._activeDashboardsLoadState = ActiveDashboardsLoadState.LOADED;
+ });
+ const onFailure = () => {
+ if (this._activeDashboardsLoadState
+ === ActiveDashboardsLoadState.NOT_LOADED) {
+ this._activeDashboardsLoadState = ActiveDashboardsLoadState.FAILED;
+ } else {
+ console.warn(
+ "Failed to reload the set of active plugins; using old value.");
+ }
+ };
+ return this._requestManager
+ .request(getRouter().pluginsListing())
+ .then(updateActiveDashboards, onFailure);
+ },
+
+ _computeActiveDashboardsNotLoaded(state) {
+ return state === ActiveDashboardsLoadState.NOT_LOADED;
+ },
+ _computeActiveDashboardsLoaded(state) {
+ return state === ActiveDashboardsLoadState.LOADED;
+ },
+ _computeActiveDashboardsFailedToLoad(state) {
+ return state === ActiveDashboardsLoadState.FAILED;
+ },
+ _computeShowNoDashboardsMessage(loaded, activeDashboards, selectedDashboard) {
+ return (loaded
+ && activeDashboards.length === 0
+ && selectedDashboard == null);
+ },
+ _computeShowNoSuchDashboardMessage(dashboards, selectedDashboard) {
+ return !!selectedDashboard && !dashboards.includes(selectedDashboard);
},
_updateRouter(router) {
@@ -393,9 +747,14 @@ Settings
if (this._isReloadDisabled) {
return;
}
- fetchRuns().then(() => {
- this._selectedDashboardComponent().reload();
+ this._fetchLogdir();
+ Promise.all([this._fetchActiveDashboards(), fetchRuns()]).then(() => {
+ const dashboard = this._selectedDashboardComponent();
+ if (dashboard) {
+ dashboard.reload();
+ }
});
+ this._lastReloadTime = new Date().toString();
},
openSettings() {
diff --git a/tensorboard/functionaltests/BUILD b/tensorboard/functionaltests/BUILD
index 4362e389d16..4dbef49286e 100644
--- a/tensorboard/functionaltests/BUILD
+++ b/tensorboard/functionaltests/BUILD
@@ -12,5 +12,9 @@ py_web_test_suite(
"//tensorboard",
],
srcs_version = "PY2AND3",
- deps = ["@io_bazel_rules_webtesting//testing/web"],
+ deps = [
+ "@io_bazel_rules_webtesting//testing/web",
+ "//tensorboard/plugins/scalar:scalars_demo",
+ "//tensorboard/plugins/audio:audio_demo",
+ ],
)
diff --git a/tensorboard/functionaltests/core_test.py b/tensorboard/functionaltests/core_test.py
index 6d5cf27ce20..93ca49dea83 100644
--- a/tensorboard/functionaltests/core_test.py
+++ b/tensorboard/functionaltests/core_test.py
@@ -21,22 +21,33 @@
import os
import subprocess
import unittest
+import tempfile
from selenium.webdriver.common import by
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support import wait
from testing.web import webtest
+from tensorboard.plugins.scalar import scalars_demo
+from tensorboard.plugins.audio import audio_demo
+
class BasicTest(unittest.TestCase):
+ """Tests that the basic chrome is displayed when there is no data."""
@classmethod
def setUpClass(cls):
src_dir = os.environ["TEST_SRCDIR"]
binary = os.path.join(src_dir,
"org_tensorflow_tensorboard/tensorboard/tensorboard")
- log_dir = "/tmp/hypothetical_log_directory"
+ cls.logdir = tempfile.mkdtemp(prefix='core_test_%s_logdir_' % cls.__name__)
+ cls.setUpData()
cls.process = subprocess.Popen(
- [binary, "--port", "8000", "--logdir", log_dir])
+ [binary, "--port", "8000", "--logdir", cls.logdir])
+
+ @classmethod
+ def setUpData(cls):
+ # Overridden by DashboardsTest.
+ pass
@classmethod
def tearDownClass(cls):
@@ -63,7 +74,92 @@ def testToolbarTitleDisplays(self):
def testLogdirDisplays(self):
self.wait.until(
expected_conditions.text_to_be_present_in_element((
- by.By.ID, "logdir"), "/tmp/hypothetical"))
+ by.By.ID, "logdir"), self.logdir))
+
+
+class DashboardsTest(BasicTest):
+ """Tests basic behavior when there is some data in TensorBoard.
+
+ This extends `BasicTest`, so it inherits its methods to test that the
+ basic chrome is displayed. We also check that we can navigate around
+ the various dashboards.
+ """
+
+ @classmethod
+ def setUpData(cls):
+ scalars_demo.run_all(cls.logdir, verbose=False)
+ audio_demo.run_all(cls.logdir, verbose=False)
+
+ def testDashboardSelection(self):
+ """Test that we can navigate among the different dashboards."""
+ selectors = {
+ "scalars_tab": "paper-tab[data-dashboard=scalars]",
+ "audio_tab": "paper-tab[data-dashboard=audio]",
+ "inactive_dropdown": "paper-dropdown-menu[label*=Inactive]",
+ "images_menu_item": "paper-item[data-dashboard=images]",
+ }
+ elements = {}
+ for (name, selector) in selectors.items():
+ locator = (by.By.CSS_SELECTOR, selector)
+ self.wait.until(expected_conditions.presence_of_element_located(locator))
+ elements[name] = self.driver.find_element_by_css_selector(selector)
+
+ def is_selected(element):
+ """Test whether a paper-tab or paper-item is selected.
+
+ The implementation of paper-* components doesn't seem to play nice
+ with Selenium's `element.is_selected()` method. Instead, we can
+ check the WAI-ARIA attributes.
+ """
+ return element.get_attribute("aria-selected")
+
+ def assert_selected_dashboard(polymer_component_name):
+ expected = {polymer_component_name}
+ actual = {
+ container.find_element_by_css_selector("*").tag_name # first child
+ for container
+ in self.driver.find_elements_by_css_selector(".dashboard-container")
+ if container.is_displayed()
+ }
+ self.assertEqual(expected, actual)
+
+ # The scalar and audio dashboards should be active, and the scalar
+ # dashboard should be selected by default. The images menu item
+ # should not be visible, as it's within the drop-down menu.
+ self.assertTrue(elements["scalars_tab"].is_displayed())
+ self.assertTrue(elements["audio_tab"].is_displayed())
+ self.assertTrue(is_selected(elements["scalars_tab"]))
+ self.assertFalse(is_selected(elements["audio_tab"]))
+ self.assertFalse(elements["images_menu_item"].is_displayed())
+ self.assertFalse(is_selected(elements["images_menu_item"]))
+ assert_selected_dashboard("tf-scalar-dashboard")
+
+ # We should be able to activate the audio dashboard.
+ elements["audio_tab"].click()
+ self.assertFalse(is_selected(elements["scalars_tab"]))
+ self.assertTrue(is_selected(elements["audio_tab"]))
+ self.assertFalse(is_selected(elements["images_menu_item"]))
+ assert_selected_dashboard("tf-audio-dashboard")
+
+ # We should then be able to open the dropdown and navigate to the
+ # image dashboard. (We have to wait until it's visible because of the
+ # dropdown menu's animations.)
+ elements["inactive_dropdown"].click()
+ self.wait.until(
+ expected_conditions.visibility_of(elements["images_menu_item"]))
+ self.assertTrue(elements["images_menu_item"].is_displayed())
+ elements["images_menu_item"].click()
+ self.assertFalse(is_selected(elements["scalars_tab"]))
+ self.assertFalse(is_selected(elements["audio_tab"]))
+ self.assertTrue(is_selected(elements["images_menu_item"]))
+ assert_selected_dashboard("tf-image-dashboard")
+
+ # Finally, we should be able to navigate back to the scalar dashboard.
+ elements["scalars_tab"].click()
+ self.assertTrue(is_selected(elements["scalars_tab"]))
+ self.assertFalse(is_selected(elements["audio_tab"]))
+ self.assertFalse(is_selected(elements["images_menu_item"]))
+ assert_selected_dashboard("tf-scalar-dashboard")
if __name__ == "__main__":