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,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>
`;