Initial Move from Azure DevOps to GitHub

This commit is contained in:
Steve Faulkner
2020-05-25 21:30:55 -05:00
commit 36581fb6d9
986 changed files with 195242 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
@import "../../../../less/Common/Constants";
.dividerContainer {
padding: @SmallSpace 0px @SmallSpace 0px;
.flex-display();
span {
border-left: @ButtonBorderWidth solid @BaseMediumHigh;
margin: 0 10px 0 10px;
}
}
.commandBarContainer {
border-bottom: 1px solid @BaseMedium;
}

View File

@@ -0,0 +1,103 @@
/**
* This adapter is responsible to render the React component
* If the component signals a change through the callback passed in the properties, it must render the React component when appropriate
* and update any knockout observables passed from the parent.
*/
import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import * as ViewModels from "../../../Contracts/ViewModels";
import { CommandBarComponentButtonFactory } from "./CommandBarComponentButtonFactory";
import { CommandBar, ICommandBarItemProps } from "office-ui-fabric-react/lib/CommandBar";
import { StyleConstants } from "../../../Common/Constants";
import { CommandBarUtil } from "./CommandBarUtil";
export class CommandBarComponentAdapter implements ReactAdapter {
public parameters: ko.Observable<number>;
public container: ViewModels.Explorer;
private tabsButtons: ViewModels.NavbarButtonConfig[];
constructor(container: ViewModels.Explorer) {
this.container = container;
this.tabsButtons = [];
// These are the parameters watched by the react binding that will trigger a renderComponent() if one of the ko mutates
const toWatch = [
container.isPreferredApiTable,
container.isPreferredApiMongoDB,
container.isPreferredApiDocumentDB,
container.isPreferredApiCassandra,
container.isPreferredApiGraph,
container.deleteCollectionText,
container.deleteDatabaseText,
container.addCollectionText,
container.addDatabaseText,
container.isDatabaseNodeOrNoneSelected,
container.isDatabaseNodeSelected,
container.isNoneSelected,
container.isResourceTokenCollectionNodeSelected,
container.isHostedDataExplorerEnabled,
container.isSynapseLinkUpdating,
container.databaseAccount,
container.isNotebookTabActive
];
ko.computed(() => ko.toJSON(toWatch)).subscribe(() => this.triggerRender());
this.parameters = ko.observable(Date.now());
}
public onUpdateTabsButtons(buttons: ViewModels.NavbarButtonConfig[]): void {
this.tabsButtons = buttons;
this.triggerRender();
}
public renderComponent(): JSX.Element {
const backgroundColor = StyleConstants.BaseLight;
const staticButtons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(this.container);
const contextButtons = (this.tabsButtons || []).concat(
CommandBarComponentButtonFactory.createContextCommandBarButtons(this.container)
);
const controlButtons = CommandBarComponentButtonFactory.createControlCommandBarButtons(this.container);
const uiFabricStaticButtons = CommandBarUtil.convertButton(staticButtons, backgroundColor);
if (this.tabsButtons && this.tabsButtons.length > 0) {
uiFabricStaticButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
}
const uiFabricTabsButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(contextButtons, backgroundColor);
if (uiFabricTabsButtons.length > 0) {
uiFabricStaticButtons.push(CommandBarUtil.createDivider("commandBarDivider"));
}
const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, backgroundColor);
uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
if (this.container && this.container.isNotebookTabActive()) {
uiFabricControlButtons.unshift(
CommandBarUtil.createMemoryTracker("memoryTracker", this.container.memoryUsageInfo)
);
}
return (
<React.Fragment>
<div className="commandBarContainer">
<CommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
items={uiFabricStaticButtons.concat(uiFabricTabsButtons)}
farItems={uiFabricControlButtons}
styles={{
root: { backgroundColor: backgroundColor }
}}
overflowButtonProps={{ ariaLabel: "More commands" }}
/>
</div>
</React.Fragment>
);
}
private triggerRender() {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
}

View File

@@ -0,0 +1,315 @@
import * as ko from "knockout";
import * as ViewModels from "../../../Contracts/ViewModels";
import { CommandBarComponentButtonFactory } from "./CommandBarComponentButtonFactory";
import { ExplorerStub } from "../../OpenActionsStubs";
import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService";
describe("CommandBarComponentButtonFactory tests", () => {
let mockExplorer: ViewModels.Explorer;
describe("Enable notebook button", () => {
const enableNotebookBtnLabel = "Enable Notebooks (Preview)";
beforeAll(() => {
mockExplorer = new ExplorerStub();
mockExplorer.addCollectionText = ko.observable("mockText");
mockExplorer.isAuthWithResourceToken = ko.observable(false);
mockExplorer.isPreferredApiTable = ko.computed(() => true);
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
});
it("Notebooks is already enabled - button should be hidden", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(true);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const enableNotebookBtn = buttons.find(button => button.commandButtonLabel === enableNotebookBtnLabel);
expect(enableNotebookBtn).toBeUndefined();
});
it("Account is running on one of the national clouds - button should be hidden", () => {
mockExplorer.isNotebookEnabled = ko.observable(false);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const enableNotebookBtn = buttons.find(button => button.commandButtonLabel === enableNotebookBtnLabel);
expect(enableNotebookBtn).toBeUndefined();
});
it("Notebooks is not enabled but is available - button should be shown and enabled", () => {
mockExplorer.isNotebookEnabled = ko.observable(false);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(true);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const enableNotebookBtn = buttons.find(button => button.commandButtonLabel === enableNotebookBtnLabel);
expect(enableNotebookBtn).toBeDefined();
expect(enableNotebookBtn.disabled).toBe(false);
expect(enableNotebookBtn.tooltipText).toBe("");
});
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
mockExplorer.isNotebookEnabled = ko.observable(false);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const enableNotebookBtn = buttons.find(button => button.commandButtonLabel === enableNotebookBtnLabel);
expect(enableNotebookBtn).toBeDefined();
expect(enableNotebookBtn.disabled).toBe(true);
expect(enableNotebookBtn.tooltipText).toBe(
"Notebooks are not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks."
);
});
});
describe("Open Mongo Shell button", () => {
const openMongoShellBtnLabel = "Open Mongo Shell";
beforeAll(() => {
mockExplorer = new ExplorerStub();
mockExplorer.addCollectionText = ko.observable("mockText");
mockExplorer.isAuthWithResourceToken = ko.observable(false);
mockExplorer.isPreferredApiTable = ko.computed(() => true);
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
});
beforeEach(() => {
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => true);
mockExplorer.isNotebookEnabled = ko.observable(false);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
});
it("Mongo Api not available - button should be hidden", () => {
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openMongoShellBtn = buttons.find(button => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeUndefined();
});
it("Running on a national cloud - button should be hidden", () => {
mockExplorer.isRunningOnNationalCloud = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openMongoShellBtn = buttons.find(button => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeUndefined();
});
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openMongoShellBtn = buttons.find(button => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeDefined();
expect(openMongoShellBtn.disabled).toBe(true);
expect(openMongoShellBtn.tooltipText).toBe(
"This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks."
);
});
it("Notebooks is not enabled and is available - button should be shown and enabled", () => {
mockExplorer.isNotebooksEnabledForAccount = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openMongoShellBtn = buttons.find(button => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeDefined();
expect(openMongoShellBtn.disabled).toBe(false);
expect(openMongoShellBtn.tooltipText).toBe("");
});
it("Notebooks is enabled and is unavailable - button should be shown and enabled", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openMongoShellBtn = buttons.find(button => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeDefined();
expect(openMongoShellBtn.disabled).toBe(false);
expect(openMongoShellBtn.tooltipText).toBe("");
});
it("Notebooks is enabled and is available - button should be shown and enabled", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openMongoShellBtn = buttons.find(button => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeDefined();
expect(openMongoShellBtn.disabled).toBe(false);
expect(openMongoShellBtn.tooltipText).toBe("");
});
});
describe("Open Cassandra Shell button", () => {
const openCassandraShellBtnLabel = "Open Cassandra Shell";
beforeAll(() => {
mockExplorer = new ExplorerStub();
mockExplorer.addCollectionText = ko.observable("mockText");
mockExplorer.isAuthWithResourceToken = ko.observable(false);
mockExplorer.isPreferredApiTable = ko.computed(() => true);
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
});
beforeEach(() => {
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => true);
mockExplorer.isNotebookEnabled = ko.observable(false);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
});
it("Cassandra Api not available - button should be hidden", () => {
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openCassandraShellBtn = buttons.find(button => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
});
it("Running on a national cloud - button should be hidden", () => {
mockExplorer.isRunningOnNationalCloud = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openCassandraShellBtn = buttons.find(button => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
});
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openCassandraShellBtn = buttons.find(button => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeDefined();
expect(openCassandraShellBtn.disabled).toBe(true);
expect(openCassandraShellBtn.tooltipText).toBe(
"This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks."
);
});
it("Notebooks is not enabled and is available - button should be shown and enabled", () => {
mockExplorer.isNotebooksEnabledForAccount = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openCassandraShellBtn = buttons.find(button => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeDefined();
expect(openCassandraShellBtn.disabled).toBe(false);
expect(openCassandraShellBtn.tooltipText).toBe("");
});
it("Notebooks is enabled and is unavailable - button should be shown and enabled", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openCassandraShellBtn = buttons.find(button => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeDefined();
expect(openCassandraShellBtn.disabled).toBe(false);
expect(openCassandraShellBtn.tooltipText).toBe("");
});
it("Notebooks is enabled and is available - button should be shown and enabled", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
mockExplorer.isNotebooksEnabledForAccount = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const openCassandraShellBtn = buttons.find(button => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeDefined();
expect(openCassandraShellBtn.disabled).toBe(false);
expect(openCassandraShellBtn.tooltipText).toBe("");
});
});
describe("GitHub buttons", () => {
const connectToGitHubBtnLabel = "Connect to GitHub";
const manageGitHubSettingsBtnLabel = "Manage GitHub settings";
beforeAll(() => {
mockExplorer = new ExplorerStub();
mockExplorer.addCollectionText = ko.observable("mockText");
mockExplorer.isAuthWithResourceToken = ko.observable(false);
mockExplorer.isPreferredApiTable = ko.computed(() => true);
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.gitHubOAuthService = new GitHubOAuthService(undefined);
});
beforeEach(() => {
mockExplorer.isNotebookEnabled = ko.observable(false);
});
afterEach(() => {
jest.resetAllMocks();
});
it("Notebooks is enabled and GitHubOAuthService is not logged in - connect to github button should be visible", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const connectToGitHubBtn = buttons.find(button => button.commandButtonLabel === connectToGitHubBtnLabel);
expect(connectToGitHubBtn).toBeDefined();
});
it("Notebooks is enabled and GitHubOAuthService is logged in - manage github settings button should be visible", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
mockExplorer.gitHubOAuthService.isLoggedIn = jest.fn().mockReturnValue(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const manageGitHubSettingsBtn = buttons.find(
button => button.commandButtonLabel === manageGitHubSettingsBtnLabel
);
expect(manageGitHubSettingsBtn).toBeDefined();
});
it("Notebooks is not enabled - connect to github and manage github settings buttons should be hidden", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const connectToGitHubBtn = buttons.find(button => button.commandButtonLabel === connectToGitHubBtnLabel);
expect(connectToGitHubBtn).toBeUndefined();
const manageGitHubSettingsBtn = buttons.find(
button => button.commandButtonLabel === manageGitHubSettingsBtnLabel
);
expect(manageGitHubSettingsBtn).toBeUndefined();
});
});
describe("Resource token", () => {
beforeAll(() => {
mockExplorer = new ExplorerStub();
mockExplorer.addCollectionText = ko.observable("mockText");
mockExplorer.isAuthWithResourceToken = ko.observable(true);
mockExplorer.isPreferredApiDocumentDB = ko.computed(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isResourceTokenCollectionNodeSelected = ko.computed(() => true);
});
it("should only show New SQL Query and Open Query buttons", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
expect(buttons.length).toBe(2);
expect(buttons[0].commandButtonLabel).toBe("New SQL Query");
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].commandButtonLabel).toBe("Open Query");
expect(buttons[1].disabled).toBe(false);
expect(buttons[1].children).toBeDefined();
expect(buttons[1].children.length).toBe(2);
});
});
});

