From 0f6c2602637f20876cd0244114266a3bdfabbbfd Mon Sep 17 00:00:00 2001 From: SimoTw <36868079+SimoTw@users.noreply.github.com> Date: Tue, 2 Feb 2021 18:36:48 +0800 Subject: [PATCH] Support upload prediction results (#864) * Add toggle for switching between file/service. * Implement prediction file picker (#861) * Handle load file flow. * Add validate logic. * handle the predict result file and draw the result * add condition to prevent nofile error (#862) * Fix error while fetching with file URL. * update prediction file reader and disable prediction selector while no file. Co-authored-by: Buddha Wang --- .../documentFilePicker.scss | 3 +- .../common/pageRange/pageRange.scss | 3 +- .../prebuiltSetting/prebuiltSetting.scss | 1 - .../predictionFilePicker.scss | 22 ++ .../predictionFilePicker.tsx | 238 +++++++++++++++ .../prebuiltPredict/prebuiltPredictPage.scss | 6 + .../prebuiltPredict/prebuiltPredictPage.tsx | 289 +++++++++++------- 7 files changed, 450 insertions(+), 112 deletions(-) create mode 100644 src/react/components/common/predictionFilePicker/predictionFilePicker.scss create mode 100644 src/react/components/common/predictionFilePicker/predictionFilePicker.tsx diff --git a/src/react/components/common/documentFilePicker/documentFilePicker.scss b/src/react/components/common/documentFilePicker/documentFilePicker.scss index c38785a0..6ad494fc 100644 --- a/src/react/components/common/documentFilePicker/documentFilePicker.scss +++ b/src/react/components/common/documentFilePicker/documentFilePicker.scss @@ -2,7 +2,7 @@ display: flex; flex-direction: row; align-items: center; - margin-bottom: 10px; + margin-bottom: 5px; .title { height: 32px; line-height: 32px; @@ -13,7 +13,6 @@ } .sourceDropdown { width: 95px; - margin-bottom: 10px; } .local-file { float: right; diff --git a/src/react/components/common/pageRange/pageRange.scss b/src/react/components/common/pageRange/pageRange.scss index f4b665c3..9c4a2fc9 100644 --- a/src/react/components/common/pageRange/pageRange.scss +++ b/src/react/components/common/pageRange/pageRange.scss @@ -9,9 +9,10 @@ flex: 1; input[name="pagerange"] { width: 100%; + height: 32px; } .page-range-alert { - border: 1px solid red; + border: 1px solid #ffffff; } } .ms-Checkbox { diff --git a/src/react/components/common/prebuiltSetting/prebuiltSetting.scss b/src/react/components/common/prebuiltSetting/prebuiltSetting.scss index e9f2ee05..474ccceb 100644 --- a/src/react/components/common/prebuiltSetting/prebuiltSetting.scss +++ b/src/react/components/common/prebuiltSetting/prebuiltSetting.scss @@ -1,7 +1,6 @@ .prebuilt-setting { .apikeyContainer { display: flex; - margin-bottom: 30px; .ms-TooltipHost, .apikey { width: 100%; diff --git a/src/react/components/common/predictionFilePicker/predictionFilePicker.scss b/src/react/components/common/predictionFilePicker/predictionFilePicker.scss new file mode 100644 index 00000000..f6f723a6 --- /dev/null +++ b/src/react/components/common/predictionFilePicker/predictionFilePicker.scss @@ -0,0 +1,22 @@ +.prediction-file-picker { + display: flex; + flex-direction: row; + align-items: center; + .title { + height: 32px; + line-height: 32px; + } + + .container-space-between { + flex: 1; + } + + .source-dropdown { + width: 95px; + } + + .local-file { + float: right; + flex: 1; + } +} diff --git a/src/react/components/common/predictionFilePicker/predictionFilePicker.tsx b/src/react/components/common/predictionFilePicker/predictionFilePicker.tsx new file mode 100644 index 00000000..5d8b7341 --- /dev/null +++ b/src/react/components/common/predictionFilePicker/predictionFilePicker.tsx @@ -0,0 +1,238 @@ +import { Dropdown, IDropdownOption, PrimaryButton, TextField } from '@fluentui/react'; +import React from 'react'; +import HtmlFileReader from '../../../../common/htmlFileReader'; +import { strings } from '../../../../common/strings'; +import { getGreenWithWhiteBackgroundTheme, getPrimaryGreenTheme } from '../../../../common/themes'; +import "./predictionFilePicker.scss"; + +interface IPredictionFile { + file: File; + fileLabel: string; + fetchedFileURL: string; +} + +interface IPredictionFilePickerProps { + disabled: boolean; + onFileChange?: (file: IPredictionFile) => void; + onError?: (err: { alertTitle: string, alertMessage: string }) => void; + onSelectSourceChange?: () => void; +} + +interface IPredictionFilePickerState { + sourceOption: string; + inputedLocalFileName: string; + inputedFileURL: string; + isFetching: boolean; +} + +export class PredictionFilePicker extends React.Component{ + state = { + sourceOption: "localFile", + inputedLocalFileName: strings.predict.defaultLocalFileInput, + inputedFileURL: "", + isFetching: false, + }; + + private filePicker: React.RefObject = React.createRef(); + + render() { + const sourceOptions: IDropdownOption[] = [ + { key: "localFile", text: strings.documentFilePicker.localFile }, + { key: "url", text: strings.documentFilePicker.url }, + ]; + + let { disabled } = this.props; + disabled = !!disabled; + const urlInputDisabled = disabled || this.state.isFetching; + const fetchDisabled: boolean = + disabled || + this.state.isFetching || + this.state.inputedFileURL.length === 0 || + this.state.inputedFileURL === strings.prebuiltPredict.defaultURLInput; + + return <> +
+
Prediction:
+
+ + {this.state.sourceOption === "localFile" && + <> + + + + } + {this.state.sourceOption === "url" && + <> + + + + } +
+
+ + } + + private onSelectSourceChange = (event, option) => { + if (option.key !== this.state.sourceOption) { + this.setState({ + sourceOption: option.key, + inputedFileURL: "" + }, () => { + if (this.props.onSelectSourceChange) { + this.props.onSelectSourceChange(); + } + }); + } + } + + private handleInputFileChange = async () => { + const { current } = this.filePicker; + if (!current.value) { + return; + } + + const fileName = current.value.split("\\").pop(); + if (!fileName) { + return; + } + + try { + const fileInfo = await HtmlFileReader.readAsText(current.files[0]); + if (!this.isValidSchema(JSON.parse(fileInfo.content as string))) { + // Throw error when invalid schema. + throw new Error("The file is not a proper prediction result, please try other file."); + } + + this.setState({ + inputedLocalFileName: fileName + }, () => { + if (this.props.onFileChange) { + this.props.onFileChange({ + file: current.files[0], + fileLabel: fileName, + fetchedFileURL: "", + }); + } + }); + } catch (err) { + // Report error. + if (this.props.onError) { + this.props.onError({ + alertTitle: "Load prediction file error", + alertMessage: err?.message ? err.message : err, + }); + } + + // Reset file input. + this.setState({ inputedLocalFileName: "" }); + } + } + + private handleInputFileClick = () => { + this.filePicker.current?.click(); + } + + private removeDefaultInputedFileURL = () => { + if (this.state.inputedFileURL === strings.prebuiltPredict.defaultURLInput) { + this.setState({ inputedFileURL: "" }); + } + } + + private setInputedFileURL = (event) => { + this.setState({ inputedFileURL: event.target.value }); + } + + private getFileFromURL = async () => { + this.setState({ isFetching: true }); + + try { + const response = await fetch(this.state.inputedFileURL, { headers: { Accept: "application/json" } }); + + if (!response.ok) { + throw new Error(response.status.toString() + " " + response.statusText); + } + + const contentType = response.headers.get("Content-Type"); + if (!["application/json", "application/octet-stream"].includes(contentType)) { + throw new Error("Content-Type " + contentType + " not supported."); + } + + const blob = await response.blob(); + if (!this.isValidSchema(JSON.parse(await blob.text()))) { + throw new Error("The file is not a proper prediction result, please try other file."); + } + + const fileAsURL = new URL(this.state.inputedFileURL); + const fileName = fileAsURL.pathname.split("/").pop(); + const file = new File([blob], fileName, { type: contentType }); + this.setState({ + isFetching: false, + }, () => { + if (this.props.onFileChange) { + this.props.onFileChange({ + file, + fileLabel: fileName, + fetchedFileURL: "" + }); + } + }); + } catch (err) { + this.setState({ isFetching: false }); + if (this.props.onError) { + this.props.onError({ + alertTitle: "Fetch failed", + alertMessage: err?.message ? err.message : err, + }); + } + } + } + + private isValidSchema = (jsonData) => { + if (jsonData && jsonData.analyzeResult) { + // We should ensure version and documentResults exists. + const { version, documentResults } = jsonData.analyzeResult; + return !!version && !!documentResults; + } + + return false; + } +} diff --git a/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.scss b/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.scss index b56196c9..2a06a898 100644 --- a/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.scss +++ b/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.scss @@ -19,4 +19,10 @@ textarea { word-break: break-all; } +} + +.predict-mode-toggle { + label { + color: rgb(250, 251, 251); + } } \ No newline at end of file diff --git a/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.tsx b/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.tsx index 4ee0a2b5..27e26bc1 100644 --- a/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.tsx +++ b/src/react/components/pages/prebuiltPredict/prebuiltPredictPage.tsx @@ -30,6 +30,7 @@ import ServiceHelper from "../../../../services/serviceHelper"; import { getAppInsights } from "../../../../services/telemetryService"; import Alert from "../../common/alert/alert"; import { DocumentFilePicker } from "../../common/documentFilePicker/documentFilePicker"; +import { PredictionFilePicker } from "../../common/predictionFilePicker/predictionFilePicker"; import { ImageMap } from "../../common/imageMap/imageMap"; import { PageRange } from "../../common/pageRange/pageRange"; import { PrebuiltSetting } from "../../common/prebuiltSetting/prebuiltSetting"; @@ -41,6 +42,7 @@ import PredictResult from "../predict/predictResult"; import { ILoadFileHelper, ILoadFileResult, LoadFileHelper } from "./LoadFileHelper"; import "./prebuiltPredictPage.scss"; import { ITableHelper, ITableState, TableHelper } from "./tableHelper"; +import { Toggle } from "office-ui-fabric-react/lib/Toggle"; import { ILayoutHelper, LayoutHelper } from "./layoutHelper"; interface IPrebuiltTypes { @@ -77,6 +79,8 @@ export interface IPrebuiltPredictPageState extends ILoadFileResult, ITableState pageRange: string; pageRangeIsValid?: boolean; predictionEndpointUrl: string; + + liveMode: boolean; } function mapStateToProps(state: IApplicationState) { @@ -150,6 +154,8 @@ export class PrebuiltPredictPage extends React.Component {interpolate(strings.prebuiltPredict.anlayWithPrebuiltModels, this.state.currentPrebuiltType)} -
-
{strings.prebuiltSetting.serviceConfigurationTitle}
-
- -
-
-
{strings.prebuiltPredict.formTypeTitle}
- ({ key: type.name, text: type.name }))} - defaultSelectedKey={this.state.currentPrebuiltType.name} - onChange={this.onPrebuiltTypeChange}> -
-
-
{strings.prebuiltPredict.locale}
- ({ key: type, text: type }))} - defaultSelectedKey={this.state.currentLocale} - onChange={this.onLocaleChange}> -
-
-
-
{strings.prebuiltPredict.selectFileAndRunAnalysis}
+ 1. Choose file for analysis. +
+ {/*
{strings.prebuiltPredict.selectFileAndRunAnalysis}
*/} this.onFileChange(data)} @@ -290,70 +270,110 @@ export class PrebuiltPredictPage extends React.Component
- {strings.prebuiltPredict.analysis} + 2. Get prediction.
-
{"The composed API request is"}
- -
- +
+ {!this.state.liveMode && +
+ +
+ } + {this.state.liveMode && + <> + +
+
+
{strings.prebuiltPredict.formTypeTitle}
+ ({ key: type.name, text: type.name }))} + defaultSelectedKey={this.state.currentPrebuiltType.name} + onChange={this.onPrebuiltTypeChange}> +
+
+
{strings.prebuiltPredict.locale}
+ ({ key: type, text: type }))} + defaultSelectedKey={this.state.currentLocale} + onChange={this.onLocaleChange}> +
+
{"The composed API request is"}
+ +
+ +
+
+ } + {this.state.isFetching && +
+
- {this.state.isFetching && -
- -
- } - {this.state.isPredicting && -
- -
- } - {Object.keys(predictions).length > 0 && - + - } - { - (Object.keys(predictions).length === 0 && this.state.predictionLoaded) && -
{strings.prebuiltPredict.noFieldCanBeExtracted}
- } -
- - + + } + {Object.keys(predictions).length > 0 && + + } + { + (Object.keys(predictions).length === 0 && this.state.predictionLoaded) && +
{strings.prebuiltPredict.noFieldCanBeExtracted}
+ } { this.setState({ file: undefined, analyzeResult: {}, @@ -392,18 +412,18 @@ export class PrebuiltPredictPage extends React.Component { this.setState({ ...err, shouldShowAlert: true, isPredicting: false, }); } - onFileChange(data: { + onFileChange = (data: { file: File, fileLabel: string, fetchedFileURL: string - }): void { + }): void => { this.setState({ currentPage: 1, analyzeResult: null, @@ -418,6 +438,57 @@ export class PrebuiltPredictPage extends React.Component { + this.setState({ + analyzeResult: {}, + predictionLoaded: false, + }); + if (this.imageMap) { + this.imageMap.removeAllFeatures(); + } + } + + onPredictionFileChange = (data: { + file: File, + fileLabel: string, + fetchedFileURL: string + }): void => { + if (this.imageMap) { + this.imageMap.removeAllFeatures(); + } + if (data.file) { + const makeHandleFile = () => { + const handlePredictionResult = this.handlePredictionResult.bind(this); + const setState = this.setState.bind(this); + return () => { + let { result } = reader; + if (result instanceof ArrayBuffer) { + const dataView = new DataView(result); + const decoder = new TextDecoder(); + result = decoder.decode(dataView) + } + result = JSON.parse(result) + setState({ + currentPage: 1, + analyzeResult: null, + predictionLoaded: false, + fileLoaded: false, + }, () => { + handlePredictionResult(result); + }) + } + } + + const reader = new FileReader(); + reader.onload = makeHandleFile(); + reader.readAsText(data.file); + } + } + + handleLiveModeToggleChange = (event, checked: boolean) => { + this.setState({ liveMode: checked }); + } + private onPrebuiltTypeChange = (e, option: IDropdownOption) => { const currentPrebuiltType = this.prebuiltTypes.find(type => type.name === option.key); if (currentPrebuiltType && this.state.currentPrebuiltType.name !== currentPrebuiltType.name) { @@ -612,25 +683,27 @@ export class PrebuiltPredictPage extends React.Component { + this.analyzeResults = _.cloneDeep(result); + this.tableHelper.setAnalyzeResult(result?.analyzeResult); + const tags = this.getTagsForPredictResults(this.getPredictionsFromAnalyzeResult(result?.analyzeResult)); + this.setState({ + tags, + analyzeResult: result.analyzeResult, + predictionLoaded: true, + isPredicting: false, + }, () => { + this.layoutHelper.setLayoutData(result); + this.layoutHelper.drawLayout(this.state.currentPage); + this.drawPredictionResult(); + }); + } private handleClick = () => { this.setState({ predictionLoaded: false, isPredicting: true }); this.getPrediction() - .then((result) => { - this.analyzeResults = _.cloneDeep(result); - this.tableHelper.setAnalyzeResult(result?.analyzeResult); - const tags = this.getTagsForPredictResults(this.getPredictionsFromAnalyzeResult(result?.analyzeResult)); - this.setState({ - tags, - analyzeResult: result.analyzeResult, - predictionLoaded: true, - isPredicting: false, - }, () => { - this.layoutHelper.setLayoutData(result); - this.layoutHelper.drawLayout(this.state.currentPage); - this.drawPredictionResult(); - }); - }).catch((error) => { + .then(this.handlePredictionResult) + .catch((error) => { let alertMessage = ""; if (error.response) { alertMessage = error.response.data;