Repository: vmware-tanzu/octant
Pull request #1305 (2020-08-30 22:35:41)

Add modal component
**What this PR does / why we need it**:
This PR adds a [modal component](https://clarity.design/documentation/modals). It is intended to handle three cases:

1. Display some content in a modal
2. Display a form
3. Display content and a form

If the modal does not contain a form, the only button available is `Close` (the modal is always closable in all cases). If a form is added to the modal component, the buttons are `Submit` and `Cancel` where submit validates the form before sending a message with action.

A modal can be opened via a button or programatically though modifying the modal config as open.

Example with a stepper in a modal to be opened via table with buttonGroup:
![image](https://user-images.githubusercontent.com/10288252/91670869-55b44580-ead6-11ea-986b-8d98fae1016f.png)

Code sample:
```
fft := component.NewFormFieldText("textFormLabel", "textFormName", "")
fft.AddValidator("placeholder", "this is an error", []string{"required"})

form := component.Form{
    Fields: []component.FormField{
        fft,
    },
}

stepper := component.NewStepper("stepper", "test")
stepper.AddStep("Step1", form, "title1", "description1")
stepper.AddStep("Step2", form, "title2", "description2")

modal := component.NewModal(component.TitleFromString("Modal example"))
modal.SetBody(stepper)
modal.SetSize(component.ModalSizeLarge)

button := component.NewButton("Click me", action.Payload{}, component.WithModal(modal))


table := component.NewTable("test", "test", []component.TableCol{})
table.Config.ButtonGroup = component.NewButtonGroup()
table.Config.ButtonGroup.AddButton(button)
```



mklanjsek: This is very cool! My only comment is that it would be good to add more storybook stories to illustrate modal opening and closing as well as interactivity inside the modal.
GuessWhoSamFoo: Rebased to be using angular 10 and added a form example/

Patch:
diff --git a/changelogs/unreleased/1305-GuessWhoSamFoo b/changelogs/unreleased/1305-GuessWhoSamFoo
new file mode 100644
index 00000000..27d09054
--- /dev/null
+++ b/changelogs/unreleased/1305-GuessWhoSamFoo
@@ -0,0 +1,1 @@
+Added modal component and opening modals through buttons
diff --git a/pkg/view/component/base.go b/pkg/view/component/base.go
index 10f46d77..772e7290 100644
--- a/pkg/view/component/base.go
+++ b/pkg/view/component/base.go
@@ -48,6 +48,8 @@ const (
 	TypeLoading = "loading"
 	// TypeLogs is a logs component.
 	TypeLogs = "logs"
+	// TypeModal is a modal component.
+	TypeModal = "modal"
 	// TypePodStatus is a pod status component.
 	TypePodStatus = "podStatus"
 	// TypePort is a port component.
diff --git a/pkg/view/component/button.go b/pkg/view/component/button.go
index 57a411ad..06c2bcd2 100644
--- a/pkg/view/component/button.go
+++ b/pkg/view/component/button.go
@@ -3,6 +3,8 @@ package component
 import (
 	"encoding/json"
 
+	"github.com/pkg/errors"
+
 	"github.com/vmware-tanzu/octant/pkg/action"
 )
 
@@ -25,11 +27,51 @@ func WithButtonConfirmation(title, body string) ButtonOption {
 	}
 }
 
+// WithModal configures a button to open a modal
+func WithModal(modal *Modal) ButtonOption {
+	return func(button *Button) {
+		button.Modal = modal
+	}
+}
+
+func (b *Button) UnmarshalJSON(data []byte) error {
+	x := struct {
+		Name         string         `json:"name"`
+		Payload      action.Payload `json:"payload"`
+		Confirmation *Confirmation  `json:"confirmation,omitempty"`
+		Modal        *TypedObject   `json:"modal,omitempty"`
+	}{}
+
+	if err := json.Unmarshal(data, &x); err != nil {
+		return err
+	}
+
+	if x.Modal != nil {
+		component, err := x.Modal.ToComponent()
+		if err != nil {
+			return err
+		}
+
+		modal, ok := component.(*Modal)
+		if !ok {
+			return errors.New("item was not a modal")
+		}
+		b.Modal = modal
+	}
+
+	b.Name = x.Name
+	b.Payload = x.Payload
+	b.Confirmation = x.Confirmation
+
+	return nil
+}
+
 // Button is a button in a group.
 type Button struct {
 	Name         string         `json:"name"`
 	Payload      action.Payload `json:"payload"`
 	Confirmation *Confirmation  `json:"confirmation,omitempty"`
+	Modal        Component      `json:"modal,omitempty"`
 }
 
 // NewButton creates an instance of Button.
diff --git a/pkg/view/component/form.go b/pkg/view/component/form.go
index e7d6e004..2d50cb45 100644
--- a/pkg/view/component/form.go
+++ b/pkg/view/component/form.go
@@ -614,6 +614,7 @@ func (ff *FormFieldHidden) UnmarshalJSON(data []byte) error {
 
 type Form struct {
 	Fields []FormField `json:"fields"`
+	Action string      `json:"action,omitempty"`
 }
 
 func (f *Form) MarshalJSON() ([]byte, error) {
@@ -651,6 +652,7 @@ func (f *Form) UnmarshalJSON(data []byte) error {
 			Error         string                 `json:"error"`
 			Validators    []string               `json:"validators"`
 		} `json:"fields"`
+		Action string `json:"action,omitempty"`
 	}{}
 
 	err := json.Unmarshal(data, &x)
@@ -694,6 +696,7 @@ func (f *Form) UnmarshalJSON(data []byte) error {
 
 		f.Fields = append(f.Fields, ff)
 	}
+	f.Action = x.Action
 
 	return nil
 }
diff --git a/pkg/view/component/modal.go b/pkg/view/component/modal.go
new file mode 100644
index 00000000..36aa948b
--- /dev/null
+++ b/pkg/view/component/modal.go
@@ -0,0 +1,110 @@
+package component
+
+import (
+	"encoding/json"
+)
+
+type ModalSize string
+
+const (
+	// ModalSizeSmall is the smallest modal
+	ModalSizeSmall ModalSize = "sm"
+	// ModalSizeLarge is a large modal
+	ModalSizeLarge ModalSize = "lg"
+	// ModalSizeExtraLarge is the largest modal
+	ModalSizeExtraLarge ModalSize = "xl"
+)
+
+// ModalConfig is a configuration for the modal component.
+type ModalConfig struct {
+	Body      Component `json:"body,omitempty"`
+	Form      *Form     `json:"form,omitempty"`
+	Opened    bool      `json:"opened"`
+	ModalSize ModalSize `json:"size,omitempty"`
+	Buttons   []Button  `json:"buttons,omitempty"`
+}
+
+// UnmarshalJSON unmarshals a modal config from JSON.
+func (m *ModalConfig) UnmarshalJSON(data []byte) error {
+	x := struct {
+		Body      *TypedObject `json:"body,omitempty"`
+		Form      *Form        `json:"form,omitempty"`
+		Opened    bool         `json:"opened"`
+		ModalSize ModalSize    `json:"size,omitempty"`
+		Buttons   []Button     `json:"buttons,omitempty"`
+	}{}
+
+	if err := json.Unmarshal(data, &x); err != nil {
+		return err
+	}
+
+	if x.Body != nil {
+		var err error
+		m.Body, err = x.Body.ToComponent()
+		if err != nil {
+			return err
+		}
+	}
+
+	m.Form = x.Form
+	m.Opened = x.Opened
+	m.ModalSize = x.ModalSize
+	m.Buttons = x.Buttons
+	return nil
+}
+
+// Modal is a modal component.
+//
+// +octant:component
+type Modal struct {
+	Base
+	Config ModalConfig `json:"config"`
+}
+
+// NewModal creates a new modal.
+func NewModal(title []TitleComponent) *Modal {
+	return &Modal{
+		Base: newBase(TypeModal, title),
+	}
+}
+
+var _ Component = (*Modal)(nil)
+
+// SetBody sets the body of a modal.
+func (m *Modal) SetBody(body Component) {
+	m.Config.Body = body
+}
+
+// AddForm adds a form to a modal. It is added after the body.
+func (m *Modal) AddForm(form Form) {
+	m.Config.Form = &form
+}
+
+// SetSize sets the size of a modal. Size is medium by default.
+func (m *Modal) SetSize(size ModalSize) {
+	m.Config.ModalSize = size
+}
+
+// AddButton is a helper to add a custom button
+func (m *Modal) AddButton(button Button) {
+	m.Config.Buttons = append(m.Config.Buttons, button)
+}
+
+// Open opens a modal. A modal is closed by default.
+func (m *Modal) Open() {
+	m.Config.Opened = true
+}
+
+// Close closes a modal.
+func (m *Modal) Close() {
+	m.Config.Opened = false
+}
+
+type modalMarshal Modal
+
+// MarshalJSON marshal a modal to JSON.
+func (m *Modal) MarshalJSON() ([]byte, error) {
+	k := modalMarshal(*m)
+	k.Metadata.Type = TypeModal
+	return json.Marshal(&k)
+}
diff --git a/pkg/view/component/unmarshal.go b/pkg/view/component/unmarshal.go
index 5ea7f17d..a9721a64 100644
--- a/pkg/view/component/unmarshal.go
+++ b/pkg/view/component/unmarshal.go
@@ -116,6 +116,11 @@ func unmarshal(to TypedObject) (Component, error) {
 		err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
 			"unmarshal logs config")
 		o = t
+	case TypeModal:
+		t := &Modal{Base: Base{Metadata: to.Metadata}}
+		err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
+			"unmarshal modal config")
+		o = t
 	case TypeQuadrant:
 		t := &Quadrant{Base: Base{Metadata: to.Metadata}}
 		err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
diff --git a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html
index bc33678c..1444aecf 100644
--- a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html
+++ b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html
@@ -2,12 +2,13 @@
   <clr-button
     *ngFor="let button of v.config.buttons; trackBy: trackByFn"
     class="{{ class }}"
-    (click)="onClick(button.payload, button.confirmation)"
+    (click)="onClick(button.payload, button.confirmation, button.modal)"
   >
     {{ button.name }}
   </clr-button>
 </clr-button-group>
 
+<app-view-modal *ngIf="modalView" [view]="modalView"></app-view-modal>
 <clr-modal [(clrModalOpen)]="isModalOpen">
   <h3 class="modal-title">{{ modalTitle }}</h3>
   <div class="modal-body">
diff --git a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts
index da63978e..f68b9d9e 100644
--- a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts
+++ b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts
@@ -1,7 +1,13 @@
 import { Component, EventEmitter, Output } from '@angular/core';
-import { ButtonGroupView, Confirmation } from '../../../models/content';
+import {
+  ButtonGroupView,
+  Confirmation,
+  View,
+  ModalView,
+} from '../../../models/content';
 import { ActionService } from '../../../services/action/action.service';
 import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
+import { ModalService } from '../../../services/modal/modal.service';
 
 @Component({
   selector: 'app-button-group',
@@ -19,7 +25,12 @@ export class ButtonGroupComponent extends AbstractViewComponent<
   payload = {};
   class = '';
 
-  constructor(private actionService: ActionService) {
+  modalView: View;
+
+  constructor(
+    private actionService: ActionService,
+    private modalService: ModalService
+  ) {
     super();
   }
 
@@ -31,11 +42,19 @@ export class ButtonGroupComponent extends AbstractViewComponent<
         } else {
           this.class = 'btn-outline btn-sm';
         }
+        if (button.modal) {
+          this.modalView = button.modal;
+          const modal = this.modalView as ModalView;
+          this.modalService.setState(modal.config.opened);
+        }
       });
     }
   }
 
