diff --git a/docs/manual_testing/manual-test-runbook.md b/docs/manual_testing/manual-test-runbook.md index bcb422308..6967d79c0 100644 --- a/docs/manual_testing/manual-test-runbook.md +++ b/docs/manual_testing/manual-test-runbook.md @@ -1,6 +1,68 @@ # Test Runbook -## **Feat: support project sharing via string** + ## Feat: support model selection for analyzing + +> ### Feature description ### +- track the five most recent trained or composed models +- display currently selected model in the analyze page right pane header +- display no models message when user has no recent models in the analyze page right pane header +- display button for selecting model in the analyze page right pane header +- display pop-up for selecting a model from a list of the five most recent models +- support analyzing with selected model + +> ### Use Case ### + +**As** a user +**I want** to be able to select a model to analyze with +**So** I can use that model to analyze with + +> ### Acceptance criteria ### + +#### Scenario One #### + +**Given** I've not trained or composed a model for my current project +**When** I go to the analyze page +**Then** I should see a message letting me know I don't have any recent models + +#### Scenario Two #### + +**Given** I've trained or composed a model for my current project +**When** I got to the analyze page +**Then** I should see my most recent model in the right pane header + +#### Scenario Three #### + +**Given** I've trained or composed a model before pulling this change +**When** I pull this change and go to the analyze page +**Then** I should see my most recent model in the right pane header + +#### Scenario Four #### + +**Given** I've selected a different model +**When** I go to another page for this project and then click on the analyze page +**Then** I should still see the same selected project + +#### Scenario Five #### + +**Given** I've opened the model selection pop-up +**When** I deselect all models from the list +**Then** I should not be able to click apply + +#### Scenario Six #### + +**Given** I've selected a different model +**When** I run an analysis on a document +**Then** I should see results for the selected model + +#### Scenario Seven #### + +**Given** I've trained or composed at least one model +**When** I train or compose another model, go to the analyze page, and click the choose model button +**Then** I should see the top five most recently change models (since pulling this change) + +___ + +## Feat: support project sharing via string > ### Feature description ### @@ -8,22 +70,23 @@ > ### Use Case ### -**`As`** a user -**`I want`** to be able to share to a project via shared string -**`So`** receiving user don't have to manually copy-paste project info into app settings +**As** a user +**I want** to be able to share to a project via shared string +**So** receiving user don't have to manually copy-paste project info into app settings > ### Acceptance criteria ### #### Scenario One #### -**`Given`** I've opened a project, clicked on "..." dropdow in Canvas Commandbar -**`When`** I click "Share Project" I should see tha message that shared string been saved to my clipboard -**`Then`** I can paste the string from clipboard +**Given** I've opened a project, clicked on "..." dropdow in Canvas Commandbar +**When** I click "Share Project" I should see tha message that shared string been saved to my clipboard +**Then** I can paste the string from clipboard + #### Scenario Two #### -**`Given`** I've received the string with a project -**`When`** I open the home page of the FOTT and click on "Open Cloud Project" icon, I can paste the string to the input field and click "OK" -**`Then`** FOTT should open the shared project as expected. +**Given** I've received the string with a project +**When** I open the home page of the FOTT and click on "Open Cloud Project" icon, I can paste the string to the input field and click "OK" +**Then** FOTT should open the shared project as expected. ___ @@ -35,22 +98,22 @@ ___ > ### Use Case ### -**As** a user -**I want** a notification when I try to open or create a project with invalid provider options +**As** a user +**I want** a notification when I try to open or create a project with invalid provider options **So** I know how to fix invalid provider options issue > ### Acceptance criteria ### #### Scenario One #### -**Given** I've created a connection with invalid provider options (e.g. invalid SAS token for Azure provider). -**When** I try to create a new project with that connection. +**Given** I've created a connection with invalid provider options (e.g. invalid SAS token for Azure provider). +**When** I try to create a new project with that connection. **Then** a notification will be displayed telling me my connection is invalid. #### Scenario Two #### -**Given** I've created a connection with invalid provider options (e.g. invalid SAS token for Azure provider). -**When** I try to open a recent project that now has an invalid connection provider options (e.g. the Azure container was deleted) +**Given** I've created a connection with invalid provider options (e.g. invalid SAS token for Azure provider). +**When** I try to open a recent project that now has an invalid connection provider options (e.g. the Azure container was deleted) **Then** a notification will be displayed telling me my connection is invalid. ___ @@ -62,8 +125,8 @@ ___ > ### Use Case ### -**As** a user -**I want** to release my project as a distributable +**As** a user +**I want** to release my project as a distributable **So** I can easily set up FOTT > ### Acceptance criteria ### @@ -71,15 +134,15 @@ ___ #### Scenario One #### -**Given** I've updated dependencies. -**When** I run `yarn release`. +**Given** I've updated dependencies. +**When** I run `yarn release`. **Then** a distributable installer should be created in the releases folder. #### Scenario Two #### -**Given** I've created a distributable installer. -**When** I execute the installer. +**Given** I've created a distributable installer. +**When** I execute the installer. **Then** a the FOTT desktop application should install and run as expected. ___ @@ -91,14 +154,14 @@ ___ > ### Use Case ### -**As** a user -**I want** to delete a document and it's files through FOTT +**As** a user +**I want** to delete a document and it's files through FOTT **So** I don't have to delete the document through a storage provider #### Scenario One #### -**Given** I've selected a document in the editor page. -**When** I click the overflow menu item on the canvas command bar and then click "Delete document." +**Given** I've selected a document in the editor page. +**When** I click the overflow menu item on the canvas command bar and then click "Delete document." **Then** FoTT should delete the document in the storage provider, remove it from FOTT's current project, and select the project's first document. ___ @@ -106,43 +169,43 @@ ___ ## Feat: support Electron for on premise solution > ### Feature description ### -- Support FoTT's existing features in Electon +- Support FoTT's existing features in Electron - Support local file system provider in Electron > ### Use Case ### -**As** a user -**I want** to use FoTT's existing features through a desktop app +**As** a user +**I want** to use FoTT's existing features through a desktop app **So** I don't have to use a browser to use FoTT -**As** a user -**I want** to use files in my local file system +**As** a user +**I want** to use files in my local file system **So** I can keep all files on premise > ### Acceptance criteria ### #### Scenario One #### -**Given** I've installed new dependencies and started FoTT in Electron. -**When** I click a command item in the title bar. +**Given** I've installed new dependencies and started FoTT in Electron. +**When** I click a command item in the title bar. **Then** FoTT should perform the command as expected. #### Scenario Two #### -**Given** I've installed new dependencies and started FoTT in Electron. -**When** I perform an action for any existing feature. +**Given** I've installed new dependencies and started FoTT in Electron. +**When** I perform an action for any existing feature. **Then** FoTT should perform as expected (the same as through a browser). #### Scenario Three #### -**Given** I've installed new dependencies and started FoTT in Electron. -**When** I create a new connection with local file system as the provider. +**Given** I've installed new dependencies and started FoTT in Electron. +**When** I create a new connection with local file system as the provider. **Then** I should be able to create a project with the created connection. #### Scenario Four #### -**Given** I've installed new dependencies and started FoTT in Electron. And, I have an existing project in my local file system. -**When** I click "Open local project" on the home page and select the existing project. +**Given** I've installed new dependencies and started FoTT in Electron. And, I have an existing project in my local file system. +**When** I click "Open local project" on the home page and select the existing project. **Then** FoTT should load the project as expected. ___ @@ -155,16 +218,16 @@ Enable reordering tags quickly > ### Use Case ### -**As** a user -**I want** to be able to move though tags list quickly +**As** a user +**I want** to be able to move though tags list quickly **So** I can reorder long list of tags faster > ### Acceptance criteria ### #### Scenario One #### -**Given** I've opened a project containing documents with long tags list. -**When** I clicking fast on tags buttons 'Move tag up' or 'Move tag down' +**Given** I've opened a project containing documents with long tags list. +**When** I clicking fast on tags buttons 'Move tag up' or 'Move tag down' **Then** it moves without visible jittering. ___ @@ -179,22 +242,22 @@ Adding the following buttons to the canvas command bar: > ### Use Case ### -**As** a user -**I want** to rerun OCR on documents +**As** a user +**I want** to rerun OCR on documents **So** I can update OCR results > ### Acceptance criteria ### #### Scenario One #### -**Given** I've opened a project containing documents and I'm on the Tag Editor page. -**When** I click "Run OCR on current document" in the canvas command bar -**Then** I should see "Running OCR..." for the current docucment. When running OCR finishes, I should be able to view the document's updated OCR JSON file. +**Given** I've opened a project containing documents and I'm on the Tag Editor page. +**When** I click "Run OCR on current document" in the canvas command bar +**Then** I should see "Running OCR..." for the current document. When running OCR finishes, I should be able to view the document's updated OCR JSON file. #### Scenario Two #### -**Given** I've opened a project containing documents and I'm on the Tag Editor page. -**When** I click "Run OCR on all documents" in the canvas command bar +**Given** I've opened a project containing documents and I'm on the Tag Editor page. +**When** I click "Run OCR on all documents" in the canvas command bar **Then** I should see "Running OCR..." for all documents. When running OCR finishes for each document, I should be ale to view each document's updated OCR JSON file. ___ @@ -202,39 +265,39 @@ ___ ## Feat: enable compose model and add model name when training a new model > ### Feature description ### -- Add model name imput field on train page to add model name when training a new model +- Add model name input field on train page to add model name when training a new model - Add model compose page in order to compose a new model with existing models > ### Use Case ### -**As** a user -**I want** to give the new train model a customerized name -**So** I can type the name in input field in train page before click train button. +**As** a user +**I want** to give the new train model a customized name +**So** I can type the name in input field in train page before click train button. -**As** a user -**I want** to generate a new mode through existing model -**So** I can use model compose +**As** a user +**I want** to generate a new mode through existing model +**So** I can use model compose > ### Acceptance criteria ### #### Scenario One #### -**Given** I've opened a project containing documents and I'm on the Train page. -**When** I type customerized name in input field and click train button +**Given** I've opened a project containing documents and I'm on the Train page. +**When** I type customized name in input field and click train button **Then** I should see typed name shows in Train Record after record shows up. #### Scenario Two #### -**Given** I've opened a project containing documents and I'm on the Model Compose page. There are enough existing models in modelList. -**When** I select more than one models then click compose button -**Then** I should see a pop up modal with a list contains selected models and a input field. -**When** I type customerized model name in input field and click compose button on modal -**Then** I should see "Model is composing, please wait...". After that the list shows up again, new composed model with given name will be on the top of the list. The new composed model also has a "combine" icon. +**Given** I've opened a project containing documents and I'm on the Model Compose page. There are enough existing models in modelList. +**When** I select more than one models then click compose button +**Then** I should see a pop up modal with a list contains selected models and a input field. +**When** I type customized model name in input field and click compose button on modal +**Then** I should see "Model is composing, please wait...". After that the list shows up again, new composed model with given name will be on the top of the list. The new composed model also has a "combine" icon. #### Scenario Three #### -**Given** I've opened a project containing documents and I'm on the Model Compose page. -**When** I click the header of a column -**Then** I should see the column becomes sorted in either ascending or discending order. -**When** I type some text inside the fliter field on top right +**Given** I've opened a project containing documents and I'm on the Model Compose page. +**When** I click the header of a column +**Then** I should see the column becomes sorted in either ascending or descending order. +**When** I type some text inside the filter field on top right **Then** I should see items whose id or name contains the text be filtered out. diff --git a/public/analyze.py b/public/analyze.py index 1214505cc..290a75475 100644 --- a/public/analyze.py +++ b/public/analyze.py @@ -17,7 +17,10 @@ def runAnalysis(input_file, output_file, file_type): apim_key = "" # Model ID model_id = "" - post_url = endpoint + "/formrecognizer/v2.0-preview/custom/models/%s/analyze" % model_id + # API version + API_version = "" + + post_url = endpoint + "/formrecognizer/%s/custom/models/%s/analyze" % (API_version, model_id) params = { "includeTextDetails": True } diff --git a/src/common/localization/en-us.ts b/src/common/localization/en-us.ts index d95fa5612..c33eecd02 100644 --- a/src/common/localization/en-us.ts +++ b/src/common/localization/en-us.ts @@ -184,12 +184,20 @@ export const english: IAppStrings = { }, predict: { title: "Analyze", - uploadFile: "Upload image and run analysis", + uploadFile: "Choose an image to analyze with", inProgress: "Analysis in progress...", - downloadScript: "Use script", + noRecentModels: "This project doesn't have any recent models. Please train or compose a new model to analyze with.", + selectModelHeader: "Model to analyze with", + modelIDPrefix: "Model ID: ", + modelNamePrefix: "Model name: ", + downloadScript: "Analyze with python script", defaultLocalFileInput: "Browse for a file...", defaultURLInput: "Paste or type URL...", }, + recentModelsView:{ + header: "Select model to analyze with", + checkboxAriaLabel: "Select model checkbox" + }, projectMetrics: { title: "Project Metrics", assetsSectionTitle: "Assets", diff --git a/src/common/localization/es-cl.ts b/src/common/localization/es-cl.ts index 76eea45cf..04334fdbb 100644 --- a/src/common/localization/es-cl.ts +++ b/src/common/localization/es-cl.ts @@ -185,12 +185,20 @@ export const spanish: IAppStrings = { }, predict: { title: "Analizar", - uploadFile: "Cargar archivo y ejecutar análisis", + uploadFile: "Elija una imagen para analizar con", inProgress: "Análisis en curso...", - downloadScript: "Usar script", + noRecentModels: "Este proyecto no tiene modelos recientes. Entrenar o componer un nuevo modelo para analizar.", + selectModelHeader: "Modelo para analizar con", + modelIDPrefix: "ID del modelo: ", + modelNamePrefix: "Nombre del modelo: ", + downloadScript: "Analizar con script python", defaultLocalFileInput: "Busca un archivo...", defaultURLInput: "Pegar o escribir URL...", }, + recentModelsView: { + header: "Seleccionar modelo para analizar con", + checkboxAriaLabel: "Seleccione la casilla de verificación del modelo", + }, projectMetrics: { title: "Métricas del proyecto", assetsSectionTitle: "Activos", diff --git a/src/common/strings.ts b/src/common/strings.ts index ceb8d8c30..9d146a4ed 100644 --- a/src/common/strings.ts +++ b/src/common/strings.ts @@ -185,10 +185,18 @@ export interface IAppStrings { title: string; uploadFile: string; inProgress: string; + noRecentModels: string; + selectModelHeader: string; + modelIDPrefix: string; + modelNamePrefix: string; downloadScript: string; defaultLocalFileInput: string; defaultURLInput: string; }; + recentModelsView: { + header: string; + checkboxAriaLabel: string; + } projectMetrics: { title: string; assetsSectionTitle: string diff --git a/src/common/themes.ts b/src/common/themes.ts index 585907c58..a4bc61b93 100644 --- a/src/common/themes.ts +++ b/src/common/themes.ts @@ -1,5 +1,30 @@ import {createTheme, IPalette} from "@fluentui/react"; +const rightPaneDefaultButtonPalette = { + themePrimary: "#E9ECEF", + themeLighterAlt: "#ebeef1", + themeLighter: "#edf0f3", + themeLight: "#f0f2f5", + themeTertiary: "#f2f4f6", + themeSecondary: "#f5f6f8", + themeDarkAlt: "#f7f9fa", + themeDark: "#fafbfb", + themeDarker: "#fcfdfd", + neutralLighterAlt: "#35393e", + neutralLighter: "#3c4046", + neutralLight: "#484c53", + neutralQuaternaryAlt: "#50545b", + neutralQuaternary: "#565a61", + neutralTertiaryAlt: "#70747c", + neutralTertiary: "#f0f2f5", + neutralSecondary: "#f2f4f6", + neutralPrimaryAlt: "#f5f6f8", + neutralPrimary: "#E9ECEF", + neutralDark: "#fafbfb", + black: "#fcfdfd", + white: "#2D3035" + } + const greenButtonPalette = { themePrimary: "#78ad0e", themeLighterAlt: "#050701", @@ -279,6 +304,7 @@ const subMenuPalette = { } +const rightPaneDefaultButtonTheme = createTheme({palette: rightPaneDefaultButtonPalette}); const defaultDarkTheme = createTheme({palette: DarkDefaultPalette}); const whiteTheme = createTheme({palette: whiteButtonPalette}); const redTheme = createTheme({palette: redButtonPalette}); @@ -291,6 +317,10 @@ const greenWithWhiteBackgroundTheme = createTheme({palette: greenWithWhiteBackgr const lightGreyTheme = createTheme({palette: lightGreyPalette}); const subMenuTheme = createTheme({palette: subMenuPalette}) +export function getRightPaneDefaultButtonTheme() { + return rightPaneDefaultButtonTheme; +} + export function getPrimaryWhiteTheme() { return whiteTheme; } diff --git a/src/react/components/common/common.scss b/src/react/components/common/common.scss index f1a716279..e066267bf 100644 --- a/src/react/components/common/common.scss +++ b/src/react/components/common/common.scss @@ -182,3 +182,17 @@ cursor: pointer; width: 100%; } + +.separator-right-pane-main { + margin: 0 1rem; + div { + background-color: #32363B; + color: #E9ECEF; + font-weight: 600; + } +} + +.model-confirm { + width: 110px; + margin-right: 20px; +} diff --git a/src/react/components/pages/modelCompose/composeModelView.tsx b/src/react/components/pages/modelCompose/composeModelView.tsx index a029a06d6..5956f8b8b 100644 --- a/src/react/components/pages/modelCompose/composeModelView.tsx +++ b/src/react/components/pages/modelCompose/composeModelView.tsx @@ -3,7 +3,7 @@ import React from "react"; import { Customizer, IColumn, ICustomizations, Modal, DetailsList, SelectionMode, DetailsListLayoutMode, PrimaryButton, TextField } from "@fluentui/react"; -import { getDarkGreyTheme, getPrimaryGreenTheme, getPrimaryRedTheme } from "../../../../common/themes"; +import { getDarkGreyTheme, getPrimaryGreenTheme, getPrimaryGreyTheme } from "../../../../common/themes"; import { strings } from "../../../../common/strings"; import { IModel } from "./modelCompose"; @@ -84,7 +84,7 @@ export default class ComposeModelView extends React.Component + /> } { this.state.cannotBeIncludeModels.length > 0 && @@ -115,7 +115,7 @@ export default class ComposeModelView extends React.Component Close diff --git a/src/react/components/pages/modelCompose/modelCompose.scss b/src/react/components/pages/modelCompose/modelCompose.scss index dedeeb2eb..35f66aa57 100644 --- a/src/react/components/pages/modelCompose/modelCompose.scss +++ b/src/react/components/pages/modelCompose/modelCompose.scss @@ -138,6 +138,7 @@ h4 { } .modal-alert { + text-align: center; color: #ff4040; font-size: small; margin-bottom: 1rem; @@ -165,7 +166,8 @@ h4 { } .modal-buttons-container { - justify-content: space-between; + justify-content: flex-end; + display: flex; margin-top: 1.5rem; } diff --git a/src/react/components/pages/predict/predictPage.scss b/src/react/components/pages/predict/predictPage.scss index a1f2b7755..1ac0772b3 100644 --- a/src/react/components/pages/predict/predictPage.scss +++ b/src/react/components/pages/predict/predictPage.scss @@ -72,3 +72,35 @@ .predict-button { margin-bottom: 20px; } + +.no-models-warning { + margin-top: 8px; +} + +.model-selection-container { + padding-top: 8px; + padding-bottom: 16px; + display: flex; + justify-content: space-between; +} + +.model-selection-header { + margin-bottom: 4px; +} + +.model-selection-info-header { + margin-bottom: 0px; +} + +.model-selection-info-key { + font-size: 12px; + color: #d1d1d1; + min-width: 80px; + float: left; +} + +.model-selection-info-value { + font-size: 12px; + color: #d1d1d1; + float: left; +} diff --git a/src/react/components/pages/predict/predictPage.tsx b/src/react/components/pages/predict/predictPage.tsx index b05adff47..9e693ef2c 100644 --- a/src/react/components/pages/predict/predictPage.tsx +++ b/src/react/components/pages/predict/predictPage.tsx @@ -5,13 +5,16 @@ import React from "react"; import { connect } from "react-redux"; import { RouteComponentProps } from "react-router-dom"; import { bindActionCreators } from "redux"; -import { FontIcon, PrimaryButton, Spinner, SpinnerSize, IconButton, TextField, IDropdownOption, Dropdown} from "@fluentui/react"; +import { + FontIcon, Selection, PrimaryButton, Spinner, SpinnerSize, IconButton, TextField, IDropdownOption, + Dropdown, DefaultButton, Separator, ISelection, SelectionMode +} from "@fluentui/react"; import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions"; import IApplicationActions, * as applicationActions from "../../../../redux/actions/applicationActions"; import IAppTitleActions, * as appTitleActions from "../../../../redux/actions/appTitleActions"; import "./predictPage.scss"; import { - IApplicationState, IConnection, IProject, IAppSettings, AppError, ErrorCode, + IApplicationState, IConnection, IProject, IAppSettings, AppError, ErrorCode, IRecentModel, } from "../../../../models/applicationState"; import { ImageMap } from "../../common/imageMap/imageMap"; import Style from "ol/style/Style"; @@ -31,9 +34,11 @@ import ServiceHelper from "../../../../services/serviceHelper"; import { parseTiffData, renderTiffToCanvas, loadImageToCanvas } from "../../../../common/utils"; import { constants } from "../../../../common/constants"; import { getPrimaryGreenTheme, getPrimaryWhiteTheme, - getGreenWithWhiteBackgroundTheme } from "../../../../common/themes"; + getGreenWithWhiteBackgroundTheme, + getRightPaneDefaultButtonTheme} from "../../../../common/themes"; import axios from "axios"; import { IAnalyzeModelInfo } from './predictResult'; +import RecentModelsView from "./recentModelsView"; pdfjsLib.GlobalWorkerOptions.workerSrc = constants.pdfjsWorkerSrc(pdfjsLib.version); const cMapUrl = constants.pdfjsCMapUrl(pdfjsLib.version); @@ -49,6 +54,11 @@ export interface IPredictPageProps extends RouteComponentProps, React.Props { public state: IPredictPageState = { + couldNotGetRecentModel: false, + selectionIndexTracker: -1, + selectedRecentModelIndex: -1, + loadingRecentModel: true, + showRecentModelsView: false, sourceOption: "localFile", isFetching: false, fetchedFileURL: "", @@ -123,6 +138,7 @@ export default class PredictPage extends React.Component = React.createRef(); private currPdf: any; private tiffImages: any[]; @@ -131,16 +147,35 @@ export default class PredictPage extends React.Component project.id === projectId); - if (project) { - await this.props.actions.loadProject(project); - this.props.appTitleActions.setTitle(project.name); - } + await this.loadProject(projectId); } document.title = strings.predict.title + " - " + strings.appName; } - public componentDidUpdate(prevProps, prevState) { + public async componentDidUpdate(prevProps, prevState) { + const onPredictPage = (new RegExp("predict$")).test(this.props.match.url) + if (!onPredictPage) { + return; // don't update if not on the predict page + } + + if (!this.props.project) { + const projectId = this.props.match.params["projectId"]; + if (projectId) { + await this.loadProject(projectId); + } + } + + if (this.props.project?.predictModelId && !this.props.project?.recentModelRecords && + !this.state.couldNotGetRecentModel) { + await this.updateRecentModels(this.props.project) + } + if (this.props.project?.recentModelRecords && this.props.project?.predictModelId && + this.state.selectedRecentModelIndex === -1) { + this.updateRecentModelsViewer(this.props.project); + } else if (this.state.loadingRecentModel) { + this.setState({loadingRecentModel: false}); + } + if (this.state.file) { if (this.state.fileChanged) { this.currPdf = null; @@ -166,6 +201,7 @@ export default class PredictPage extends React.Component Analyze -
-
- {strings.predict.downloadScript} -
- -
-
- or -
-
-
- {strings.predict.uploadFile} -
-
Image source
-
- - { this.state.sourceOption === "localFile" && - - } - { this.state.sourceOption === "localFile" && - - } - { this.state.sourceOption === "localFile" && - - } - { this.state.sourceOption === "url" && - - } - { this.state.sourceOption === "url" && - + {!this.state.loadingRecentModel ? + <> + {!mostRecentModel ? +
+
+ {strings.predict.noRecentModels} +
+
: + <> +
+
+
+ {strings.predict.selectModelHeader} +
+
+ + {strings.predict.modelIDPrefix} + + + {mostRecentModel.modelInfo.modelId.substring(0,8) + "..."} + +
+ {mostRecentModel.modelInfo.modelName && +
+ + {strings.predict.modelNamePrefix} + + + {mostRecentModel.modelInfo.modelName} + +
+ } +
+ {this.setState({showRecentModelsView: true})}} + disabled={!mostRecentModel || browseFileDisabled} + /> +
+
+
+
+ {strings.predict.downloadScript} +
+ +
+ or +
+ {strings.predict.uploadFile} +
+
Image source
+
+ + { this.state.sourceOption === "localFile" && + + } + { this.state.sourceOption === "localFile" && + + } + { this.state.sourceOption === "localFile" && + + } + { this.state.sourceOption === "url" && + + } + { this.state.sourceOption === "url" && + + } +
+
+ +
+ {this.state.isFetching && +
+ +
+ } + {!this.state.predictionLoaded && +
+ +
+ } + {Object.keys(predictions).length > 0 && this.props.project && + + } + { + (Object.keys(predictions).length === 0 && this.state.predictRun) && +
+ No field can be extracted. +
+ } +
+ } -
-
- -
- {this.state.isFetching && -
- -
- } - - {!this.state.predictionLoaded && -
- -
- } - {Object.keys(predictions).length > 0 && this.props.project && - - } - { - (Object.keys(predictions).length === 0 && this.state.predictRun) && -
- No field can be extracted. -
- } -
+ : + }
+ {mostRecentModel && this.state.showRecentModelsView && + + } ); } @@ -600,7 +688,7 @@ export default class PredictPage extends React.Component { axios.get("/analyze.py").then((response) => { - const modelID = this.props.project.predictModelId as string; + const modelID = this.props.project?.recentModelRecords?.[this.state.selectedRecentModelIndex].modelInfo.modelId; if (!modelID) { throw new AppError( ErrorCode.PredictWithoutTrainForbidden, @@ -609,7 +697,7 @@ export default class PredictPage extends React.Component||/gi, + const analyzeScript = response.data.replace(/|||/gi, (matched: string) => { switch (matched) { case "": @@ -618,6 +706,8 @@ export default class PredictPage extends React.Component": return modelID; + case "": + return constants.apiVersion; } }); const fileURL = window.URL.createObjectURL( @@ -652,7 +742,7 @@ export default class PredictPage extends React.Component { - const modelID = this.props.project.predictModelId; + const modelID = this.props.project?.recentModelRecords?.[this.state.selectedRecentModelIndex].modelInfo.modelId; if (!modelID) { throw new AppError( ErrorCode.PredictWithoutTrainForbidden, @@ -948,4 +1038,118 @@ export default class PredictPage extends React.Component { + const selectedIndex = this.getSelectedIndex(); + if (selectedIndex !== this.state.selectionIndexTracker) { + this.setState({selectionIndexTracker: selectedIndex}) + } + } + + private handleRecentModelsViewClose = () => { + this.setState({showRecentModelsView: false}); + const selectedIndex = this.getSelectedIndex(); + if (selectedIndex !== this.state.selectedRecentModelIndex) { + this.selectionHandler.setIndexSelected(this.state.selectedRecentModelIndex, true, true); + } + } + + private handleRecentModelsApply = async () => { + const selectedIndex = this.selectionHandler.getSelectedIndices()[0]; + this.setState({ + selectedRecentModelIndex: selectedIndex, + showRecentModelsView: false, + }); + } + + private async getRecentModelFromPredictModelId(): Promise { + const modelID = this.props.project.predictModelId; + const endpointURL = url.resolve( + this.props.project.apiUriBase, + `${constants.apiModelsPath}/${modelID}`, + ); + let response; + try { + response = await axios.get(endpointURL, + {headers: { [constants.apiKeyHeader]: this.props.project.apiKey as string}}) + .catch((err) => { + const status = err.response.status; + if (status === 401) { + this.setState({ + couldNotGetRecentModel: true, + shouldShowAlert: true, + alertTitle: "Failed to get recent model", + alertMessage: "Permission denied. Check API key", + }); + } else { + this.setState({ + couldNotGetRecentModel: true, + }); + } + }) + } catch { + this.setState({ + couldNotGetRecentModel: true, + shouldShowAlert: true, + alertTitle: "Failed to get recent model", + alertMessage: "Check network and service URI in project settings", + }); + } + if (!response) { + return; + } + response = response["data"]; + return { + modelInfo: { + createdDateTime: response["modelInfo"]["createdDateTime"], + modelId: response["modelInfo"]["modelId"], + modelName: response["modelInfo"]["modelName"], + isComposed: response["modelInfo"]["attributes"]["isComposed"], + } + } as IRecentModel; + } + + private getSelectedIndex(): number { + const selectedIndexArray = this.selectionHandler.getSelectedIndices(); + return selectedIndexArray.length > 0 ? selectedIndexArray[0] : -1; + } + + private async updateRecentModelsViewer(project) { + this.selectionHandler = new Selection({ + selectionMode: SelectionMode.single, + onSelectionChanged: this.handleModelSelection, + }) + const recentModelRecordsWithKey = []; + let predictModelIndex; + project.recentModelRecords.forEach((model: IRecentModel, index) => { + if (model.modelInfo.modelId === project.predictModelId) { + predictModelIndex = index + } + recentModelRecordsWithKey[index] = Object.assign({key: index}, model); + }) + this.selectionHandler.setItems(recentModelRecordsWithKey, false); + this.selectionHandler.setIndexSelected(predictModelIndex, true, false); + this.setState({ + loadingRecentModel: false, + selectedRecentModelIndex: predictModelIndex, + selectionIndexTracker: predictModelIndex + }); + } + + private async updateRecentModels(project) { + const recentModel = await this.getRecentModelFromPredictModelId(); + if (!recentModel) { + return; + } + const recentModelRecords: IRecentModel[] = [recentModel] + project = await this.props.actions.saveProject({ + ...project, + recentModelRecords, + }, false, false); + } + + private async loadProject(projectId: string) { + const project = this.props.recentProjects.find((project) => project.id === projectId); + await this.props.actions.loadProject(project); + this.props.appTitleActions.setTitle(project.name); + } } diff --git a/src/react/components/pages/predict/recentModelsView.tsx b/src/react/components/pages/predict/recentModelsView.tsx new file mode 100644 index 000000000..c571a351f --- /dev/null +++ b/src/react/components/pages/predict/recentModelsView.tsx @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { Customizer, IColumn, ICustomizations, Modal, DetailsList, SelectionMode, DetailsListLayoutMode, PrimaryButton, ISelection } from "@fluentui/react"; +import { getDarkGreyTheme, getPrimaryGreenTheme, getPrimaryGreyTheme } from "../../../../common/themes"; +import { strings } from "../../../../common/strings"; +import { IRecentModel } from "../../../../models/applicationState"; + + +export interface IRecentModelsViewProps { + selectedIndex: number; + selectionHandler: ISelection; + recentModels: IRecentModel[]; + onModelSelect?: () => void; + onCancel?: () => void; + onApply: () => any; +} + +export default function RecentModelsView(props: IRecentModelsViewProps) { + const columns: IColumn[] = [ + { + key: "column1", + name: "Model ID", + minWidth: 100, + maxWidth: 250, + isResizable: true, + onRender: (model) => {model.modelInfo.modelId}, + }, + { + key: "column2", + name: "Model name", + minWidth: 150, + maxWidth: 250, + isResizable: true, + onRender: (model) => {model.modelInfo?.modelName}, + }, + { + key: "column3", + name: "Created date", + minWidth: 150, + isCollapsable: true, + isResizable: true, + onRender: (model) => {model.modelInfo.createdDateTime}, + } + ]; + + const dark: ICustomizations = { + settings: { + theme: getDarkGreyTheme(), + }, + scopedSettings: {}, + }; + + return ( + + +

{strings.recentModelsView.header}

+ +
+ + Apply + + + Cancel + +
+
+
+ ); +} diff --git a/src/react/components/shell/preditcPageRoute.tsx b/src/react/components/shell/preditcPageRoute.tsx index 73a3e3014..ec486464f 100644 --- a/src/react/components/shell/preditcPageRoute.tsx +++ b/src/react/components/shell/preditcPageRoute.tsx @@ -17,12 +17,8 @@ import { IApplicationState } from '../../../models/applicationState'; export function PredictPageRoute() { const projectProperties = useSelector((state: IApplicationState) => { if (state && state.currentProject) { - const { apiKey, folderPath, apiUriBase, id, trainRecord } = state.currentProject; - let modelId: string; - if (trainRecord) { - modelId = trainRecord.modelInfo.modelId; - } - return JSON.stringify({ id, apiKey, apiUriBase, folderPath, modelId }); + const { apiKey, folderPath, apiUriBase, id, predictModelId } = state.currentProject; + return JSON.stringify({ id, apiKey, apiUriBase, folderPath, predictModelId }); } });