You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@submarine.apache.org by pi...@apache.org on 2020/07/14 22:00:14 UTC

[submarine] branch master updated: SUBMARINE-554. [WEB] UI for Submarine experiment creation

This is an automated email from the ASF dual-hosted git repository.

pingsutw pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/submarine.git


The following commit(s) were added to refs/heads/master by this push:
     new d11db22  SUBMARINE-554. [WEB] UI for Submarine experiment creation
d11db22 is described below

commit d11db228997c3a12e1f9fce70f5f8776b39c0b74
Author: wang0630 <j2...@gmail.com>
AuthorDate: Tue Jul 14 11:40:18 2020 +0800

    SUBMARINE-554. [WEB] UI for Submarine experiment creation
    
    ### What is this PR for?
    Renew UI for experiment creation.
    
    ### What type of PR is it?
    [Improvement]
    
    ### Todos
    * [ ] - Task
    
    ### What is the Jira issue?
    [SUBMARINE-554](https://issues.apache.org/jira/browse/SUBMARINE-554)
    
    ### How should this be tested?
    https://travis-ci.com/github/wang0630/submarine/builds/174373510
    
    ### Screenshots (if appropriate)
    ![new](https://user-images.githubusercontent.com/26138982/86907595-de061200-c147-11ea-82ac-66ae3e8ea114.gif)
    
    ### Questions:
    * Does the licenses files need update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    Author: wang0630 <j2...@gmail.com>
    
    Closes #342 from wang0630/SUBMARINE-554 and squashes the following commits:
    
    558f360 [wang0630] SUBMARINE-554. [WEB] UI for experiment creation
    b912354 [wang0630] SUBMARINE-554. [WEB] UI for experiment creation
    2f2a933 [wang0630] SUBMARINE-554. [WEB] UI for experiment creation
    f450d6b [wang0630] SUBMARINE-554. [WEB] UI for experiment creation
    daa96d7 [wang0630] SUBMARINE-554. [WEB] UI for experiment creation
    45c2b0a [wang0630] Duplicate key left
    4982677 [wang0630] Make function call to pipe
    119d844 [wang0630] Make css more structural
    1635477 [wang0630] After renew
    9218c42 [wang0630] SUBMARINE-554. [WEB] UI for experiment creation
    c809a31 [wang0630] Start env page
    12d3356 [wang0630] Change frameworks
---
 .../org/apache/submarine/AbstractSubmarineIT.java  |  17 ++
 .../org/apache/submarine/WebDriverManager.java     |   1 +
 .../apache/submarine/integration/experimentIT.java |  44 ++++-
 submarine-workbench/workbench-web-ng/package.json  |   1 +
 .../workbench/experiment/experiment.component.html | 216 ++++++++++++---------
 .../workbench/experiment/experiment.component.scss |  87 +++++++--
 .../workbench/experiment/experiment.component.ts   | 136 +++++++++++--
 .../src/app/pages/workbench/workbench.module.ts    |   4 +-
 .../condition.pipe.ts}                             |  58 ++----
 .../pipe-shared.module.ts}                         |  59 +-----
 .../app/services/experiment.validator.service.ts   |  95 +++++++++
 11 files changed, 491 insertions(+), 227 deletions(-)

diff --git a/submarine-test/test-e2e/src/test/java/org/apache/submarine/AbstractSubmarineIT.java b/submarine-test/test-e2e/src/test/java/org/apache/submarine/AbstractSubmarineIT.java
index c04389a..87fd83a 100644
--- a/submarine-test/test-e2e/src/test/java/org/apache/submarine/AbstractSubmarineIT.java
+++ b/submarine-test/test-e2e/src/test/java/org/apache/submarine/AbstractSubmarineIT.java
@@ -115,6 +115,23 @@ abstract public class AbstractSubmarineIT {
     });
   }
 