-  onClick(payload: {}, confirmation?: Confirmation) {
+  onClick(payload: {}, confirmation?: Confirmation, modal?: View) {
+    if (modal) {
+      this.modalService.openModal();
+    }
     if (confirmation) {
       this.activateModal(payload, confirmation);
     } else {
diff --git a/web/src/app/modules/shared/components/presentation/card/card.component.html b/web/src/app/modules/shared/components/presentation/card/card.component.html
index 07a1406e..74923d87 100644
--- a/web/src/app/modules/shared/components/presentation/card/card.component.html
+++ b/web/src/app/modules/shared/components/presentation/card/card.component.html
@@ -1,45 +1,56 @@
-<ng-container
-  *ngTemplateOutlet="currentAction ? action : content"
-></ng-container>
+<div class="card">
+  <div class="card-block">
+    <ng-container
+      *ngTemplateOutlet="currentAction ? action : content"
+    ></ng-container>
+  </div>
+  <ng-container *ngTemplateOutlet="currentAction ? formFooter : actionFooter"></ng-container>
+</div>
+
 
 <ng-template #action>
+  <h3 class="card-title">{{ currentAction.title }}</h3>
   <app-form
     #appForm
     [form]="currentAction.form"
-    [title]="currentAction.title"
-    (submit)="onActionSubmit($event)"
-    (cancel)="onActionCancel()"
   >
   </app-form>
 </ng-template>
 
-<ng-template #content>
-  <div class="card">
-    <div class="card-block">
-      <ng-container *ngIf="v.config.alert">
-        <div class="alert alert-{{ v.config.alert.type }} alert-sm">
-          <div class="alert-item static">
-            <span class="alert-text">
-              {{ v.config.alert.message }}
-            </span>
-          </div>
-        </div>
-      </ng-container>
-      <h4 class="card-title">
-        <app-view-title [views]="title"></app-view-title>
-      </h4>
+<ng-template #formFooter>
+  <div class="card-footer">
+    <button class="btn btn-primary btn-sm" type="submit" (click)="onActionSubmit()">Submit</button>
+    <button class="btn btn-sm" type="button" (click)="onActionCancel()">
+      Cancel
+    </button>
+  </div>
+</ng-template>
 
-      <div class="card-text">
-        <app-view-container [view]="body"></app-view-container>
+<ng-template #actionFooter>
+  <div class="card-footer" *ngIf="v.config.actions?.length > 0">
+    <ng-container *ngFor="let action of v.config.actions; trackBy: trackByFn">
+      <button class="btn btn-sm btn-link" (click)="setAction(action)">
+        {{ action.name }}
+      </button>
+    </ng-container>
+  </div>
+</ng-template>
+
+<ng-template #content>
+  <ng-container *ngIf="v.config.alert">
+    <div class="alert alert-{{ v.config.alert.type }} alert-sm">
+      <div class="alert-item static">
+        <span class="alert-text">
+          {{ v.config.alert.message }}
+        </span>
       </div>
     </div>
+  </ng-container>
+  <h4 class="card-title">
+    <app-view-title [views]="title"></app-view-title>
+  </h4>
 
-    <div class="card-footer" *ngIf="v.config.actions?.length > 0">
-      <ng-container *ngFor="let action of v.config.actions; trackBy: trackByFn">
-        <button class="btn btn-sm btn-link" (click)="setAction(action)">
-          {{ action.name }}
-        </button>
-      </ng-container>
-    </div>
+  <div class="card-text">
+    <app-view-container [view]="body"></app-view-container>
   </div>
 </ng-template>
diff --git a/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts b/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts
index 16021f17..87dde1ee 100644
--- a/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts
+++ b/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts
@@ -12,10 +12,13 @@ import { FormBuilder, FormGroup } from '@angular/forms';
 import { ViewService } from '../../../services/view/view.service';
 import { viewServiceStub } from 'src/app/testing/view-service.stub';
 import { SharedModule } from '../../../shared.module';
+import { FormComponent } from '../form/form.component';
 
 describe('CardComponent', () => {
   let component: CardComponent;
   let fixture: ComponentFixture<CardComponent>;
+  let formComponent: FormComponent;
+  let formFixture: ComponentFixture<FormComponent>;
   const formBuilder: FormBuilder = new FormBuilder();
 
   const action: Action = {
@@ -83,12 +86,17 @@ describe('CardComponent', () => {
       formGroupExample: 'justForTest',
     });
 
-    component.onActionSubmit(formGroup);
+    formFixture = TestBed.createComponent(FormComponent);
+    formComponent = formFixture.componentInstance;
+    formComponent.formGroup = formGroup;
+    component.appForm = formComponent;
+
+    component.onActionSubmit();
     expect(component.currentAction).toBeUndefined();
   });
 
   it('should not submit action', () => {
-    component.onActionSubmit({} as FormGroup);
+    component.onActionSubmit();
     expect(component.currentAction).toBeDefined();
   });
 
@@ -117,10 +125,7 @@ describe('CardComponent', () => {
 
   it('should call "onActionCancel" method when cancelling the form', fakeAsync(() => {
     spyOn(component, 'onActionCancel');
-    fixture.detectChanges();
-    component.appForm.onFormCancel();
-    tick();
-    fixture.detectChanges();
+    component.onActionCancel();
     expect(component.onActionCancel).toHaveBeenCalled();
   }));
 });
diff --git a/web/src/app/modules/shared/components/presentation/card/card.component.ts b/web/src/app/modules/shared/components/presentation/card/card.component.ts
index 6bd22a01..fd45fdbc 100644
--- a/web/src/app/modules/shared/components/presentation/card/card.component.ts
+++ b/web/src/app/modules/shared/components/presentation/card/card.component.ts
@@ -1,6 +1,5 @@
-import { Component, Input, ViewChild } from '@angular/core';
+import { Component, ViewChild } from '@angular/core';
 import { Action, CardView, TitleView, View } from '../../../models/content';
-import { FormGroup } from '@angular/forms';
 import { ActionService } from '../../../services/action/action.service';
 import { FormComponent } from '../form/form.component';
 import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
@@ -28,9 +27,9 @@ export class CardComponent extends AbstractViewComponent<CardView> {
     this.body = this.v.config.body;
   }
 
-  onActionSubmit(formGroup: FormGroup) {
-    if (formGroup && formGroup.value) {
-      this.actionService.perform(formGroup.value);
+  onActionSubmit() {
+    if (this.appForm?.formGroup && this.appForm?.formGroup.value) {
+      this.actionService.perform(this.appForm.formGroup.value);
       this.currentAction = undefined;
     }
   }
diff --git a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html
index 24c940f6..1d19b107 100644
--- a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html
+++ b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html
@@ -3,9 +3,9 @@
   *ngIf="showTitle() || buttonGroup"
 >
   <h4 class="clr-col-10" *ngIf="showTitle()">{{ title }}</h4>
-  <clr-dg-action-bar class="clr-col-2" *ngIf="buttonGroup">
-    <app-button-group class="btn-sm" [view]="buttonGroup"></app-button-group>
-  </clr-dg-action-bar>
+    <clr-dg-action-bar class="clr-col-2" *ngIf="buttonGroup">
+      <app-button-group [view]="buttonGroup"></app-button-group>
+    </clr-dg-action-bar>
 </div>
 <clr-datagrid [clrDgLoading]="false">
   <clr-dg-placeholder>
diff --git a/web/src/app/modules/shared/components/presentation/form/form.component.html b/web/src/app/modules/shared/components/presentation/form/form.component.html
index ae464816..8d12a22a 100644
--- a/web/src/app/modules/shared/components/presentation/form/form.component.html
+++ b/web/src/app/modules/shared/components/presentation/form/form.component.html
@@ -1,95 +1,91 @@
-<form clrForm [formGroup]="formGroup" (ngSubmit)="onFormSubmit()">
-  <div class="card">
-    <div class="card-block">
-      <h3 class="card-title">{{ title }}</h3>
-      <ng-container *ngFor="let field of form.fields">
-        <ng-container [ngSwitch]="field.type">
-          <ng-container *ngSwitchCase="'checkbox'">
-            <clr-checkbox-container>
-              <label [for]="field.name">{{ field.label }}</label>
-              <clr-checkbox-wrapper
-                *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
-              >
-                <input
-                  type="checkbox"
-                  clrCheckbox
-                  [formControlName]="field.name"
-                  [value]="opt.value"
-                  [checked]="opt.checked"
-                />
-                <label>{{ opt.label }}</label>
-              </clr-checkbox-wrapper>
-            </clr-checkbox-container>
-          </ng-container>
-          <ng-container *ngSwitchCase="'radio'">
-            <clr-radio-container>
-              <label [for]="field.name">{{ field.label }}</label>
-              <clr-radio-wrapper
-                *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
-              >
-                <input
-                  type="radio"
-                  clrRadio
-                  [formControlName]="field.name"
-                  [value]="opt.value"
-                />
-                <label>{{ opt.label }}</label>
-              </clr-radio-wrapper>
-            </clr-radio-container>
-          </ng-container>
-          <ng-container *ngSwitchCase="'text'">
-            <clr-input-container>
-              <label [for]="field.name">{{ field.label }}</label>
-              <input clrInput type="text" [formControlName]="field.name" />
-            </clr-input-container>
-          </ng-container>
-          <ng-container *ngSwitchCase="'number'">
-            <clr-input-container>
-              <label [for]="field.name">{{ field.label }}</label>
-              <input clrInput type="number" [formControlName]="field.name" />
-            </clr-input-container>
-          </ng-container>
-          <ng-container *ngSwitchCase="'password'">
-            <clr-input-container>
-              <label [for]="field.name">{{ field.label }}</label>
-              <input clrInput type="password" [formControlName]="field.name" />
-            </clr-input-container>
-          </ng-container>
-          <ng-container *ngSwitchCase="'select'">
-            <clr-select-container>
-              <label [for]="field.name">{{ field.label }}</label>
-              <select
-                clrSelect
-                [formControlName]="field.name"
-                [multiple]="field.configuration.multiple"
-              >
-                <option
-                  *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
-                  [value]="opt.value"
-                >
-                  {{ opt.label }}
-                </option>
-              </select>
-            </clr-select-container>
-          </ng-container>
-          <ng-container *ngSwitchCase="'textarea'">
-            <clr-textarea-container>
-              <label [for]="field.name">{{ field.label }}</label>
-              <textarea clrTextarea [formControlName]="field.name"></textarea>
-            </clr-textarea-container>
-          </ng-container>
-          <ng-container *ngSwitchCase="'hidden'"> </ng-container>
-          <ng-container *ngSwitchDefault>
-            Unable to display form field type {{ field.type }}
-          </ng-container>
-        </ng-container>
+<form clrForm [formGroup]="formGroup" >
+  <ng-container *ngFor="let field of form.fields; trackBy: trackByFn">
+    <ng-container [ngSwitch]="field.type">
+      <ng-container *ngSwitchCase="'checkbox'">
+        <clr-checkbox-container>
+          <label [for]="field.name">{{ field.label }}</label>
+          <clr-checkbox-wrapper
+            *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
+          >
+            <input
+              type="checkbox"
+              clrCheckbox
+              [formControlName]="field.name"
+              [value]="opt.value"
+              [checked]="opt.checked"
+            />
+            <label>{{ opt.label }}</label>
+          </clr-checkbox-wrapper>
+          <clr-control-error>{{ field.error }}</clr-control-error>
+        </clr-checkbox-container>
       </ng-container>
-    </div>
-    <div class="card-footer">
-      <button class="btn btn-primary btn-sm" type="submit">Submit</button>
-      <button class="btn btn-sm" type="button" (click)="onFormCancel()">
-        Cancel
-      </button>
-    </div>
-  </div>
+      <ng-container *ngSwitchCase="'radio'">
+        <clr-radio-container>
+          <label [for]="field.name">{{ field.label }}</label>
+          <clr-radio-wrapper
+            *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
+          >
+            <input
+              type="radio"
+              clrRadio
+              [formControlName]="field.name"
+              [value]="opt.value"
+            />
+            <label>{{ opt.label }}</label>
+          </clr-radio-wrapper>
+          <clr-control-error>{{ field.error }}</clr-control-error>
+        </clr-radio-container>
+      </ng-container>
+      <ng-container *ngSwitchCase="'text'">
+        <clr-input-container>
+          <label [for]="field.name">{{ field.label }}</label>
+          <input clrInput type="text" [formControlName]="field.name" />
+          <clr-control-error>{{ field.error }}</clr-control-error>
+        </clr-input-container>
+      </ng-container>
+      <ng-container *ngSwitchCase="'number'">
+        <clr-input-container>
+          <label [for]="field.name">{{ field.label }}</label>
+          <input clrInput type="number" [formControlName]="field.name" />
+          <clr-control-error>{{ field.error }}</clr-control-error>
+        </clr-input-container>
+      </ng-container>
+      <ng-container *ngSwitchCase="'password'">
+        <clr-input-container>
+          <label [for]="field.name">{{ field.label }}</label>
+          <input clrInput type="password" [formControlName]="field.name" />
+          <clr-control-error>{{ field.error }}</clr-control-error>
+        </clr-input-container>
+      </ng-container>
+      <ng-container *ngSwitchCase="'select'">
+        <clr-select-container>
+          <label [for]="field.name">{{ field.label }}</label>
+          <select
+            clrSelect
+            [formControlName]="field.name"
+            [multiple]="field.configuration.multiple"
+          >
+            <option
+              *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
+              [value]="opt.value"
+            >
+              {{ opt.label }}
+            </option>
+          </select>
+          <clr-control-error>{{ field.error }}</clr-control-error>
+        </clr-select-container>
+      </ng-container>
+      <ng-container *ngSwitchCase="'textarea'">
+        <clr-textarea-container>
+          <label [for]="field.name">{{ field.label }}</label>
+          <textarea clrTextarea [formControlName]="field.name"></textarea>
+          <clr-control-error>{{ field.error }}</clr-control-error>
+        </clr-textarea-container>
+      </ng-container>
+      <ng-container *ngSwitchCase="'hidden'"> </ng-container>
+      <ng-container *ngSwitchDefault>
+        Unable to display form field type {{ field.type }}
+      </ng-container>
+    </ng-container>
+  </ng-container>
 </form>
diff --git a/web/src/app/modules/shared/components/presentation/form/form.component.spec.ts b/web/src/app/modules/shared/components/presentation/form/form.component.spec.ts
index f3bff5c1..91fcfaea 100644
--- a/web/src/app/modules/shared/components/presentation/form/form.component.spec.ts
+++ b/web/src/app/modules/shared/components/presentation/form/form.component.spec.ts
@@ -25,8 +25,6 @@ describe('FormComponent', () => {
     component.form = {
       fields: [],
     };
-    component.title = 'Title';
-
     fixture.detectChanges();
   });
 
diff --git a/web/src/app/modules/shared/components/presentation/form/form.component.ts b/web/src/app/modules/shared/components/presentation/form/form.component.ts
index 1dc94169..f0cb7053 100644
--- a/web/src/app/modules/shared/components/presentation/form/form.component.ts
+++ b/web/src/app/modules/shared/components/presentation/form/form.component.ts
@@ -2,13 +2,13 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { Component, Input, OnInit, Output } from '@angular/core';
 import { ActionField, ActionForm } from '../../../models/content';
 import {
-  AbstractControl,
   FormBuilder,
-  FormControl,
   FormGroup,
+  ValidatorFn,
+  Validators,
 } from '@angular/forms';
 
 interface Choice {
@@ -26,44 +26,40 @@ export class FormComponent implements OnInit {
   @Input()
   form: ActionForm;
 
-  @Input()
-  title: string;
-
-  @Output()
-  submit: EventEmitter<FormGroup> = new EventEmitter(true);
-
-  @Output()
-  cancel: EventEmitter<boolean> = new EventEmitter(true);
-
   formGroup: FormGroup;
 
   constructor(private formBuilder: FormBuilder) {}
 
   ngOnInit() {
     if (this.form) {
-      const controls: { [name: string]: AbstractControl } = {};
+      const controls: { [name: string]: any } = {};
       this.form.fields.forEach(field => {
-        const value = field.value;
-        controls[field.name] = new FormControl(value);
+        controls[field.name] = [
+          field.value,
+          this.getValidators(field.validators),
+        ];
       });
 
       this.formGroup = this.formBuilder.group(controls);
     }
   }
 
-  onFormSubmit() {
-    this.submit.emit(this.formGroup);
-  }
-
-  onFormCancel() {
-    this.cancel.emit(true);
+  getValidators(validators: string[]): ValidatorFn[] {
+    if (validators) {
+      const vFn: ValidatorFn[] = [];
+      validators.forEach(v => {
+        vFn.push(Validators[v]);
+      });
+      return vFn;
+    }
+    return [];
   }
 
   fieldChoices(field: ActionField) {
     return field.configuration.choices as Choice[];
   }
 
-  trackByFn(index, item) {
+  trackByFn(index, _) {
     return index;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/modal/modal.component.html b/web/src/app/modules/shared/components/presentation/modal/modal.component.html
new file mode 100644
index 00000000..cf443237
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/modal/modal.component.html
@@ -0,0 +1,27 @@
+<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="false" [clrModalClosable]="true" [clrModalSize]="size">
+  <h3 class="modal-title">
+    <app-view-title [views]="title"></app-view-title>
+  </h3>
+  <div class="modal-body">
+    <app-view-container [view]="body"></app-view-container>
+    <app-form *ngIf="form?.fields" #modalAppForm [form]="form"></app-form>
+  </div>
+  <div class="modal-footer">
+    <ng-container *ngIf="buttons">
+      <ng-container *ngFor="let button of buttons; trackBy: trackByFn; last as isLast">
+        <button type="button" class="btn" [ngClass]="(isLast)?'btn-primary':'btn-outline'" (click)="onClick(button.payload)">
+          {{ button.name }}
+        </button>
+      </ng-container>
+    </ng-container>
+    <ng-container *ngIf="form?.fields">
+      <button type="button" class="btn btn-outline" (click)="opened = false">Cancel</button>
+      <button type="button" class="btn btn-primary submit" (click)="onFormSubmit()">Submit</button>
+    </ng-container>
+    <ng-container *ngIf="!buttons && !form?.fields">
+      <button type="button" class="btn btn-primary" (click)="opened = false">
+        Close
+      </button>
+    </ng-container>
+  </div>
+</clr-modal>
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.sass b/web/src/app/modules/shared/components/presentation/modal/modal.component.scss
similarity index 100%
rename from web/src/app/modules/shared/components/presentation/stepper/stepper.component.sass
rename to web/src/app/modules/shared/components/presentation/modal/modal.component.scss
diff --git a/web/src/app/modules/shared/components/presentation/modal/modal.component.spec.ts b/web/src/app/modules/shared/components/presentation/modal/modal.component.spec.ts
new file mode 100644
index 00000000..42dcf154
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/modal/modal.component.spec.ts
@@ -0,0 +1,31 @@
+// Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ModalComponent } from './modal.component';
+import { SharedModule } from '../../../shared.module';
+import { ModalService } from '../../../services/modal/modal.service';
+
+describe('ModalComponent', () => {
+  let component: ModalComponent;
+  let fixture: ComponentFixture<ModalComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [SharedModule],
+      declarations: [ModalComponent],
+      providers: [{ provide: ModalService }],
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(ModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/web/src/app/modules/shared/components/presentation/modal/modal.component.ts b/web/src/app/modules/shared/components/presentation/modal/modal.component.ts
new file mode 100644
index 00000000..62348088
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/modal/modal.component.ts
@@ -0,0 +1,87 @@
+import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
+import {
+  TitleView,
+  ModalView,
+  View,
+  ActionForm,
+  Button,
+} from '../../../models/content';
+import { FormComponent } from '../form/form.component';
+import { ModalService } from '../../../services/modal/modal.service';
+import { Subscription } from 'rxjs';
+import { WebsocketService } from '../../../services/websocket/websocket.service';
+import { ActionService } from '../../../services/action/action.service';
+
+interface Choice {
+  label: string;
+  value: string;
+  checked: boolean;
+}
+
+@Component({
+  selector: 'app-view-modal',
+  templateUrl: './modal.component.html',
+  styleUrls: ['./modal.component.scss'],
+})
+export class ModalComponent
+  extends AbstractViewComponent<ModalView>
+  implements OnInit, OnDestroy {
+  @ViewChild('modalAppForm') modalAppForm: FormComponent;
+
+  title: TitleView[];
+  body: View;
+  form: ActionForm;
+  opened = false;
+  size: string;
+  action: string;
+  buttons: Button[];
+
+  private modalSubscription: Subscription;
+
+  constructor(
+    private actionService: ActionService,
+    private modalService: ModalService,
+    private websocketService: WebsocketService
+  ) {
+    super();
+  }
+
+  ngOnInit() {
+    this.modalSubscription = this.modalService.isOpened.subscribe(isOpened => {
+      this.opened = isOpened;
+    });
+  }
+
+  ngOnDestroy(): void {
+    if (this.modalSubscription) {
+      this.modalSubscription.unsubscribe();
+    }
+  }
+
+  update() {
+    this.title = this.v.metadata.title as TitleView[];
+    this.body = this.v.config.body;
+    this.size = this.v.config.size;
+    this.form = this.v.config.form;
+    this.opened = this.v.config.opened;
+    this.modalService.setState(this.opened);
+    this.action = this.form?.action;
+    this.buttons = this.v.config.buttons;
+  }
+
+  onFormSubmit() {
+    if (this.modalAppForm && this.modalAppForm.formGroup.valid) {
+      this.websocketService.sendMessage('action.octant.dev/performAction', {
+        action: this.action,
+        formGroup: this.modalAppForm.formGroup.value,
+      });
+      this.opened = false;
+    }
+  }
+
+  onClick(payload: {}) {
+    this.actionService.perform(payload);
+    this.opened = false;
+  }
+}
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.scss b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts
index b6fa83ee..7835d6a3 100644
--- a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts
+++ b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts
@@ -18,7 +18,7 @@ interface Choice {
 @Component({
   selector: 'app-stepper',
   templateUrl: './stepper.component.html',
-  styleUrls: ['./stepper.component.sass'],
+  styleUrls: ['./stepper.component.scss'],
 })
 export class StepperComponent extends AbstractViewComponent<StepperView> {
   @Output()
diff --git a/web/src/app/modules/shared/components/presentation/summary/summary.component.html b/web/src/app/modules/shared/components/presentation/summary/summary.component.html
index a468add0..5e11bfb4 100644
--- a/web/src/app/modules/shared/components/presentation/summary/summary.component.html
+++ b/web/src/app/modules/shared/components/presentation/summary/summary.component.html
@@ -1,45 +1,58 @@
-<ng-container *ngTemplateOutlet="currentAction ? action : content">
-</ng-container>
+<div class="card">
+  <div class="progress loop" *ngIf="isLoading">
+    <progress></progress>
+  </div>
+  <div class="card-block">
+    <ng-container *ngTemplateOutlet="currentAction ? action : content">
+    </ng-container>
+  </div>
+  <ng-container *ngTemplateOutlet="currentAction ? formFooter :  contentFooter"></ng-container>
+</div>
 
 <ng-template #action>
   <app-form
+    #appForm
     [form]="currentAction.form"
-    [title]="currentAction.title"
-    (submit)="onActionSubmit($event)"
-    (cancel)="onActionCancel()"
   >
   </app-form>
 </ng-template>
 
 <ng-template #content>
-  <div class="card">
-    <div class="progress loop" *ngIf="isLoading">
-      <progress></progress>
-    </div>
-    <div class="card-block">
-      <h3 class="card-title">{{ title }}</h3>
+  <div class="card-block">
+    <h3 class="card-title">{{ title }}</h3>
 
-      <app-alert *ngIf="v?.config.alert" [alert]="v.config.alert"></app-alert>
+    <app-alert *ngIf="v?.config.alert" [alert]="v.config.alert"></app-alert>
 
-      <table class="table-noborder">
-        <tbody>
-          <tr *ngFor="let item of v?.config.sections; trackBy: identifyItem">
-            <td class="left">{{ item.header }}</td>
-            <td class="left">
-              <app-view-container [view]="item.content"></app-view-container>
-            </td>
-          </tr>
-        </tbody>
-      </table>
-    </div>
-    <div class="card-footer" *ngIf="shouldShowFooter()">
-      <ng-container
-        *ngFor="let action of v.config.actions; trackBy: identifyItem"
-      >
-        <button class="btn btn-sm btn-link" (click)="setAction(action)">
-          {{ action.name }}
-        </button>
-      </ng-container>
-    </div>
+    <table class="table-noborder">
+      <tbody>
+        <tr *ngFor="let item of v?.config.sections; trackBy: identifyItem">
+          <td class="left">{{ item.header }}</td>
+          <td class="left">
+            <app-view-container [view]="item.content"></app-view-container>
+          </td>
+        </tr>
+      </tbody>
+    </table>
   </div>
 </ng-template>
+
+<ng-template #contentFooter>
+  <div class="card-footer" *ngIf="shouldShowFooter()">
+    <ng-container
+      *ngFor="let action of v.config.actions; trackBy: identifyItem"
+    >
+      <button class="btn btn-sm btn-link" (click)="setAction(action)">
+        {{ action.name }}
+      </button>
+    </ng-container>
+  </div>
+</ng-template>
+
+<ng-template #formFooter>
+  <div class="card-footer">
+    <button class="btn btn-primary btn-sm" type="submit" (click)="onActionSubmit()">Submit</button>
+    <button class="btn btn-sm" type="button" (click)="onActionCancel()">
+      Cancel
+    </button>
+  </div>
+</ng-template>
\ No newline at end of file
diff --git a/web/src/app/modules/shared/components/presentation/summary/summary.component.ts b/web/src/app/modules/shared/components/presentation/summary/summary.component.ts
index 7900bc58..fa303fb5 100644
--- a/web/src/app/modules/shared/components/presentation/summary/summary.component.ts
+++ b/web/src/app/modules/shared/components/presentation/summary/summary.component.ts
@@ -2,7 +2,7 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component } from '@angular/core';
+import { Component, ViewChild } from '@angular/core';
 import {
   Action,
   SummaryItem,
@@ -10,6 +10,7 @@ import {
 } from 'src/app/modules/shared/models/content';
 import { FormGroup } from '@angular/forms';
 import { ActionService } from '../../../services/action/action.service';
+import { FormComponent } from '../form/form.component';
 import { ViewService } from '../../../services/view/view.service';
 import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
@@ -19,6 +20,8 @@ import { AbstractViewComponent } from '../../abstract-view/abstract-view.compone
   styleUrls: ['./summary.component.scss'],
 })
 export class SummaryComponent extends AbstractViewComponent<SummaryView> {
+  @ViewChild('appForm') appForm: FormComponent;
+
   title: string;
   isLoading = false;
 
@@ -48,9 +51,9 @@ export class SummaryComponent extends AbstractViewComponent<SummaryView> {
     this.currentAction = action;
   }
 
-  onActionSubmit(formGroup: FormGroup) {
-    if (formGroup && formGroup.value) {
-      this.actionService.perform(formGroup.value);
+  onActionSubmit() {
+    if (this.appForm?.formGroup && this.appForm?.formGroup.value) {
+      this.actionService.perform(this.appForm.formGroup.value);
       this.currentAction = undefined;
     }
   }
diff --git a/web/src/app/modules/shared/dynamic-components.ts b/web/src/app/modules/shared/dynamic-components.ts
index b1af6c26..106d1292 100644
--- a/web/src/app/modules/shared/dynamic-components.ts
+++ b/web/src/app/modules/shared/dynamic-components.ts
@@ -37,6 +37,7 @@ import { SummaryComponent } from './components/presentation/summary/summary.comp
 import { StepperComponent } from './components/presentation/stepper/stepper.component';
 import { TimestampComponent } from './components/presentation/timestamp/timestamp.component';
 import { ContainersComponent } from './components/presentation/containers/containers.component';
+import { ModalComponent } from './components/presentation/modal/modal.component';
 
 export interface ComponentMapping {
   [key: string]: Type<any>;
@@ -62,6 +63,7 @@ const DynamicComponentMapping: ComponentMapping = {
   link: LinkComponent,
   list: ListComponent,
   logs: LogsComponent,
+  modal: ModalComponent,
   podStatus: PodStatusComponent,
   portforward: PortForwardComponent,
   ports: PortsComponent,
diff --git a/web/src/app/modules/shared/models/content.ts b/web/src/app/modules/shared/models/content.ts
index bd64dea0..82017e93 100644
--- a/web/src/app/modules/shared/models/content.ts
+++ b/web/src/app/modules/shared/models/content.ts
@@ -109,6 +109,7 @@ export interface Button {
   payload: {};
   name: string;
   confirmation?: Confirmation;
+  modal?: ModalView;
 }
 
 export interface ButtonGroupView extends View {
@@ -185,6 +186,16 @@ export interface SingleStatView extends View {
   };
 }
 
+export interface ModalView extends View {
+  config: {
+    body?: View;
+    opened: boolean;
+    size?: string;
+    form?: ActionForm;
+    buttons?: Button[];
+  };
+}
+
 export interface PodSummary {
   details: View[];
   status: string;
@@ -279,6 +290,7 @@ export interface ActionField {
 
 export interface ActionForm {
   fields: ActionField[];
+  action?: string;
 }
 
 export interface Action {
diff --git a/web/src/app/modules/shared/services/loading/loading.service.spec.ts b/web/src/app/modules/shared/services/loading/loading.service.spec.ts
index 52d8dacf..4fa9100c 100644
--- a/web/src/app/modules/shared/services/loading/loading.service.spec.ts
+++ b/web/src/app/modules/shared/services/loading/loading.service.spec.ts
@@ -14,7 +14,7 @@ describe('LoadingService', () => {
   );
 
   it('should be created', () => {
-    const service: LoadingService = TestBed.get(LoadingService);
+    const service: LoadingService = TestBed.inject(LoadingService);
     expect(service).toBeTruthy();
   });
 });
diff --git a/web/src/app/modules/shared/services/modal/modal.service.spec.ts b/web/src/app/modules/shared/services/modal/modal.service.spec.ts
new file mode 100644
index 00000000..8c0025b5
--- /dev/null
+++ b/web/src/app/modules/shared/services/modal/modal.service.spec.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { ModalService } from './modal.service';
+
+describe('ModalService', () => {
+  beforeEach(() =>
+    TestBed.configureTestingModule({
+      providers: [ModalService],
+    })
+  );
+
+  it('should be created', () => {
+    const service: ModalService = TestBed.inject(ModalService);
+    expect(service).toBeTruthy();
+  });
+});
diff --git a/web/src/app/modules/shared/services/modal/modal.service.ts b/web/src/app/modules/shared/services/modal/modal.service.ts
new file mode 100644
index 00000000..e06e0f0f
--- /dev/null
+++ b/web/src/app/modules/shared/services/modal/modal.service.ts
@@ -0,0 +1,28 @@
+// Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+
+@Injectable({
+  providedIn: 'root',
+})
+export class ModalService {
+  private modalOpened = new BehaviorSubject<boolean>(false);
+  isOpened = this.modalOpened.asObservable();
+
+  constructor() {}
+
+  openModal() {
+    this.modalOpened.next(true);
+  }
+
+  closeModal() {
+    this.modalOpened.next(false);
+  }
+
+  setState(opened: boolean) {
+    this.modalOpened.next(opened);
+  }
+}
diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts
index dca0c2fa..fbb2678f 100644
--- a/web/src/app/modules/shared/shared.module.ts
+++ b/web/src/app/modules/shared/shared.module.ts
@@ -32,6 +32,7 @@ import { TimestampComponent } from './components/presentation/timestamp/timestam
 import { LoadingComponent } from './components/presentation/loading/loading.component';
 import { HighlightModule } from 'ngx-highlightjs';
 import { LabelSelectorComponent } from './components/presentation/label-selector/label-selector.component';
+import { ModalComponent } from './components/presentation/modal/modal.component';
 import { CytoscapeComponent } from './components/presentation/cytoscape/cytoscape.component';
 import { Cytoscape2Component } from './components/presentation/cytoscape2/cytoscape2.component';
 import { SelectorsComponent } from './components/presentation/selectors/selectors.component';
@@ -113,6 +114,7 @@ import { MissingComponentComponent } from './components/missing-component/missin
     ListComponent,
     LoadingComponent,
     LogsComponent,
+    ModalComponent,
     ObjectStatusComponent,
     PodStatusComponent,
     PortForwardComponent,
@@ -179,6 +181,7 @@ import { MissingComponentComponent } from './components/missing-component/missin
     ListComponent,
     LoadingComponent,
     LogsComponent,
+    ModalComponent,
     ObjectStatusComponent,
     PodStatusComponent,
     PortForwardComponent,
@@ -255,6 +258,7 @@ import { MissingComponentComponent } from './components/missing-component/missin
     ListComponent,
     LoadingComponent,
     LogsComponent,
+    ModalComponent,
     ObjectStatusComponent,
     PodStatusComponent,
     PortForwardComponent,
diff --git a/web/src/stories/modal.stories.mdx b/web/src/stories/modal.stories.mdx
new file mode 100644
index 00000000..1c587adc
--- /dev/null
+++ b/web/src/stories/modal.stories.mdx
@@ -0,0 +1,121 @@
+import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs/blocks';
+import { object, withKnobs } from '@storybook/addon-knobs';
+import { ModalComponent } from '../app/modules/shared/components/presentation/modal/modal.component';
+
+export const modalDocs = { source: { code: `modal := component.NewModal(component.TitleFromString("Modal Title"))
+  modal.SetBody(component.NewText("Modal Body"))
+  modal.SetSize(component.ModalSizeLarge)
+  modal.Open()`
+}}
+
+export const modalFormDocs = { source: { code: `modal := component.NewModal(component.TitleFromString("Modal Title"))
+  modal.SetBody(component.NewText("Modal Body"))
+  fft := component.NewFormFieldText("textFormLabel", "textFormName", "")
+	fft.AddValidator("placeholder", "this is an error", []string{"required"})
+  form := component.Form{
+    Fields: []component.FormField{
+      fft,
+    },
+  }
+  modal.AddForm(form)
+  modal.SetSize(component.ModalSizeLarge)
+  modal.Open()`
+}}
+
+export const textView = {
+  config: {
+    value: 'Modal Title',
+  },
+  metadata: {
+    type: 'text',
+  },
+};
+
+export const bodyView = {
+  config: {
+    value: 'Modal body',
+  },
+  metadata: {
+    type: 'text',
+  },
+};
+
+export const modalView = {
+  metadata: {
+    type: 'modal',
+    title: [textView],
+  },
+  config: {
+    body: bodyView,
+    opened: true,
+    size: 'lg',
+  },
+};
+
+export const formFields = {
+  fields: [
+    {
+      configuration: {},
+      error: 'this is an error',
+      label: 'textFormLabel',
+      name: 'textFormName',
+      placeholder: 'placeholder',
+      type: 'text',
+      validators: ["required"],
+      value: '',
+    },
+  ],
+};
+
+export const modalFormView = {
+  metadata: {
+    type: 'modal',
+    title: [textView],
+  },
+  config: {
+    body: bodyView,
+    form: formFields,
+    opened: true,
+    size: 'lg',
+  },
+};
+
+<h1>Modal component</h1>
+<h2>Description</h2>
+
+<p>The modal component provides a modal</p>
+<h2>Example</h2>
+
+<Meta title="Components/Modal" component={ModalComponent} />
+
+<Canvas withToolbar>
+<Story name="Modal component" parameters={{ docs: modalDocs }} height="500px" >
+  {{
+    props: {
+      view: object('View', modalView),
+    },
+    component: ModalComponent,
+  }}
+</Story>
+</Canvas>
+
+<h2>Props</h2>
+<ArgsTable of={ModalComponent} />
+
+<p>
+Modal Component with a form
+</p>
+
+<Canvas withToolbar>
+  <Story name="Modal component with form" parameters={{ docs: modalFormDocs }} height="500px" >
+  {{
+      props: {
+        view: object('View', modalFormView),
+      },
+      component: ModalComponent,
+  }}
+  </Story>
+</Canvas>
+
+<h2>Props</h2>
+<ArgsTable of={ModalComponent} />
\ No newline at end of file
diff --git a/web/tsconfig.compodoc.json b/web/tsconfig.compodoc.json
index cbbea671..2179a401 100644
--- a/web/tsconfig.compodoc.json
+++ b/web/tsconfig.compodoc.json
@@ -160,6 +160,8 @@
     "src//app/modules/shared/components/presentation/containers/containers.component.ts",
     "src//app/modules/shared/components/presentation/text/text.component.spec.ts",
     "src//app/modules/shared/components/presentation/text/text.component.ts",
+    "src//app/modules/shared/components/presentation/modal/modal.component.spec.ts",
+    "src//app/modules/shared/components/presentation/modal/modal.component.ts",
     "src//app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.spec.ts",
     "src//app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.ts",
     "src//app/modules/shared/components/presentation/timestamp/timestamp.component.spec.ts",


Test patch:
diff --git a/pkg/view/component/modal_test.go b/pkg/view/component/modal_test.go
new file mode 100644
index 00000000..e3a67132
--- /dev/null
+++ b/pkg/view/component/modal_test.go
@@ -0,0 +1,73 @@
+package component
+
+import (
+	"testing"
+)
+
+func TestModal_SetBody(t *testing.T) {
+	modal := NewModal(TitleFromString("modal"))
+	body := NewText("body")
+	modal.SetBody(body)
+
+	expected := NewModal(TitleFromString("modal"))
+	expected.Config.Body = body
+
+	AssertEqual(t, expected, modal)
+}
+
+func TestModal_SetSize(t *testing.T) {
+	tests := []struct {
+		name     string
+		size     ModalSize
+		expected *Modal
+	}{
+		{
+			name: "small",
+			size: ModalSizeSmall,
+			expected: &Modal{
+				Base: newBase(TypeModal, TitleFromString("modal")),
+				Config: ModalConfig{
+					ModalSize: ModalSizeSmall,
+				},
+			},
+		},
+		{
+			name: "large",
+			size: ModalSizeLarge,
+			expected: &Modal{
+				Base: newBase(TypeModal, TitleFromString("modal")),
+				Config: ModalConfig{
+					ModalSize: ModalSizeLarge,
+				},
+			},
+		},
+		{
+			name: "extra large",
+			size: ModalSizeExtraLarge,
+			expected: &Modal{
+				Base: newBase(TypeModal, TitleFromString("modal")),
+				Config: ModalConfig{
+					ModalSize: ModalSizeExtraLarge,
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			modal := NewModal(TitleFromString("modal"))
+			modal.SetSize(test.size)
+
+			AssertEqual(t, test.expected, modal)
+		})
+	}
+}
+
+func TestModal_Open(t *testing.T) {
+	modal := NewModal(TitleFromString("modal"))
+	modal.Open()
+
+	expected := NewModal(TitleFromString("modal"))
+	expected.Config.Opened = true
+	AssertEqual(t, expected, modal)
+}
diff --git a/pkg/view/component/testdata/config_modal.json b/pkg/view/component/testdata/config_modal.json
new file mode 100644
index 00000000..c69824b2
--- /dev/null
+++ b/pkg/view/component/testdata/config_modal.json
@@ -0,0 +1,12 @@
+{
+  "body":{
+    "metadata":{
+      "type":"text"
+    },
+    "config":{
+      "value":"test"
+    }
+  },
+  "opened":true,
+  "size":"sm"
+}
diff --git a/pkg/view/component/unmarshal_test.go b/pkg/view/component/unmarshal_test.go
index 9ceff5f9..f3956c83 100644
--- a/pkg/view/component/unmarshal_test.go
+++ b/pkg/view/component/unmarshal_test.go
@@ -260,6 +260,19 @@ func Test_unmarshal(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:       "modal",
+			configFile: "config_modal.json",
+			objectType: TypeModal,
+			expected: &Modal{
+				Config: ModalConfig{
+					Body:      NewText("test"),
+					Opened:    true,
+					ModalSize: ModalSizeSmall,
+				},
+				Base: newBase(TypeModal, nil),
+			},
+		},
 		{
 			name:       "quadrant",
 			configFile: "config_quadrant.json",