4. 동적 폼 구성하기
설문지를 폼으로 구현한다면 설문지마다 구성 형식과 의도가 비슷한 경우가 대부분일 것이다. 이런 경우라면 업무 효율성을 위해 비즈니스 객체 모델을 메타데이터로 정의하고 동적 폼 템플릿(dynmaic form template)을 구성해둔 뒤에 개별 폼을 빠르게 자동으로 생성하는 것이 더 좋다.
내용이 계속 바뀌고 이 변화에 빠르게 대응해야 하는 업무일수록 이 방식이 특히 효율적이다. 개별 질문마다 사용자에게 입력을 받는 설문지가 대표적인 경우이다. 설문지는 질문이 달라지더라도 사용자가 입력하는 형식이나 스타일은 거의 비슷하다.
이 문서에서는 동적 폼을 구성하면서 설문지 기본 양식을 만들어 본다. 구체적으로 설명하면, 히어로가 히어로 관리 회사에 지원할 수 있는 온라인 애플리케이션을 만들어 본다. 히어로 관리 회사는 신청 프로세스를 지속적으로 개선하고 있지만 동적 폼을 사용하면 애플리케이션 코드를 변경하지 않아도 새로운 양식을 만들 수 있다.
이 문서는 이런 순서로 진행한다.
1] 프로젝트에 반응형 폼을 로드한다.
2] 폼 컨트롤을 표현하는 데이터 모델을 정의한다.
3] 데이터 모델로 샘플 데이터를 만들어 본다.
4] 폼 컨트롤을 동적으로 구성하는 컴포넌트를 구현한다.
이 문서에서 폼에 유효성 검사를 추가하고 스타일을 적용하면 사용자 UX도 좋아질 수 있다. 폼에 있는 제출 버튼은 사용자가 모든 필드를 입력했을 때만 활성화되며, 입력값이 유효하지 않은 필드는 다른 색으로 표시하면서 에러 메시지도 함께 표시하는 식이다.
동적 폼은 간단하게 구성할 수 있지만 질문의 개수, 종류를 더 늘리거나 더 나은 스타일로 꾸미기도 쉽다.
1. 사전지식
이 문서를 보기 전에 이런 내용을 미리 이해하고 있는 것이 좋다.
- TypeScript, HTML 사용방법
- Angular 개요 문서에서 설명하는 Angular 기본 개념
- 반응형 폼에 대한 기본 지식
2. 프로젝트에 반응형 폼 로드하기
동적 폼은 반응형 폼을 기반으로 구성한다. 그래서 애플리케이션에 반응형 폼을 구성하려면 @angular/forms
패키지로 제공하는 ReactiveFormsModule
을 앱 최상위 모듈에 로드해야 한다.
최상위 모듈은 이렇게 설정하면 된다.
import { BrowserModule } from "@angular/platform-browser";
import { ReactiveFormsModule } from "@angular/forms";
import { NgModule } from "@angular/core";
import { AppComponent } from "./app.component";
import { DynamicFormComponent } from "./dynamic-form.component";
import { DynamicFormQuestionComponent } from "./dynamic-form-question.component";
@NgModule({
imports: [BrowserModule, ReactiveFormsModule],
declarations: [
AppComponent,
DynamicFormComponent,
DynamicFormQuestionComponent,
],
bootstrap: [AppComponent],
})
export class AppModule {
constructor() {}
}
import { enableProdMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { AppModule } from "./app/app.module";
import { environment } from "./environments/environment";
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule);
3. 폼 객체 모델 정의하기
동적 폼을 구성하려면 폼에 필요한 시나리오를 표현하는 객체 모델을 먼저 정의해야 한다. 예제 애플리케이션은 질문들로 구성되며, 사용자가 개별 폼 컨트롤을 모두 입력해야 한다.
개별 질문은 데이터 모델로 표현한 후에 DynamicFormQuestionComponent
로 처리해 보자. 아래 예제 코드는 폼에 있는 질문과 답 한 쌍을 표현하는 QuestionBase
코드이다.
export class QuestionBase<T> {
value: T | undefined;
key: string;
label: string;
required: boolean;
order: number;
controlType: string;
type: string;
options: { key: string; value: string }[];
constructor(
options: {
value?: T;
key?: string;
label?: string;
required?: boolean;
order?: number;
controlType?: string;
type?: string;
options?: { key: string; value: string }[];
} = {}
) {
this.value = options.value;
this.key = options.key || "";
this.label = options.label || "";
this.required = !!options.required;
this.order = options.order === undefined ? 1 : options.order;
this.controlType = options.controlType || "";
this.type = options.type || "";
this.options = options.options || [];
}
}
1) 폼 컨트롤 클래스 정의하기
이 클래스는 TextboxQuestion
과 DropdownQuestion
으로 확장되어 새로운 컨트롤 타입을 구성할 때 사용된다. 그리고 이후에 폼 템플릿을 동적으로 구성할 때도 질문의 종류에 따라 컴포넌트를 다양하게 사용할 것이다.
TextboxQuestion
컨트롤 타입은 사용자가 질문에 직접 답을 입력할 때 사용한다.
import { QuestionBase } from "./question-base";
export class TextboxQuestion extends QuestionBase<string> {
override controlType = "textbox";
}
TextboxQuestion
타입은 폼 템플릿을 구성할 때 <input>
엘리먼트를 사용한다. 이 때 엘리먼트의 type
어트리뷰트는 option
으로 받으며 text
, email
, url
과 같은 형식을 사용할 것이다.
DorpdownQuestion
타입은 셀렉트 박스에서 항목 하나를 고를 때 사용한다.
import { QuestionBase } from "./question-base";
export class DropdownQuestion extends QuestionBase<string> {
override controlType = "dropdown";
}
2) 폼 그룹 구성하기
폼 모델을 폼 컨트롤로 변환하는 서비스를 만들어 보자. 아래 코드는 데이터 모델을 모아서 FormGroup
인스턴스로 변환하는 QuestionControlService
클래스 코드이다. 폼 컨트롤의 기본값이나 유효성 검사 함수는 이 서비스에서 지정하면 된다.
import { Injectable } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { QuestionBase } from "./question-base";
@Injectable()
export class QuestionControlService {
constructor() {}
toFormGroup(questions: QuestionBase<string>[]) {
const group: any = {};
questions.forEach((question) => {
group[question.key] = question.required
? new FormControl(question.value || "", Validators.required)
: new FormControl(question.value || "");
});
return new FormGroup(group);
}
}
4. 동적 폼 구성하기
이번 예제에서 동적 폼은 그 자체로 컨테이너 컴포넌트의 역할도 한다. 이 내용은 다음 단계에서 추가해 보자. 개별 질문은 폼 컴포넌트 템플릿에 <app-question>
태그를 사용하는데, 이 태그는 DynamicFormQuestionComponent
를 의미한다.
DynamicFormQuestionComponent
는 바인딩된 질문 객체에 따라 개별 질문과 대답을 렌더링하는 역할을 한다. 그리고 템플릿 HTML과 폼 컨트롤 객체를 연결할 때는 [formGroup]
디렉티브를 활용한다. 결국 DynamicFormQuestionComponent
는 사전에 정의된 질문 모델에 따라 전체 폼 그룹을 구성하며 화면에 표시하고 유효성을 검사한다.
<div [formGroup]="form">
<label [attr.for]="question.key">{{question.label}}</label>
<div [ngSwitch]="question.controlType">
<input
*ngSwitchCase="'textbox'"
[formControlName]="question.key"
[id]="question.key"
[type]="question.type"
/>
<select
[id]="question.key"
*ngSwitchCase="'dropdown'"
[formControlName]="question.key"
>
<option *ngFor="let opt of question.options" [value]="opt.key">
{{opt.value}}
</option>
</select>
</div>
<div class="errorMessage" *ngIf="!isValid">
{{question.label}} is required
</div>
</div>
import { Component, Input } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { QuestionBase } from "./question-base";
@Component({
selector: "app-question",
templateUrl: "./dynamic-form-question.component.html",
})
export class DynamicFormQuestionComponent {
@Input() question!: QuestionBase<string>;
@Input() form!: FormGroup;
get isValid() {
return this.form.controls[this.question.key].valid;
}
}
DynamicFormQuestionComponent
는 데이터 모델로 정의한 질문들을 표시하는 역할을 한다. 지금 단계에서는 질무느이 종류가 두 가지밖에 없지만, 이후에 이 종류는 얼마든지 확장할 수 있다. 이 때 질문 종류에 맞는 컴포넌트를 사용하려면 템플릿에 ngSwitch
를 사용하면 된다. 그리고 개별 스위치는 formControlName
디렉티브와 formGroup
디렉티브를 함께 사용한다. 두 디렉티브 모두 ReactiveFormsModule
이 제공하는 디렉티브이다.
1) 데이터 제공하기
개별 폼을 구성하려면 각 질문 데이터를 제공하는 서비스가 하나 더 필요하다. 그래서 이번에는 QuestionService
를 만들어서 질문을 QuestionBase
배열 타입으로 반환하는 코드를 구현해 보자. 실제로 운영하는 애플리케이션이라면 이 데이터는 백엔드 서버에서 받아올 것이다. 하지만 이 문서는 QuestionService
를 활용하는 측면을 다루는 것이 주요 내용이기 때문에, 예제에서는 하드코딩된 샘플 데이터를 사용한다. 설문지 항목이 계속 변경된다면 questions
배열을 수정하면 된다.
QuestionService
는 질문 데이터를 배열 형태로 반환하며, 이 데이터는 @Input()
프로퍼티에 바인딩하는 방식으로 사용할 것이다.
import { Injectable } from "@angular/core";
import { DropdownQuestion } from "./question-dropdown";
import { QuestionBase } from "./question-base";
import { TextboxQuestion } from "./question-textbox";
import { of } from "rxjs";
@Injectable()
export class QuestionService {
// TODO: 질문을 정의하는 메타데이터는 리모트 서버에서 받아오기
getQuestions() {
const questions: QuestionBase<string>[] = [
new DropdownQuestion({
key: "brave",
label: "Bravery Rating",
options: [
{ key: "solid", value: "Solid" },
{ key: "great", value: "Great" },
{ key: "good", value: "Good" },
{ key: "unproven", value: "Unproven" },
],
order: 3,
}),
new TextboxQuestion({
key: "firstName",
label: "First name",
value: "Bombasto",
required: true,
order: 1,
}),
new TextboxQuestion({
key: "emailAddress",
label: "Email",
type: "email",
order: 2,
}),
];
return of(questions.sort((a, b) => a.order - b.order));
}
}
5. 템플릿 구성하기
DynamicFormComponent
컴포넌트는 폼 전체를 구성하는 컴포넌트이며 템플릿에는 <app-dynamic-form>
으로 사용된다.
그리고 이 컴포넌트는 질문 목록을 <app-question>
엘리먼트로 구성한다. 개별 <app-question>
엘리먼트는 DynamicFormQuestionComponent
를 의미한다.
<div>
<form (ngSubmit)="onSubmit()" [formGroup]="form">
<div *ngFor="let question of questions" class="form-row">
<app-question [question]="question" [form]="form"></app-question>
</div>
<div class="form-row">
<button type="submit" [disabled]="!form.valid">Save</button>
</div>
</form>
<div *ngIf="payLoad" class="form-row">
<strong>Saved the following values</strong><br />{{payLoad}}
</div>
</div>
import { Component, Input, OnInit } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { QuestionBase } from "./question-base";
import { QuestionControlService } from "./question-control.service";
@Component({
selector: "app-dynamic-form",
templateUrl: "./dynamic-form.component.html",
providers: [QuestionControlService],
})
export class DynamicFormComponent implements OnInit {
@Input() questions: QuestionBase<string>[] | null = [];
form!: FormGroup;
payLoad = "";
constructor(private qcs: QuestionControlService) {}
ngOnInit() {
this.form = this.qcs.toFormGroup(this.questions as QuestionBase<string>[]);
}
onSubmit() {
this.payLoad = JSON.stringify(this.form.getRawValue());
}
}
1) 폼 표시하기
동적 폼 인스턴스를 화면에 표시하려면 QuestionService
가 반환하는 questions
배열을 AppComponent
에 있는 <app-dynamic-form>
엘리먼트에 바인딩하면 된다.
import { Component } from "@angular/core";
import { QuestionService } from "./question.service";
import { QuestionBase } from "./question-base";
import { Observable } from "rxjs";
@Component({
selector: "app-root",
template: `
<div>
<h2>Job Application for Heroes</h2>
<app-dynamic-form [questions]="questions$ | async"></app-dynamic-form>
</div>
`,
providers: [QuestionService],
})
export class AppComponent {
questions$: Observable<QuestionBase<any>[]>;
constructor(service: QuestionService) {
this.questions$ = service.getQuestions();
}
}
이 문서에서 다루는 예제는 히어로가 입력하는 지원서를 구현한 것이며, 지원서에 입력하는 질문은 모두 QuestionService
에 정의되어 있다. 폼 모델과 데이터를 분리했기 때문에 이 컴포넌트는 QuestionBase
객체 타입에 맞기만 하면 질문이 변경되더라도 문제없이 동작한다.
2) 데이터 유효성 검사하기
지금까지 구현한 폼 템플릿은 컴포넌트에 바인딩된 동적 데이터를 기반으로 폼을 구성하며, 개별 질문에 대해 하드코딩된 내용은 아무것도 없다. 그래서 폼 컨트롤의 메타데이터나 유효성 검사 규칙도 동적으로 구성할 수 있다.
이 상황에서 폼에 입력된 값이 유효하다는 것을 보장하기 위해 Save 버튼은 폼 전체가 유효한 상태가 되어야만 활성화해야 한다. 폼이 유효한 상태가 된 이후에 Save 버튼을 클릭하면 폼에 입력된 데이터를 JSON 형태로 표시할 것이다.
폼이 모두 구성되면 이런 모습이 된다.