+  protected WebElement buttonCheck(final By locator, final long timeWait) {
+    Wait<WebDriver> wait = new FluentWait<>(driver)
+        .withTimeout(timeWait, TimeUnit.SECONDS)
+        .pollingEvery(1, TimeUnit.SECONDS)
+        .ignoring(NoSuchElementException.class);
+    return wait.until(ExpectedConditions.elementToBeClickable(locator));
+  }
+
+  protected void takeScreenShot(final String path) {
+    File scrFile1 = ((TakesScreenshot)driver).getScreenshotAs(OutputType.FILE);
+    try {
+      FileUtils.copyFile(scrFile1, new File(path));
+    } catch (java.io.IOException e) {
+      e.fillInStackTrace();
+    }
+  }
+
   protected void createNewNote() {
     clickAndWait(By.xpath("//div[contains(@class, \"col-md-4\")]/div/h5/a[contains(.,'Create new" +
         " note')]"));
diff --git a/submarine-test/test-e2e/src/test/java/org/apache/submarine/WebDriverManager.java b/submarine-test/test-e2e/src/test/java/org/apache/submarine/WebDriverManager.java
index 515dca2..3122fb7 100644
--- a/submarine-test/test-e2e/src/test/java/org/apache/submarine/WebDriverManager.java
+++ b/submarine-test/test-e2e/src/test/java/org/apache/submarine/WebDriverManager.java
@@ -61,6 +61,7 @@ public class WebDriverManager {
       }
     }
 
