发布于 2026-01-06 2 阅读
0

使用 NgRx 的模块化多步骤表格,20 分钟内即可完成。

使用 NgRx 的模块化多步骤表格,20 分钟内即可完成

使用 NgRx 的模块化多步骤表格,20 分钟内即可完成

使用 NgRx 的模块化多步骤表格,20 分钟内即可完成

这是 ReactiveForm 系列的第三部分。现在您已经了解了如何使用 ReactiveForm 以及如何使其易于访问,是时候进行实际操作了。我们将创建一个多步骤表单,并添加易于访问的验证功能。不仅如此,我们还将使用 NgRx 来保持各个步骤的同步。

问题

在 This Dot,我们不断发展壮大。招聘是我们流程中的关键环节,我们通过导师制赋能开发者。这固然很好,但也意味着我们会收到大量的申请。因此,我们需要创建一个多步骤的申请表,供有意加入 This Dot 的开发者填写。

因为我们是一家包容性公司,所以我们需要确保每个人都能使用表格。因此,无障碍设计在这里至关重要。我们将使用本系列第二部分讨论的技术来实现这一点。但这还不是全部。由于我们不知道谁会申请,所以我们需要确保所有申请都有效。

我们需要很多信息才能启动这个流程:个人信息、地址详情和工作经历。因此,如果我们把它做成一个单页表单,使用起来会非常困难,更糟糕的是,它会让人们感到非常无聊,以至于他们干脆放弃尝试。

既然你已经了解了设计背后的原理,那么我们就开始吧。

解决方案

我相信,当你充满动力时,工作效率会更高,而界面糟糕的应用程序会让人非常厌烦。一个应用程序可能存在一些漏洞,但如果它看起来很棒,它很可能会激励你去修复或改进它。(至少对于我这个视觉型的人来说是这样。)

既然我负责这个项目的开发,为了激励大家,我们先从优化多步骤表单的外观入手。等我们对表单的美观度满意后,再继续完善它的功能。

该应用程序将使用 Angular 构建。我们不会手动创建所有文件夹、文件和配置文件,而是依赖 Angular CLI。为此,请按照以下步骤操作:

  • 打开你最喜欢的命令行工具
  • 使用以下命令全局安装 Angular CLInpm install -g @angular/cli
  • 前往您想要创建应用程序的位置,然后运行该命令ng new embrace-power

此时,您已经生成了一个全新的应用程序。我喜欢做的一件事是创建一个名为 variables.scss 的文件,用来存储所有我想使用的变量。在这个例子中,我只有一个变量,$base-color: #444所以我把它保存在.scss 文件中src/assets/styles/。然后,在任何需要访问它的 scss 文件中,您都可以使用它@import '~src/assets/styles/variables.scss';

如果你和我一样,你肯定会好奇为什么开头有个“ ~”符号。
这是告诉webpack使用基础源代码的

将应用模板的内容替换为以下内容:

<!-- src/app/app.component.html  -->
<main>
  <router-outlet></router-outlet>
</main>
Enter fullscreen mode Exit fullscreen mode

现在,设置应用程序的基本样式。

// src/styles.scss
body,
html {
  margin: 0;
  background: #333;
  font-family: 'Roboto';
}
Enter fullscreen mode Exit fullscreen mode

最后,在 `<head>` 标签中添加 Roboto 字体系列和 Material 图标。

<!-- /src/index.html -->
<link
  href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap"
  rel="stylesheet"
/>
<link
  href="https://fonts.googleapis.com/icon?family=Material+Icons"
  rel="stylesheet"
/>
Enter fullscreen mode Exit fullscreen mode

外观像原生应用的网页应用很棒。所以我打算尝试让它拥有移动/桌面应用的外观和体验。

步骤标题

如果您要构建一个多步骤表单,就需要一种方法来实现步骤间的导航。有时,允许用户快速跳转到任何步骤会很有用。在这种情况下,我们需要一个包含指向每个步骤链接的头部组件。

由于我们的应用程序只会用到单个头部组件,我们可以认为它是应用程序的核心部分。首先,让我们使用 Angular CLI 在ng generate module core应用程序文件夹内创建核心模块。现在,您已经拥有了核心模块——接下来您需要头部组件。

Angular CLI 又一次拯救了我!

只需运行即可ng generate component core/header。这将在核心模块中创建一个新组件。

要使用这个新组件,您需要将其添加到核心模块声明的 exports 数组中。或者,您可以使用标志--export=true指示 CLI 将组件添加到 exports 数组中。

