diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index de3d4da0..d30bc1ee 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -35,12 +35,12 @@ runs: - name: Check out repository if: inputs.variant == 'dev' uses: actions/checkout@v3 + - name: install current repository + run: pip install . - name: Install using pip (requirements-dev.txt) if: inputs.variant == 'dev' shell: bash -l {0} - run: | - pip install --progress-bar off -r requirements-dev.txt - + run: pip install --progress-bar off -r requirements-dev.txt - name: Install using pip (git+... URL) if: inputs.variant == 'standard' shell: bash -l {0} diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index acd585cb..e153605f 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -65,7 +65,7 @@ jobs: runner-image: ubuntu-20.04 - os: win64 runner-image: windows-2022 - steps: + steps: - uses: IDAES/idaes-ui/.github/actions/install@main with: variant: ${{ matrix.install-variant }} @@ -114,13 +114,18 @@ jobs: package.json package-lock.json cypress.config.js + - name: Install node packages run: npm install + - name: Start UI - run: npm run ui & echo "UI Server started" + run: | + python -m idaes_ui.fv.example & + sleep 30 + - name: Cypress run uses: cypress-io/github-action@v5 with: - wait-on-timeout: 20 + wait-on-timeout: 50 command: npm run test browser: chrome \ No newline at end of file diff --git a/.gitignore b/.gitignore index a4028c5c..1ee2d4d0 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,8 @@ dmypy.json # auto generated files sample_visualization.json shared_variable.json +*.pickle +saved_flowsheet/ # cypress screenshots diff --git a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/mainFV.tsx b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/mainFV.tsx index 1be4fc9a..952ff6e7 100644 --- a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/mainFV.tsx +++ b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/mainFV.tsx @@ -35,6 +35,8 @@ export class MainFV { baseUrl:string; getFSUrl:string; putFSUrl:string; + saveFSUrl:string; + getAppSettingUrl:string; model:any; paper:any; _is_graph_changed:boolean; @@ -43,6 +45,7 @@ export class MainFV { _save_time_interval:any; stream_table:any; toolbar: any; + cleanToolBarEvent: any; constructor (flowsheetId:any, port:string | number, isFvShow:boolean, isVariablesShow:boolean, isStreamTableShow:boolean) { @@ -53,9 +56,11 @@ export class MainFV { this.isStreamTableShow = isStreamTableShow; //Gerneate url for fetch data - this.baseUrl = `http://localhost:${port}` - this.getFSUrl = VITE_MODE === "dev" ? `${this.baseUrl}/fs?id=${flowsheetId}` : `/fs?id=${flowsheetId}`; - this.putFSUrl = VITE_MODE === "dev" ? `${this.baseUrl}/fs?id=${flowsheetId}` : `/fs?id=${flowsheetId}`; + this.baseUrl = `http://localhost:${port}`; + this.getFSUrl = `${this.baseUrl}/api/get_fs?get_which=flowsheet`; + this.putFSUrl = `${this.baseUrl}/api/put_fs`; + this.saveFSUrl = `${this.baseUrl}/api/post_save_flowsheet`; + this.getAppSettingUrl = `${this.baseUrl}/api/get_app_setting`; //Define model this.model = {} @@ -70,22 +75,21 @@ export class MainFV { // Setting name (key) that defines the save model time interval this._save_time_interval_key = 'save_time_interval'; this._default_save_time_interval = 5000; // Default time interval - this._save_time_interval = this.getSaveTimeInterval(); + this._save_time_interval = this.getSaveTimeInterval(this.getAppSettingUrl); this.setupGraphChangeChecker(this._save_time_interval, flowsheetId); - //fetch model data from python server, once get data then render model and stream table - //default is from sample_visualization if no ?example=1 etc. in url - //define if fetch from example - this.setGetFSUrl(); - /** * @param */ axios.get(this.getFSUrl) .then((response) => { - console.log(this.getFSUrl) //get data from python server /fs - this.model = response.data; + if(response.data._old && response.data._new){ + this.model = response.data._new + }else{ + this.model = response.data; + } + //debug when flowsheet has no position it should not stack on each other isDevTest && this.debug_removeFlowsheetPosition(this.model); //render model @@ -93,12 +97,18 @@ export class MainFV { //render stream table //if statment control when stream table not show the stream table should not render if(isStreamTableShow) this.stream_table = new StreamTable(this, this.model); + // new this.toolbar this.toolbar = new Toolbar(this, this.paper, this.stream_table, this.flowsheetId, this.getFSUrl,this.putFSUrl, this.isFvShow); + // get toolbar event cleanup function + this.cleanToolBarEvent = this.toolbar.cleanUpEvent; }) .catch((error) => { console.log(error.message); console.log(error.response.status); }); + + // cleanup #fv container extra joint paper element + this.fvExtraContentCleanUp() } /** @@ -224,15 +234,12 @@ export class MainFV { }); } - /** - * Get the save time interval value from the application's setting block. - */ - getSaveTimeInterval() { - //question: - //this is the old way to write setting_url question its key=" then concat, should I keep "? - //let settings_url = `${this.baseUrl}/setting?setting_key="`.concat(this._save_time_interval_key); - - let settings_url = `${this.baseUrl}/setting?setting_key=${this._save_time_interval_key}`; + /** + * @description Get the save time interval value from the application's setting block. + * @returns save_time_interval + */ + getSaveTimeInterval(setting_url:string) { + let settings_url = `${this.baseUrl}/api/get_app_setting`; let save_time_interval = this._default_save_time_interval; axios.get(settings_url, { @@ -241,7 +248,7 @@ export class MainFV { } }) .then((response) => { - if (response.data.value != 'None') { + if (response.data.value) { save_time_interval = response.data.value; } else { this.informUser( @@ -282,6 +289,7 @@ export class MainFV { var graphChangedChecker = setInterval(() => { if (this._is_graph_changed) { + console.log(this.paper.graph) this.saveModel(flowsheet_url, this.paper.graph); // reset flag this._is_graph_changed = false; @@ -304,18 +312,50 @@ export class MainFV { * @param model The model to save */ saveModel(url:any, model:any) { - let clientData = JSON.stringify(model.toJSON()); - axios.put(url, clientData, { + let modelData = { + "flowsheet_type": "jjs_fs", + "flowsheet": model //data type in python is dict + }; + axios.put(url, JSON.stringify(modelData), { headers: { 'Content-Type': 'application/json' } }) .then((response) => { console.log(`saved`) + console.log(response.data) this.informUser(0, "Saved new model values"); }) .catch((error) => { this.informUser(2, "Fatal error: cannot save current model: " + error); }); + + axios.post(this.saveFSUrl, JSON.stringify({save_flowsheet_model:true})).then(res=>res).then(data => console.log(data)) + } + + /** + * @Description This function help check if fv has mutiple children, + * if has will remove all children and keep the last one. + * + * @Reason When react render, will create a new instence of MainFv, + * it will create a new fv display stack under the old one. + * this behivor cause zoom in and out btn not working so we need to clear all + * fv display other than the last one. + * + * @returns void + */ + fvExtraContentCleanUp(){ + let fv = document.getElementById("fv"); + + //validation if fv and fv has mutiple child + if(!fv || fv.childNodes.length <= 1){ + return; + } + + //get fv last child and remove others + let lastFvChild = fv.childNodes[fv?.childNodes.length - 1] + while(fv.firstChild !== fv.lastChild){ + fv.removeChild(fv.firstChild as Node) + } } } \ No newline at end of file diff --git a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/stream_table.tsx b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/stream_table.tsx index 8e6f57b3..7fc39c62 100644 --- a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/stream_table.tsx +++ b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/stream_table.tsx @@ -143,6 +143,10 @@ export class StreamTable { // Get the hide fields list const hide_fields_list = document.querySelector("#hide-fields-list"); + // reset hide_fields_list innerHTML to empty for prevent accumulating duplicate list items. + if(hide_fields_list){ + hide_fields_list.innerHTML = ""; + } // Specify the column headers let columns = stream_table_data["columns"]; let column_defs = []; diff --git a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/toolbar.tsx b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/toolbar.tsx index b4d61428..c1c442c5 100644 --- a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/toolbar.tsx +++ b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/toolbar.tsx @@ -31,13 +31,17 @@ export class Toolbar { getFSUrl: string; putFSUrl:string; isFvShow:boolean; + zoomRate:number; toggleStreamNameBtn:HTMLElement | undefined; toggleLabelsBtn:HTMLElement | undefined; zoomInBtn:HTMLElement | undefined; zoomOutBtn:HTMLElement | undefined; - zoomToFitBtn: HTMLElement | undefined; + zoomFitBtn: HTMLElement | undefined; + zoomInHandler: (() => void) | undefined; + zoomOutHandler: (() => void) | undefined; + zoomFitHandler: (() => void) | undefined; constructor(app:any, paper:any, stream_table:any | undefined, flowsheetId:string, getFSUrl:string, putFSUrl:string, isFvShow:boolean) { //initial arguments @@ -48,6 +52,10 @@ export class Toolbar { this.getFSUrl = getFSUrl; this.putFSUrl = putFSUrl; this.isFvShow = isFvShow; + this.zoomRate = 0.2; + this.zoomInHandler = undefined; + this.zoomOutHandler = undefined; + this.zoomFitHandler = undefined; // this.setupPageToolbar(); // this.setupFlowsheetToolbar(); @@ -60,9 +68,16 @@ export class Toolbar { //call & register click event to save flowsheet this.registerEventSave(this.putFSUrl) - + + /** + * Tool bar zoom in out and fit + */ //isFvShow repersent stream name, labels, zoom in, zoom out, zoom fit btn //if !isFvShow these event has no need to register event + + this.zoomInBtn = document.querySelector("#zoom-in-btn") as HTMLElement; + this.zoomOutBtn = document.querySelector("#zoom-out-btn") as HTMLElement; + this.zoomFitBtn = document.querySelector("#zoom-to-fit") as HTMLElement; if(isFvShow){ /** * Tool bar stream names toggle @@ -85,14 +100,11 @@ export class Toolbar { /** * Tool bar zoom in out and fit */ - //initial zoom btn from selector assign to this - this.zoomInBtn = document.querySelector("#zoom-in-btn") as HTMLElement; - this.zoomOutBtn = document.querySelector("#zoom-out-btn") as HTMLElement; - this.zoomToFitBtn = document.querySelector("#zoom-to-fit") as HTMLElement; + //registerZoomEvent //call registerZoomEvent function, register 3 zoom events to zoom in zoom out zoom to fit btn on dom //when button is not exist it should not register event, one example is isFvShow = false - if(this.zoomInBtn && this.zoomOutBtn && this.zoomToFitBtn){ - this.registerZoomEvent(this.zoomInBtn, this.zoomOutBtn, this.zoomToFitBtn); + if(this.zoomInBtn && this.zoomOutBtn && this.zoomFitBtn){ + this.registerZoomEvent(this.zoomInBtn, this.zoomOutBtn, this.zoomFitBtn); } } } @@ -104,21 +116,32 @@ export class Toolbar { * @param zoomOutBtn HTML element, selected by ID zoom-out-btn * @param zoomToFitBtn HTML element, selected by ID zoom-to-fit */ - registerZoomEvent(zoomInBtn:HTMLElement, zoomOutBtn:HTMLElement, zoomToFitBtn:HTMLElement){ - // Zoom in event listener - zoomInBtn.addEventListener("click", () => { - this._paper.paperScroller.zoom(0.2, { max: 4 }); - }); + registerZoomEvent(zoomInBtn:HTMLElement, zoomOutBtn:HTMLElement, zoomFitBtn:HTMLElement){ + this.zoomInHandler = () => this.zoomInEvent(this._paper.paperScroller, this.zoomRate); + this.zoomOutHandler = () => this.zoomOutEvent(this._paper.paperScroller, this.zoomRate); + this.zoomFitHandler = () => this.zoomFitEvent(); + // Zoom in event listener + zoomInBtn.addEventListener("click", this.zoomInHandler as EventListener); // Zoom out event listener - zoomOutBtn.addEventListener("click", () => { - this._paper.paperScroller.zoom(-0.2, { min: 0.2 }); - }); - + zoomOutBtn.addEventListener("click", this.zoomOutHandler as EventListener); // Zoom to fit event listener - zoomToFitBtn.addEventListener("click", () => { - this._paper.zoomToFit(); - }); + zoomFitBtn.addEventListener("click", this.zoomFitHandler as EventListener); + } + + /** + * create zoom events handler + */ + zoomInEvent(paperScroller:any, zoomRate:number){ + paperScroller.zoom(zoomRate, { max: 100 }); + } + + zoomOutEvent(paperScroller:any, zoomRate:number){ + paperScroller.zoom(-(zoomRate), { min: 0.01 }); + } + + zoomFitEvent(){ + this._paper.zoomToFit(); } /** @@ -217,4 +240,30 @@ export class Toolbar { this._app.saveModel(save_url, this._paper.graph); }); } + + /** + * Description: clean up event, when react update state new class instance will be created + * event listener will double registered, have to remove them to prevent multiple event trigger at one click issue. + * + * this remove event by clone current node and replace the old node, way to reset event. + */ + + cleanUpEvent(){ + let zoomInBtn = document.getElementById('zoom-in-btn'); + let zoomOutBtn = document.getElementById('zoom-out-btn'); + let zoomFitBtn = document.getElementById('zoom-to-fit'); + + if(zoomInBtn){ + let zoomInBtnClone = zoomInBtn.cloneNode(true); + zoomInBtn.parentNode!.replaceChild(zoomInBtnClone, zoomInBtn); + }; + if(zoomOutBtn){ + let zoomOutBtnClone = zoomOutBtn.cloneNode(true); + zoomOutBtn.parentNode!.replaceChild(zoomOutBtnClone, zoomOutBtn); + }; + if(zoomFitBtn){ + let zoomFitBtnClone = zoomFitBtn.cloneNode(true); + zoomFitBtn.parentNode!.replaceChild(zoomFitBtnClone, zoomFitBtn); + }; + } } diff --git a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/universal_functions.tsx b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/universal_functions.tsx index 571d6e4f..abf5bbb9 100644 --- a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/universal_functions.tsx +++ b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_functions/universal_functions.tsx @@ -25,14 +25,14 @@ export function minimizePanel(panelStatekey:string, setState:setPanelState){ * @param panelStateKey string, the key of panelState, which panel you want to maxmize * @param setState callback fn, setPanelState, read from context */ -export function maxmizePanel(panelStateKey:string, setState:setPanelState){ - setState((prevState:PanelStateInterface)=>{ - const newState = {...prevState}; - Object.keys(newState).forEach((objKey:string)=>{ - if(objKey !== panelStateKey){ - newState[objKey].show = false; - } - }) - return newState; - }) -} \ No newline at end of file +// export function maxmizePanel(panelStateKey:string, setState:setPanelState){ +// setState((prevState:PanelStateInterface)=>{ +// const newState = {...prevState}; +// Object.keys(newState).forEach((objKey:string)=>{ +// if(objKey !== panelStateKey){ +// newState[objKey].show = false; +// } +// }) +// return newState; +// }) +// } \ No newline at end of file diff --git a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_header/flowsheet_header_component.tsx b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_header/flowsheet_header_component.tsx index 5d8decfb..91866541 100644 --- a/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_header/flowsheet_header_component.tsx +++ b/IDAES-UI/src/components/flowsheet_main_component/flowsheet_component/flowsheet_header/flowsheet_header_component.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faMagnifyingGlassPlus, faMagnifyingGlassMinus,faExpand, faUpRightAndDownLeftFromCenter, faMinus, faSquareCheck, faSquare } from '@fortawesome/free-solid-svg-icons' import {FvHeaderStateInterface} from "../../../../interface/appMainContext_interface"; -import {minimizePanel, maxmizePanel} from "../flowsheet_functions/universal_functions"; +import { minimizePanel } from "../flowsheet_functions/universal_functions"; import css from "./flowsheet_header.module.css"; export default function FlowsheetHeader(){ @@ -29,8 +29,8 @@ export default function FlowsheetHeader(){ } return( -
FLOWSHEET
+FLOWSHEET
STREAM TABLE
-{fv_id ? fv_id : "loading..."}
+{fv_id ? fv_id : "Name not found"}
) } \ No newline at end of file diff --git a/IDAES-UI/src/context/contextFN_parse_url.tsx b/IDAES-UI/src/context/contextFN_parse_url.tsx index 38236f85..eb160bbc 100644 --- a/IDAES-UI/src/context/contextFN_parse_url.tsx +++ b/IDAES-UI/src/context/contextFN_parse_url.tsx @@ -18,8 +18,6 @@ const currentENV = import.meta.env.VITE_MODE; export function context_parse_url(){ - - if(currentENV === "prod"){ /** * When env is prod read server port and fv id from url @@ -36,7 +34,7 @@ export function context_parse_url(){ the port and id are setup in example.py fv_example() */ //fixed port when example - const server_port= "49999"; + const server_port= 49999; //fixed id when example const fv_id = "sample_visualization"; return {server_port, fv_id}; diff --git a/cypress.config.js b/cypress.config.js index dfaa9c0d..6d446e58 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -6,7 +6,7 @@ module.exports = { screenshots: true, video : false, e2e: { - baseUrl: "http://localhost:49999/app?id=sample_visualization", + baseUrl: "http://127.0.0.1:49999/?id=sample_visualization", pageLoadTimeout: 100000, taskTimeout: 100000, responseTimeout: 60000 diff --git a/cypress/e2e/app.cy.js b/cypress/e2e/app.cy.js index 3b1e184d..34efab7f 100644 --- a/cypress/e2e/app.cy.js +++ b/cypress/e2e/app.cy.js @@ -1,32 +1,33 @@ describe('loads the app UI correctly', () => { + // base on webapp url "baseUrl" visit webapp beforeEach(() =>{ cy.visit(Cypress.config('baseUrl')); }) - //loading app component - it('loads app', () => { + //check if app component exists + it('check app #root exists', () => { cy.get('#root').should('exist'); - cy.log(`App is up and running at ${Cypress.config('baseUrl')}`) + cy.log(`App is up and running at ${Cypress.config('baseUrl')}`); }) - //loading header component - it('loads header', () => { + //check if header component exists + it('check app #header exists', () => { cy.get('#header').should('exist'); }) - //loading page contents - it('loads page contents', () => { + //check flowsheet component exists + it('check app #flowsheet-wrapper exists', () => { cy.get('#flowsheet-wrapper').should('exist'); }) - //loading stream table - it('loads stream table', () => { + //check stream table component exists + it('check app #stream-table exist', () => { cy.get('#stream-table').should('exist'); }) /** - * This mean to comment out, use to test if CI can fail + * This comment out on purpose and if enabled, will use it to test if CI can fail */ //loading fake test check if test working // it('loads stream table', () => { diff --git a/cypress/e2e/flowsheet.cy.js b/cypress/e2e/flowsheet.cy.js deleted file mode 100644 index ca0297df..00000000 --- a/cypress/e2e/flowsheet.cy.js +++ /dev/null @@ -1,9 +0,0 @@ -describe('flowsheet visualizer spec', () => { - it('successfully loads', () => { - cy.visit(Cypress.config('baseUrl')); - }); - it('has jointjs diagram', () => { - cy.visit(Cypress.config('baseUrl')); - cy.get('#fv div.joint-paper svg'); - }); -}) \ No newline at end of file diff --git a/cypress/e2e/flowsheet_component.cy.js b/cypress/e2e/flowsheet_component.cy.js new file mode 100644 index 00000000..e975d0f5 --- /dev/null +++ b/cypress/e2e/flowsheet_component.cy.js @@ -0,0 +1,155 @@ +/** + * This test file is use for testing flowsheet component + */ +describe('flowsheet visualizer component spec', () => { + // initial wait time for cypress delay give fetch api some time to call backend + const waitTime = 300; + + // define visit app url before each test + beforeEach(()=>{ + cy.visit(Cypress.config('baseUrl')); + }); + + // all component's header button are changed these test may update or remove (start) + // check flowsheet header component exists and visible + it('check flowsheet header component exists and visible', ()=>{ + // header component exists + cy.get('#flowsheet-header-component').should('be.visible'); + }) + + // check flowsheet header contain title and title is correct + it('check flowsheet header contain title and title is correct',()=>{ + cy.get('#flowsheet-header-component-title').should('has.text', 'FLOWSHEET') + }) + + // check flowsheet header has all required button exist and visible + it('check flowsheet header component\'s functions button exists', ()=>{ + // initial header button as an array + const buttons = [ + { id: '#stream-names-toggle', name: 'Stream Names Toggle' }, + { id: '#show-label-toggle', name: 'Show Label Toggle' }, + { id: '#zoom-in-btn', name: 'Zoom In' }, + { id: '#zoom-out-btn', name: 'Zoom Out' }, + { id: '#zoom-to-fit', name: 'Zoom To Fit' }, + { id: '#minimize-flowsheet-panel-btn', name: 'Minimize Flowsheet Panel' }, + ]; + + // loop through buttons array run each test + buttons.forEach(button => { + cy.get(`#flowsheet-header-component ${button.id}`).then($el => { + expect($el).to.be.visible, `${button.name} button should be visible`; + }); + }); + }); + + // test flowsheet header component buttons + // stream names button + it('test flowsheet header component stream names button', ()=>{}) + + // label button + it('test flowsheet header component label button', ()=>{}) + + // zoom in button + // basic zoom in function + it('test zoom in button', () => { + cy.wait(waitTime) + cy.get('.joint-paper.joint-theme-default').then($el => { + // record old joint paper element width + const oldWidth = $el.width(); + const oldHeight = $el.height(); + + // click zoom in btn + cy.get('#zoom-in-btn').click(); + + // read joint paper element again + cy.get('.joint-paper.joint-theme-default').then($newEl => { + const newWidth = $newEl.width(); + const newHeight = $newEl.height(); + + expect(newWidth).to.be.greaterThan(oldWidth); + expect(newHeight).to.be.greaterThan(oldHeight); + }); + }); + }); + + // zoom out button + // basic zoom out function + it('test zoom out button', ()=>{ + cy.wait(waitTime) + cy.get('.joint-paper.joint-theme-default').then($el => { + // record old joint paper element width + const oldWidth = $el.width(); + const oldHeight = $el.height(); + + // click zoom in btn + cy.get('#zoom-out-btn').click(); + + // read joint paper element again + cy.get('.joint-paper.joint-theme-default').then($newEl => { + const newWidth = $newEl.width(); + const newHeight = $newEl.height(); + + expect(newWidth).to.be.lessThan(oldWidth); + expect(newHeight).to.be.lessThan(oldHeight); + }); + }); + }) + + // zoom to fit button + it('test zoom to fit button', ()=>{ + cy.wait(waitTime); + // read joint paper element when load as default + cy.get('.joint-paper.joint-theme-default').then($el=>{ + // record default joint paper width height + const defaultWidth = $el.width(); + const defaultHeight = $el.height(); + + // change flowsheet size zoom in x 3 + cy.get('#zoom-in-btn').click(); + cy.get('#zoom-in-btn').click(); + cy.get('#zoom-in-btn').click(); + + // read joint paper element after zoom in + cy.get('.joint-paper.joint-theme-default').then($newEl=>{ + // record zoom in joint paper width height + const newWidth = $newEl.width(); + const newHeight= $newEl.height(); + + // make sure zoom in joint paper width and height is greater than default + expect(newWidth).to.be.greaterThan(defaultWidth); + expect(newHeight).to.be.greaterThan(defaultHeight); + + // trigger zoom to fit + cy.get('#zoom-to-fit').click(); + + // read joint paper element after zoom to fit + cy.get('.joint-paper.joint-theme-default').then($elAfterClickZoomToFit=>{ + // record joint paper width height after click zoom to fit + const elWidthAfterClickZoomToFit = $elAfterClickZoomToFit.width(); + const elHeightAfterClickZoomToFit = $elAfterClickZoomToFit.height(); + + // final check + expect(elWidthAfterClickZoomToFit === defaultWidth) + expect(elHeightAfterClickZoomToFit === defaultHeight) + }) + }) + + }) + + }) + + // check flowsheet fv container only has 1 child + it('check flowsheet fv container only has 1 child', ()=>{ + cy.get('#fvContainer').children().should('have.length', 1); + }) + + // check has flowsheet jointjs svg diagram + it('has jointjs diagram', () => { + cy.get('#fv div.joint-paper svg'); + }); + + + // check header full window button functions: + + // all component's header button are changed these test may update or remove (end) +}) \ No newline at end of file diff --git a/idaes_ui/fv/app.py b/idaes_ui/fv/app.py index 53640928..524120f4 100644 --- a/idaes_ui/fv/app.py +++ b/idaes_ui/fv/app.py @@ -6,56 +6,99 @@ __created__ = "2023-10-08" # stdlib +import sys from pathlib import Path +from typing import Optional, Union, Dict, Tuple + +# from pathlib import Path + # external packages from fastapi import FastAPI, HTTPException -from fastapi.staticfiles import StaticFiles -import uvicorn + +# from fastapi.staticfiles import StaticFiles +# from fastapi.responses import FileResponse + # package from idaes_ui.fv.models import DiagnosticsData, DiagnosticsException, DiagnosticsError from idaes_ui.fv.models.settings import AppSettings -from idaes_ui.fv.models.flowsheet import Flowsheet, merge_flowsheets + +# from idaes_ui.fv.models.flowsheet import Flowsheet, merge_flowsheets + +# defined functions +from .fastAPI_functions.initial_params import InitialParams +from .fastAPI_functions.cors import enable_fastapi_cors +from idaes_ui.fv.fastAPI_functions.uvicorn import WebUvicorn + +# defined route +from .fastAPI_route.router import Router class FlowsheetApp: - _root_dir = Path(__file__).parent.absolute() # static dir in same dir as this file - _static_dir = _root_dir / "static" + def __init__( + self, + flowsheet, + name, + port: Optional[int] = None, + save_time_interval: Optional[int] = 5, + save: Optional[Union[Path, str, bool]] = None, + save_dir: Optional[Path] = None, + load_from_saved: bool = True, + overwrite: bool = False, + test: bool = False, + browser: bool = True, + ): + # Initial self.... params + InitialParams( + main_class=self, + flowsheet=flowsheet, + name=name, + port=port, + save_time_interval=save_time_interval, + save=save, + save_dir=save_dir, + load_from_saved=load_from_saved, + overwrite=overwrite, + test=test, + ) - def __init__(self, flowsheet, name="my flowsheet"): - self.app = FastAPI() - self.app.mount( - "/static", StaticFiles(directory=self._static_dir), name="static" + # initial FastAPI + self.app = FastAPI( + docs_url="/api/v1/docs", + redoc="/api/v1/redoc", + title="IDAES UI API DOC", + description="IDAES UI API endpoint detail.", ) + + # enable CORS let allowed port can talk to this server + enable_fastapi_cors(self.app) + + # get diagnostics json self.diag_data = DiagnosticsData(flowsheet) - self.settings = AppSettings() - self.flowsheet = Flowsheet(fs=flowsheet, name=name) - - @self.app.get("/diagnostics/") - async def get_diagnostics() -> DiagnosticsData: - try: - return self.diag_data - except DiagnosticsException as exc: - error_json = DiagnosticsError.from_exception(exc).model_dump_json() - raise HTTPException(status_code=500, detail=error_json) - - @self.app.get("/settings/") - def get_settings() -> AppSettings: - return self.settings - - @self.app.put("/settings/") - def put_settings(settings: AppSettings): - self.settings = settings - - @self.app.get("/fs/") - def get_flowsheet() -> Flowsheet: - # todo: check 1st time for saved one (merge if found) - return self.flowsheet - - @self.app.put("/fs/") - def put_flowsheet(fs: Flowsheet): - self.flowsheet = merge_flowsheets(self.flowsheet, fs) - # todo: save result - return self.flowsheet - - def run(self, port: int = 8000): - uvicorn.run(self.app, host="127.0.0.1", port=port) + + # API router + Router( + fastAPIApp=self.app, + flowsheet=self.flowsheet, + flowsheet_name=self.flowsheet_name, + save_time_interval=self.save_time_interval, + save=self.save, + save_dir=self.save_dir, + load_from_saved=self.load_from_saved, + overwrite=self.overwrite, + ) + + # print message why browser not start + if self.test: + print("Test mode enabled with 'test = True', browser won't start!") + if not browser: + print( + "Browser mode disenabled with 'browser = False', browser won't start!" + ) + + # # Uvicorn serve fastAPI app + # # condation not test only not test case will start uvicorn + if not self.test and browser: + WebUvicorn(self.app, self.port, self.flowsheet_name) + + def get_fast_api_app(self): + return self.app diff --git a/idaes_ui/fv/example.py b/idaes_ui/fv/example.py index 0f2242f8..ddbdee28 100644 --- a/idaes_ui/fv/example.py +++ b/idaes_ui/fv/example.py @@ -4,6 +4,7 @@ To change logging level, set the FV_LOG_LEVEL environment variable to a numeric or string value that matches one of the standard Python logging levels. """ + __author__ = "Dan Gunter" # stdlib @@ -24,19 +25,21 @@ def fv_example(): m = build_flowsheet() - visualize(m.fs, "sample_visualization", port=49999) + visualize(m.fs, "sample_visualization", port=49999, clean_up=True) _log.info("Starting Flowsheet Visualizer") _log.info("Press Control-C to stop") - try: - while 1: - time.sleep(1) - except KeyboardInterrupt: - _log.info("Flowsheet Visualizer stopped") - return 0 - - -level_map = {logging.getLevelName(n): n for n in (logging.DEBUG, logging.INFO, - logging.WARN, logging.ERROR)} + # try: + # while 1: + # time.sleep(1) + # except KeyboardInterrupt: + # _log.info("Flowsheet Visualizer stopped") + # return 0 + + +level_map = { + logging.getLevelName(n): n + for n in (logging.DEBUG, logging.INFO, logging.WARN, logging.ERROR) +} def parse_logging_level(s: str, level: int) -> int: diff --git a/idaes_ui/fv/fastAPI_functions/__init__.py b/idaes_ui/fv/fastAPI_functions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/idaes_ui/fv/fastAPI_functions/cors.py b/idaes_ui/fv/fastAPI_functions/cors.py new file mode 100644 index 00000000..150c8923 --- /dev/null +++ b/idaes_ui/fv/fastAPI_functions/cors.py @@ -0,0 +1,15 @@ +from fastapi.middleware.cors import CORSMiddleware + + +def enable_fastapi_cors(app): + """Help fastapi app assign allowed origins + Args: + app: the class enables fastAPI + """ + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # allowed url list + allow_credentials=True, # support cookies + allow_methods=["*"], # allowed methord + allow_headers=["*"], # allowed header + ) diff --git a/idaes_ui/fv/fastAPI_functions/flowsheet_manager.py b/idaes_ui/fv/fastAPI_functions/flowsheet_manager.py new file mode 100644 index 00000000..54af53b3 --- /dev/null +++ b/idaes_ui/fv/fastAPI_functions/flowsheet_manager.py @@ -0,0 +1,80 @@ +import json +from idaes_ui.fv.models.flowsheet import Flowsheet +from idaes_ui.fv.flowsheet import FlowsheetSerializer +from idaes_ui.fv.models.flowsheet import merge_flowsheets +from idaes_ui.fv.flowsheet import FlowsheetDiff +import time +from threading import Thread + + +class FlowsheetManager: + """Use to manage flowsheet, use as a universal container can allow different class call its getter ans setter to update and read new flowsheet""" + + def __init__(self, flowsheet, flowsheet_name, save_time_interval): + """init assign define slef's + Args: + flowsheet: the flowsheet pass eather from fsvis -> FlowsheetApp -> Router + """ + self.flowsheet_name = flowsheet_name + self.flowsheet = flowsheet + self.updated_fs = None + self.front_end_jjs_flowsheet = None + self.save_time_interval = save_time_interval + + def get_flowsheet_name(self): + return self.flowsheet_name + + def get_original_flowsheet(self): + return self.flowsheet + + def update_original_flowsheet(self, new_original_flowsheet): + print("update") + # TODO eather update this or remove it check all route use this fn + # self.original_flowsheet = new_original_flowsheet + # self.jjs_flowsheet = Flowsheet(new_original_flowsheet) + + def get_jjs_flowsheet(self): + """Return return populated JJS flowsheet depends on if user has modified the flowsheet + Returns: flowsheet + """ + if self.updated_fs: + old_fs = FlowsheetSerializer( + self.flowsheet, self.flowsheet_name, True + ).as_dict() + + new_fs = self.front_end_jjs_flowsheet + + updated_fs = merge_flowsheets(old_fs, new_fs) + self.updated_fs = updated_fs + return self.updated_fs + else: + # jjs_fs = Flowsheet(self.flowsheet) + jjs_fs = FlowsheetSerializer( + self.flowsheet, self.flowsheet_name, True + ).as_dict() + return jjs_fs + + def update_jjs_flowsheet(self, frontend_put_jjs_flowsheet): + """Update self flowsheet to user saved flowsheet use in joint js + Args: + frontend_put_jjs_flowsheet: the flowsheet user saved and passed from api/put_fs + """ + self.front_end_jjs_flowsheet = frontend_put_jjs_flowsheet + old_fs = FlowsheetSerializer( + self.flowsheet, self.flowsheet_name, True + ).as_dict() + updated_fs = merge_flowsheets(old_fs, frontend_put_jjs_flowsheet) + self.updated_fs = updated_fs + return updated_fs + + # App settings + def get_save_time_interval(self): + """reading current save time interval""" + return self.save_time_interval + + def update_save_time_interval(self, new_save_time_interval: int): + """update current save time interval + Args: + new_save_time_interval: int, the value of new auto save time + """ + self.save_time_interval = new_save_time_interval diff --git a/idaes_ui/fv/fastAPI_functions/initial_params.py b/idaes_ui/fv/fastAPI_functions/initial_params.py new file mode 100644 index 00000000..c5ea22a3 --- /dev/null +++ b/idaes_ui/fv/fastAPI_functions/initial_params.py @@ -0,0 +1,62 @@ +from pathlib import Path +from typing import Optional, Union, Dict, Tuple + + +class InitialParams: + """This class use to populate all params use in "self" (main_class) in the main. + Args: + main_class: the class instence called this class + flowsheet: flowsheet + name: flowsheet name + port: Optional, port for uvicorn serve web default 8000 + save_time_interval: Optional, the duration time will send to front end to call api/put_fs to update flowsheet, default 5s + save_dir: Optional, dir use to store user saved flowsheet, default "./saved_flowsheet" + """ + + def __init__( + self, + main_class, + flowsheet, + name, + port: Optional[int] = None, + save_time_interval: Optional[int] = 5, + save: Optional[Union[Path, str, bool]] = None, + save_dir: Optional[Path] = None, + load_from_saved: bool = True, + overwrite: bool = False, + test: bool = False, + browser: bool = True, + ): + # initial everything related to flowsheet + main_class.flowsheet = flowsheet + main_class.flowsheet_name = name + + # populate web port + if port is not None: + main_class.port = port + else: + main_class.port = 8000 + + # initial save_time_interval + main_class.save_time_interval = save_time_interval + + # initial save + main_class.save = save + + # initial save dir + if save_dir: + main_class.save_dir = save_dir + else: + main_class.save_dir = "." + + # initial load from save + main_class.load_from_saved = load_from_saved + + # initial overwite + main_class.overwrite = overwrite + + # initial test param + main_class.test = test + + # initial browser + main_class.browser = browser diff --git a/idaes_ui/fv/fastAPI_functions/save_flowsheet.py b/idaes_ui/fv/fastAPI_functions/save_flowsheet.py new file mode 100644 index 00000000..851dbd8a --- /dev/null +++ b/idaes_ui/fv/fastAPI_functions/save_flowsheet.py @@ -0,0 +1,158 @@ +import sys +import json +from pathlib import Path +from idaes import logger +from idaes_ui.fv import persist, errors +from idaes_ui.fv.flowsheet import FlowsheetSerializer + + +class SaveFlowsheet: + def __init__( + self, flowsheet_name, flowsheet, save, save_dir, load_from_saved, overwrite + ): + """This class use for handle save flowsheet to file + Args: + flowsheet_name: Name of flowsheet to display as the title of the visualization + save: Where to save the current flowsheet layout and values. If this argument is not specified, + "``name``.json" will be used (if this file already exists, a "-`