From 582ac865ffd91ff889f1e7619eb740c4daa9d900 Mon Sep 17 00:00:00 2001 From: victor-meng <56978073+victor-meng@users.noreply.github.com> Date: Wed, 10 Jun 2020 00:15:32 -0700 Subject: [PATCH] Migrate UploadItemPane to react (#17) * Create GenericPaneComponent and use it to migrate UploadItemsPane to React * Add helper functions for building each panel section * Address comments and some styling changes * Unsubscribe to isNotificationConsoleExpanded when component unmounts --- less/documentDB.less | 58 ++++- src/Common/Constants.ts | 1 + src/Contracts/ViewModels.ts | 3 + src/Explorer/Explorer.ts | 6 + src/Explorer/OpenActionsStubs.ts | 3 + .../Panes/GenericRightPaneComponent.tsx | 141 ++++++++++ src/Explorer/Panes/UploadItemsPaneAdapter.tsx | 242 ++++++++++++++++++ src/Explorer/Tabs/DocumentsTab.ts | 5 +- src/explorer.html | 2 +- 9 files changed, 448 insertions(+), 13 deletions(-) create mode 100644 src/Explorer/Panes/GenericRightPaneComponent.tsx create mode 100644 src/Explorer/Panes/UploadItemsPaneAdapter.tsx diff --git a/less/documentDB.less b/less/documentDB.less index f146818f5..9ef913527 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -570,6 +570,12 @@ body { } } +.fileImportButton { + height: 24px; + border: @ButtonBorderWidth solid transparent; + vertical-align: top; +} + .fileUploadSummaryContainer { margin-top: 40px; @@ -1016,6 +1022,18 @@ menuQuickStart { background: #262626; } +.panelContent { + display: flex; + flex-direction: column; + flex: 1; +} + +.panelContentWrapper { + display: flex; + flex-direction: column; + height: 100%; +} + .contextual-pane { top: 0px; right: 0 !important; @@ -1232,23 +1250,25 @@ menuQuickStart { padding: 2px 30px; cursor: pointer; font-size: 12px; + + &:active { + border-color: #0072c6; + background-color: #0072c6; + } } -.btncreatecoll1:hover { - background: @AccentMediumHigh; +.leftpanel-okbut .genericPaneSubmitBtn { + border: 1px solid @AccentMediumHigh; + background-color: @AccentMediumHigh; color: #fff; - border-color: @AccentMediumHigh; cursor: pointer; font-size: 12px; -} + height: 24px; -.btncreatecoll1:active { - border: 1px solid #0072c6; - background-color: #0072c6; - color: white; - padding: 2px 30px; - cursor: pointer; - font-size: 12px; + &:active { + border-color: #0072c6; + background-color: #0072c6; + } } .btncreatecoll1-off { @@ -1361,6 +1381,15 @@ p { color: #000; } +.headerline .closePaneBtn { + float: right; + cursor: pointer; + width: 16px; + height: 100%; + margin-right: 4px; + color: #000; +} + .closeImg { float: right; cursor: pointer; @@ -1710,6 +1739,13 @@ input::-webkit-calendar-picker-indicator { margin: (2 * @MediumSpace) 0px; } +.contextual-pane .panelMainContent { + padding-left: 34px; + padding-right: 34px; + color: @BaseDark; + margin: (2 * @MediumSpace) 0px; +} + .contextual-pane .paneFooter { width: 100%; height: 60px; diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 2c211f041..35112f27b 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -127,6 +127,7 @@ export class Features { public static readonly enableFixedCollectionWithSharedThroughput = "enablefixedcollectionwithsharedthroughput"; public static readonly enableAutoPilotV2 = "enableautopilotv2"; public static readonly ttl90Days = "ttl90days"; + public static readonly enableRightPanelV2 = "enablerightpanelv2"; } export class AfecFeatures { diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index c532112e7..e9d858992 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -27,6 +27,7 @@ import { Splitter } from "../Common/Splitter"; import { StringInputPane } from "../Explorer/Panes/StringInputPane"; import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent"; import { UploadDetails } from "../workers/upload/definitions"; +import { UploadItemsPaneAdapter } from "../Explorer/Panes/UploadItemsPaneAdapter"; export interface ExplorerOptions { documentClientUtility: DocumentClientUtilityBase; @@ -87,6 +88,7 @@ export interface Explorer { isGalleryEnabled: ko.Computed; isGitHubPaneEnabled: ko.Observable; isGraphsEnabled: ko.Computed; + isRightPanelV2Enabled: ko.Computed; canExceedMaximumValue: ko.Computed; hasAutoPilotV2FeatureFlag: ko.Computed; isHostedDataExplorerEnabled: ko.Computed; @@ -141,6 +143,7 @@ export interface Explorer { executeSprocParamsPane: ExecuteSprocParamsPane; renewAdHocAccessPane: RenewAdHocAccessPane; uploadItemsPane: UploadItemsPane; + uploadItemsPaneAdapter: UploadItemsPaneAdapter; loadQueryPane: LoadQueryPane; saveQueryPane: ContextualPane; browseQueriesPane: BrowseQueriesPane; diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 542909ee8..476fbad22 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -86,6 +86,7 @@ import { StringInputPane } from "./Panes/StringInputPane"; import { TableColumnOptionsPane } from "./Panes/Tables/TableColumnOptionsPane"; import { UploadFilePane } from "./Panes/UploadFilePane"; import { UploadItemsPane } from "./Panes/UploadItemsPane"; +import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter"; BindingHandlersRegisterer.registerBindingHandlers(); // Hold a reference to ComponentRegisterer to prevent transpiler to ignore import @@ -188,6 +189,7 @@ export default class Explorer implements ViewModels.Explorer { public executeSprocParamsPane: ViewModels.ExecuteSprocParamsPane; public renewAdHocAccessPane: ViewModels.RenewAdHocAccessPane; public uploadItemsPane: ViewModels.UploadItemsPane; + public uploadItemsPaneAdapter: UploadItemsPaneAdapter; public loadQueryPane: ViewModels.LoadQueryPane; public saveQueryPane: ViewModels.ContextualPane; public browseQueriesPane: ViewModels.BrowseQueriesPane; @@ -205,6 +207,7 @@ export default class Explorer implements ViewModels.Explorer { public isGitHubPaneEnabled: ko.Observable; public isGraphsEnabled: ko.Computed; public isHostedDataExplorerEnabled: ko.Computed; + public isRightPanelV2Enabled: ko.Computed; public canExceedMaximumValue: ko.Computed; public hasAutoPilotV2FeatureFlag: ko.Computed; @@ -551,6 +554,7 @@ export default class Explorer implements ViewModels.Explorer { !this.isRunningOnNationalCloud() && !this.isPreferredApiGraph() ); + this.isRightPanelV2Enabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableRightPanelV2)); this.defaultExperience.subscribe((defaultExperience: string) => { if ( defaultExperience && @@ -707,6 +711,8 @@ export default class Explorer implements ViewModels.Explorer { container: this }); + this.uploadItemsPaneAdapter = new UploadItemsPaneAdapter(this); + this.loadQueryPane = new LoadQueryPane({ documentClientUtility: this.documentClientUtility, id: "loadquerypane", diff --git a/src/Explorer/OpenActionsStubs.ts b/src/Explorer/OpenActionsStubs.ts index bd0ba879e..e919c27ba 100644 --- a/src/Explorer/OpenActionsStubs.ts +++ b/src/Explorer/OpenActionsStubs.ts @@ -19,6 +19,7 @@ import { TableColumnOptionsPane } from "../../src/Explorer/Panes/Tables/TableCol import { TextFieldProps } from "./Controls/DialogReactComponent/DialogComponent"; import { UploadDetails } from "../workers/upload/definitions"; import { UploadFilePane } from "./Panes/UploadFilePane"; +import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter"; import { Versions } from "../../src/Contracts/ExplorerContracts"; import { CollectionCreationDefaults } from "../Shared/Constants"; @@ -86,6 +87,7 @@ export class ExplorerStub implements ViewModels.Explorer { public settingsPane: ViewModels.SettingsPane; public executeSprocParamsPane: ViewModels.ExecuteSprocParamsPane; public uploadItemsPane: ViewModels.UploadItemsPane; + public uploadItemsPaneAdapter: UploadItemsPaneAdapter; public loadQueryPane: ViewModels.LoadQueryPane; public saveQueryPane: ViewModels.ContextualPane; public browseQueriesPane: ViewModels.BrowseQueriesPane; @@ -97,6 +99,7 @@ export class ExplorerStub implements ViewModels.Explorer { public isGalleryEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; public isGraphsEnabled: ko.Computed; + public isRightPanelV2Enabled: ko.Computed; public canExceedMaximumValue: ko.Computed; public isHostedDataExplorerEnabled: ko.Computed; public parentFrameDataExplorerVersion: ko.Observable = ko.observable(Versions.DataExplorer); diff --git a/src/Explorer/Panes/GenericRightPaneComponent.tsx b/src/Explorer/Panes/GenericRightPaneComponent.tsx new file mode 100644 index 000000000..407fb6ab9 --- /dev/null +++ b/src/Explorer/Panes/GenericRightPaneComponent.tsx @@ -0,0 +1,141 @@ +import * as React from "react"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { IconButton, PrimaryButton } from "office-ui-fabric-react/lib/Button"; +import { KeyCodes } from "../../Common/Constants"; +import { Subscription } from "knockout"; +import ErrorRedIcon from "../../../images/error_red.svg"; +import LoadingIndicatorIcon from "../../../images/LoadingIndicator_3Squares.gif"; + +export interface GenericRightPaneProps { + container: ViewModels.Explorer; + content: JSX.Element; + formError: string; + formErrorDetail: string; + id: string; + isExecuting: boolean; + onClose: () => void; + onSubmit: () => void; + submitButtonText: string; + title: string; +} + +export interface GenericRightPaneState { + panelHeight: number; +} + +export class GenericRightPaneComponent extends React.Component { + private notificationConsoleSubscription: Subscription; + + constructor(props: GenericRightPaneProps) { + super(props); + + this.state = { + panelHeight: this.getPanelHeight() + }; + } + + public componentDidMount(): void { + this.notificationConsoleSubscription = this.props.container.isNotificationConsoleExpanded.subscribe(() => { + this.setState({ panelHeight: this.getPanelHeight() }); + }); + this.props.container.isNotificationConsoleExpanded.extend({ rateLimit: 10 }); + } + + public componentWillUnmount(): void { + this.notificationConsoleSubscription && this.notificationConsoleSubscription.dispose(); + } + + public render(): JSX.Element { + return ( +
+
+
+
+ {this.createPanelHeader()} + {this.createErrorSection()} + {this.props.content} + {this.createPanelFooter()} +
+ {this.createLoadingScreen()} +
+
+ ); + } + + private createPanelHeader = (): JSX.Element => { + return ( +
+ {this.props.title} + +
+ ); + }; + + private createErrorSection = (): JSX.Element => { + return ( + + ); + }; + + private createPanelFooter = (): JSX.Element => { + return ( +
+
+ +
+
+ ); + }; + + private createLoadingScreen = (): JSX.Element => { + return ( + + ); + }; + + private onKeyDown = (event: React.KeyboardEvent): void => { + if (event.keyCode === KeyCodes.Escape) { + this.props.onClose(); + event.stopPropagation(); + } + }; + + private showErrorDetail = (): void => { + this.props.container.expandConsole(); + }; + + private getPanelHeight = (): number => { + const notificationConsoleElement: HTMLElement = document.getElementById("explorerNotificationConsole"); + return window.innerHeight - $(notificationConsoleElement).height(); + } +} diff --git a/src/Explorer/Panes/UploadItemsPaneAdapter.tsx b/src/Explorer/Panes/UploadItemsPaneAdapter.tsx new file mode 100644 index 000000000..730b30480 --- /dev/null +++ b/src/Explorer/Panes/UploadItemsPaneAdapter.tsx @@ -0,0 +1,242 @@ +import * as Constants from "../../Common/Constants"; +import * as ErrorParserUtility from "../../Common/ErrorParserUtility"; +import * as ko from "knockout"; +import * as React from "react"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; +import { IconButton } from "office-ui-fabric-react/lib/Button"; +import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent"; +import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils"; +import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; +import { UploadDetailsRecord, UploadDetails } from "../../workers/upload/definitions"; +import InfoBubbleIcon from "../../../images/info-bubble.svg"; + +const UPLOAD_FILE_SIZE_LIMIT = 2097152; + +export class UploadItemsPaneAdapter implements ReactAdapter { + public parameters: ko.Observable; + private isOpened: boolean; + private isExecuting: boolean; + private formError: string; + private formErrorDetail: string; + private selectedFiles: FileList; + private selectedFilesTitle: string; + private uploadFileData: UploadDetailsRecord[]; + + public constructor(private container: ViewModels.Explorer) { + this.parameters = ko.observable(Date.now()); + this.reset(); + this.triggerRender(); + } + + public renderComponent(): JSX.Element { + if (!this.isOpened) { + return undefined; + } + + const props: GenericRightPaneProps = { + container: this.container, + content: this.createContent(), + formError: this.formError, + formErrorDetail: this.formErrorDetail, + id: "uploaditemspane", + isExecuting: this.isExecuting, + title: "Upload Items", + submitButtonText: "Upload", + onClose: () => this.close(), + onSubmit: () => this.submit() + }; + return ; + } + + public triggerRender(): void { + window.requestAnimationFrame(() => this.parameters(Date.now())); + } + + public open(): void { + this.isOpened = true; + this.triggerRender(); + } + + public close(): void { + this.reset(); + this.triggerRender(); + } + + public submit(): void { + this.formError = ""; + if (!this.selectedFiles || this.selectedFiles.length === 0) { + this.formError = "No files specified"; + this.formErrorDetail = "No files were specified. Please input at least one file."; + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + "Could not upload items -- No files were specified. Please input at least one file." + ); + this.triggerRender(); + return; + } else if (this._totalFileSizeForFileList() > UPLOAD_FILE_SIZE_LIMIT) { + this.formError = "Upload file size limit exceeded"; + this.formErrorDetail = "Total file upload size exceeds the 2 MB file size limit."; + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + "Could not upload items -- Total file upload size exceeds the 2 MB file size limit." + ); + this.triggerRender(); + return; + } + + const selectedCollection: ViewModels.Collection = this.container.findSelectedCollection(); + this.isExecuting = true; + this.triggerRender(); + selectedCollection && + selectedCollection + .uploadFiles(this.selectedFiles) + .then( + (uploadDetails: UploadDetails) => { + this.uploadFileData = uploadDetails.data; + this.selectedFiles = undefined; + this.selectedFilesTitle = ""; + }, + error => { + const message = ErrorParserUtility.parse(error); + this.formError = message[0].message; + this.formErrorDetail = message[0].message; + } + ) + .finally(() => { + this.triggerRender(); + this.isExecuting = false; + }); + } + + private createContent = (): JSX.Element => { + return ( +
+ {this.createMainContentSection()} +
+ ); + }; + + private createMainContentSection = (): JSX.Element => { + return ( +
+
+ Select JSON Files + + More information + + Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON + documents. The combined size of all files in an individual upload operation must be less than 2 MB. You + can perform multiple upload operations for larger data sets. + + +
+ + + + +
+ ); + }; + + private updateSelectedFiles = (event: React.ChangeEvent): void => { + this.selectedFiles = event.target.files; + this._updateSelectedFilesTitle(); + this.triggerRender(); + }; + + private _updateSelectedFilesTitle = (): void => { + this.selectedFilesTitle = ""; + + if (!this.selectedFiles || this.selectedFiles.length === 0) { + return; + } + + for (let i = 0; i < this.selectedFiles.length; i++) { + this.selectedFilesTitle += `"${this.selectedFiles.item(i).name}"`; + } + }; + + private _totalFileSizeForFileList(): number { + let totalFileSize = 0; + if (!this.selectedFiles) { + return totalFileSize; + } + + for (let i = 0; i < this.selectedFiles.length; i++) { + totalFileSize += this.selectedFiles.item(i).size; + } + + return totalFileSize; + } + + private fileUploadSummaryText = (numSucceeded: number, numFailed: number): string => { + return `${numSucceeded} items created, ${numFailed} errors`; + }; + + private onImportButtonClick = (): void => { + document.getElementById("importDocsInput").click(); + }; + + private onImportButtonKeyPress = (event: React.KeyboardEvent): void => { + if (event.charCode === Constants.KeyCodes.Enter || event.charCode === Constants.KeyCodes.Space) { + this.onImportButtonClick(); + event.stopPropagation(); + } + }; + + private reset = (): void => { + this.isOpened = false; + this.isExecuting = false; + this.formError = ""; + this.formErrorDetail = ""; + this.selectedFiles = undefined; + this.selectedFilesTitle = ""; + this.uploadFileData = []; + }; +} diff --git a/src/Explorer/Tabs/DocumentsTab.ts b/src/Explorer/Tabs/DocumentsTab.ts index 0f8e54763..4eda49040 100644 --- a/src/Explorer/Tabs/DocumentsTab.ts +++ b/src/Explorer/Tabs/DocumentsTab.ts @@ -965,7 +965,10 @@ export default class DocumentsTab extends TabsBase implements ViewModels.Documen onCommandClick: () => { const selectedCollection: ViewModels.Collection = container.findSelectedCollection(); const focusElement = document.getElementById("itemImportLink"); - selectedCollection && container.uploadItemsPane.open(); + const uploadItemsPane = container.isRightPanelV2Enabled() + ? container.uploadItemsPaneAdapter + : container.uploadItemsPane; + selectedCollection && uploadItemsPane.open(); focusElement && focusElement.focus(); }, commandButtonLabel: label, diff --git a/src/explorer.html b/src/explorer.html index 793c60c29..766905fde 100644 --- a/src/explorer.html +++ b/src/explorer.html @@ -431,7 +431,7 @@ - +