Repository: vmware-tanzu/octant
Pull request #1207 (2020-08-05 06:24:37)

Bump actions/upload-artifact from v2.1.2 to v2.1.3
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from v2.1.2 to v2.1.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's releases</a>.</em></p>
<blockquote>
<h2>v2.1.3</h2>
<ul>
<li>Use updated version of the <code>@action/artifact</code> NPM package</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/actions/upload-artifact/commit/268d7547644ab8a9d0c1163299e59a1f5d93f39b"><code>268d754</code></a> Retry on 413 response codes (<a href="https://github-redirect.dependabot.com/actions/upload-artifact/issues/108">#108</a>)</li>
<li>See full diff in <a href="https://github.com/actions/upload-artifact/compare/v2.1.2...268d7547644ab8a9d0c1163299e59a1f5d93f39b">compare view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml
index 45cffb26..dc4a1e53 100644
--- a/.github/workflows/nightly.yaml
+++ b/.github/workflows/nightly.yaml
@@ -50,7 +50,7 @@ jobs:
           go generate ./web
           go generate ./pkg/plugin/plugin.go
       - name: Upload web/rice-box.go
-        uses: actions/upload-artifact@v2.1.2
+        uses: actions/upload-artifact@v2.1.3
         with:
           name: rice-web
           path: web/rice-box.go
diff --git a/.github/workflows/preflight-checks.yaml b/.github/workflows/preflight-checks.yaml
index a52ba38e..8bddc9f9 100644
--- a/.github/workflows/preflight-checks.yaml
+++ b/.github/workflows/preflight-checks.yaml
@@ -145,7 +145,7 @@ jobs:
           ./build/octant version
       - name: Upload web/rice-box.go
         if: matrix.platform == 'ubuntu-latest'
-        uses: actions/upload-artifact@v2.1.2
+        uses: actions/upload-artifact@v2.1.3
         with:
           name: rice-web
           path: web/rice-box.go