View File

@@ -0,0 +1,727 @@
import * as ViewModels from "../../../Contracts/ViewModels";
import { PlatformType } from "../../../PlatformType";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import { Areas } from "../../../Common/Constants";
import TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import ApacheSparkIcon from "../../../../images/notebook/Apache-spark.svg";
import AddDatabaseIcon from "../../../../images/AddDatabase.svg";
import AddCollectionIcon from "../../../../images/AddCollection.svg";
import AddSqlQueryIcon from "../../../../images/AddSqlQuery_16x16.svg";
import BrowseQueriesIcon from "../../../../images/BrowseQuery.svg";
import * as Constants from "../../../Common/Constants";
import DeleteIcon from "../../../../images/delete.svg";
import EditIcon from "../../../../images/edit.svg";
import OpenInTabIcon from "../../../../images/open-in-tab.svg";
import OpenQueryFromDiskIcon from "../../../../images/OpenQueryFromDisk.svg";
import CosmosTerminalIcon from "../../../../images/Cosmos-Terminal.svg";
import HostedTerminalIcon from "../../../../images/Hosted-Terminal.svg";
import AddStoredProcedureIcon from "../../../../images/AddStoredProcedure.svg";
import SettingsIcon from "../../../../images/settings_15x15.svg";
import AddUdfIcon from "../../../../images/AddUdf.svg";
import AddTriggerIcon from "../../../../images/AddTrigger.svg";
import ScaleIcon from "../../../../images/Scale_15x15.svg";
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
import EnableNotebooksIcon from "../../../../images/notebook/Notebook-enable.svg";
import NewNotebookIcon from "../../../../images/notebook/Notebook-new.svg";
import ResetWorkspaceIcon from "../../../../images/notebook/Notebook-reset-workspace.svg";
import LibraryManageIcon from "../../../../images/notebook/Spark-library-manage.svg";
import GalleryIcon from "../../../../images/GalleryIcon.svg";
import GitHubIcon from "../../../../images/github.svg";
import SynapseIcon from "../../../../images/synapse-link.svg";
import { config, Platform } from "../../../Config";
export class CommandBarComponentButtonFactory {
private static counter: number = 0;
public static createStaticCommandBarButtons(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig[] {
if (container.isAuthWithResourceToken()) {
return CommandBarComponentButtonFactory.createStaticCommandBarButtonsForResourceToken(container);
}
const newCollectionBtn = CommandBarComponentButtonFactory.createNewCollectionGroup(container);
const buttons: ViewModels.NavbarButtonConfig[] = [newCollectionBtn];
const addSynapseLink = CommandBarComponentButtonFactory.createOpenSynapseLinkDialogButton(container);
if (addSynapseLink) {
buttons.push(CommandBarComponentButtonFactory.createDivider());
buttons.push(addSynapseLink);
}
if (!container.isPreferredApiTable()) {
newCollectionBtn.children = [CommandBarComponentButtonFactory.createNewCollectionGroup(container)];
const newDatabaseBtn = CommandBarComponentButtonFactory.createNewDatabase(container);
newCollectionBtn.children.push(newDatabaseBtn);
}
buttons.push(CommandBarComponentButtonFactory.createDivider());
if (container.isNotebookEnabled()) {
const newNotebookButton = CommandBarComponentButtonFactory.createNewNotebookButton(container);
newNotebookButton.children = [
CommandBarComponentButtonFactory.createNewNotebookButton(container),
CommandBarComponentButtonFactory.createuploadNotebookButton(container)
];
buttons.push(newNotebookButton);
if (container.gitHubOAuthService) {
buttons.push(CommandBarComponentButtonFactory.createManageGitHubAccountButton(container));
}
}
if (!container.isRunningOnNationalCloud()) {
if (!container.isNotebookEnabled()) {
buttons.push(CommandBarComponentButtonFactory.createEnableNotebooksButton(container));
}
if (container.isPreferredApiMongoDB()) {
buttons.push(CommandBarComponentButtonFactory.createOpenMongoTerminalButton(container));
}
if (container.isPreferredApiCassandra()) {
buttons.push(CommandBarComponentButtonFactory.createOpenCassandraTerminalButton(container));
}
}
if (container.isNotebookEnabled()) {
buttons.push(CommandBarComponentButtonFactory.createOpenTerminalButton(container));
buttons.push(CommandBarComponentButtonFactory.createNotebookWorkspaceResetButton(container));
if (container.isGalleryEnabled()) {
buttons.push(CommandBarComponentButtonFactory.createGalleryButton(container));
}
}
// TODO: Should be replaced with the create arcadia spark pool button
// if (!container.isSparkEnabled() && container.isSparkEnabledForAccount()) {
// const createSparkClusterButton = CommandBarComponentButtonFactory.createSparkClusterButton(container);
// buttons.push(createSparkClusterButton);
// }
// TODO: Should be replaced with the edit/manage/delete arcadia spark pool button
// if (container.isSparkEnabled()) {
// const manageSparkClusterButton = CommandBarComponentButtonFactory.createMonitorClusterButton(container);
// manageSparkClusterButton.children = [
// CommandBarComponentButtonFactory.createMonitorClusterButton(container),
// CommandBarComponentButtonFactory.createEditClusterButton(container),
// CommandBarComponentButtonFactory.createDeleteClusterButton(container),
// CommandBarComponentButtonFactory.createLibraryManageButton(container),
// CommandBarComponentButtonFactory.createClusterLibraryButton(container)
// ];
// buttons.push(manageSparkClusterButton);
// }
if (!container.isDatabaseNodeOrNoneSelected()) {
if (container.isNotebookEnabled()) {
buttons.push(CommandBarComponentButtonFactory.createDivider());
}
const isSqlQuerySupported = container.isPreferredApiDocumentDB() || container.isPreferredApiGraph();
if (isSqlQuerySupported) {
const newSqlQueryBtn = CommandBarComponentButtonFactory.createNewSQLQueryButton(container);
buttons.push(newSqlQueryBtn);
}
const isSupportedOpenQueryApi =
container.isPreferredApiDocumentDB() || container.isPreferredApiMongoDB() || container.isPreferredApiGraph();
const isSupportedOpenQueryFromDiskApi = container.isPreferredApiDocumentDB() || container.isPreferredApiGraph();
if (isSupportedOpenQueryApi && container.selectedNode() && container.findSelectedCollection()) {
const openQueryBtn = CommandBarComponentButtonFactory.createOpenQueryButton(container);
openQueryBtn.children = [
CommandBarComponentButtonFactory.createOpenQueryButton(container),
CommandBarComponentButtonFactory.createOpenQueryFromDiskButton(container)
];
buttons.push(openQueryBtn);
} else if (isSupportedOpenQueryFromDiskApi && container.selectedNode() && container.findSelectedCollection()) {
buttons.push(CommandBarComponentButtonFactory.createOpenQueryFromDiskButton(container));
}
if (CommandBarComponentButtonFactory.areScriptsSupported(container)) {
const label = "New Stored Procedure";
const newStoredProcedureBtn: ViewModels.NavbarButtonConfig = {
iconSrc: AddStoredProcedureIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection, null);
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
newStoredProcedureBtn.children = CommandBarComponentButtonFactory.createScriptCommandButtons(container);
buttons.push(newStoredProcedureBtn);
}
}
return buttons;
}
public static createContextCommandBarButtons(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig[] {
const buttons: ViewModels.NavbarButtonConfig[] = [];
if (!container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiMongoDB()) {
const label = "New Shell";
const newMongoShellBtn: ViewModels.NavbarButtonConfig = {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && (<any>selectedCollection).onNewMongoShellClick();
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiMongoDB()
};
buttons.push(newMongoShellBtn);
}
return buttons;
}
public static createControlCommandBarButtons(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig[] {
const buttons: ViewModels.NavbarButtonConfig[] = [];
if (window.dataExplorerPlatform === PlatformType.Hosted) {
return buttons;
}
if (!container.isPreferredApiCassandra()) {
const label = "Settings";
const settingsPaneButton: ViewModels.NavbarButtonConfig = {
iconSrc: SettingsIcon,
iconAlt: label,
onCommandClick: () => container.settingsPane.open(),
commandButtonLabel: null,
ariaLabel: label,
tooltipText: label,
hasPopup: true,
disabled: false
};
buttons.push(settingsPaneButton);
}
if (container.isHostedDataExplorerEnabled()) {
const label = "Open Full Screen";
const fullScreenButton: ViewModels.NavbarButtonConfig = {
iconSrc: OpenInTabIcon,
iconAlt: label,
onCommandClick: () => container.generateSharedAccessData(),
commandButtonLabel: null,
ariaLabel: label,
tooltipText: label,
hasPopup: false,
disabled: !container.isHostedDataExplorerEnabled(),
className: "OpenFullScreen"
};
buttons.push(fullScreenButton);
}
if (!container.hasOwnProperty("isEmulator") || !container.isEmulator) {
const label = "Feedback";
const feedbackButtonOptions: ViewModels.NavbarButtonConfig = {
iconSrc: FeedbackIcon,
iconAlt: label,
onCommandClick: () => container.provideFeedbackEmail(),
commandButtonLabel: null,
ariaLabel: label,
tooltipText: label,
hasPopup: false,
disabled: false
};
buttons.push(feedbackButtonOptions);
}
return buttons;
}
public static createDivider(): ViewModels.NavbarButtonConfig {
const label = `divider${CommandBarComponentButtonFactory.counter++}`;
return {
isDivider: true,
commandButtonLabel: label,
hasPopup: false,
iconSrc: null,
iconAlt: null,
onCommandClick: null,
ariaLabel: label
};
}
private static areScriptsSupported(container: ViewModels.Explorer): boolean {
return container.isPreferredApiDocumentDB() || container.isPreferredApiGraph();
}
private static createNewCollectionGroup(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = container.addCollectionText();
return {
iconSrc: AddCollectionIcon,
iconAlt: label,
onCommandClick: () => container.onNewCollectionClicked(),
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
id: "createNewContainerCommandButton"
};
}
private static createOpenSynapseLinkDialogButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
if (config.platform === Platform.Emulator) {
return null;
}
if (
container.databaseAccount &&
container.databaseAccount() &&
container.databaseAccount().properties &&
container.databaseAccount().properties.enableAnalyticalStorage
) {
return null;
}
const capabilities =
(container.databaseAccount &&
container.databaseAccount() &&
container.databaseAccount().properties &&
container.databaseAccount().properties.capabilities) ||
[];
if (capabilities.some(capability => capability.name === Constants.CapabilityNames.EnableStorageAnalytics)) {
return null;
}
const label = "Enable Azure Synapse Link (Preview)";
return {
iconSrc: SynapseIcon,
iconAlt: label,
onCommandClick: () => container.openEnableSynapseLinkDialog(),
commandButtonLabel: label,
hasPopup: false,
disabled: container.isSynapseLinkUpdating(),
ariaLabel: label
};
}
private static createNewDatabase(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = container.addDatabaseText();
return {
iconSrc: AddDatabaseIcon,
iconAlt: label,
onCommandClick: () => {
container.addDatabasePane.open();
document.getElementById("linkAddDatabase").focus();
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true
};
}
private static createNewSQLQueryButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
if (container.isPreferredApiDocumentDB() || container.isPreferredApiGraph()) {
const label = "New SQL Query";
return {
iconSrc: AddSqlQueryIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewQueryClick(selectedCollection, null);
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
} else if (container.isPreferredApiMongoDB()) {
const label = "New Query";
return {
iconSrc: AddSqlQueryIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && (<any>selectedCollection).onNewMongoQueryClick(selectedCollection, null);
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
}
return null;
}
public static createScriptCommandButtons(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig[] {
const buttons: ViewModels.NavbarButtonConfig[] = [];
const shouldEnableScriptsCommands: boolean =
!container.isDatabaseNodeOrNoneSelected() && CommandBarComponentButtonFactory.areScriptsSupported(container);
if (shouldEnableScriptsCommands) {
const label = "New Stored Procedure";
const newStoredProcedureBtn: ViewModels.NavbarButtonConfig = {
iconSrc: AddStoredProcedureIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection, null);
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
buttons.push(newStoredProcedureBtn);
}
if (shouldEnableScriptsCommands) {
const label = "New UDF";
const newUserDefinedFunctionBtn: ViewModels.NavbarButtonConfig = {
iconSrc: AddUdfIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewUserDefinedFunctionClick(selectedCollection, null);
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
buttons.push(newUserDefinedFunctionBtn);
}
if (shouldEnableScriptsCommands) {
const label = "New Trigger";
const newTriggerBtn: ViewModels.NavbarButtonConfig = {
iconSrc: AddTriggerIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewTriggerClick(selectedCollection, null);
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
buttons.push(newTriggerBtn);
}
return buttons;
}
private static createScaleAndSettingsButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
let isShared = false;
if (container.isDatabaseNodeSelected()) {
isShared = container.findSelectedDatabase().isDatabaseShared();
} else if (container.isNodeKindSelected("Collection")) {
const database: ViewModels.Database = container.findSelectedCollection().getDatabase();
isShared = database && database.isDatabaseShared();
}
const label = isShared ? "Settings" : "Scale & Settings";
return {
iconSrc: ScaleIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && (<any>selectedCollection).onSettingsClick();
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
}
private static createNewNotebookButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "New Notebook";
return {
iconSrc: NewNotebookIcon,
iconAlt: label,
onCommandClick: () => container.onNewNotebookClicked(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createuploadNotebookButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Upload to Notebook Server";
return {
iconSrc: NewNotebookIcon,
iconAlt: label,
onCommandClick: () => container.onUploadToNotebookServerClicked(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createOpenQueryButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Open Query";
return {
iconSrc: BrowseQueriesIcon,
iconAlt: label,
onCommandClick: () => container.browseQueriesPane.open(),
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: false
};
}
private static createOpenQueryFromDiskButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Open Query From Disk";
return {
iconSrc: OpenQueryFromDiskIcon,
iconAlt: label,
onCommandClick: () => container.loadQueryPane.open(),
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: false
};
}
private static createEnableNotebooksButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
if (config.platform === Platform.Emulator) {
return null;
}
const label = "Enable Notebooks (Preview)";
const tooltip =
"Notebooks are not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks.";
const description =
"Looks like you have not yet created a notebooks workspace for this account. To proceed and start using notebooks, we'll need to create a default notebooks workspace in this account.";
return {
iconSrc: EnableNotebooksIcon,
iconAlt: label,
onCommandClick: () => container.setupNotebooksPane.openWithTitleAndDescription(label, description),
commandButtonLabel: label,
hasPopup: false,
disabled: !container.isNotebooksEnabledForAccount(),
ariaLabel: label,
tooltipText: container.isNotebooksEnabledForAccount() ? "" : tooltip
};
}
private static createSparkClusterButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Enable Spark";
return {
iconSrc: ApacheSparkIcon,
iconAlt: "Enable spark icon",
onCommandClick: () => container.setupSparkClusterPane.open(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createEditClusterButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Edit Cluster";
return {
iconSrc: EditIcon,
iconAlt: "Edit cluster icon",
onCommandClick: () => container.manageSparkClusterPane.open(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createDeleteClusterButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Delete Cluster";
return {
iconSrc: DeleteIcon,
iconAlt: "Delete cluster icon",
onCommandClick: () => container.deleteCluster(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createMonitorClusterButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Monitor Cluster";
return {
iconSrc: ApacheSparkIcon,
iconAlt: "Monitor cluster icon",
onCommandClick: () => container.openSparkMasterTab(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createOpenTerminalButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Open Terminal";
return {
iconSrc: CosmosTerminalIcon,
iconAlt: label,
onCommandClick: () => container.openNotebookTerminal(ViewModels.TerminalKind.Default),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createGalleryButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "View Gallery";
return {
iconSrc: GalleryIcon,
iconAlt: label,
onCommandClick: () => container.openGallery(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createOpenMongoTerminalButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Open Mongo Shell";
const tooltip =
"This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks.";
const title = "Set up workspace";
const description =
"Looks like you have not created a workspace for this account. To proceed and start using features including mongo shell and notebook, we will need to create a default workspace in this account.";
const disableButton = !container.isNotebooksEnabledForAccount() && !container.isNotebookEnabled();
return {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
if (container.isNotebookEnabled()) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
} else {
container.setupNotebooksPane.openWithTitleAndDescription(title, description);
}
},
commandButtonLabel: label,
hasPopup: false,
disabled: disableButton,
ariaLabel: label,
tooltipText: !disableButton ? "" : tooltip
};
}
private static createOpenCassandraTerminalButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Open Cassandra Shell";
const tooltip =
"This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks.";
const title = "Set up workspace";
const description =
"Looks like you have not created a workspace for this account. To proceed and start using features including cassandra shell and notebook, we will need to create a default workspace in this account.";
const disableButton = !container.isNotebooksEnabledForAccount() && !container.isNotebookEnabled();
return {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
if (container.isNotebookEnabled()) {
container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra);
} else {
container.setupNotebooksPane.openWithTitleAndDescription(title, description);
}
},
commandButtonLabel: label,
hasPopup: false,
disabled: disableButton,
ariaLabel: label,
tooltipText: !disableButton ? "" : tooltip
};
}
private static createNotebookWorkspaceResetButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Reset Workspace";
return {
iconSrc: ResetWorkspaceIcon,
iconAlt: label,
onCommandClick: () => container.resetNotebookWorkspace(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createManageGitHubAccountButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
let connectedToGitHub: boolean = container.gitHubOAuthService.isLoggedIn();
const label = connectedToGitHub ? "Manage GitHub settings" : "Connect to GitHub";
return {
iconSrc: GitHubIcon,
iconAlt: label,
onCommandClick: () => {
if (!connectedToGitHub) {
TelemetryProcessor.trace(Action.NotebooksGitHubConnect, ActionModifiers.Mark, {
databaseAccountName: container.databaseAccount() && container.databaseAccount().name,
defaultExperience: container.defaultExperience && container.defaultExperience(),
dataExplorerArea: Areas.Notebook
});
}
container.gitHubReposPane.open();
},
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createLibraryManageButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Manage Libraries";
return {
iconSrc: LibraryManageIcon,
iconAlt: label,
onCommandClick: () => container.libraryManagePane.open(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createClusterLibraryButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Manage Cluster Libraries";
return {
iconSrc: LibraryManageIcon,
iconAlt: label,
onCommandClick: () => container.clusterLibraryPane.open(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createStaticCommandBarButtonsForResourceToken(
container: ViewModels.Explorer
): ViewModels.NavbarButtonConfig[] {
const newSqlQueryBtn = CommandBarComponentButtonFactory.createNewSQLQueryButton(container);
const openQueryBtn = CommandBarComponentButtonFactory.createOpenQueryButton(container);
newSqlQueryBtn.disabled = !container.isResourceTokenCollectionNodeSelected();
newSqlQueryBtn.onCommandClick = () => {
const resourceTokenCollection: ViewModels.CollectionBase = container.resourceTokenCollection();
resourceTokenCollection && resourceTokenCollection.onNewQueryClick(resourceTokenCollection, undefined);
};
openQueryBtn.disabled = !container.isResourceTokenCollectionNodeSelected();
if (!openQueryBtn.disabled) {
openQueryBtn.children = [
CommandBarComponentButtonFactory.createOpenQueryButton(container),
CommandBarComponentButtonFactory.createOpenQueryFromDiskButton(container)
];
}
return [newSqlQueryBtn, openQueryBtn];
}
}

View File

@@ -0,0 +1,82 @@
import { CommandBarUtil } from "./CommandBarUtil";
import * as ViewModels from "../../../Contracts/ViewModels";
import { ICommandBarItemProps } from "office-ui-fabric-react/lib/CommandBar";
describe("CommandBarUtil tests", () => {
const createButton = (): ViewModels.NavbarButtonConfig => {
return {
iconSrc: "icon",
iconAlt: "label",
onCommandClick: (e: React.SyntheticEvent): void => {},
commandButtonLabel: "label",
ariaLabel: "ariaLabel",
hasPopup: true,
disabled: true,
tooltipText: "tooltipText",
children: [],
className: "className"
};
};
it("should convert simple NavbarButtonConfig button", () => {
const btn = createButton();
const backgroundColor = "backgroundColor";
const converteds = CommandBarUtil.convertButton([btn], backgroundColor);
expect(converteds.length).toBe(1);
const converted = converteds[0];
expect(!converted.split);
expect(converted.iconProps.imageProps.src).toEqual(btn.iconSrc);
expect(converted.iconProps.imageProps.alt).toEqual(btn.iconAlt);
expect(converted.onClick).toEqual(btn.onCommandClick);
expect(converted.text).toEqual(btn.commandButtonLabel);
expect(converted.ariaLabel).toEqual(btn.ariaLabel);
expect(converted.disabled).toEqual(btn.disabled);
expect(converted.className).toEqual(btn.className);
});
it("should convert NavbarButtonConfig to split button", () => {
const btn = createButton();
for (let i = 0; i < 3; i++) {
const child = createButton();
child.commandButtonLabel = `child${i}`;
btn.children.push(child);
}
const converteds = CommandBarUtil.convertButton([btn], "backgroundColor");
expect(converteds.length).toBe(1);
const converted = converteds[0];
expect(converted.split);
expect(converted.subMenuProps.items.length).toBe(btn.children.length);
for (let i = 0; i < converted.subMenuProps.items.length; i++) {
expect(converted.subMenuProps.items[i].text).toEqual(btn.children[i].commandButtonLabel);
}
});
it("should create buttons with unique keys", () => {
const btns: ViewModels.NavbarButtonConfig[] = [];
for (let i = 0; i < 5; i++) {
btns.push(createButton());
}
const converteds = CommandBarUtil.convertButton(btns, "backgroundColor");
const keys = converteds.map((btn: ICommandBarItemProps) => btn.key);
const uniqueKeys = converteds
.map((btn: ICommandBarItemProps) => btn.key)
.filter((value: string, index: number, self: string[]) => self.indexOf(value) === index);
expect(uniqueKeys.length).toBe(btns.length);
});
it("should create icon buttons with tooltips", () => {
const btn = createButton();
const backgroundColor = "backgroundColor";
btn.commandButtonLabel = null;
let converted = CommandBarUtil.convertButton([btn], backgroundColor)[0];
expect(converted.text).toEqual(btn.tooltipText);
converted = CommandBarUtil.convertButton([btn], backgroundColor)[0];
delete btn.commandButtonLabel;
expect(converted.text).toEqual(btn.tooltipText);
});
});

View File

@@ -0,0 +1,183 @@
import * as React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { Observable } from "knockout";
import { IconType } from "office-ui-fabric-react/lib/Icon";
import { IComponentAsProps } from "office-ui-fabric-react/lib/Utilities";
import { KeyCodes, StyleConstants } from "../../../Common/Constants";
import { ICommandBarItemProps } from "office-ui-fabric-react/lib/CommandBar";
import { Dropdown, DropdownMenuItemType, IDropdownStyles, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import ChevronDownIcon from "../../../../images/Chevron_down.svg";
import { ArcadiaMenuPicker } from "../../Controls/Arcadia/ArcadiaMenuPicker";
import { MemoryTrackerComponent } from "./MemoryTrackerComponent";
import { MemoryUsageInfo } from "../../../Contracts/DataModels";
/**
* Utilities for CommandBar
*/
export class CommandBarUtil {
/**
* Convert our NavbarButtonConfig to UI Fabric buttons
* @param btns
*/
public static convertButton(btns: ViewModels.NavbarButtonConfig[], backgroundColor: string): ICommandBarItemProps[] {
const buttonHeightPx = StyleConstants.CommandBarButtonHeight;
return btns
.filter(btn => btn)
.map(
(btn: ViewModels.NavbarButtonConfig, index: number): ICommandBarItemProps => {
if (btn.isDivider) {
return CommandBarUtil.createDivider(btn.commandButtonLabel);
}
const isSplit = !!btn.children && btn.children.length > 0;
const result: ICommandBarItemProps = {
iconProps: {
iconType: IconType.image,
style: {
width: StyleConstants.CommandBarIconWidth // 16
},
imageProps: { src: btn.iconSrc, alt: btn.iconAlt }
},
onClick: btn.onCommandClick,
key: `${btn.commandButtonLabel}${index}`,
text: btn.commandButtonLabel || btn.tooltipText,
"data-test": btn.commandButtonLabel || btn.tooltipText,
title: btn.tooltipText,
name: "menuitem",
disabled: btn.disabled,
ariaLabel: btn.ariaLabel,
buttonStyles: {
root: {
backgroundColor: backgroundColor,
height: buttonHeightPx,
paddingRight: 0,
paddingLeft: 0,
minWidth: 24,
marginLeft: isSplit ? 0 : 5,
marginRight: isSplit ? 0 : 5
},
rootDisabled: {
backgroundColor: backgroundColor,
pointerEvents: "auto"
},
splitButtonMenuButton: {
backgroundColor: backgroundColor,
selectors: {
":hover": { backgroundColor: StyleConstants.AccentLight }
},
width: 16
},
label: { fontSize: StyleConstants.mediumFontSize },
rootHovered: { backgroundColor: StyleConstants.AccentLight },
rootPressed: { backgroundColor: StyleConstants.AccentLight },
splitButtonMenuButtonExpanded: {
backgroundColor: StyleConstants.AccentExtra,
selectors: {
":hover": { backgroundColor: StyleConstants.AccentLight }
}
},
splitButtonDivider: {
display: "none"
},
icon: {
paddingLeft: 0,
paddingRight: 0
},
splitButtonContainer: {
marginLeft: 5,
marginRight: 5
}
},
className: btn.className,
id: btn.id
};
if (isSplit) {
// It's a split button
result.split = true;
result.subMenuProps = {
items: CommandBarUtil.convertButton(btn.children, backgroundColor),
styles: {
list: {
// TODO Figure out how to do it the proper way with subComponentStyles.
// TODO Remove all this crazy styling once we adopt Ui-Fabric Azure themes
selectors: {
".ms-ContextualMenu-itemText": { fontSize: StyleConstants.mediumFontSize },
".ms-ContextualMenu-link:hover": { backgroundColor: StyleConstants.AccentLight },
".ms-ContextualMenu-icon": { width: 16, height: 16 }
}
}
}
};
result.menuIconProps = {
iconType: IconType.image,
style: {
width: 12,
paddingLeft: 1,
paddingTop: 6
},
imageProps: { src: ChevronDownIcon, alt: btn.iconAlt }
};
}
if (btn.isDropdown) {
const dropdownStyles: Partial<IDropdownStyles> = {
root: { margin: 5 },
dropdown: { width: btn.dropdownWidth },
title: { fontSize: 12, height: 30, lineHeight: 28 },
dropdownItem: { fontSize: 12, lineHeight: 28, minHeight: 30 },
dropdownItemSelected: { fontSize: 12, lineHeight: 28, minHeight: 30 }
};
result.commandBarButtonAs = (props: IComponentAsProps<ICommandBarItemProps>) => {
return (
<Dropdown
placeholder={btn.dropdownPlaceholder}
defaultSelectedKey={btn.dropdownSelectedKey}
onChange={(event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number): void =>
btn.children[index].onCommandClick(event)
}
options={btn.children.map((child: CommandButtonComponentProps) => ({
key: child.dropdownItemKey,
text: child.commandButtonLabel
}))}
styles={dropdownStyles}
/>
);
};
}
if (btn.isArcadiaPicker && btn.arcadiaProps) {
result.commandBarButtonAs = () => <ArcadiaMenuPicker {...btn.arcadiaProps} />;
}
return result;
}
);
}
public static createDivider(key: string): ICommandBarItemProps {
return {
onRender: () => (
<div className="dividerContainer">
<span />
</div>
),
iconOnly: true,
disabled: true,
key: key
};
}
public static createMemoryTracker(key: string, memoryUsageInfo: Observable<MemoryUsageInfo>): ICommandBarItemProps {
return {
key,
onRender: () => <MemoryTrackerComponent memoryUsageInfo={memoryUsageInfo} />
};
}
}

View File

@@ -0,0 +1,24 @@
@import "../../../../less/Common/Constants";
.memoryTrackerContainer {
cursor: default;
align-items: center;
margin: 0 9px;
border: 1px;
> span {
padding-right: 12px;
font-size: 13px;
font-family: @DataExplorerFont;
color: @DefaultFontColor;
}
> .lowMemory {
.ms-ProgressIndicator-progressBar {
background-color: @SelectionHigh;
}
.ms-ProgressIndicator-itemDescription {
color: @SelectionHigh;
}
}
}

View File

@@ -0,0 +1,50 @@
import * as React from "react";
import { Observable, Subscription } from "knockout";
import { MemoryUsageInfo } from "../../../Contracts/DataModels";
import { ProgressIndicator } from "office-ui-fabric-react/lib/ProgressIndicator";
import { Spinner, SpinnerSize } from "office-ui-fabric-react/lib/Spinner";
import { Stack } from "office-ui-fabric-react/lib/Stack";
interface MemoryTrackerProps {
memoryUsageInfo: Observable<MemoryUsageInfo>;
}
export class MemoryTrackerComponent extends React.Component<MemoryTrackerProps> {
private memoryUsageInfoSubscription: Subscription;
public componentDidMount(): void {
this.memoryUsageInfoSubscription = this.props.memoryUsageInfo.subscribe(() => {
this.forceUpdate();
});
}
public componentWillUnmount(): void {
this.memoryUsageInfoSubscription && this.memoryUsageInfoSubscription.dispose();
}
public render(): JSX.Element {
const memoryUsageInfo: MemoryUsageInfo = this.props.memoryUsageInfo();
if (!memoryUsageInfo) {
return (
<Stack className="memoryTrackerContainer" horizontal>
<span>Memory</span>
<Spinner size={SpinnerSize.medium} />
</Stack>
);
}
const totalGB = memoryUsageInfo.totalKB / 1048576;
const usedGB = totalGB - memoryUsageInfo.freeKB / 1048576;
return (
<Stack className="memoryTrackerContainer" horizontal>
<span>Memory</span>
<ProgressIndicator
className={usedGB / totalGB > 0.8 ? "lowMemory" : ""}
description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"}
percentComplete={usedGB / totalGB}
/>
</Stack>
);
}
}

View File

@@ -0,0 +1,43 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import { CommandButtonOptions } from "./../Controls/CommandButton/CommandButton";
export default class ContextMenu implements ViewModels.ContextMenu {
public container: ViewModels.Explorer;
public visible: ko.Observable<boolean>;
public elementId: string;
public options: ko.ObservableArray<CommandButtonOptions>;
public tabIndex: ko.Observable<number>;
constructor(container: ViewModels.Explorer, rid: string) {
this.container = container;
this.visible = ko.observable<boolean>(false);
this.elementId = `contextMenu${rid}`;
this.options = ko.observableArray<CommandButtonOptions>([]);
this.tabIndex = ko.observable<number>(0);
}
public show(source: ViewModels.TreeNode, event: MouseEvent) {
if (source && source.contextMenu && source.contextMenu.visible && source.contextMenu.visible()) {
return;
}
this.container.selectedNode(source);
const elementId = source.contextMenu.elementId;
const htmlElement = document.getElementById(elementId);
htmlElement.style.left = `${event.clientX}px`;
htmlElement.style.top = `${event.clientY}px`;
!!source.contextMenu && source.contextMenu.visible(true);
source.contextMenu.tabIndex(0);
htmlElement.focus();
}
public hide(source: ViewModels.TreeNode, event: MouseEvent) {
if (!source || !source.contextMenu || !source.contextMenu.visible || !source.contextMenu.visible()) {
return;
}
source.contextMenu.tabIndex(-1);
source.contextMenu.visible(false);
}
}

View File

@@ -0,0 +1,31 @@
/**
* React component for control bar
*/
import * as React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { CommandButtonComponent } from "../../Controls/CommandButton/CommandButtonComponent";
export interface ControlBarComponentProps {
buttons: ViewModels.NavbarButtonConfig[];
}
export class ControlBarComponent extends React.Component<ControlBarComponentProps> {
private static renderButtons(commandButtonOptions: ViewModels.NavbarButtonConfig[]): JSX.Element[] {
return commandButtonOptions.map(
(btn: ViewModels.NavbarButtonConfig, index: number): JSX.Element => {
// Remove label
btn.commandButtonLabel = null;
return CommandButtonComponent.renderButton(btn, `${index}`);
}
);
}
public render(): JSX.Element {
if (!this.props.buttons || this.props.buttons.length < 1) {
return <React.Fragment />;
}
return <React.Fragment>{ControlBarComponent.renderButtons(this.props.buttons)}</React.Fragment>;
}
}

View File

@@ -0,0 +1,28 @@
/**
* This adapter is responsible to render the React component
* If the component signals a change through the callback passed in the properties, it must render the React component when appropriate
* and update any knockout observables passed from the parent.
*/
import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import { ControlBarComponent } from "./ControlBarComponent";
import * as ViewModels from "../../../Contracts/ViewModels";
export class ControlBarComponentAdapter implements ReactAdapter {
public parameters: ko.Observable<number>;
constructor(private buttons: ko.ObservableArray<ViewModels.NavbarButtonConfig>) {
this.buttons.subscribe(() => this.forceRender());
this.parameters = ko.observable<number>(Date.now());
}
public renderComponent(): JSX.Element {
return <ControlBarComponent buttons={this.buttons()} />;
}
public forceRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
}

View File

@@ -0,0 +1,93 @@
.meControl {
.mecontrolHeaderButton {
height : auto;
border : 0;
margin : 0;
padding : 0 10px;
background-color: black;
:hover,
:active {
background-color: #393939
}
.mecontrolHeaderPersona {
height: 40px;
margin: 0;
.ms-Persona-details {
order : -1;
text-align : right;
padding-left : 0;
padding-right: 10;
.ms-Persona-primaryText {
line-height: 18px;
color : white;
}
.ms-Persona-secondaryText {
color : white;
font-size : 10px;
font-family: "Segoe UI Semibold", "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif;
}
}
:hover .ms-Persona-details .ms-Persona-primaryText {
color: white;
}
}
}
.mecontrolSigninButton {
height : 40px;
border : 0;
background-color: black;
color : white;
}
}
.mecontrolContextualMenu {
.mecontrolContextualMenuPersona {
margin : 16px;
margin-bottom: 0;
.ms-Persona-details {
padding: 0;
margin : -32px 0 0 16;
.ms-Persona-primaryText {
color : black;
font-size : 18px;
height : 20px;
font-family: "Segoe UI Semibold", "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif;
}
.ms-Persona-secondaryText {
color : black;
font-size: 14px;
}
}
}
.switchDirectoryLink,
.signOutLink {
color : #0078d6;
margin-left: 104px;
font-size : 14px;
cursor : pointer;
:hover,
:focus {
text-decoration: underline;
}
}
.switchDirectoryLink {
margin-top: -32px;
}
.signOutLink {
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,108 @@
import React from "react";
import { shallow, mount } from "enzyme";
import { MeControlComponent, MeControlComponentProps } from "./MeControlComponent";
const createNotSignedInProps = (): MeControlComponentProps => {
return {
isUserSignedIn: false,
user: null,
onSignInClick: jest.fn(),
onSignOutClick: jest.fn(),
onSwitchDirectoryClick: jest.fn()
};
};
const createSignedInProps = (): MeControlComponentProps => {
return {
isUserSignedIn: true,
user: {
name: "Test User",
email: "testuser@contoso.com",
tenantName: "Contoso",
imageUrl: "../../../../images/dotnet.png"
},
onSignInClick: jest.fn(),
onSignOutClick: jest.fn(),
onSwitchDirectoryClick: jest.fn()
};
};
describe("test render", () => {
it("renders not signed in", () => {
const props = createNotSignedInProps();
const wrapper = shallow(<MeControlComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders signed in with full info", () => {
const props = createSignedInProps();
const wrapper = shallow(<MeControlComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("change not signed in to signed in", () => {
const notSignInProps = createNotSignedInProps();
const wrapper = mount(<MeControlComponent {...notSignInProps} />);
expect(wrapper.exists(".mecontrolSigninButton")).toBe(true);
expect(wrapper.exists(".mecontrolHeaderButton")).toBe(false);
const signInProps = createSignedInProps();
wrapper.setProps(signInProps);
expect(wrapper.exists(".mecontrolSigninButton")).toBe(false);
expect(wrapper.exists(".mecontrolHeaderButton")).toBe(true);
wrapper.unmount;
});
it("render contextual menu", () => {
const signInProps = createSignedInProps();
const wrapper = mount(<MeControlComponent {...signInProps} />);
wrapper.find("button.mecontrolHeaderButton").simulate("click");
expect(wrapper.exists(".mecontrolContextualMenu")).toBe(true);
wrapper.find("button.mecontrolHeaderButton").simulate("click");
expect(wrapper.exists(".mecontrolContextualMenu")).toBe(false);
wrapper.unmount;
});
});
describe("test function got called", () => {
it("sign in click", () => {
const notSignInProps = createNotSignedInProps();
const wrapper = mount(<MeControlComponent {...notSignInProps} />);
wrapper.find("button.mecontrolSigninButton").simulate("click");
expect(notSignInProps.onSignInClick).toBeCalled();
expect(notSignInProps.onSignInClick).toHaveBeenCalled();
});
it("sign out click", () => {
const signInProps = createSignedInProps();
const wrapper = mount(<MeControlComponent {...signInProps} />);
wrapper.find("button.mecontrolHeaderButton").simulate("click");
expect(wrapper.exists(".mecontrolContextualMenu")).toBe(true);
wrapper.find("div.signOutLink").simulate("click");
expect(signInProps.onSignOutClick).toBeCalled();
expect(signInProps.onSignOutClick).toHaveBeenCalled();
});
it("switch directory", () => {
const signInProps = createSignedInProps();
const wrapper = mount(<MeControlComponent {...signInProps} />);
wrapper.find("button.mecontrolHeaderButton").simulate("click");
expect(wrapper.exists(".mecontrolContextualMenu")).toBe(true);
wrapper.find("div.switchDirectoryLink").simulate("click");
expect(signInProps.onSwitchDirectoryClick).toBeCalled();
expect(signInProps.onSwitchDirectoryClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,167 @@
import * as React from "react";
import { DefaultButton, BaseButton, IButtonProps } from "office-ui-fabric-react/lib/Button";
import { DirectionalHint, IContextualMenuProps } from "office-ui-fabric-react/lib/ContextualMenu";
import { FocusZone } from "office-ui-fabric-react/lib/FocusZone";
import { IPersonaSharedProps, Persona, PersonaInitialsColor, PersonaSize } from "office-ui-fabric-react/lib/Persona";
export interface MeControlComponentProps {
/**
* Wheather user is signed in or not
*/
isUserSignedIn: boolean;
/**
* User info
*/
user: MeControlUser;
/**
* Click handler for sign in click
*/
onSignInClick: (e: React.MouseEvent<BaseButton>) => void;
/**
* Click handler for sign out click
*/
onSignOutClick: (e: React.SyntheticEvent) => void;
/**
* Click handler for switch directory click
*/
onSwitchDirectoryClick: (e: React.SyntheticEvent) => void;
}
export interface MeControlUser {
/**
* Display name for user
*/
name: string;
/**
* Display email for user
*/
email: string;
/**
* Display tenant for user
*/
tenantName: string;
/**
* image source for the profic photo
*/
imageUrl: string;
}
export class MeControlComponent extends React.Component<MeControlComponentProps> {
public render(): JSX.Element {
return this.props.isUserSignedIn ? this._renderProfileComponent() : this._renderSignInComponent();
}
private _renderProfileComponent(): JSX.Element {
const { user } = this.props;
const menuProps: IContextualMenuProps = {
className: "mecontrolContextualMenu",
isBeakVisible: false,
directionalHintFixed: true,
directionalHint: DirectionalHint.bottomRightEdge,
calloutProps: {
minPagePadding: 0
},
items: [
{
key: "Persona",
onRender: this._renderPersonaComponent
},
{
key: "SwitchDirectory",
onRender: this._renderSwitchDirectory
},
{
key: "SignOut",
onRender: this._renderSignOut
}
]
};
const personaProps: IPersonaSharedProps = {
imageUrl: user.imageUrl,
text: user.email,
secondaryText: user.tenantName,
showSecondaryText: true,
showInitialsUntilImageLoads: true,
initialsColor: PersonaInitialsColor.teal,
size: PersonaSize.size28,
className: "mecontrolHeaderPersona"
};
const buttonProps: IButtonProps = {
id: "mecontrolHeader",
className: "mecontrolHeaderButton",
menuProps: menuProps,
onRenderMenuIcon: () => <span />,
styles: {
rootHovered: { backgroundColor: "#393939" },
rootFocused: { backgroundColor: "#393939" },
rootPressed: { backgroundColor: "#393939" },
rootExpanded: { backgroundColor: "#393939" }
}
};
return (
<FocusZone>
<DefaultButton {...buttonProps}>
<Persona {...personaProps} />
</DefaultButton>
</FocusZone>
);
}
private _renderPersonaComponent = (): JSX.Element => {
const { user } = this.props;
const personaProps: IPersonaSharedProps = {
imageUrl: user.imageUrl,
text: user.name,
secondaryText: user.email,
showSecondaryText: true,
showInitialsUntilImageLoads: true,
initialsColor: PersonaInitialsColor.teal,
size: PersonaSize.size72,
className: "mecontrolContextualMenuPersona"
};
return <Persona {...personaProps} />;
};
private _renderSwitchDirectory = (): JSX.Element => {
return (
<div
className="switchDirectoryLink"
onClick={(e: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) =>
this.props.onSwitchDirectoryClick(e)
}
>
Switch Directory
</div>
);
};
private _renderSignOut = (): JSX.Element => {
return (
<div
className="signOutLink"
onClick={(e: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => this.props.onSignOutClick(e)}
>
Sign out
</div>
);
};
private _renderSignInComponent = (): JSX.Element => {
const buttonProps: IButtonProps = {
className: "mecontrolSigninButton",
text: "Sign In",
onClick: (e: React.MouseEvent<BaseButton>) => this.props.onSignInClick(e),
styles: {
rootHovered: { backgroundColor: "#393939", color: "#fff" },
rootFocused: { backgroundColor: "#393939", color: "#fff" },
rootPressed: { backgroundColor: "#393939", color: "#fff" }
}
};
return <DefaultButton {...buttonProps} />;
};
}

View File

@@ -0,0 +1,16 @@
/**
* This adapter is responsible to render the React component
* If the component signals a change through the callback passed in the properties, it must render the React component when appropriate
* and update any knockout observables passed from the parent.
*/
import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import { MeControlComponent, MeControlComponentProps } from "./MeControlComponent";
export class MeControlComponentAdapter implements ReactAdapter {
public parameters: ko.Observable<MeControlComponentProps>;
public renderComponent(): JSX.Element {
return <MeControlComponent {...this.parameters()} />;
}
}

View File

@@ -0,0 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`test render renders not signed in 1`] = `
<CustomizedDefaultButton
className="mecontrolSigninButton"
onClick={[Function]}
styles={
Object {
"rootFocused": Object {
"backgroundColor": "#393939",
"color": "#fff",
},
"rootHovered": Object {
"backgroundColor": "#393939",
"color": "#fff",
},
"rootPressed": Object {
"backgroundColor": "#393939",
"color": "#fff",
},
}
}
text="Sign In"
/>
`;
exports[`test render renders signed in with full info 1`] = `
<FocusZone
direction={2}
isCircularNavigation={false}
>
<CustomizedDefaultButton
className="mecontrolHeaderButton"
id="mecontrolHeader"
menuProps={
Object {
"calloutProps": Object {
"minPagePadding": 0,
},
"className": "mecontrolContextualMenu",
"directionalHint": 6,
"directionalHintFixed": true,
"isBeakVisible": false,
"items": Array [
Object {
"key": "Persona",
"onRender": [Function],
},
Object {
"key": "SwitchDirectory",
"onRender": [Function],
},
Object {
"key": "SignOut",
"onRender": [Function],
},
],
}
}
onRenderMenuIcon={[Function]}
styles={
Object {
"rootExpanded": Object {
"backgroundColor": "#393939",
},
"rootFocused": Object {
"backgroundColor": "#393939",
},
"rootHovered": Object {
"backgroundColor": "#393939",
},
"rootPressed": Object {
"backgroundColor": "#393939",
},
}
}
>
<StyledPersonaBase
className="mecontrolHeaderPersona"
imageUrl="../../../../images/dotnet.png"
initialsColor={3}
secondaryText="Contoso"
showInitialsUntilImageLoads={true}
showSecondaryText={true}
size={7}
text="testuser@contoso.com"
/>
</CustomizedDefaultButton>
</FocusZone>
`;

View File

@@ -0,0 +1,160 @@
@import "../../../../less/Common/Constants";
@ConsoleHeaderHeight: 32px;
@ConsoleContentsPaneHeight: 220px;
@ConsoleStatusMaxWidth: 672px;
@ConsoleIconSize: 12px;
@ExpandCollapseIconSize: 20px;
.notificationConsoleContainer {
width: 100%;
.flex-display();
.flex-direction();
img {
width: @ConsoleIconSize;
height: @ConsoleIconSize;
}
.notificationConsoleHeader {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
height: @ConsoleHeaderHeight;
width: 100%;
background-color: @NotificationLow;
border-top: @ButtonBorderWidth @BaseMedium solid;
cursor: pointer;
flex-shrink:0;
&:hover {
background-color:@NotificationHigh;
}
&:active {
background-color:@NotificationHigh;
}
.statusBar {
.dataTypeIcons {
cursor: pointer;
margin: 0px @DefaultSpace 0px @MediumSpace;
padding-left: @DefaultSpace;
.notificationConsoleHeaderIconWithData{
&:not(:last-child) {
padding-right: @LargeSpace;
}
img {
margin-bottom: @SmallSpace;
margin-right: @DefaultSpace;
}
.numInProgress, .numErroredItems, .numInfoItems {
padding-left: 2px;
margin-right: 5px;
}
}
}
.consoleSplitter {
border-left: 1px solid @BaseMedium;
margin-right: @LargeSpace;
padding: 0px 0px 2px;
}
.headerStatus {
display: inline-flex;
.headerStatusEllipsis {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: @ConsoleStatusMaxWidth;
}
}
}
.expandCollapseButton {
cursor: pointer;
padding-right: 5px;
img {
width: @ExpandCollapseIconSize;
height: @ExpandCollapseIconSize;
}
}
}
.notificationConsoleContents {
width: 100%;
height: @ConsoleContentsPaneHeight;
display: flex;
flex-direction: column;
background-color: @BaseLow;
.notificationConsoleControls {
padding: @MediumSpace;
margin-left:@DefaultSpace;
#consoleFilterLabel {
padding: 4px;
}
.consoleSplitter {
border-left: 1px solid @BaseMedium;
margin: @MediumSpace;
}
.clearNotificationsButton {
cursor: pointer;
padding:@SmallSpace;
border:@ButtonBorderWidth solid @BaseLow;
&:hover {
background-color:@BaseMediumLow;
}
&:active {
border: @ButtonBorderWidth dashed @AccentMedium;
background-color: @AccentMediumLow;
}
img{
margin-bottom:@SmallSpace;
margin-right:2px;
}
}
}
.notificationConsoleData {
overflow-y: auto;
overflow-x:hidden;
margin-left:@LargeSpace;
.rowData {
display: flex;
justify-content: space-between;
width: 100%;
padding: @SmallSpace;
img {
margin-top:@SmallSpace;
}
.date {
margin: 0px @LargeSpace;
white-space: nowrap;
}
.message {
flex-grow: 1;
white-space:pre-wrap;
}
}
}
}
}

View File

@@ -0,0 +1,164 @@
import React from "react";
import { shallow } from "enzyme";
import {
NotificationConsoleComponentProps,
ConsoleData,
NotificationConsoleComponent,
ConsoleDataType
} from "./NotificationConsoleComponent";
describe("NotificationConsoleComponent", () => {
const createBlankProps = (): NotificationConsoleComponentProps => {
return {
consoleData: [],
isConsoleExpanded: true,
onConsoleDataChange: (consoleData: ConsoleData[]) => {},
onConsoleExpandedChange: (isExpanded: boolean) => {}
};
};
it("renders the console (expanded)", () => {
const props = createBlankProps();
props.consoleData.push({
type: ConsoleDataType.Info,
date: "date",
message: "message"
});
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("shows proper progress count", () => {
const count = 100;
const props = createBlankProps();
for (let i = 0; i < count; i++) {
props.consoleData.push({
type: ConsoleDataType.InProgress,
date: "date",
message: "message"
});
}
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual(count.toString());
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual("0");
});
it("shows proper error count", () => {
const count = 100;
const props = createBlankProps();
for (let i = 0; i < count; i++) {
props.consoleData.push({
type: ConsoleDataType.Error,
date: "date",
message: "message"
});
}
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual(count.toString());
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual("0");
});
it("shows proper info count", () => {
const count = 100;
const props = createBlankProps();
for (let i = 0; i < count; i++) {
props.consoleData.push({
type: ConsoleDataType.Info,
date: "date",
message: "message"
});
}
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual(count.toString());
});
const testRenderNotification = (date: string, msg: string, type: ConsoleDataType, iconClassName: string) => {
const props = createBlankProps();
props.consoleData.push({
date: date,
message: msg,
type: type
});
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleData .date").text()).toEqual(date);
expect(wrapper.find(".notificationConsoleData .message").text()).toEqual(msg);
expect(wrapper.exists(`.notificationConsoleData .${iconClassName}`));
};
it("renders progress notifications", () => {
testRenderNotification("date", "message", ConsoleDataType.InProgress, "loaderIcon");
});
it("renders error notifications", () => {
testRenderNotification("date", "message", ConsoleDataType.Error, "errorIcon");
});
it("renders info notifications", () => {
testRenderNotification("date", "message", ConsoleDataType.Info, "infoIcon");
});
it("clears notifications", () => {
const props = createBlankProps();
props.consoleData.push({
type: ConsoleDataType.InProgress,
date: "date",
message: "message1"
});
props.consoleData.push({
type: ConsoleDataType.Error,
date: "date",
message: "message2"
});
props.consoleData.push({
type: ConsoleDataType.Info,
date: "date",
message: "message3"
});
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
wrapper.find(".clearNotificationsButton").simulate("click");
expect(!wrapper.exists(".notificationConsoleData"));
});
it("collapses and hide content", () => {
const props = createBlankProps();
props.consoleData.push({
date: "date",
message: "message",
type: ConsoleDataType.Info
});
props.isConsoleExpanded = true;
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
wrapper.find(".notificationConsoleHeader").simulate("click");
expect(!wrapper.exists(".notificationConsoleContent"));
});
it("display latest data in header", () => {
const latestData = "latest data";
const props1 = createBlankProps();
const props2 = createBlankProps();
props2.consoleData.push({
date: "date",
message: latestData,
type: ConsoleDataType.Info
});
props2.isConsoleExpanded = true;
const wrapper = shallow(<NotificationConsoleComponent {...props1} />);
wrapper.setProps(props2);
expect(wrapper.find(".headerStatusEllipsis").text()).toEqual(latestData);
});
});

View File

@@ -0,0 +1,273 @@
/**
* React component for control bar
*/
import * as React from "react";
import { ClientDefaults, KeyCodes } from "../../../Common/Constants";
import AnimateHeight from "react-animate-height";
import LoadingIcon from "../../../../images/loading.svg";
import ErrorBlackIcon from "../../../../images/error_black.svg";
import infoBubbleIcon from "../../../../images/info-bubble-9x9.svg";
import InfoIcon from "../../../../images/info_color.svg";
import ErrorRedIcon from "../../../../images/error_red.svg";
import LoaderIcon from "../../../../images/circular_loader_black_16x16.gif";
import ClearIcon from "../../../../images/Clear.svg";
import ChevronUpIcon from "../../../../images/QueryBuilder/CollapseChevronUp_16x.png";
import ChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png";
/**
* Log levels
*/
export enum ConsoleDataType {
Info = 0,
Error = 1,
InProgress = 2
}
/**
* Interface for the data/content that will be recorded
*/
export interface ConsoleData {
type: ConsoleDataType;
date: string;
message: string;
id?: string;
}
export interface NotificationConsoleComponentProps {
isConsoleExpanded: boolean;
onConsoleExpandedChange: (isExpanded: boolean) => void;
consoleData: ConsoleData[];
onConsoleDataChange: (consoleData: ConsoleData[]) => void;
}
interface NotificationConsoleComponentState {
headerStatus: string;
selectedFilter: string;
isExpanded: boolean;
}
export class NotificationConsoleComponent extends React.Component<
NotificationConsoleComponentProps,
NotificationConsoleComponentState
> {
private static readonly transitionDurationMs = 200;
private static readonly FilterOptions = ["All", "In Progress", "Info", "Error"];
private headerTimeoutId: number;
private prevHeaderStatus: string;
private consoleHeaderElement: HTMLElement;
constructor(props: NotificationConsoleComponentProps) {
super(props);
this.state = {
headerStatus: "",
selectedFilter: NotificationConsoleComponent.FilterOptions[0],
isExpanded: props.isConsoleExpanded
};
this.prevHeaderStatus = null;
}
public componentDidUpdate(
prevProps: NotificationConsoleComponentProps,
prevState: NotificationConsoleComponentState
) {
const currentHeaderStatus = NotificationConsoleComponent.extractHeaderStatus(this.props);
if (
this.prevHeaderStatus !== currentHeaderStatus &&
currentHeaderStatus !== null &&
prevState.headerStatus !== currentHeaderStatus
) {
this.setHeaderStatus(currentHeaderStatus);
}
// Call setHeaderStatus() only to clear HeaderStatus or update status to a different value.
// Cache previous headerStatus externally. Otherwise, simply comparing with previous state/props will cause circular
// updates: currentHeaderStatus -> "" -> currentHeaderStatus -> "" etc.
this.prevHeaderStatus = currentHeaderStatus;
if (prevProps.isConsoleExpanded !== this.props.isConsoleExpanded) {
// Sync state and props
// TODO react anti-pattern: remove isExpanded from state which duplicates prop's isConsoleExpanded
this.setState({ isExpanded: this.props.isConsoleExpanded });
}
}
public render(): JSX.Element {
const numInProgress = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.InProgress)
.length;
const numErroredItems = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Error)
.length;
const numInfoItems = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Info)
.length;
return (
<div className="notificationConsoleContainer">
<div
className="notificationConsoleHeader"
ref={(element: HTMLElement) => (this.consoleHeaderElement = element)}
onClick={(event: React.MouseEvent<HTMLDivElement>) => this.expandCollapseConsole()}
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => this.onExpandCollapseKeyPress(event)}
tabIndex={0}
>
<div className="statusBar">
<span className="dataTypeIcons">
<span className="notificationConsoleHeaderIconWithData">
<img src={LoadingIcon} alt="in progress items" />
<span className="numInProgress">{numInProgress}</span>
</span>
<span className="notificationConsoleHeaderIconWithData">
<img src={ErrorBlackIcon} alt="error items" />
<span className="numErroredItems">{numErroredItems}</span>
</span>
<span className="notificationConsoleHeaderIconWithData">
<img src={infoBubbleIcon} alt="info items" />
<span className="numInfoItems">{numInfoItems}</span>
</span>
</span>
<span className="consoleSplitter" />
<span className="headerStatus">
<span className="headerStatusEllipsis">{this.state.headerStatus}</span>
</span>
</div>
<div className="expandCollapseButton" role="button" tabIndex={0}>
<img
src={this.state.isExpanded ? ChevronDownIcon : ChevronUpIcon}
alt={this.state.isExpanded ? "collapse console" : "expand console"}
/>
</div>
</div>
<AnimateHeight
duration={NotificationConsoleComponent.transitionDurationMs}
height={this.state.isExpanded ? "auto" : 0}
onAnimationEnd={this.onConsoleWasExpanded}
>
<div className="notificationConsoleContents">
<div className="notificationConsoleControls">
<label id="consoleFilterLabel">Filter</label>
<select
aria-labelledby="consoleFilterLabel"
role="combobox"
aria-label={this.state.selectedFilter}
value={this.state.selectedFilter}
onChange={this.onFilterSelected.bind(this)}
>
{NotificationConsoleComponent.FilterOptions.map((value: string) => (
<option value={value} key={value}>
{value}
</option>
))}
</select>
<span className="consoleSplitter" />
<span
className="clearNotificationsButton"
onClick={() => this.clearNotifications()}
role="button"
onKeyDown={(event: React.KeyboardEvent<HTMLSpanElement>) => this.onClearNotificationsKeyPress(event)}
tabIndex={0}
>
<img src={ClearIcon} alt="clear notifications image" />
Clear Notifications
</span>
</div>
<div className="notificationConsoleData">
{this.renderAllFilteredConsoleData(this.getFilteredConsoleData())}
</div>
</div>
</AnimateHeight>
</div>
);
}
private expandCollapseConsole() {
this.setState({ isExpanded: !this.state.isExpanded });
}
private onExpandCollapseKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (event.keyCode === KeyCodes.Space || event.keyCode === KeyCodes.Enter) {
this.expandCollapseConsole();
event.stopPropagation();
event.preventDefault();
}
};
private onClearNotificationsKeyPress = (event: React.KeyboardEvent<HTMLSpanElement>): void => {
if (event.keyCode === KeyCodes.Space || event.keyCode === KeyCodes.Enter) {
this.clearNotifications();
event.stopPropagation();
event.preventDefault();
}
};
private clearNotifications(): void {
this.props.onConsoleDataChange([]);
}
private renderAllFilteredConsoleData(rowData: ConsoleData[]): JSX.Element[] {
return rowData.map((item: ConsoleData, index: number) => (
<div className="rowData" key={index}>
{item.type === ConsoleDataType.Info && <img className="infoIcon" src={InfoIcon} alt="info" />}
{item.type === ConsoleDataType.Error && <img className="errorIcon" src={ErrorRedIcon} alt="error" />}
{item.type === ConsoleDataType.InProgress && <img className="loaderIcon" src={LoaderIcon} alt="in progress" />}
<span className="date">{item.date}</span>
<span className="message">{item.message}</span>
</div>
));
}
private onFilterSelected(event: React.ChangeEvent<HTMLSelectElement>): void {
this.setState({ selectedFilter: event.target.value });
}
private getFilteredConsoleData(): ConsoleData[] {
let filterType: ConsoleDataType = null;
switch (this.state.selectedFilter) {
case "All":
filterType = null;
break;
case "In Progress":
filterType = ConsoleDataType.InProgress;
break;
case "Info":
filterType = ConsoleDataType.Info;
break;
case "Error":
filterType = ConsoleDataType.Error;
break;
default:
filterType = null;
}
return filterType == null
? this.props.consoleData
: this.props.consoleData.filter((data: ConsoleData) => data.type === filterType);
}
private setHeaderStatus(statusMessage: string): void {
if (this.state.headerStatus === statusMessage) {
return;
}
this.headerTimeoutId && clearTimeout(this.headerTimeoutId);
this.setState({ headerStatus: statusMessage });
this.headerTimeoutId = window.setTimeout(
() => this.setState({ headerStatus: "" }),
ClientDefaults.errorNotificationTimeoutMs
);
}
private static extractHeaderStatus(props: NotificationConsoleComponentProps) {
if (props.consoleData && props.consoleData.length > 0) {
return props.consoleData[0].message.split(":\n")[0];
} else {
return null;
}
}
private onConsoleWasExpanded = (): void => {
this.props.onConsoleExpandedChange(this.state.isExpanded);
if (this.state.isExpanded) {
this.consoleHeaderElement.focus();
}
};
}

View File

@@ -0,0 +1,46 @@
import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import * as ViewModels from "../../../Contracts/ViewModels";
import { NotificationConsoleComponent } from "./NotificationConsoleComponent";
import { ConsoleData } from "./NotificationConsoleComponent";
export class NotificationConsoleComponentAdapter implements ReactAdapter {
public parameters: ko.Observable<number>;
public container: ViewModels.Explorer;
private consoleData: ko.ObservableArray<ConsoleData>;
constructor(container: ViewModels.Explorer) {
this.container = container;
this.consoleData = container.notificationConsoleData;
this.consoleData.subscribe((newValue: ConsoleData[]) => this.triggerRender());
container.isNotificationConsoleExpanded.subscribe(() => this.triggerRender());
this.parameters = ko.observable(Date.now());
}
private onConsoleExpandedChange(isExpanded: boolean): void {
isExpanded ? this.container.expandConsole() : this.container.collapseConsole();
this.triggerRender();
}
private onConsoleDataChange(consoleData: ConsoleData[]): void {
this.consoleData(consoleData);
this.triggerRender();
}
public renderComponent(): JSX.Element {
return (
<NotificationConsoleComponent
isConsoleExpanded={this.container.isNotificationConsoleExpanded()}
onConsoleExpandedChange={this.onConsoleExpandedChange.bind(this)}
consoleData={this.consoleData()}
onConsoleDataChange={this.onConsoleDataChange.bind(this)}
/>
);
}
private triggerRender() {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
}

View File

@@ -0,0 +1,192 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
<div
className="notificationConsoleContainer"
>
<div
className="notificationConsoleHeader"
onClick={[Function]}
onKeyDown={[Function]}
tabIndex={0}
>
<div
className="statusBar"
>
<span
className="dataTypeIcons"
>
<span
className="notificationConsoleHeaderIconWithData"
>
<img
alt="in progress items"
src=""
/>
<span
className="numInProgress"
>
0
</span>
</span>
<span
className="notificationConsoleHeaderIconWithData"
>
<img
alt="error items"
src=""
/>
<span
className="numErroredItems"
>
0
</span>
</span>
<span
className="notificationConsoleHeaderIconWithData"
>
<img
alt="info items"
src=""
/>
<span
className="numInfoItems"
>
1
</span>
</span>
</span>
<span
className="consoleSplitter"
/>
<span
className="headerStatus"
>
<span
className="headerStatusEllipsis"
/>
</span>
</div>
<div
className="expandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="collapse console"
src=""
/>
</div>
</div>
<AnimateHeight
animateOpacity={false}
animationStateClasses={
Object {
"animating": "rah-animating",
"animatingDown": "rah-animating--down",
"animatingToHeightAuto": "rah-animating--to-height-auto",
"animatingToHeightSpecific": "rah-animating--to-height-specific",
"animatingToHeightZero": "rah-animating--to-height-zero",
"animatingUp": "rah-animating--up",
"static": "rah-static",
"staticHeightAuto": "rah-static--height-auto",
"staticHeightSpecific": "rah-static--height-specific",
"staticHeightZero": "rah-static--height-zero",
}
}
applyInlineTransitions={true}
delay={0}
duration={200}
easing="ease"
height="auto"
onAnimationEnd={[Function]}
style={Object {}}
>
<div
className="notificationConsoleContents"
>
<div
className="notificationConsoleControls"
>
<label
id="consoleFilterLabel"
>
Filter
</label>
<select
aria-label="All"
aria-labelledby="consoleFilterLabel"
onChange={[Function]}
role="combobox"
value="All"
>
<option
key="All"
value="All"
>
All
</option>
<option
key="In Progress"
value="In Progress"
>
In Progress
</option>
<option
key="Info"
value="Info"
>
Info
</option>
<option
key="Error"
value="Error"
>
Error
</option>
</select>
<span
className="consoleSplitter"
/>
<span
className="clearNotificationsButton"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
<img
alt="clear notifications image"
src=""
/>
Clear Notifications
</span>
</div>
<div
className="notificationConsoleData"
>
<div
className="rowData"
key="0"
>
<img
alt="info"
className="infoIcon"
src=""
/>
<span
className="date"
>
date
</span>
<span
className="message"
>
message
</span>
</div>
</div>
</div>
</AnimateHeight>
</div>
`;