+
     String url;
     if (System.getenv("url") != null) {
       url = System.getenv("url");
diff --git a/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java b/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java
index 334108e..a525749 100644
--- a/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java
+++ b/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java
@@ -17,9 +17,14 @@
 
 package org.apache.submarine.integration;
 
+import org.apache.commons.io.FileUtils;
 import org.apache.submarine.AbstractSubmarineIT;
 import org.apache.submarine.WebDriverManager;
 import org.openqa.selenium.By;
+import org.openqa.selenium.OutputType;
+import org.openqa.selenium.TakesScreenshot;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.interactions.Actions;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.testng.annotations.AfterClass;
@@ -28,6 +33,9 @@ import org.testng.annotations.Test;
 import org.testng.Assert;
 import org.openqa.selenium.support.ui.WebDriverWait;
 import org.openqa.selenium.support.ui.ExpectedConditions;
+import sun.rmi.runtime.Log;
+
+import java.io.File;
 
 public class experimentIT extends AbstractSubmarineIT {
 
@@ -54,21 +62,41 @@ public class experimentIT extends AbstractSubmarineIT {
     pollingWait(By.cssSelector("a[routerlink='/workbench/dashboard']"), MAX_BROWSER_TIMEOUT_SEC);
 
     // Routing to workspace
+    LOG.info("url");
     pollingWait(By.xpath("//span[contains(text(), \"Experiment\")]"), MAX_BROWSER_TIMEOUT_SEC).click();
     Assert.assertEquals(driver.getCurrentUrl(), "http://localhost:8080/workbench/experiment");
 
     // Test create new experiment
+    LOG.info("new experiment");
     pollingWait(By.xpath("//button[@id='openExperiment']"), MAX_BROWSER_TIMEOUT_SEC).click();
     Assert.assertTrue(pollingWait(By.xpath("//form"), MAX_BROWSER_TIMEOUT_SEC).isDisplayed());
-    WebDriverWait wait = new WebDriverWait( driver, 60);
-    wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//label[contains(text(), \"Experiment Name\")]")));
-    pollingWait(By.xpath("//input[@id='experimentName']"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e test Experiment");
-    pollingWait(By.xpath("//textarea"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e test Project description");
-    pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).click();
-    //Next step
-    Assert.assertTrue(pollingWait(By.xpath("//div[@id='page2']"), MAX_BROWSER_TIMEOUT_SEC).isDisplayed());
+    WebDriverWait wait = new WebDriverWait( driver, 15);
+    // Basic information section
+    pollingWait(By.name("experimentName"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e test Experiment");
+    pollingWait(By.name("description"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e test Project description");
+    pollingWait(By.name("namespace"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e namespace");
+    pollingWait(By.name("cmd"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("python3 -m e2e cmd");
+    pollingWait(By.name("image"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e custom image");
     pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).click();
-    Assert.assertTrue(pollingWait(By.xpath("//label[@class='pg3-form-label']"), MAX_BROWSER_TIMEOUT_SEC).isDisplayed());
+    // env variables section
+    LOG.info("in env");
+    Assert.assertTrue(pollingWait(By.xpath("//button[@id='env-btn']"), MAX_BROWSER_TIMEOUT_SEC).isDisplayed());
+    WebElement envBtn = buttonCheck(By.id("env-btn"), MAX_BROWSER_TIMEOUT_SEC);
+    envBtn.click();
+    wait.until(ExpectedConditions.visibilityOfAllElementsLocatedBy(By.xpath("//input[@name='key0' or name='value0']")));
+    pollingWait(By.name("key0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e key");
+    pollingWait(By.name("value0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e value");
+
     pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).click();
+    // Spec section
+    LOG.info("in spec");
+    WebElement specBtn = wait.until(ExpectedConditions.elementToBeClickable(By.id("spec-btn")));
+    specBtn.click();
+    pollingWait(By.name("spec0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e spec");
+    pollingWait(By.name("replica0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("1");
+    pollingWait(By.name("cpu0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("1");
+    pollingWait(By.name("memory0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("512M");
+    Assert.assertTrue(pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).isEnabled());
+//    pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).click();
   }
 }
diff --git a/submarine-workbench/workbench-web-ng/package.json b/submarine-workbench/workbench-web-ng/package.json
index eb92afe..4c23700 100644
--- a/submarine-workbench/workbench-web-ng/package.json
+++ b/submarine-workbench/workbench-web-ng/package.json
@@ -9,6 +9,7 @@
     "test": "ng test",
     "lint": "ng lint",
     "e2e": "ng e2e",
+    "autoformat": "prettier --write --trailing-comma none --single-quote 'src/**/*.{js,json,ts,html}'",
     "webdriver": "webdriver-manager update --ignore_ssl = true"
   },
   "private": true,
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html
index 1f5ddba..39dacd9 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html
@@ -145,26 +145,20 @@
     [(nzOkText)]="okText"
     [nzOkLoading]="isOkLoading"
     (nzOnCancel)="isVisible = false"
-    [nzWidth]="740"
+    [nzWidth]="1000"
   >
     <div>
       <nz-steps [nzCurrent]="current">
-        <nz-step nzTitle="Basic Information"></nz-step>
-        <nz-step nzTitle="Configuration"></nz-step>
-        <nz-step nzTitle="Scheduling Cycle"></nz-step>
+        <nz-step nzTitle="Meta"></nz-step>
+        <nz-step nzTitle="Env"></nz-step>
+        <nz-step nzTitle="Spec"></nz-step>
       </nz-steps>
     </div>
     <div>
       <form [formGroup]="createExperiment">
         <div *nzModalFooter>
           <button nz-button nzType="default" (click)="isVisible = false">Cancel</button>
-          <button
-            id="go"
-            nz-button
-            nzType="primary"
-            [disabled]="!(createExperiment.get('experimentName').valid && createExperiment.get('description').valid)"
-            (click)="handleOk()"
-          >
+          <button id="go" nz-button nzType="primary" [disabled]="checkStatus()" (click)="handleOk()">
             {{ okText }}
           </button>
           <button
@@ -179,106 +173,154 @@
         </div>
         <div [ngSwitch]="current" style="margin-top: 30px;">
           <div *ngSwitchCase="0">
-            <div>
-              <label class="form-label">
+            <div class="single-field-group">
+              <label for="experimentName">
                 <span class="red-star">*</span>
-                Experiment Name:
+                Experiment Name
               </label>
-              <label for="experimentName"></label>
-              <input
-                type="text"
-                id="experimentName"
-                style="margin-top: 32px; width: 350px;"
-                class="form-control"
-                formControlName="experimentName"
-              />
+              <input nz-input type="text" name="experimentName" id="experimentName" formControlName="experimentName" />
             </div>
-            <div>
-              <label class="form-label">
+            <div class="single-field-group">
+              <label for="description">
                 <span class="red-star">*</span>
-                Description:
-              </label>
-              <label>
-                <textarea
-                  rows="6"
-                  class="form-control"
-                  style="margin-top: 32px; width: 350px;"
-                  formControlName="description"
-                ></textarea>
+                Description
               </label>
+              <textarea
+                nz-input
+                [nzAutosize]="{ minRows: 3, maxRows: 6 }"
+                name="description"
+                formControlName="description"
+              ></textarea>
             </div>
-          </div>
-          <div *ngSwitchCase="1" id="page2">
-            <div>
-              <label class="form-label">
+
+            <div class="single-field-group">
+              <label for="frameworks">
                 <span class="red-star">*</span>
-                Experiment Spec:
+                Frameworks
               </label>
-              <nz-select
-                formControlName="experimentSpec"
-                nzPlaceHolder="Choose"
-                style="width: 350px; margin-top: 30px;"
-              >
+              <nz-select formControlName="frameworks" nzPlaceHolder="Choose" style="width: 48%;">
                 <nz-option
-                  *ngFor="let experimentSpec of ExperimentSpecs"
-                  [nzValue]="experimentSpec"
-                  [nzLabel]="experimentSpec"
+                  *ngFor="let framework of frameworkNames"
+                  [nzValue]="framework"
+                  [nzLabel]="framework"
                 ></nz-option>
               </nz-select>
             </div>
-            <div>
-              <label class="form-label">
+            <div class="single-field-group">
+              <label for="namespace">
                 <span class="red-star">*</span>
-                Rule Template:
+                Namespace
               </label>
-              <nz-select formControlName="ruleTemplate" nzPlaceHolder="Choose" style="width: 350px; margin-top: 30px;">
-                <nz-option
-                  *ngFor="let ruleTemplate of ruleTemplates"
-                  [nzValue]="ruleTemplate"
-                  [nzLabel]="ruleTemplate"
-                ></nz-option>
-              </nz-select>
+              <input nz-input name="namespace" formControlName="namespace" />
             </div>
-            <div>
-              <label class="form-label">
+            <div class="single-field-group">
+              <label for="cmd">
                 <span class="red-star">*</span>
-                Rule Type:
+                Command
               </label>
-              <nz-radio-group formControlName="ruleType" style="width: 350px; margin-top: 30px; margin-bottom: 30px;">
-                <label *ngFor="let ruletype of ruleTypes" nz-radio [nzValue]="ruletype">{{ ruletype }}</label>
-              </nz-radio-group>
+              <input nz-input name="cmd" formControlName="cmd" placeholder="Command to run" />
             </div>
-          </div>
-          <div *ngSwitchCase="2">
-            <div>
-              <label class="pg3-form-label">
+            <div class="single-field-group">
+              <label for="image">
                 <span class="red-star">*</span>
-                Start Date:
+                Image
               </label>
-              <nz-date-picker
-                [nzFormat]="'yyyy/MM/dd'"
-                formControlName="startDate"
-                style="width: 350px; margin-top: 30px;"
-              ></nz-date-picker>
+              <input nz-input name="image" formControlName="image" placeholder="Image to use" />
             </div>
+          </div>
+          <div *ngSwitchCase="1" id="page2">
             <div>
-              <label class="pg3-form-label">
-                <span class="red-star">*</span>
-                Schedule Cycle:
-              </label>
-              <nz-select
-                formControlName="scheduleCycle"
-                nzPlaceHolder="Choose"
-                style="width: 195px; margin-top: 30px; margin-bottom: 30px;"
-              >
-                <nz-option
-                  *ngFor="let scheduleCycle of scheduleCycles"
-                  [nzValue]="scheduleCycle"
-                  [nzLabel]="scheduleCycle"
-                ></nz-option>
-              </nz-select>
+              <button nz-button id="env-btn" type="default" (click)="createEnvInput()">
+                Add new env
+              </button>
+              <ul formArrayName="envs" class="list-container">
+                <ng-container *ngFor="let env of envs.controls; index as i">
+                  <li *ngIf="i | indexInRange: currentEnvPage:PAGESIZE" [formGroupName]="i" class="input-group">
+                    <input nz-input name="key{{ i }}" placeholder="Key" formControlName="key" />
+                    <input nz-input name="value{{ i }}" placeholder="Value" formControlName="value" />
+                    <i nz-icon nzType="close-circle" nzTheme="fill" (click)="deleteItem(envs, i)"></i>
+                  </li>
+                  <div
+                    class="alert-message"
+                    *ngIf="env.get('key').dirty | logicalAnd: env.get('value').dirty:env.hasError('envMissing')"
+                  >
+                    {{ env.getError('envMissing') }}
+                  </div>
+                  <div
+                    class="alert-message"
+                    *ngIf="env.get('key').dirty | logicalAnd: env.get('key').hasError('duplicateError')"
+                  >
+                    {{ env.get('key').getError('duplicateError') }}
+                  </div>
+                </ng-container>
+              </ul>
+              <nz-pagination
+                [(nzPageIndex)]="currentEnvPage"
+                [nzPageSize]="PAGESIZE"
+                [nzTotal]="envs.controls.length"
+                nzSimple
+              ></nz-pagination>
             </div>
           </div>
+          <div *ngSwitchCase="2">
+            <label class="pg3-form-label"></label>
+            <button nz-button id="spec-btn" type="default" (click)="createSpec()">
+              Add new spec
+            </button>
+            <ul formArrayName="specs" class="list-container">
+              <ng-container *ngFor="let spec of specs.controls; index as i">
+                <li *ngIf="i | indexInRange: currentSpecPage:PAGESIZE" [formGroupName]="i" class="input-group">
+                  <input nz-input name="spec{{ i }}" placeholder="spec name" formControlName="name" />
+                  <input
+                    nz-input
+                    name="replica{{ i }}"
+                    type="number"
+                    placeholder="number of replica"
+                    formControlName="replicas"
+                  />
+                  <input nz-input name="cpu{{ i }}" type="number" placeholder="number of cpu" formControlName="cpus" />
+                  <input nz-input name="memory{{ i }}" placeholder="memory" formControlName="memory" />
+                  <i
+                    nz-icon
+                    nzType="close-circle"
+                    nzTheme="fill"
+                    class="delete-icon"
+                    (click)="deleteItem(specs, i)"
+                  ></i>
+                </li>
+                <div
+                  class="alert-message"
+                  *ngIf="spec.get('name').dirty | logicalAnd: spec.get('name').hasError('duplicateError')"
+                >
+                  {{ spec.get('name').getError('duplicateError') }}
+                </div>
+                <div
+                  class="alert-message"
+                  *ngIf="spec.get('memory').dirty | logicalAnd: spec.get('memory').hasError('memoryPatternError')"
+                >
+                  {{ spec.get('memory').getError('memoryPatternError') }}
+                </div>
+                <div
+                  class="alert-message"
+                  *ngIf="spec.get('replicas').dirty | logicalAnd: spec.get('replicas').hasError('min')"
+                >
+                  replicas must be at least 1
+                </div>
+                <div
+                  class="alert-message"
+                  *ngIf="spec.get('cpus').dirty | logicalAnd: spec.get('cpus').hasError('min')"
+                >
+                  cpus must be at least 1
+                </div>
+              </ng-container>
+            </ul>
+            <nz-pagination
+              [(nzPageIndex)]="currentSpecPage"
+              [nzPageSize]="PAGESIZE"
+              [nzTotal]="specs.controls.length"
+              nzSimple
+            ></nz-pagination>
+          </div>
         </div>
       </form>
     </div>
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss
index 3838a1b..1cc8199 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss
@@ -45,24 +45,75 @@
    color: red;
 }
 
-.form-label {
-   float:left;
-   width:200px;
-   text-align: right;
-   padding-right: 12px;
-   margin-top: 32px;
-   margin-left: 10px;
-   clear: left;
-   color: black;
+.list-container {
+   padding: 0;
+   margin: 1rem 0;
 }
 
-.pg3-form-label {
-   float:left;
-   width:200px;
-   text-align: right;
-   padding-right: 12px;
-   margin-top: 32px;
-   margin-left: 100px;
-   clear: left;
-   color: black;
+/* For env section */
+.env-page-container {
+   display: flex;
+   flex-direction: column;
+}
+
+.env-input-container {
+   padding: 0;
+   margin: 1rem 0;
+}
+
+.input-group {
+   display: flex;
+   align-items: center;
+   &:not(:first-child) {
+      margin-top: 1rem;
+   }
+
+   & input {
+      flex: 0 2 20%;
+      &:not(:last-child) {
+         margin-right: .5rem;
+      }
+   }
+
+   & i {
+      cursor: pointer;
+      font-size: 1.2rem;
+   }
+}
+
+/* utility */
+.single-field-group {
+   display: flex;
+   align-items: center;
+   margin-bottom: 1.5rem;
+   & label {
+      flex: 0 0 25%;
+      text-align: right;
+      font-weight: 500;
+      &:not(:last-child) {
+         margin-right: 1.5rem;
+      }
+   }
+   & input {
+      flex: 0 0 48%;
+   }
+   & textarea {
+      flex: 0 0 55%;
+   }
+}
+
+.alert-message {
+   color: red;
+   margin-top: .3rem;
+   margin-left: .5rem;
 }
+
+.test {
+   display: flex;
+   flex-direction: column;
+   align-items: center;
+}
+
+.pg3-form-label {
+   
+}
\ No newline at end of file
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts
index 298ff82..c71acb3 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts
@@ -18,10 +18,11 @@
  */
 
 import { Component, OnInit } from '@angular/core';
-import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { FormControl, FormGroup, Validators, FormArray } from '@angular/forms';
 import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
 import { ExperimentInfo } from '@submarine/interfaces/experiment-info';
 import { ExperimentService } from '@submarine/services/experiment.service';
+import { ExperimentFormService } from '@submarine/services/experiment.validator.service';
 import { NzMessageService } from 'ng-zorro-antd';
 
 @Component({
@@ -45,13 +46,19 @@ export class ExperimentComponent implements OnInit {
   okText = 'Next Step';
   isVisible = false;
 
-  ExperimentSpecs = ['Adhoc', 'Predefined'];
-  ruleTemplates = ['Template1', 'Template2'];
-  ruleTypes = ['Strong', 'Weak'];
-  scheduleCycles = ['Month', 'Week'];
+  // ExperimentSpecs = ['Adhoc', 'Predefined'];
+  frameworkNames = ['Tensorflow', 'Pytorch'];
+
+  // About env page
+  currentEnvPage = 1;
+  PAGESIZE = 5;
+
+  // About spec
+  currentSpecPage = 1;
 
   constructor(
     private experimentService: ExperimentService,
+    private experimentFormService: ExperimentFormService,
     private nzMessageService: NzMessageService,
     private route: ActivatedRoute,
     private router: Router
@@ -61,11 +68,14 @@ export class ExperimentComponent implements OnInit {
     this.createExperiment = new FormGroup({
       experimentName: new FormControl(null, Validators.required),
       description: new FormControl(null, [Validators.required]),
-      experimentSpec: new FormControl('Adhoc'),
-      ruleTemplate: new FormControl('Template1'),
-      ruleType: new FormControl('Strong'),
-      startDate: new FormControl(new Date()),
-      scheduleCycle: new FormControl('Month')
+      // experimentSpec: new FormControl('Adhoc'),
+      frameworks: new FormControl('Tensorflow', [Validators.required]),
+      namespace: new FormControl('default', [Validators.required]),
+      // ruleType: new FormControl('Strong'),
+      cmd: new FormControl('', [Validators.required]),
+      envs: new FormArray([], [this.experimentFormService.nameValidatorFactory('key')]),
+      image: new FormControl('', [Validators.required]),
+      specs: new FormArray([], [this.experimentFormService.nameValidatorFactory('name')])
     });
     this.fetchExperimentList();
     this.isInfo = this.router.url !== '/workbench/experiment';
@@ -83,21 +93,111 @@ export class ExperimentComponent implements OnInit {
     });
   }
 
+  // Getters of experiment request form
+  get experimentName() {
+    return this.createExperiment.get('experimentName');
+  }
+  get description() {
+    return this.createExperiment.get('description');
+  }
+  get frameworks() {
+    return this.createExperiment.get('frameworks');
+  }
+  get namespace() {
+    return this.createExperiment.get('namespace');
+  }
+  get cmd() {
+    return this.createExperiment.get('cmd');
+  }
+  get envs() {
+    return this.createExperiment.get('envs') as FormArray;
+  }
+  get image() {
+    return this.createExperiment.get('image');
+  }
+  get specs() {
+    return this.createExperiment.get('specs') as FormArray;
+  }
+  /**
+   * Check the validity of the experiment page
+   *
+   */
+  checkStatus() {
+    if (this.current == 0) {
+      return (
+        this.experimentName.invalid ||
+        this.description.invalid ||
+        this.frameworks.invalid ||
+        this.namespace.invalid ||
+        this.cmd.invalid ||
+        this.image.invalid
+      );
+    } else if (this.current == 1) {
+      return this.envs.invalid;
+    } else if (this.current == 2) {
+      return this.specs.invalid;
+    }
+  }
+
   handleOk() {
     if (this.current === 1) {
       this.okText = 'Submit';
+    }
+
+    if (this.current < 2) {
       this.current++;
-    } else if (this.current === 2) {
-      this.okText = 'Next Step';
-      this.current = 0;
-      this.isVisible = false;
-      // TODO(jasoonn): Create Real experiment
-      console.log(this.createExperiment);
-    } else {
-      this.current++;
     }
   }
 
+  /**
+   * Create a new env variable input
+   */
+  createEnvInput() {
+    // Create a new FormGroup
+    const env = new FormGroup(
+      {
+        key: new FormControl(''),
+        value: new FormControl()
+      },
+      [this.experimentFormService.envValidator]
+    );
+    this.envs.push(env);
+    // If the new page is created, jump to that page
+    if (this.envs.controls.length > 1 && this.envs.controls.length % this.PAGESIZE === 1) {
+      this.currentEnvPage += 1;
+    }
+  }
+  /**
+   * Create a new spec
+   *
+   */
+  createSpec() {
+    const spec = new FormGroup(
+      {
+        name: new FormControl(''),
+        replicas: new FormControl(null, [Validators.min(1)]),
+        cpus: new FormControl(null, [Validators.min(1)]),
+        memory: new FormControl('', [this.experimentFormService.memoryValidator])
+      },
+      [this.experimentFormService.specValidator]
+    );
+    this.specs.push(spec);
+    // If the new page is created, jump to that page
+    if (this.specs.controls.length > 1 && this.specs.controls.length % this.PAGESIZE === 1) {
+      this.currentSpecPage += 1;
+    }
+  }
+
+  /**
+   * Delete list items(envs or specs)
+   *
+   * @param arr - The FormArray containing the item
+   * @param index - The index of the item
+   */
+  deleteItem(arr: FormArray, index: number) {
+    arr.removeAt(index);
+  }
+
   fetchExperimentList() {
     this.experimentService.fetchExperimentList().subscribe((list) => {
       this.experimentList = list;
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.module.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.module.ts
index ba34d02..84ebbe0 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.module.ts
@@ -22,6 +22,7 @@ import { NgModule } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { RouterModule } from '@angular/router';
 import { WorkbenchRoutingModule } from '@submarine/pages/workbench/workbench-routing.module';
+import { PipeSharedModule } from '@submarine/pipe/pipe-shared.module';
 import { NgZorroAntdModule } from 'ng-zorro-antd';
 import { DataComponent } from './data/data.component';
 import { ExperimentComponent } from './experiment/experiment.component';
@@ -51,7 +52,8 @@ import { WorkspaceModule } from './workspace/workspace.module';
     FormsModule,
     WorkspaceModule,
     ExperimentModule,
-    InterpreterModule
+    InterpreterModule,
+    PipeSharedModule
   ]
 })
 export class WorkbenchModule {}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss b/submarine-workbench/workbench-web-ng/src/app/pipe/condition.pipe.ts
similarity index 50%
copy from submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss
copy to submarine-workbench/workbench-web-ng/src/app/pipe/condition.pipe.ts
index 3838a1b..f5cd2c3 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss
+++ b/submarine-workbench/workbench-web-ng/src/app/pipe/condition.pipe.ts
@@ -1,4 +1,4 @@
-/*!
+/*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
  * distributed with this work for additional information
@@ -17,52 +17,18 @@
  * under the License.
  */
 
- #experimentOuter{
-    background-color: white;
-    padding-left: 30px;
-    padding-top: 20px;
- }
+import { Pipe, PipeTransform } from '@angular/core';
 
- #experimentData{
-    margin-top: 16px;
-    margin-left: 25px;
-    margin-right: 25px;
-    background-color:white;
-    padding-left: 10px;
-    padding-right: 10px;
- }
-
- input.ng-invalid.ng-touched {
-   border: 1px solid red;
- }
-
- textarea.ng-invalid.ng-touched {
-   border: 1px solid red;
-}
-
-.red-star {
-   margin-top: 20px;
-   color: red;
-}
-
-.form-label {
-   float:left;
-   width:200px;
-   text-align: right;
-   padding-right: 12px;
-   margin-top: 32px;
-   margin-left: 10px;
-   clear: left;
-   color: black;
+@Pipe({ name: 'logicalAnd' }) // The name of the pipe to use in the template
+export class LogicalAnd implements PipeTransform {
+  transform(value: boolean, ...flags: boolean[]) {
+    return value && !flags.includes(false);
+  }
 }
 
-.pg3-form-label {
-   float:left;
-   width:200px;
-   text-align: right;
-   padding-right: 12px;
-   margin-top: 32px;
-   margin-left: 100px;
-   clear: left;
-   color: black;
+@Pipe({ name: 'indexInRange' })
+export class IndexInRange implements PipeTransform {
+  transform(index: number, currentPage: number, itemPerPage: number): boolean {
+    return index < currentPage * itemPerPage && index >= (currentPage - 1) * itemPerPage;
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss b/submarine-workbench/workbench-web-ng/src/app/pipe/pipe-shared.module.ts
similarity index 50%
copy from submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss
copy to submarine-workbench/workbench-web-ng/src/app/pipe/pipe-shared.module.ts
index 3838a1b..e9ce712 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss
+++ b/submarine-workbench/workbench-web-ng/src/app/pipe/pipe-shared.module.ts
@@ -1,4 +1,4 @@
-/*!
+/*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
  * distributed with this work for additional information
@@ -17,52 +17,13 @@
  * under the License.
  */
 
- #experimentOuter{
-    background-color: white;
-    padding-left: 30px;
-    padding-top: 20px;
- }
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { LogicalAnd, IndexInRange } from './condition.pipe';
 
- #experimentData{
-    margin-top: 16px;
-    margin-left: 25px;
-    margin-right: 25px;
-    background-color:white;
-    padding-left: 10px;
-    padding-right: 10px;
- }
-
- input.ng-invalid.ng-touched {
-   border: 1px solid red;
- }
-
- textarea.ng-invalid.ng-touched {
-   border: 1px solid red;
-}
-
-.red-star {
-   margin-top: 20px;
-   color: red;
-}
-
-.form-label {
-   float:left;
-   width:200px;
-   text-align: right;
-   padding-right: 12px;
-   margin-top: 32px;
-   margin-left: 10px;
-   clear: left;
-   color: black;
-}
-
-.pg3-form-label {
-   float:left;
-   width:200px;
-   text-align: right;
-   padding-right: 12px;
-   margin-top: 32px;
-   margin-left: 100px;
-   clear: left;
-   color: black;
-}
+@NgModule({
+  imports: [CommonModule],
+  declarations: [LogicalAnd, IndexInRange],
+  exports: [LogicalAnd, IndexInRange]
+})
+export class PipeSharedModule {}
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/experiment.validator.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/experiment.validator.service.ts
new file mode 100644
index 0000000..b9b2461
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/services/experiment.validator.service.ts
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { FormGroup, ValidatorFn, ValidationErrors, FormControl, FormArray } from '@angular/forms';
+import { Injectable } from '@angular/core';
+import { ExperimentModule } from '@submarine/pages/workbench/experiment/experiment.module';
+
+@Injectable({
+  providedIn: ExperimentModule
+})
+export class ExperimentFormService {
+  /**
+   * The validator for env key/value pair
+   * @param envGroup A FormGroup resides in `envs` FromArray in createExperiment
+   */
+  envValidator: ValidatorFn = (envGroup: FormGroup): ValidationErrors | null => {
+    const key = envGroup.get('key');
+    const keyValue = envGroup.get('value');
+    return (key.value && keyValue.value) || (!key.value && !keyValue.value)
+      ? null
+      : { envMissing: 'Missing key or value' };
+  };
+
+  specValidator: ValidatorFn = (specGroup: FormGroup): ValidationErrors | null => {
+    const name = specGroup.get('name');
+    const replicas = specGroup.get('replicas');
+    const cpus = specGroup.get('cpus');
+    const memory = specGroup.get('memory');
+
+    const allValid = !(name.invalid || replicas.invalid || cpus.invalid || memory.invalid);
+    const exists =
+      (name.value && replicas.value && cpus.value && memory.value) ||
+      !(name.value || replicas.value || cpus.value || memory.value);
+    return allValid && exists ? null : { specError: 'Invalid or missing input' };
+  };
+
+  /**
+   * Validate memory input in Spec
+   *
+   * @param memory - The memory field in Spec
+   */
+  memoryValidator: ValidatorFn = (memory: FormControl): ValidationErrors | null => {
+    // Must match number + digit ex. 512M
+    return memory.value && /^\d+M$/.test(memory.value)
+      ? null
+      : { memoryPatternError: 'Memory pattern must match number + M ex. 512M' };
+  };
+
+  /**
+   * Validate name or key property
+   * Name and key cannot have its duplicate, must be unique
+   * @param fieldName - The field name of the form
+   * @returns The actual ValidatorFn to check duplicates
+   */
+  nameValidatorFactory: (fieldName: string) => ValidatorFn = (fieldName) => {
+    return (arr: FormArray): ValidationErrors | null => {
+      const duplicateSet = new Set();
+      for (let i = 0; i < arr.length; i++) {
+        const nameControl = arr.controls[i].get(fieldName);
+        // We don't consider empty string
+        if (!nameControl.value) continue;
+
+        if (duplicateSet.has(nameControl.value)) {
+          // Found duplicates, manually set errors on FormControl level
+          nameControl.setErrors({
+            duplicateError: 'Duplicate key or name'
+          });
+        } else {
+          duplicateSet.add(nameControl.value);
+          if (nameControl.hasError('duplicateError')) {
+            delete nameControl.errors.duplicateError;
+            nameControl.updateValueAndValidity();
+          }
+        }
+      }
+      return null;
+    };
+  };
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@submarine.apache.org
For additional commands, e-mail: dev-help@submarine.apache.org