diff --git a/changelogs/unreleased/1102-wwitzel3 b/changelogs/unreleased/1102-wwitzel3
new file mode 100644
index 00000000..b7ef3e54
--- /dev/null
+++ b/changelogs/unreleased/1102-wwitzel3
@@ -0,0 +1,1 @@
+Added stepper component.
diff --git a/internal/modules/clusteroverview/clusteroverview.go b/internal/modules/clusteroverview/clusteroverview.go
index f2dda834..3a87a3a8 100644
--- a/internal/modules/clusteroverview/clusteroverview.go
+++ b/internal/modules/clusteroverview/clusteroverview.go
@@ -180,10 +180,10 @@ func (co *ClusterOverview) Navigation(ctx context.Context, _ string, root string
 	navigationEntries := octant.NavigationEntries{
 		Lookup: map[string]string{
 			"Namespaces":                  "namespaces",
-			"API Server":                  "api-server",
 			"Custom Resources":            "custom-resources",
 			"Custom Resource Definitions": "custom-resource-definitions",
 			"RBAC":                        "rbac",
+			"Webhooks":                    "webhooks",
 			"Nodes":                       "nodes",
 			"Storage":                     "storage",
 			"Port Forwards":               "port-forward",
@@ -191,10 +191,10 @@ func (co *ClusterOverview) Navigation(ctx context.Context, _ string, root string
 		EntriesFuncs: map[string]octant.EntriesFunc{
 			"Cluster Overview":            nil,
 			"Namespaces":                  nil,
-			"API Server":                  apiServerEntries,
 			"Custom Resources":            navigation.CRDEntries,
 			"Custom Resource Definitions": nil,
 			"RBAC":                        rbacEntries,
+			"Webhooks":                    webhookEntries,
 			"Nodes":                       nil,
 			"Storage":                     storageEntries,
 			"Port Forwards":               nil,
@@ -202,10 +202,10 @@ func (co *ClusterOverview) Navigation(ctx context.Context, _ string, root string
 		IconMap: map[string]string{
 			"Cluster Overview":            icon.Overview,
 			"Namespaces":                  icon.Namespaces,
-			"API Server":                  icon.ApiServer,
 			"Custom Resources":            icon.CustomResources,
 			"Custom Resource Definitions": icon.CustomResourceDefinition,
 			"RBAC":                        icon.RBAC,
+			"Webhooks":                    icon.Webhooks,
 			"Nodes":                       icon.Nodes,
 			"Storage":                     icon.ConfigAndStorage,
 			"Port Forwards":               icon.PortForwards,
@@ -213,10 +213,10 @@ func (co *ClusterOverview) Navigation(ctx context.Context, _ string, root string
 		Order: []string{
 			"Cluster Overview",
 			"Namespaces",
-			"API Server",
 			"Custom Resources",
 			"Custom Resource Definitions",
 			"RBAC",
+			"Webhooks",
 			"Nodes",
 			"Storage",
 			"Port Forwards",
@@ -251,15 +251,13 @@ func (co *ClusterOverview) Generators() []octant.Generator {
 	return []octant.Generator{}
 }
 
-func apiServerEntries(ctx context.Context, prefix, namespace string, objectStore store.Store, _ bool) ([]navigation.Navigation, bool, error) {
+func rbacEntries(ctx context.Context, prefix, namespace string, objectStore store.Store, _ bool) ([]navigation.Navigation, bool, error) {
 	neh := navigation.EntriesHelper{}
 
-	neh.Add("API Services", "api-services",
-		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.APIService), objectStore))
-	neh.Add("Mutating Webhooks", "mutating-webhooks",
-		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.MutatingWebhookConfiguration), objectStore))
-	neh.Add("Validating Webhooks", "validating-webhooks",
-		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.ValidatingWebhookConfiguration), objectStore))
+	neh.Add("Cluster Roles", "cluster-roles",
+		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.ClusterRole), objectStore))
+	neh.Add("Cluster Role Bindings", "cluster-role-bindings",
+		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.ClusterRoleBinding), objectStore))
 
 	children, err := neh.Generate(prefix, namespace, "")
 	if err != nil {
@@ -269,13 +267,13 @@ func apiServerEntries(ctx context.Context, prefix, namespace string, objectStore
 	return children, false, nil
 }
 
-func rbacEntries(ctx context.Context, prefix, namespace string, objectStore store.Store, _ bool) ([]navigation.Navigation, bool, error) {
+func webhookEntries(ctx context.Context, prefix, namespace string, objectStore store.Store, _ bool) ([]navigation.Navigation, bool, error) {
 	neh := navigation.EntriesHelper{}
 
-	neh.Add("Cluster Roles", "cluster-roles",
-		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.ClusterRole), objectStore))
-	neh.Add("Cluster Role Bindings", "cluster-role-bindings",
-		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.ClusterRoleBinding), objectStore))
+	neh.Add("Mutating Webhooks", "mutating-webhooks",
+		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.MutatingWebhookConfiguration), objectStore))
+	neh.Add("Validating Webhooks", "validating-webhooks",
+		loading.IsObjectLoading(ctx, namespace, store.KeyFromGroupVersionKind(gvk.ValidatingWebhookConfiguration), objectStore))
 
 	children, err := neh.Generate(prefix, namespace, "")
 	if err != nil {
diff --git a/internal/modules/clusteroverview/objects.go b/internal/modules/clusteroverview/objects.go
index b2a03d22..4bd1b709 100644
--- a/internal/modules/clusteroverview/objects.go
+++ b/internal/modules/clusteroverview/objects.go
@@ -64,6 +64,35 @@ var (
 		rbacClusterRoleBindings,
 	)
 
+	webhooksDescriber = describer.NewSection(
+		"/webhooks",
+		"Webhooks",
+		webhooksMutatingWebhooks,
+		webhooksValidatingWebhooks,
+	)
+
+	webhooksValidatingWebhooks = describer.NewResource(describer.ResourceOptions{
+		Path:           "/webhooks/validating-webhooks",
+		ObjectStoreKey: store.Key{APIVersion: "admissionregistration.k8s.io/v1beta1", Kind: "ValidatingWebhookConfiguration"},
+		ListType:       &admissionregistrationv1beta1.ValidatingWebhookConfigurationList{},
+		ObjectType:     &admissionregistrationv1beta1.ValidatingWebhookConfiguration{},
+		Titles:         describer.ResourceTitle{List: "Validating Webhook Configurations", Object: "Validating Webhook Configuration"},
+		ClusterWide:    true,
+		IconName:       icon.Webhooks,
+		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
+	})
+
+	webhooksMutatingWebhooks = describer.NewResource(describer.ResourceOptions{
+		Path:           "/webhooks/mutating-webhooks",
+		ObjectStoreKey: store.Key{APIVersion: "admissionregistration.k8s.io/v1beta1", Kind: "MutatingWebhookConfiguration"},
+		ListType:       &admissionregistrationv1beta1.MutatingWebhookConfigurationList{},
+		ObjectType:     &admissionregistrationv1beta1.MutatingWebhookConfiguration{},
+		Titles:         describer.ResourceTitle{List: "Mutating Webhook Configurations", Object: "Mutating Webhook Configuration"},
+		ClusterWide:    true,
+		IconName:       icon.Webhooks,
+		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
+	})
+
 	nodesDescriber = describer.NewResource(describer.ResourceOptions{
 		Path:                  "/nodes",
 		ObjectStoreKey:        store.Key{APIVersion: "v1", Kind: "Node"},
@@ -111,8 +140,6 @@ var (
 		"/api-server",
 		"API Server",
 		apiServerApiServices,
-		apiServerMutatingWebhooks,
-		apiServerValidatingWebhooks,
 	)
 
 	apiServerApiServices = describer.NewResource(describer.ResourceOptions{
@@ -126,38 +153,17 @@ var (
 		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
 	})
 
-	apiServerValidatingWebhooks = describer.NewResource(describer.ResourceOptions{
-		Path:           "/api-server/validating-webhooks",
-		ObjectStoreKey: store.Key{APIVersion: "admissionregistration.k8s.io/v1beta1", Kind: "ValidatingWebhookConfiguration"},
-		ListType:       &admissionregistrationv1beta1.ValidatingWebhookConfigurationList{},
-		ObjectType:     &admissionregistrationv1beta1.ValidatingWebhookConfiguration{},
-		Titles:         describer.ResourceTitle{List: "Validating Webhook Configurations", Object: "Validating Webhook Configuration"},
-		ClusterWide:    true,
-		IconName:       icon.ApiServer,
-		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
-	})
-
-	apiServerMutatingWebhooks = describer.NewResource(describer.ResourceOptions{
-		Path:           "/api-server/mutating-webhooks",
-		ObjectStoreKey: store.Key{APIVersion: "admissionregistration.k8s.io/v1beta1", Kind: "MutatingWebhookConfiguration"},
-		ListType:       &admissionregistrationv1beta1.MutatingWebhookConfigurationList{},
-		ObjectType:     &admissionregistrationv1beta1.MutatingWebhookConfiguration{},
-		Titles:         describer.ResourceTitle{List: "Mutating Webhook Configurations", Object: "Mutating Webhook Configuration"},
-		ClusterWide:    true,
-		IconName:       icon.ApiServer,
-		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
-	})
-
 	rootDescriber = describer.NewSection(
 		"/",
 		"Cluster Overview",
 		namespacesDescriber,
-		apiServerDescriber,
 		customResourcesDescriber,
 		crdsDescriber,
 		rbacDescriber,
+		webhooksDescriber,
 		nodesDescriber,
 		storageDescriber,
 		portForwardDescriber,
+		apiServerDescriber,
 	)
 )
diff --git a/internal/modules/clusteroverview/path.go b/internal/modules/clusteroverview/path.go
index 4cec282f..0c046c42 100644
--- a/internal/modules/clusteroverview/path.go
+++ b/internal/modules/clusteroverview/path.go
@@ -54,9 +54,9 @@ func gvkPath(namespace, apiVersion, kind, name string) (string, error) {
 	case apiVersion == "apiregistration.k8s.io/v1" && kind == "APIService":
 		p = "/api-server/api-services"
 	case apiVersion == "admissionregistration.k8s.io/v1beta1" && kind == "MutatingWebhookConfiguration":
-		p = "/api-server/mutating-webhooks"
+		p = "/webhooks/mutating-webhooks"
 	case apiVersion == "admissionregistration.k8s.io/v1beta1" && kind == "ValidatingWebhookConfiguration":
-		p = "/api-server/validating-webhooks"
+		p = "/webhooks/validating-webhooks"
 	default:
 		return "", fmt.Errorf("unknown object %s %s", apiVersion, kind)
 	}
diff --git a/pkg/icon/icon.go b/pkg/icon/icon.go
index 0301a69a..6235bb6e 100644
--- a/pkg/icon/icon.go
+++ b/pkg/icon/icon.go
@@ -18,6 +18,7 @@ const (
 	Namespaces      = "namespace"
 	ApiServer       = "hard-disk"
 	CustomResources = "file-group"
+	Webhooks        = "animation"
 	Nodes           = "nodes"
 	PortForwards    = "router"
 
diff --git a/pkg/view/component/base.go b/pkg/view/component/base.go
index 6d63886c..ffa4e786 100644
--- a/pkg/view/component/base.go
+++ b/pkg/view/component/base.go
@@ -34,6 +34,7 @@ const (
 	typeResourceViewer     = "resourceViewer"
 	typeSelectors          = "selectors"
 	typeSingleStat         = "singleStat"
+	typeStepper            = "stepper"
 	typeSummary            = "summary"
 	typeTable              = "table"
 	typeTerminal           = "terminal"
diff --git a/pkg/view/component/stepper.go b/pkg/view/component/stepper.go
new file mode 100644
index 00000000..80321884
--- /dev/null
+++ b/pkg/view/component/stepper.go
@@ -0,0 +1,50 @@
+/*
+Copyright (c) 2019 the Octant contributors. All Rights Reserved.
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package component
+
+import "encoding/json"
+
+// Stepper component implements json.Marshaler
+type Stepper struct {
+	base
+	Config StepperConfig `json:"config"`
+}
+
+type stepperMarshal Stepper
+
+func (t *Stepper) MarshalJSON() ([]byte, error) {
+	m := stepperMarshal(*t)
+	m.Metadata.Type = typeStepper
+	return json.Marshal(&m)
+}
+
+type StepperConfig struct {
+	Action string       `json:"action"`
+	Steps  []StepConfig `json:"steps"`
+}
+
+func (t *StepperConfig) UnmarshalJSON(data []byte) error {
+	x := struct {
+		Action string
+		Steps  []StepConfig
+	}{}
+
+	if err := json.Unmarshal(data, &x); err != nil {
+		return err
+	}
+
+	t.Action = x.Action
+	t.Steps = x.Steps
+
+	return nil
+}
+
+type StepConfig struct {
+	Name        string `json:"name"`
+	Form        Form   `json:"form"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
+}
diff --git a/pkg/view/component/unmarshal.go b/pkg/view/component/unmarshal.go
index 8b09dbf0..07de6c22 100644
--- a/pkg/view/component/unmarshal.go
+++ b/pkg/view/component/unmarshal.go
@@ -7,6 +7,7 @@ package component
 
 import (
 	"encoding/json"
+	"fmt"
 
 	"github.com/pkg/errors"
 )
@@ -136,6 +137,12 @@ func unmarshal(to TypedObject) (Component, error) {
 		err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
 			"unmarshal singleStat config")
 		o = t
+	case typeStepper:
+		t := &Stepper{base: base{Metadata: to.Metadata}}
+		if uErr := json.Unmarshal(to.Config, &t.Config); uErr != nil {
+			err = fmt.Errorf("unmarshal stepper config: %w", uErr)
+		}
+		o = t
 	case typeSummary:
 		t := &Summary{base: base{Metadata: to.Metadata}}
 		err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
diff --git a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.html b/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.html
index ce62b315..c9554baa 100644
--- a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.html
+++ b/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.html
@@ -98,6 +98,9 @@
     <ng-container *ngSwitchCase="'terminal'">
       <app-terminal [view]="view"></app-terminal>
     </ng-container>
+    <ng-container *ngSwitchCase="'stepper'">
+      <app-stepper [view]="view"></app-stepper>
+    </ng-container>
     <div class="alert alert-danger static" *ngSwitchDefault>
       <span class="alert-icon-wrapper">
         <clr-icon class="alert-icon" shape="exclamation-circle"></clr-icon>
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.html b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.html
new file mode 100644
index 00000000..a57c8fe7
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.html
@@ -0,0 +1,114 @@
+<form
+  clrStepper
+  [formGroup]="formGroup"
+>
+  <div
+    class="step-item"
+    *ngFor="let step of steps; index as i; trackBy: trackByFn"
+  >
+    <clr-stepper-panel [formGroupName]="step.name">
+      <clr-step-title>{{ step.title }}</clr-step-title>
+      <clr-step-description>{{ step.description }}</clr-step-description>
+      <clr-step-content>
+        <div *ngFor="let field of step.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>
+                <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" [name]="field.name" [formControlName]="field.name" [placeholder]="field.placeholder ? field.placeholder : ''" />
+                    <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>
+        </div>
+        <button
+          *ngIf="v.config.steps.length - 1 === i; else elseBlock"
+          clrStepButton="submit"
+        >
+          submit
+        </button>
+        <ng-template #elseBlock
+          ><button clrStepButton="next">next</button></ng-template
+        >
+      </clr-step-content>
+    </clr-stepper-panel>
+  </div>
+</form>
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.sass b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.sass
new file mode 100644
index 00000000..e69de29b
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.spec.ts b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.spec.ts
new file mode 100644
index 00000000..87c91d77
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.spec.ts
@@ -0,0 +1,54 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { StepperComponent } from './stepper.component';
+import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
+import { StepperView } from '../../../models/content';
+import {
+  BrowserAnimationsModule,
+  NoopAnimationsModule,
+} from '@angular/platform-browser/animations';
+
+describe('StepperComponent', () => {
+  let component: StepperComponent;
+  let fixture: ComponentFixture<StepperComponent>;
+  const formBuilder: FormBuilder = new FormBuilder();
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [
+        ReactiveFormsModule,
+        BrowserAnimationsModule,
+        NoopAnimationsModule,
+      ],
+      providers: [{ provide: FormBuilder, useValue: formBuilder }],
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(StepperComponent);
+    component = fixture.componentInstance;
+
+    const view: StepperView = {
+      metadata: {
+        type: 'stepper',
+      },
+      config: {
+        action: 'action.octant.dev/test',
+        steps: [
+          {
+            name: 'step name',
+            form: { fields: [] },
+            title: 'step title',
+            description: 'step description',
+          },
+        ],
+      },
+    };
+    component.view = view;
+
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
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
new file mode 100644
index 00000000..e6ab4ed7
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts
@@ -0,0 +1,114 @@
+import {
+  Component,
+  OnInit,
+  Input,
+  SimpleChanges,
+  EventEmitter,
+  Output,
+  OnChanges,
+  OnDestroy,
+  isDevMode,
+} from '@angular/core';
+import {
+  StepperView,
+  View,
+  ActionField,
+  StepItem,
+} from '../../../models/content';
+import {
+  FormGroup,
+  FormBuilder,
+  Validators,
+  ValidatorFn,
+} from '@angular/forms';
+import { WebsocketService } from '../../../services/websocket/websocket.service';
+
+interface Choice {
+  label: string;
+  value: string;
+  checked: boolean;
+}
+
+@Component({
+  selector: 'app-stepper',
+  templateUrl: './stepper.component.html',
+  styleUrls: ['./stepper.component.sass'],
+})
+export class StepperComponent implements OnInit {
+  private v: StepperView;
+
+  @Input() set view(v: View) {
+    this.v = v as StepperView;
+  }
+  get view() {
+    return this.v;
+  }
+
+  @Output()
+  submit: EventEmitter<FormGroup> = new EventEmitter(true);
+
+  @Output()
+  cancel: EventEmitter<boolean> = new EventEmitter(true);
+
+  formGroup: FormGroup;
+  action: string;
+  steps: StepItem[];
+
+  constructor(
+    private formBuilder: FormBuilder,
+    private websocketService: WebsocketService
+  ) {
+    this.steps = [] as StepItem[];
+  }
+
+  ngOnInit() {
+    if (this.v) {
+      this.action = this.v.config.action;
+      this.steps = this.v.config.steps;
+
+      const stepGroups: { [name: string]: any } = {};
+      this.steps?.forEach(step => {
+        const controls: { [name: string]: any } = {};
+        step.form?.fields?.forEach(field => {
+          controls[field.name] = [
+            field.value,
+            this.getValidators(field.validators),
+          ];
+        });
+        stepGroups[step.name] = this.formBuilder.group(controls);
+      });
+      this.formGroup = this.formBuilder.group(stepGroups);
+    }
+  }
+
+  onFormSubmit() {
+    const form = JSON.stringify(this.formGroup);
+    this.websocketService.sendMessage(this.action, form);
+    if (isDevMode()) {
+      console.log('stepper form: ' + form);
+    }
+  }
+
+  onFormCancel() {
+    this.cancel.emit(true);
+  }
+
+  trackByFn(index, item) {
+    return index;
+  }
+
+  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[];
+  }
+}
diff --git a/web/src/app/modules/shared/models/content.ts b/web/src/app/modules/shared/models/content.ts
index 91d29e1b..e69819c8 100644
--- a/web/src/app/modules/shared/models/content.ts
+++ b/web/src/app/modules/shared/models/content.ts
@@ -272,6 +272,9 @@ export interface ActionField {
   name: string;
   type: string;
   value: any;
+  placeholder: string;
+  error: string;
+  validators: string[];
 }
 
 export interface ActionForm {
@@ -494,3 +497,17 @@ export interface CodeView extends View {
     value: string;
   };
 }
+
+export interface StepItem {
+  name: string;
+  form: ActionForm;
+  title: string;
+  description: string;
+}
+
+export interface StepperView extends View {
+  config: {
+    action: string;
+    steps: StepItem[];
+  };
+}
diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts
index 7e60b0c9..4ccf7d75 100644
--- a/web/src/app/modules/shared/shared.module.ts
+++ b/web/src/app/modules/shared/shared.module.ts
@@ -71,6 +71,7 @@ import { HelperComponent } from './components/smart/helper/helper.component';
 import { FilterDeletedDatagridRowPipe } from './pipes/filterDeletedDatagridRow/filter-deleted-datagrid-row.pipe';
 import { ContentTextFilterComponent } from './components/presentation/content-text-filter/content-text-filter.component';
 import { TruncatePipe } from './pipes/truncate/truncate.pipe';
+import { StepperComponent } from './components/presentation/stepper/stepper.component';
 
 @NgModule({
   declarations: [
@@ -137,6 +138,7 @@ import { TruncatePipe } from './pipes/truncate/truncate.pipe';
     PreferencesComponent,
     HelperComponent,
     ContentTextFilterComponent,
+    StepperComponent,
   ],
   imports: [
     ClarityModule,
@@ -210,6 +212,7 @@ import { TruncatePipe } from './pipes/truncate/truncate.pipe';
     OverflowLabelsComponent,
     PreferencesComponent,
     HelperComponent,
+    StepperComponent,
   ],
 })
 export class SharedModule {}
diff --git a/web/src/stories/stepper.stories.mdx b/web/src/stories/stepper.stories.mdx
new file mode 100644
index 00000000..a6e7d1d1
--- /dev/null
+++ b/web/src/stories/stepper.stories.mdx
@@ -0,0 +1,95 @@
+import { moduleMetadata } from '@storybook/angular';
+import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
+import { object, withKnobs } from '@storybook/addon-knobs';
+import { StepperComponent } from '../app/modules/shared/components/presentation/stepper/stepper.component';
+
+export const stepperDocs = {
+  source: { code: `title = "Example Stepper" component.NewStepper()` },
+};
+
+export const stepperView = {
+  metadata: {
+    type: 'stepper',
+  },
+  config: {
+    action: 'octant.dev/test-action',
+    steps: [
+      {
+        title: 'Contact Information',
+        description: 'Personal contact information.',
+        name: 'contact',
+        form: { fields: [
+          {
+            placeholder: 'First name',
+            label: 'First Name',
+            name: 'firstName',
+            type: 'text',
+            value: '',
+            error: 'First name is required.',
+            validators: ['required'],
+          },
+          {
+            label: 'Last Name',
+            name: 'lastName',
+            type: 'text',
+            value: '',
+          },
+        ],
+        },
+      },
+      {
+        title: 'More Information',
+        description: 'Extended information.',
+        name: 'extended',
+        form: {fields: [
+          {
+            label: 'City',
+            name: 'city',
+            type: 'text',
+            value: '',
+          },
+          {
+            label: 'State',
+            name: 'state',
+            type: 'select',
+            error: 'State is required.',
+            validators: ['required'],
+            configuration: {
+              choices: [
+                { label: '', value: '' },
+                { label: 'North Carolina', value: 'NC' },
+              ],
+            },
+          },
+        ],
+        },
+      },
+    ],
+  },
+};
+
+<h1>Stepper component</h1>
+<h2>Description</h2>
+
+<p>The Stepper component provides guided steps to a user</p>
+<h2>Example</h2>
+
+<Meta title="Components/Stepper" component={StepperComponent} />
+
+<Preview withToolbar>
+  <Story
+    name="Stepper component"
+    parameters={{ docs: stepperDocs }}
+    height="450px"
+  >
+    {{
+      props: {
+        view: object('View', stepperView),
+      },
+      component: StepperComponent,
+    }}
+  </Story>
+</Preview>
+
+<h2>Props</h2>
+<Props of={StepperComponent} />
diff --git a/web/tsconfig.compodoc.json b/web/tsconfig.compodoc.json
index 8c5c1dab..a3751745 100644
--- a/web/tsconfig.compodoc.json
+++ b/web/tsconfig.compodoc.json
@@ -178,6 +178,8 @@
     "src//app/modules/shared/components/presentation/summary/summary.component.spec.ts",
     "src//app/modules/shared/components/presentation/selectors/selectors.component.spec.ts",
     "src//app/modules/shared/components/presentation/selectors/selectors.component.ts",
+    "src//app/modules/shared/components/presentation/stepper/stepper.component.spec.ts",
+    "src//app/modules/shared/components/presentation/stepper/stepper.component.ts",
     "src//app/modules/shared/components/presentation/button-group/button-group.component.spec.ts",
     "src//app/modules/shared/components/presentation/button-group/button-group.component.ts",
     "src//app/modules/shared/components/presentation/donut-chart/donut-chart.component.spec.ts",


Test patch:
diff --git a/pkg/view/component/stepper_test.go b/pkg/view/component/stepper_test.go
new file mode 100644
index 00000000..b5a0035e
--- /dev/null
+++ b/pkg/view/component/stepper_test.go
@@ -0,0 +1,62 @@
+package component
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"path"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_stepper_Marshal(t *testing.T) {
+	form := Form{}
+	form.Fields = append(form.Fields, NewFormFieldText("test", "test", "test"))
+
+	tests := []struct {
+		name         string
+		input        Component
+		expectedPath string
+		isErr        bool
+	}{
+		{
+			name: "general",
+			input: &Stepper{
+				base: newBase(typeStepper, TitleFromString("my stepper")),
+				Config: StepperConfig{
+					Action: "action.octant.dev/stepperTest",
+					Steps: []StepConfig{
+						{
+							Name:        "Step 1",
+							Title:       "First Step",
+							Description: "Setup step",
+							Form:        form,
+						},
+						{
+							Name:        "Step 2",
+							Title:       "Second Step",
+							Description: "Confirmation step",
+							Form:        form,
+						},
+					},
+				},
+			},
+			expectedPath: "stepper.json",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			actual, err := json.Marshal(tc.input)
+			isErr := err != nil
+			if isErr != tc.isErr {
+				t.Fatalf("Unexpected error: %v", err)
+			}
+
+			expected, err := ioutil.ReadFile(path.Join("testdata", tc.expectedPath))
+			require.NoError(t, err, "reading test fixtures")
+			assert.JSONEq(t, string(expected), string(actual))
+		})
+	}
+}
diff --git a/pkg/view/component/testdata/config_stepper.json b/pkg/view/component/testdata/config_stepper.json
new file mode 100644
index 00000000..f9f7ca09
--- /dev/null
+++ b/pkg/view/component/testdata/config_stepper.json
@@ -0,0 +1,27 @@
+{
+    "action": "action.octant.dev/stepperTest",
+    "steps": [
+      {
+        "name": "Step 1",
+        "title": "First Step",
+        "description": "Setup step",
+        "form": {
+          "fields": [
+            {
+              "type": "text",
+              "label": "test",
+              "name": "test",
+              "value": "test",
+              "configuration": {}
+            }
+          ]
+        }
+      },
+      {
+        "name": "Step 2",
+        "title": "Second Step",
+        "description": "Confirmation step",
+        "form": {}
+      }
+    ]
+  }
diff --git a/pkg/view/component/testdata/stepper.json b/pkg/view/component/testdata/stepper.json
new file mode 100644
index 00000000..c695f5eb
--- /dev/null
+++ b/pkg/view/component/testdata/stepper.json
@@ -0,0 +1,48 @@
+{
+  "metadata": {
+    "type": "stepper",
+    "title": [
+      {
+        "config": { "value": "my stepper" },
+        "metadata": { "type": "text" }
+      }
+    ]
+  },
+  "config": {
+    "action": "action.octant.dev/stepperTest",
+    "steps": [
+      {
+        "name": "Step 1",
+        "title": "First Step",
+        "description": "Setup step",
+        "form": {
+          "fields": [
+            {
+              "type": "text",
+              "label": "test",
+              "name": "test",
+              "value": "test",
+              "configuration": {}
+            }
+          ]
+        }
+      },
+      {
+        "name": "Step 2",
+        "title": "Second Step",
+        "description": "Confirmation step",
+        "form": {
+          "fields": [
+            {
+              "type": "text",
+              "label": "test",
+              "name": "test",
+              "value": "test",
+              "configuration": {}
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
diff --git a/pkg/view/component/unmarshal_test.go b/pkg/view/component/unmarshal_test.go
index 189fb32a..2e09c3ac 100644
--- a/pkg/view/component/unmarshal_test.go
+++ b/pkg/view/component/unmarshal_test.go
@@ -353,6 +353,28 @@ func Test_unmarshal(t *testing.T) {
 				base: newBase(typeSingleStat, nil),
 			},
 		},
+		{
+			name:       "stepper",
+			configFile: "config_stepper.json",
+			objectType: "stepper",
+			expected: &Stepper{
+				Config: StepperConfig{
+					Action: "action.octant.dev/stepperTest",
+					Steps: []StepConfig{{
+						Name:        "Step 1",
+						Title:       "First Step",
+						Description: "Setup step",
+						Form:        Form{Fields: []FormField{NewFormFieldText("test", "test", "test")}},
+					}, {
+						Name:        "Step 2",
+						Title:       "Second Step",
+						Description: "Confirmation step",
+						Form:        Form{},
+					}},
+				},
+				base: newBase(typeStepper, nil),
+			},
+		},
 		{
 			name:       "summary",
 			configFile: "config_summary.json",