现在是时候编写实际的模板了。

<!-- src/app/core/header/header.component.html  -->
<header>
  <nav>
    <ul>
      <li><a href="#">Personal</a></li>
      <li><a href="#">Address</a></li>
      <li><a href="#">Experience</a></li>
    </ul>
  </nav>
</header>
Enter fullscreen mode Exit fullscreen mode

如果您按照上述说明生成了组件,则头部组件已包含在声明中,导出也已包含在内CoreModule。遗憾的是,您必须先在应用程序组件中导入该组件,才能在应用程序组件中使用它CoreModuleAppModule您只需导入一次此模块。如果出于某种原因需要在其他地方导入它,则应该考虑采用其他SharedModule方法。

在 AppModule 中导入 CoreModule

// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CoreModule } from './core/core.module';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, AppRoutingModule, CoreModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

现在,你可以在应用程序组件模板中这样使用它。

<app-header></app-header>

<main>
  <router-outlet></router-outlet>
</main>
Enter fullscreen mode Exit fullscreen mode

但这看起来很糟糕,对吧?让我们添加一些 CSS 代码来让它好一些。

// src/app/core/header/header.component.scss
@import '~src/assets/styles/variables.scss';

:host {
  display: block;
}

header {
  background-color: darken($base-color, 20);
  min-height: 10vh;

  nav {
    height: 100%;

    ul {
      display: flex;
      flex-direction: column;
      margin: 0;
      padding: 0;
      height: 100%;

      li {
        display: flex;
        margin: 0.5rem;
        list-style-type: none;
        justify-content: center;

        & > * {
          color: darken(white, 20);
          padding: 0.5rem;
          font-size: 1.5rem;
          letter-spacing: 0.1rem;
          line-height: 1.5;
        }

        a {
          text-decoration: none;

          &:visited {
            color: darken(white, 30);
          }

          &.active,
          &:active,
          &:hover,
          &:focus {
            text-decoration: underline;
            color: white;
          }

          &:hover,
          &:focus {
            outline: 1px white solid;
          }
        }
      }
    }
  }
}

@media all and (min-width: 768px) {
  header {
    nav {
      ul {
        flex-direction: row;
        justify-content: space-around;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

如果你好奇我们为什么要使用媒体查询,那是因为根据我的经验,移动优先设计总是最佳选择。具体做法是:首先定义移动端样式,当视口宽度大于或等于 768px 时,我们只需稍微调整样式即可。我们利用了 CSS 的层叠特性。

步骤组件

每个步骤都会有所不同,但它们共享一些布局逻辑。它们都包含标题、“上一步”和“下一步”按钮。在继续之前,我们将创建一个新组件,用于封装每个步骤特定的逻辑。这样可以确保界面的一致性。

所有步骤都将拆分成模块。我们稍后会详细讨论这一点。现在,让我们专注于创建这个可重用组件。我喜欢将所有可重用组件存储在一个共享模块中。这样,我就可以导入这个共享模块,并根据需要使用这些可重用组件。

我们将再次使用 Angular CLI:

  • 打开你最喜欢的命令行工具
  • 将目录更改为项目所在位置。
  • 运行该命令ng generate module shared
  • 运行该命令ng generate component shared/wizard-step --export=true

我们先来编写模板内容。

<!-- src/app/shared/wizard-step/wizard-step.component.html  -->
<section>
  <header>
    <h1>{{ title }}</h1>
  </header>

  <div>
    <button id="previous-button" (click)="goToPreviousStep()">
      <i class="material-icons">navigate_before</i> <span>Previous</span>
    </button>

    <ng-content></ng-content>

    <button id="next-button" (click)="goToNextStep()">
      <span>Next</span> <i class="material-icons">navigate_next</i>
    </button>
  </div>
</section>
Enter fullscreen mode Exit fullscreen mode

通过使用内容投影<ng-content>,我们可以使用这个新模块来共享步骤的所有标记逻辑。正如您所看到的,按钮中有一个 title 属性,以及两个通过事件绑定执行的方法。让我们看看它们在文件中是如何实现的.ts

// src/app/shared/wizard-step/wizard-step.component.ts
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-wizard-step',
  templateUrl: './wizard-step.component.html',
  styleUrls: ['./wizard-step.component.scss']
})
export class WizardStepComponent implements OnInit {
  @Input() title: string;
  @Output() previousStepClicked = new EventEmitter();
  @Output() nextStepClicked = new EventEmitter();

  constructor() {}

  ngOnInit() {}

  goToPreviousStep() {
    this.previousStepClicked.emit();
  }

  goToNextStep() {
    this.nextStepClicked.emit();
  }
}
Enter fullscreen mode Exit fullscreen mode

还有款式,别忘了。

// src/app/shared/wizard-step/wizard-step.component.scss
@import '~src/assets/styles/variables.scss';

header {
  background-color: darken($base-color, 15);
  height: 10vh;
  display: flex;
  align-items: center;
  justify-content: center;

  h1 {
    color: white;
    margin: 0;
    padding: 1rem;
    text-align: center;
    font-size: 2.8rem;
  }
}

section {
  height: 80vh;

  div {
    display: flex;
    justify-content: space-around;
    height: 100%;

    button {
      border: none;
      background: darken($base-color, 10);
      height: max-content;
      align-self: center;
      display: flex;
      align-items: center;
      justify-content: center;
      color: white;
      font-size: 2rem;
      padding: 0.5rem 0;
      outline: 0.1rem darken(white, 30) solid;
      cursor: pointer;

      &#previous-button {
        padding-right: 1rem;
      }

      &#next-button {
        padding-left: 1rem;
      }

      span {
        display: none;
      }

      &:focus,
      &:hover {
        outline: 0.2rem white solid;
        background: darken($base-color, 20);
      }
    }
  }
}

