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 <shihw@microsoft.com>
This commit is contained in:
SimoTw 2021-02-02 18:36:48 +08:00 коммит произвёл GitHub
Родитель b65cea1334
Коммит 0f6c260263
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 450 добавлений и 112 удалений

Просмотреть файл

@ -2,7 +2,7 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-bottom: 10px; margin-bottom: 5px;
.title { .title {
height: 32px; height: 32px;
line-height: 32px; line-height: 32px;
@ -13,7 +13,6 @@
} }
.sourceDropdown { .sourceDropdown {
width: 95px; width: 95px;
margin-bottom: 10px;
} }
.local-file { .local-file {
float: right; float: right;

Просмотреть файл

@ -9,9 +9,10 @@
flex: 1; flex: 1;
input[name="pagerange"] { input[name="pagerange"] {
width: 100%; width: 100%;
height: 32px;
} }
.page-range-alert { .page-range-alert {
border: 1px solid red; border: 1px solid #ffffff;
} }
} }
.ms-Checkbox { .ms-Checkbox {

Просмотреть файл

@ -1,7 +1,6 @@
.prebuilt-setting { .prebuilt-setting {
.apikeyContainer { .apikeyContainer {
display: flex; display: flex;
margin-bottom: 30px;
.ms-TooltipHost, .ms-TooltipHost,
.apikey { .apikey {
width: 100%; width: 100%;

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -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<IPredictionFilePickerProps, IPredictionFilePickerState>{
state = {
sourceOption: "localFile",
inputedLocalFileName: strings.predict.defaultLocalFileInput,
inputedFileURL: "",
isFetching: false,
};
private filePicker: React.RefObject<HTMLInputElement> = 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 <>
<div className="prediction-file-picker">
<div className="title mr-2">Prediction:</div>
<div className="container-space-between">
<Dropdown
className="source-dropdown"
selectedKey={this.state.sourceOption}
options={sourceOptions}
disabled={disabled}
onChange={this.onSelectSourceChange}
/>
{this.state.sourceOption === "localFile" &&
<>
<input ref={this.filePicker}
aria-hidden="true"
type="file"
accept="application/json"
disabled={disabled}
onChange={this.handleInputFileChange}
/>
<TextField
className="ml-2 local-file"
theme={getGreenWithWhiteBackgroundTheme()}
style={{ cursor: (disabled ? "default" : "pointer") }}
onClick={this.handleInputFileClick}
readOnly={true}
aria-label={strings.prebuiltPredict.defaultLocalFileInput}
value={this.state.inputedLocalFileName}
placeholder={strings.predict.defaultLocalFileInput}
disabled={disabled}
/>
</>
}
{this.state.sourceOption === "url" &&
<>
<TextField
className="mr-2 ml-2"
theme={getGreenWithWhiteBackgroundTheme()}
onFocus={this.removeDefaultInputedFileURL}
onChange={this.setInputedFileURL}
aria-label={strings.prebuiltPredict.defaultLocalFileInput}
value={this.state.inputedFileURL}
disabled={urlInputDisabled}
placeholder={strings.predict.defaultURLInput}
/>
<PrimaryButton
theme={getPrimaryGreenTheme()}
className="keep-button-80px"
text="Fetch"
allowDisabledFocus
disabled={fetchDisabled}
autoFocus={true}
onClick={this.getFileFromURL}
/>
</>
}
</div>
</div>
</>
}
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;
}
}

Просмотреть файл

@ -19,4 +19,10 @@
textarea { textarea {
word-break: break-all; word-break: break-all;
} }
}
.predict-mode-toggle {
label {
color: rgb(250, 251, 251);
}
} }

Просмотреть файл

@ -30,6 +30,7 @@ import ServiceHelper from "../../../../services/serviceHelper";
import { getAppInsights } from "../../../../services/telemetryService"; import { getAppInsights } from "../../../../services/telemetryService";
import Alert from "../../common/alert/alert"; import Alert from "../../common/alert/alert";
import { DocumentFilePicker } from "../../common/documentFilePicker/documentFilePicker"; import { DocumentFilePicker } from "../../common/documentFilePicker/documentFilePicker";
import { PredictionFilePicker } from "../../common/predictionFilePicker/predictionFilePicker";
import { ImageMap } from "../../common/imageMap/imageMap"; import { ImageMap } from "../../common/imageMap/imageMap";
import { PageRange } from "../../common/pageRange/pageRange"; import { PageRange } from "../../common/pageRange/pageRange";
import { PrebuiltSetting } from "../../common/prebuiltSetting/prebuiltSetting"; import { PrebuiltSetting } from "../../common/prebuiltSetting/prebuiltSetting";
@ -41,6 +42,7 @@ import PredictResult from "../predict/predictResult";
import { ILoadFileHelper, ILoadFileResult, LoadFileHelper } from "./LoadFileHelper"; import { ILoadFileHelper, ILoadFileResult, LoadFileHelper } from "./LoadFileHelper";
import "./prebuiltPredictPage.scss"; import "./prebuiltPredictPage.scss";
import { ITableHelper, ITableState, TableHelper } from "./tableHelper"; import { ITableHelper, ITableState, TableHelper } from "./tableHelper";
import { Toggle } from "office-ui-fabric-react/lib/Toggle";
import { ILayoutHelper, LayoutHelper } from "./layoutHelper"; import { ILayoutHelper, LayoutHelper } from "./layoutHelper";
interface IPrebuiltTypes { interface IPrebuiltTypes {
@ -77,6 +79,8 @@ export interface IPrebuiltPredictPageState extends ILoadFileResult, ITableState
pageRange: string; pageRange: string;
pageRangeIsValid?: boolean; pageRangeIsValid?: boolean;
predictionEndpointUrl: string; predictionEndpointUrl: string;
liveMode: boolean;
} }
function mapStateToProps(state: IApplicationState) { function mapStateToProps(state: IApplicationState) {
@ -150,6 +154,8 @@ export class PrebuiltPredictPage extends React.Component<IPrebuiltPredictPagePro
withPageRange: false, withPageRange: false,
pageRange: "", pageRange: "",
predictionEndpointUrl: "", predictionEndpointUrl: "",
liveMode: true,
}; };
private analyzeResults: any; private analyzeResults: any;
@ -248,35 +254,9 @@ export class PrebuiltPredictPage extends React.Component<IPrebuiltPredictPagePro
<FontIcon className="mr-1" iconName="ContactCard" /> <FontIcon className="mr-1" iconName="ContactCard" />
<span>{interpolate(strings.prebuiltPredict.anlayWithPrebuiltModels, this.state.currentPrebuiltType)}</span> <span>{interpolate(strings.prebuiltPredict.anlayWithPrebuiltModels, this.state.currentPrebuiltType)}</span>
</h6> </h6>
<div className="p-3 prebuilt-setting" style={{ marginTop: "8px" }}> <Separator className="separator-right-pane-main">1. Choose file for analysis.</Separator>
<h5>{strings.prebuiltSetting.serviceConfigurationTitle}</h5> <div className="p-3">
</div> {/* <h5>{strings.prebuiltPredict.selectFileAndRunAnalysis}</h5> */}
<PrebuiltSetting prebuiltSettings={this.props.prebuiltSettings}
disabled={this.state.isPredicting}
actions={this.props.actions}
/>
<div className="p-3" style={{ marginTop: "-3rem" }}>
<div className="formtype-section">
<div style={{ marginBottom: "3px" }}>{strings.prebuiltPredict.formTypeTitle}</div>
<Dropdown
disabled={this.state.isPredicting}
className="prebuilt-type-dropdown"
options={this.prebuiltTypes.map(type => ({ key: type.name, text: type.name }))}
defaultSelectedKey={this.state.currentPrebuiltType.name}
onChange={this.onPrebuiltTypeChange}></Dropdown>
</div>
<div className="locales-section" style={{ display: this.state.currentPrebuiltType.useLocale ? "block" : "none" }}>
<div style={{ marginBottom: "3px" }}>{strings.prebuiltPredict.locale}</div>
<Dropdown
disabled={this.state.isPredicting}
className="prebuilt-type-dropdown"
options={this.locales.map(type => ({ key: type, text: type }))}
defaultSelectedKey={this.state.currentLocale}
onChange={this.onLocaleChange}></Dropdown>
</div>
</div>
<div className="p-3" style={{ marginTop: "8px" }}>
<h5>{strings.prebuiltPredict.selectFileAndRunAnalysis}</h5>
<DocumentFilePicker <DocumentFilePicker
disabled={this.state.isPredicting || this.state.isFetching} disabled={this.state.isPredicting || this.state.isFetching}
onFileChange={(data) => this.onFileChange(data)} onFileChange={(data) => this.onFileChange(data)}
@ -290,70 +270,110 @@ export class PrebuiltPredictPage extends React.Component<IPrebuiltPredictPagePro
onPageRangeChange={this.onPageRangeChange} /> onPageRangeChange={this.onPageRangeChange} />
</div> </div>
</div> </div>
<Separator className="separator-right-pane-main">{strings.prebuiltPredict.analysis}</Separator> <Separator className="separator-right-pane-main">2. Get prediction.</Separator>
<div className="p-3" style={{ marginTop: "8px" }}> <div className="p-3" style={{ marginTop: "8px" }}>
<div style={{ marginBottom: "3px" }}>{"The composed API request is"}</div> <Toggle theme={getLightGreyTheme()}
<TextField className="predict-mode-toggle"
className="mb-1 request-uri-textfield" defaultChecked
name="endpointUrl" onText="Call live service"
theme={getLightGreyTheme()} offText="Use predicted file"
value={this.state.predictionEndpointUrl} onChange={this.handleLiveModeToggleChange} />
onChange={this.setRequestURI} </div>
disabled={this.state.isPredicting} {!this.state.liveMode &&
multiline={true} <div className="p-3" style={{ marginTop: "-2rem" }}>
autoAdjustHeight={true} <PredictionFilePicker
/> disabled={this.state.isPredicting || this.state.isFetching || !this.state.file}
<div className="container-items-end predict-button"> onFileChange={this.onPredictionFileChange}
<PrimaryButton onSelectSourceChange={this.onPredictionSelectSourceChange}
theme={getPrimaryWhiteTheme()} onError={this.onFileLoadError} />
iconProps={{ iconName: "ContactCard" }} </div>
text={strings.prebuiltPredict.runAnalysis} }
aria-label={!this.state.isPredicting ? strings.prebuiltPredict.inProgress : ""} {this.state.liveMode &&
allowDisabledFocus <>
disabled={predictDisabled} <PrebuiltSetting prebuiltSettings={this.props.prebuiltSettings}
onClick={this.handleClick} disabled={this.state.isPredicting}
actions={this.props.actions}
/>
<div className="p-3" style={{ marginTop: "-28px" }}>
<div className="formtype-section">
<div style={{ marginBottom: "3px" }}>{strings.prebuiltPredict.formTypeTitle}</div>
<Dropdown
disabled={this.state.isPredicting}
className="prebuilt-type-dropdown"
options={this.prebuiltTypes.map(type => ({ key: type.name, text: type.name }))}
defaultSelectedKey={this.state.currentPrebuiltType.name}
onChange={this.onPrebuiltTypeChange}></Dropdown>
</div>
<div className="locales-section" style={{ visibility: this.state.currentPrebuiltType.useLocale ? "visible" : "hidden" }}>
<div style={{ marginBottom: "3px" }}>{strings.prebuiltPredict.locale}</div>
<Dropdown
disabled={this.state.isPredicting}
className="prebuilt-type-dropdown"
options={this.locales.map(type => ({ key: type, text: type }))}
defaultSelectedKey={this.state.currentLocale}
onChange={this.onLocaleChange}></Dropdown>
</div>
<div style={{ marginBottom: "3px" }}>{"The composed API request is"}</div>
<TextField
className="mb-1 request-uri-textfield"
name="endpointUrl"
theme={getLightGreyTheme()}
value={this.state.predictionEndpointUrl}
onChange={this.setRequestURI}
disabled={this.state.isPredicting}
multiline={true}
autoAdjustHeight={true}
/>
<div className="container-items-end predict-button">
<PrimaryButton
theme={getPrimaryWhiteTheme()}
iconProps={{ iconName: "ContactCard" }}
text={strings.prebuiltPredict.runAnalysis}
aria-label={!this.state.isPredicting ? strings.prebuiltPredict.inProgress : ""}
allowDisabledFocus
disabled={predictDisabled}
onClick={this.handleClick}
/>
</div>
</div>
</> }
{this.state.isFetching &&
<div className="loading-container">
<Spinner
label="Fetching..."
ariaLive="assertive"
labelPosition="right"
size={SpinnerSize.large}
/> />
</div> </div>
{this.state.isFetching && }
<div className="loading-container"> {this.state.isPredicting &&
<Spinner <div className="loading-container">
label="Fetching..." <Spinner
ariaLive="assertive" label={strings.prebuiltPredict.inProgress}
labelPosition="right" ariaLive="assertive"
size={SpinnerSize.large} labelPosition="right"
/> size={SpinnerSize.large}
</div>
}
{this.state.isPredicting &&
<div className="loading-container">
<Spinner
label={strings.prebuiltPredict.inProgress}
ariaLive="assertive"
labelPosition="right"
size={SpinnerSize.large}
/>
</div>
}
{Object.keys(predictions).length > 0 &&
<PredictResult
predictions={predictions}
analyzeResult={this.analyzeResults}
page={this.state.currentPage}
tags={this.state.tags}
downloadPrefix={this.state.currentPrebuiltType.name}
downloadResultLabel={this.state.fileLabel}
onPredictionClick={this.onPredictionClick}
onPredictionMouseEnter={this.onPredictionMouseEnter}
onPredictionMouseLeave={this.onPredictionMouseLeave}
/> />
} </div>
{ }
(Object.keys(predictions).length === 0 && this.state.predictionLoaded) && {Object.keys(predictions).length > 0 &&
<div>{strings.prebuiltPredict.noFieldCanBeExtracted}</div> <PredictResult
} predictions={predictions}
</div> analyzeResult={this.analyzeResults}
page={this.state.currentPage}
tags={this.state.tags}
downloadPrefix={this.state.currentPrebuiltType.name}
downloadResultLabel={this.state.fileLabel}
onPredictionClick={this.onPredictionClick}
onPredictionMouseEnter={this.onPredictionMouseEnter}
onPredictionMouseLeave={this.onPredictionMouseLeave}
/>
}
{
(Object.keys(predictions).length === 0 && this.state.predictionLoaded) &&
<div>{strings.prebuiltPredict.noFieldCanBeExtracted}</div>
}
</div> </div>
</div> </div>
<Alert <Alert
@ -381,7 +401,7 @@ export class PrebuiltPredictPage extends React.Component<IPrebuiltPredictPagePro
}); });
} }
onSelectSourceChange(): void { onSelectSourceChange = (): void => {
this.setState({ this.setState({
file: undefined, file: undefined,
analyzeResult: {}, analyzeResult: {},
@ -392,18 +412,18 @@ export class PrebuiltPredictPage extends React.Component<IPrebuiltPredictPagePro
} }
} }
onFileLoadError(err: { alertTitle: string; alertMessage: string; }): void { onFileLoadError = (err: { alertTitle: string; alertMessage: string; }): void => {
this.setState({ this.setState({
...err, ...err,
shouldShowAlert: true, shouldShowAlert: true,
isPredicting: false, isPredicting: false,
}); });
} }
onFileChange(data: { onFileChange = (data: {
file: File, file: File,
fileLabel: string, fileLabel: string,
fetchedFileURL: string fetchedFileURL: string
}): void { }): void => {
this.setState({ this.setState({
currentPage: 1, currentPage: 1,
analyzeResult: null, analyzeResult: null,
@ -418,6 +438,57 @@ export class PrebuiltPredictPage extends React.Component<IPrebuiltPredictPagePro
}); });
} }
onPredictionSelectSourceChange = (): void => {
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) => { private onPrebuiltTypeChange = (e, option: IDropdownOption) => {
const currentPrebuiltType = this.prebuiltTypes.find(type => type.name === option.key); const currentPrebuiltType = this.prebuiltTypes.find(type => type.name === option.key);
if (currentPrebuiltType && this.state.currentPrebuiltType.name !== currentPrebuiltType.name) { if (currentPrebuiltType && this.state.currentPrebuiltType.name !== currentPrebuiltType.name) {
@ -612,25 +683,27 @@ export class PrebuiltPredictPage extends React.Component<IPrebuiltPredictPagePro
this.setState({ imageAngle: this.state.imageAngle + degrees }); this.setState({ imageAngle: this.state.imageAngle + degrees });
} }
private handlePredictionResult = (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();
});
}
private handleClick = () => { private handleClick = () => {
this.setState({ predictionLoaded: false, isPredicting: true }); this.setState({ predictionLoaded: false, isPredicting: true });
this.getPrediction() this.getPrediction()
.then((result) => { .then(this.handlePredictionResult)
this.analyzeResults = _.cloneDeep(result); .catch((error) => {
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) => {
let alertMessage = ""; let alertMessage = "";
if (error.response) { if (error.response) {
alertMessage = error.response.data; alertMessage = error.response.data;