@media all and (min-width: 768px) {
  section {
    div {
      button {
        span {
          display: block;
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

步骤内容

现在,是时候把所有东西整合起来了。首先,我们要创建一个专门用于个人信息填写步骤的新模块。这可以通过你已经知道的模块生成命令来实现ng generate module personal。但这还不够,对吧?现在,我们需要一个组件来存储实际的表单,这可以通过命令来实现ng generate component personal

应用启动时将用户重定向到个人页面。

// src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'embrace-power';

  constructor(private router: Router) {}

  ngOnInit() {
    this.router.navigate(['personal']);
  }
}
Enter fullscreen mode Exit fullscreen mode

导入 SharedModule 和 RouterModule,以设置默认路由。

// src/app/personal/personal.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { PersonalComponent } from './personal.component';
import { SharedModule } from '../shared/shared.module';
import { RouterModule } from '@angular/router';

@NgModule({
  declarations: [PersonalComponent],
  imports: [
    CommonModule,
    SharedModule,
    RouterModule.forChild([{ path: '', component: PersonalComponent }]),
    ReactiveFormsModule
  ]
})
export class PersonalModule {}
Enter fullscreen mode Exit fullscreen mode

注意:请记住在新模块中导入 ReactiveFormsModule 和 SharedModule。

我们还有些事情要做。我们需要将新模块连接到路由结构中,以便能够正确导航。

// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  {
    path: 'personal',
    loadChildren: () =>
      import('./personal/personal.module').then(m => m.PersonalModule)
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}
Enter fullscreen mode Exit fullscreen mode

现在,更新标题中的链接,但首先需要RouterModule导入CoreModule

// src/app/core/core.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HeaderComponent } from './header/header.component';
import { RouterModule } from '@angular/router';

@NgModule({
  declarations: [HeaderComponent],
  imports: [CommonModule, RouterModule.forChild([])],
  exports: [HeaderComponent]
})
export class CoreModule {}
Enter fullscreen mode Exit fullscreen mode

在 HeaderComponent 模板中,更新链接以使用routerLink, 和routerLinkActivefromRouterModule在标题中。

<header>
  <nav>
    <ul>
      <li>
        <a [routerLink]="['/personal']" routerLinkActive="active">Personal</a>
      </li>
      <li><a href="#">Address</a></li>
      <li><a href="#">Experience</a></li>
    </ul>
  </nav>
</header>
Enter fullscreen mode Exit fullscreen mode

现在该着手编写组件类声明了。

// src/app/personal/personal.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-personal',
  templateUrl: './personal.component.html',
  styleUrls: ['./personal.component.scss']
})
export class PersonalComponent implements OnInit {
  title = 'Personal';
  personalForm = this.fb.group(
    {
      firstName: [null, [Validators.required]],
      lastName: [null, [Validators.required]],
      age: [
        null,
        [Validators.required, Validators.min(18), Validators.max(120)]
      ],
      about: [null, [Validators.required]]
    },
    {
      updateOn: 'blur'
    }
  );
  firstNameCtrl = this.personalForm.get('firstName');
  lastNameCtrl = this.personalForm.get('lastName');
  ageCtrl = this.personalForm.get('age');
  aboutCtrl = this.personalForm.get('about');
  submitted = false;

  constructor(private fb: FormBuilder) {}

  goToNextStep() {
    this.submitted = true;
  }

  ngOnInit() {
    // this method comes from OnInit interface
  }
}
Enter fullscreen mode Exit fullscreen mode

这里发生了什么?我们声明了将作为输入传递给 WizardStepComponent 的标题,以及用于处理 PersonalComponent 中表单数据的 ReactiveForm。如果您阅读过本系列之前的文章,您会注意到一些新内容:验证器和{ updateOn: 'blur' }配置对象的使用。

`updateOn` 选项的含义显而易见。它只是让响应式表单仅在用户离开输入框时才记录更改。验证器则稍微复杂一些。你可以使用一个验证器数组,这些验证器本质上是返回布尔值的函数。本示例中使用的所有验证器都是库内置的,但你也可以编写自己的验证器。

现在表单里有了验证器,每当发现错误,它都会被添加到表单的 error 属性中。这样,ngIf我们就可以使用指令来有条件地显示错误。还有一个非常巧妙的技巧:设置一个submitted默认值为 false 的属性。表单提交后,该属性的值会被更改,这样,只有在表单提交后才会显示错误。

这就是模板现在的样子:

<!-- src/app/personal/personal.component.html -->
<app-wizard-step [title]="title" (nextStepClicked)="goToNextStep()">
  <form [formGroup]="personalForm" [attr.aria-label]="title">
    <label>
      <span>First name *</span>
      <input
        class="form-control"
        type="text"
        formControlName="firstName"
        required
      />
    </label>
    <span
      class="form-error"
      *ngIf="submitted && firstNameCtrl?.errors?.required"
    >
      First name is required
    </span>

    <label>
      <span>Last name *</span>
      <input
        class="form-control"
        type="text"
        formControlName="lastName"
        required
      />
    </label>
    <span
      class="form-error"
      *ngIf="submitted && lastNameCtrl?.errors?.required"
    >
      Last name is required
    </span>

    <label>
      <span>Age *</span>
      <input
        class="form-control"
        type="number"
        formControlName="age"
        required
      />
    </label>
    <span class="form-error" *ngIf="submitted && ageCtrl?.errors?.required">
      Age is required
    </span>
    <span class="form-error" *ngIf="submitted && ageCtrl?.errors?.min">
      Age has to be greater or equal than 18
    </span>
    <span class="form-error" *ngIf="submitted && ageCtrl?.errors?.max">
      Age has to be less or equal than 120
    </span>

    <label>
      <span>About *</span>
      <textarea
        class="form-control"
        rows="4"
        formControlName="about"
        required
      ></textarea>
    </label>
    <span class="form-error" *ngIf="submitted && aboutCtrl?.errors?.required">
      About is required
    </span>
  </form>
</app-wizard-step>
Enter fullscreen mode Exit fullscreen mode

别忘了样式设计。还记得我说过我讨厌用自己不喜欢的视觉效果吗?经过一些改进,我得到了以下样式。

// src/app/personal/personal.component.scss
@import '~src/assets/styles/variables.scss';

form {
  width: 100%;
  max-width: 700px;
  padding: 2rem;
  background: darken($base-color, 10);
  overflow-y: auto;
}

label {
  display: flex;
  justify-content: space-around;
  min-height: 2rem;
  padding: 1rem;

  flex-direction: column;

  span {
    color: white;
    font-size: 1.2rem;
    width: 100%;
  }

  &:not(:last-child) {
    margin-bottom: 0.5rem;
  }

  &:hover,
  &:focus-within {
    outline: 1px white solid;
  }
}

.form-control {
  width: 100%;
  background: transparent;
  border: none;
  border-bottom: 1px solid white;
  color: white;
  font-size: 1.3rem;
  padding-bottom: 0.3rem;
  margin: 1rem 0;

  &:focus {
    outline: none;
  }
}

.form-error {
  display: block;
  color: red;
  margin: 0.5rem 0;
}

@media all and (min-width: 768px) {
  label {
    flex-direction: row;

    span {
      font-size: 1.5rem;
      width: 30%;
    }
  }

  .form-control {
    width: 60%;
    margin: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

你还会在这个样式表中发现一些经典的移动优先设计理念。欢迎查看。我把它作为可选作业。

其他步骤

第一步已经完成,我们可以轻松地将这套逻辑复用于其他步骤。我们可以添加任意数量的步骤。只需记住通过路由器连接,并将其作为步骤之一添加到请求头中即可。我相信您自己就能做到,所以我就跳过这一步。

如果您不想自己完成我们所做的一切,但又想直接进入状态管理部分,这里有一个可自定义的版本

如果您已经创建了新的步骤,您可能想知道接下来该做什么?所有这些模块都是分离的,现在很难跟踪整个表单的状态。您可能还注意到,如果您在不同的状态之间切换,您输入的值就会丢失。这些对我们来说都不是问题,因为我们知道 NgRx 可以帮您解决这个问题。您现在需要做的是:

  • 为表单中的步骤创建 reducer。
  • 为每个步骤创建选择器。
  • 所有步骤都会使用选择器来填充表单。
  • 为每个步骤创建一组操作。
  • 每次表单中的值发生更改时,都会将其更新到数据存储中。

首先,我们需要安装 NgRx Store,这可以通过npm install --save @ngrx/store在应用程序目录中运行命令轻松完成。

注意:我建议您通过执行以下命令安装 StoreDevtools 以进行测试。npm install --save @ngrx/store-devtools

现在我们来创建 reducer(我将重点介绍个人步骤,但其他步骤的策略相同)。在 `<path>` 下
创建一个名为 `<folder>` 的文件夹,并将文件放入其中,内容如下:statesrc/app/corepersonal.reducer.ts

import { createReducer, on } from '@ngrx/store';
import { PersonalPageActions } from '../../personal/actions';
import { Personal } from '../interfaces/personal.interface';
import { PersonalGroup } from '../models/personal.model';

export interface State {
  data: Personal;
  isValid: boolean;
}

const initialState = new PersonalGroup();

const personalReducer = createReducer(
  initialState,
  on(
    PersonalPageActions.patch,
    (state: State, action: ReturnType<typeof PersonalPageActions.patch>) => ({
      ...state,
      data: { ...state.data, ...action.payload }
    })
  ),
  on(
    PersonalPageActions.changeValidationStatus,
    (
      state: State,
      { isValid }: ReturnType<typeof PersonalPageActions.changeValidationStatus>
    ) => ({
      ...state,
      isValid
    })
  )
);

export function reducer(state: State, action: PersonalPageActions.Union) {
  return personalReducer(state, action);
}

export const selectPersonalGroupData = (state: State) => state.data;
export const selectPersonalGroupIsValid = (state: State) => state.isValid;
Enter fullscreen mode Exit fullscreen mode

接口(src/app/core/interfaces/personal.interface.ts)和模型(src/app/core/models/personal.model.ts)如下所示:

export interface Personal {
  firstName: string;
  lastName: string;
  age: number;
  about: string;
}
Enter fullscreen mode Exit fullscreen mode
import { Personal } from '../interfaces/personal.interface';
export class PersonalGroup {
  data = {
    firstName: '',
    lastName: '',
    age: 18,
    about: ''
  } as Personal;
  isValid = false;
}
Enter fullscreen mode Exit fullscreen mode

我将首先在缩减器中使用桶导入,这将有助于您稍后使用其他缩减器(src/app/core/state/index.ts)。

import { ActionReducerMap, createSelector, MetaReducer } from '@ngrx/store';
import * as fromPersonal from './personal.reducer';
import { PersonalGroup } from '../models/personal.model';

export interface State {
  personal: PersonalGroup;
}

export const reducers: ActionReducerMap<State> = {
  personal: fromPersonal.reducer
};

export const metaReducers: MetaReducer<State>[] = [];

export const selectPersonalGroup = (state: State) => state.personal;
export const selectPersonalGroupData = createSelector(
  selectPersonalGroup,
  fromPersonal.selectPersonalGroupData
);
export const selectPersonalGroupIsValid = createSelector(
  selectPersonalGroup,
  fromPersonal.selectPersonalGroupIsValid
);
Enter fullscreen mode Exit fullscreen mode

此外,还有一些与操作相关的内容。我为每个页面创建了一个操作文件。这样,操作就与特定上下文相关,也便于日后思考。这些操作直接存储在可以分发它们的模块中。例如,“个人”操作存储在src/app/personal/actions/personal-page.actions.ts……

import { createAction, props } from '@ngrx/store';
import { Personal } from '../../core/interfaces/personal.interface';

export const patch = createAction(
  '[Personal Page] Patch Value',
  props<{ payload: Partial<Personal> }>()
);

export const changeValidationStatus = createAction(
  '[Personal Page] Change Validation Status',
  props<{ isValid: boolean }>()
);

export type Union = ReturnType<typeof patch | typeof changeValidationStatus>;
Enter fullscreen mode Exit fullscreen mode

另外,别忘了为这些操作创建一个索引文件(src/app/personal/actions/index.ts):

import * as PersonalPageActions from './personal-page.actions';

export { PersonalPageActions };
Enter fullscreen mode Exit fullscreen mode

现在唯一缺少的就是运用这些新特性。首先,我们将 reducer 添加到 AppModule 中,然后将所有内容连接到相应的组件中。

import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { StoreModule } from '@ngrx/store';
import { reducers, metaReducers } from './core/state';

@NgModule({
  imports: [
    // ...
    StoreModule.forRoot(reducers, { metaReducers }),
    StoreDevtoolsModule.instrument({
      maxAge: 25
    })
    // ...
  ]
  // ...
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

注意:我还注入了 StoreDevtools 以启用 Chrome Devtools 的 redux 小部件。

太好了。我们快完成了。现在只需要把步骤的组件连接到src/personal/personal.component.ts……

// 1) New imports
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as fromRoot from '../core/state';
import { PersonalPageActions } from './actions';
import { map, take, distinctUntilChanged } from 'rxjs/operators';
import { merge } from 'rxjs';
import { Personal } from '../core/interfaces/personal.interface';
// ...

export class PersonalComponent implements OnInit {
  // ...

  // 2) Inject the router and the store
  constructor(
    private router: Router,
    private fb: FormBuilder,
    private store: Store<fromRoot.State>
  ) {}

  ngOnInit() {
    // 3) Get the last state of the personal data and patch the form with it
    this.store
      .select(fromRoot.selectPersonalGroupData)
      .pipe(take(1))
      .subscribe((personal: Personal) =>
        this.personalForm.patchValue(personal, { emitEvent: false })
      );

    // 4) For each field create an observable that maps the change as a key value
    const firstName$ = this.firstNameCtrl.valueChanges.pipe(
      map((firstName: string) => ({ firstName } as Partial<Personal>))
    );
    const lastName$ = this.lastNameCtrl.valueChanges.pipe(
      map((lastName: string) => ({ lastName } as Partial<Personal>))
    );
    const age$ = this.ageCtrl.valueChanges.pipe(
      map((age: number) => ({ age } as Partial<Personal>))
    );
    const about$ = this.aboutCtrl.valueChanges.pipe(
      map((about: string) => ({ about } as Partial<Personal>))
    );

    // 5) For each change trigger an action to update the store
    merge(firstName$, lastName$, age$, about$).subscribe(
      (payload: Partial<Personal>) => {
        this.store.dispatch(PersonalPageActions.patch({ payload }));
      }
    );

    // 6) If the validaty status of the form changes dispatch an action to the store
    this.personalForm.valueChanges
      .pipe(
        map(() => this.personalForm.valid),
        distinctUntilChanged()
      )
      .subscribe((isValid: boolean) =>
        this.store.dispatch(
          PersonalPageActions.changeValidationStatus({ isValid })
        )
      );
  }

  // 7) Add method to go to next step through navigation if the form is valid
  goToNextStep() {
    if (this.personalForm.invalid) {
      this.submitted = true;
      return;
    }

    this.router.navigate(['address']);
  }
}
Enter fullscreen mode Exit fullscreen mode

我们在这里所做的只是从数据存储中获取最新状态,并将其更新到表单中。然后,我们创建一个流,每当输入内容发生变化时,该流都会发出一个操作。

如果对每个步骤重复此操作,您将得到一个完整的、带有验证功能的、易于访问的多步骤表单。如果您想直接跳到完整版本,这里有一个功能齐全的应用程序

结论

ReactiveForms 功能非常强大。在前几部分中,我们讨论了一些核心概念,现在我们需要真正发挥它的威力。如果你运用了这里提到的所有概念,你几乎可以创建任何复杂的表单。如果你想了解测试方面的内容,这篇文章本身已经足够长了,所以我计划专门写一篇关于 ReactiveForms 测试的文章。

文章来源:https://dev.to/thisdotmedia/modular-multi-step-form-with-ngrx-in-less-than-20-minutes-3770