NgRx にビビってたみんなゴメン。
もう難所はだいたい抜けました。
麻酔の注射が痛いだけであとは大したことないパターンでしたね。
前回の話
tercel-tech.hatenablog.com
今まで地味だったからね。
結局、バックエンドをいくら作り込んでも、実際にフロントから動かせるようにならないといまいち面白くないよね。
今回は、削除・追加・更新処理で章を分けていて、それぞれ各章の冒頭に完成イメージの GIF アニメを載せているよ。
無味乾燥な仕様書をそのまま載せるよりビジュアル的だし、これから何をしようとしているか予め分かった方が不安にならずに済むかと思って。
削除処理
この章の完成イメージ
まずコンポーネントクラスに削除用のメソッドを追加するところからだ。
これがないと始まらない。
app.components.ts
※ 前回まで、ファイル名をパス付きで記載していましたが、今回はファイル名のみを記載しています。
@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { private heroService: EntityCollectionService<Hero>; heroes$: Observable<Hero[]>; constructor(entityServices: EntityServices) { this.heroService = entityServices.getEntityCollectionService<Hero>('Hero'); this.heroes$ = this.heroService.entities$; } ngOnInit() { this.heroService.getAll() .subscribe( () => console.log('エンティティを取得しました'), err => console.error(`エンティティの取得に失敗しました: ${err}`)); } + deleteHero(id: number) { + const hero = <Hero>{ id }; + + this.heroService.delete(hero) + .subscribe( + () => console.log('エンティティを削除しました'), + err => console.error(`エンティティの削除に失敗しました: ${err}`)); + } }
たった今追加した deleteHero()
メソッドに注目しよう。
主キーである id
を指定して、エンティティを削除しているよ。
heroService
を通して、以下の delete()
メソッドを呼んでいるわけか。METHOD | MEANING | HTTP METHOD WITH ENDPOINT |
---|---|---|
add(entity: T) | 新しいエンティティを追加します | POST /api/hero/ |
delete(id: any) | 主キーの値でエンティティを削除します | DELETE /api/hero/5 |
getAll() | このエンティティタイプのすべてのインスタンスを取得します | GET /api/heroes/ |
getById(id: any) | 主キーでエンティティを取得します | GET /api/hero/5 |
getWithQuery(queryParams: QueryParams | string) | クエリを満たすエンティティを取得します | GET /api/heroes/?name=bombasto |
update(update: Update |
既存のエンティティを更新します | PUT /api/hero/5 |
なんとなく想像がつくけど一応訊いておこう。
const hero = <Hero>{ id };
というのは?
こんなとき、const hero = <Hero>{id: id};
みたいに書けるんだよ。
{プロパティ名: 設定したい変数}
という感じ。
さらにこのとき、{id: id}
のように、:
の左右で識別子が一致している場合は、単に { id }
と略記していいことになっている。
だから結局、const hero = <Hero>{ id };
になる。
app.components.html
<table> <tr> <th>id</th> <th>name</th> + <th>delete</th> </tr> <tr *ngFor="let hero of heroes$ | async"> <td>{{hero.id}}</td> <td>{{hero.name}}</td> + <td><button (click)="deleteHero(hero.id)">削除</button></td> </tr> </table>
テーブルの各行に「削除」ボタンを並べている。
消したい行のボタンを押すと、その行は消える。
deleteHero()
メソッドに id
が引き渡される仕組みだね。ちなみに上記のコードは、「*ngFor
で一覧表示している明細のうち、どの行が選択されたかを特定したい場合」によく用いられるイディオムなので、覚えておくとよいと思う。
ハイ、削除の実装おしまい。
新規登録
この章の完成イメージ
おー、なんかテキストボックスやボタンが出たり消えたりしている。
いかにも動的って感じがするね。
そういうこと。
Angular のフォームの実装方式は、テンプレート駆動フォームとリアクティブフォームに大別できる。
まぁ一長一短あって、後者の方が細かい制御ができるけど、簡単なのは前者の方。
開発の途中で他方の方式に変更することは難しいので、最初によく考えて決めておく必要がある。
そうだね、テンプレート駆動フォームでいこう。
まずは FormsModule
を使えるようにするよ。
app.module.ts
+ import { FormsModule } from '@angular/forms'; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, AppRoutingModule, + FormsModule, HttpClientModule, InMemoryWebApiModule.forRoot(MockWebApi), EffectsModule.forRoot([AppEffects]), StoreModule.forRoot(reducers, { metaReducers, runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true } }), StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production }), EntityDataModule.forRoot(entityConfig) ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
もう一度言うけど、ここから一気にコードの記述量が増える。
でも大事なのは this.heroService.add(hero)
のところだよ。 忘れないでね。
METHOD | MEANING | HTTP METHOD WITH ENDPOINT |
---|---|---|
add(entity: T) | 新しいエンティティを追加します | POST /api/hero/ |
delete(id: any) | 主キーの値でエンティティを削除します | DELETE /api/hero/5 |
getAll() | このエンティティタイプのすべてのインスタンスを取得します | GET /api/heroes/ |
getById(id: any) | 主キーでエンティティを取得します | GET /api/hero/5 |
getWithQuery(queryParams: QueryParams | string) | クエリを満たすエンティティを取得します | GET /api/heroes/?name=bombasto |
update(update: Update |
既存のエンティティを更新します | PUT /api/hero/5 |
そういうわけではないけど、どうしても画面周りは装飾やら演出やらでコードが冗長になるから本質を見失いがちなんだ。
たとえば同じデータが何度も登録されないよう、入力欄をクリアしたりとかね。
app.components.ts
@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { private heroService: EntityCollectionService<Hero>; + loading$: Observable<boolean>; heroes$: Observable<Hero[]>; + isEditing = false; + hero: Hero; constructor(entityServices: EntityServices) { this.heroService = entityServices.getEntityCollectionService<Hero>('Hero'); + this.loading$ = this.heroService.loading$; this.heroes$ = this.heroService.entities$; } ngOnInit() { this.heroService.getAll() .subscribe( () => console.log('エンティティを取得しました'), err => console.error(`エンティティの取得に失敗しました: ${err}`)); } deleteHero(id: number) { const hero = <Hero>{ id }; this.heroService.delete(hero) .subscribe( () => console.log('エンティティを削除しました'), err => console.error(`エンティティの削除に失敗しました: ${err}`)); } + beginEdit(id?: number, name?: string) { + this.isEditing = true; + this.hero = <Hero>{ id, name }; + } + endEdit() { + this.isEditing = false; + this.hero = <Hero>{} + } + addHero(hero: Hero) { + this.heroService.add(hero) + .subscribe( + () => console.log('エンティティを追加しました'), + err => console.error(`エンティティの追加に失敗しました: ${err}`), + () => this.endEdit()); + } }
addHero()
は、テンプレート(HTML 側)から、Hero
オブジェクトを受け取って、それを heroService
経由でエンティティに追加している。
このとき、id
には何も設定されていない。
サーバ側の API で主キーを採番して追加することを期待しているからだ。
add()
してもいいの?いいけど、一意制約を守らないといけないよ。
たとえば何百台の端末が各々適当な乱数を主キーに設定したら、まず衝突する。
事故を避けるためにも、UUID のように分散環境で安全に使えるものを選ぶのが吉だね。
loading$
とか isEditing
とかフラグっぽいものが出てきたね。app.components.html
+ <ng-container *ngIf="isEditing; else newButton"> + <form #myForm="ngForm"> + <input [(ngModel)]="hero.name" name="heroName" required="heroName" /> + <button (click)="addHero(hero)" + [disabled]="myForm.invalid || (loading$ | async)">追加</button> + <button (click)="endEdit()" + [disabled]="loading$ | async">キャンセル</button> + </form> + </ng-container> + <ng-template #newButton> + <button (click)="beginEdit()">新規追加</button> + </ng-template> <table> <tr> <th>id</th> <th>name</th> <th>delete</th> </tr> <tr *ngFor="let hero of heroes$ | async"> <td>{{hero.id}}</td> <td>{{hero.name}}</td> - <td><button (click)="deleteHero(hero.id)">削除</button></td> + <td> + <button [disabled]="isEditing" + (click)="deleteHero(hero.id)">削除</button> + </td> </tr> </table>
heroes$
や loading$
の後ろの $
はなにもの?これはね、Observable<T>
型のプロパティであることを示すシルシみたいなものだよ。
そのままではバインドできないので、async
パイプをセットで使う必要がある。
更新
最後に、既存データを更新する処理を追加しよう。
今までやってきたことの合わせ技みたいなものなので、決して難しくはない。
この章の完成イメージ
app.components.ts
@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { private heroService: EntityCollectionService<Hero>; loading$: Observable<boolean>; heroes$: Observable<Hero[]>; isEditing = false; hero: Hero; /* 略 */ endEdit() { this.isEditing = false; this.hero = <Hero>{} } addHero(hero: Hero) { this.heroService.add(hero) .subscribe( () => console.log('エンティティを追加しました'), err => console.error(`エンティティの追加に失敗しました: ${err}`), () => this.endEdit()); } + updateHero(hero: Hero) { + this.heroService.update(hero) + .subscribe( + () => console.log('エンティティを更新しました'), + err => console.error(`エンティティの更新に失敗しました: ${err}`), + () => this.endEdit()); + } }
あー…… なるほど、わかってきたぞ。
更新対象のエンティティオブジェクトを引数に渡して this.heroService.update(hero)
を叩いているだけだね。
METHOD | MEANING | HTTP METHOD WITH ENDPOINT |
---|---|---|
add(entity: T) | 新しいエンティティを追加します | POST /api/hero/ |
delete(id: any) | 主キーの値でエンティティを削除します | DELETE /api/hero/5 |
getAll() | このエンティティタイプのすべてのインスタンスを取得します | GET /api/heroes/ |
getById(id: any) | 主キーでエンティティを取得します | GET /api/hero/5 |
getWithQuery(queryParams: QueryParams | string) | クエリを満たすエンティティを取得します | GET /api/heroes/?name=bombasto |
update(update: Update |
既存のエンティティを更新します | PUT /api/hero/5 |
app.components.html
<ng-container *ngIf="isEditing; else newButton"> <form #myForm="ngForm"> <input [(ngModel)]="hero.name" name="heroName" required="heroName" /> + <ng-container *ngIf="hero.id; else add"> + <button (click)="updateHero(hero)" + [disabled]="myForm.invalid || myForm.pristine || (loading$ | async)"> + 更新 + </button> + </ng-container> + <ng-template #add> <button (click)="addHero(hero)" [disabled]="myForm.invalid || (loading$ | async)">追加</button> + </ng-template> <button (click)="endEdit()" [disabled]="loading$ | async">キャンセル</button> </form> </ng-container> <ng-template #newButton> <button (click)="beginEdit()">新規追加</button> </ng-template> <table> <tr> <th>id</th> <th>name</th> <th>delete</th> + <th>update</th> </tr> <tr *ngFor="let hero of heroes$ | async"> <td>{{hero.id}}</td> <td>{{hero.name}}</td> <td> <button [disabled]="isEditing" (click)="deleteHero(hero.id)">削除</button> </td> + <td> + <button [disabled]="isEditing" + (click)="beginEdit(hero.id, hero.name)">更新</button> + </td> </tr> </table>
<button>
の [disabled]
には、 myForm.pristine
がバインドされているね。これは、フォームが未編集の状態ではボタンを押せないようにするためだよ。
フォームが空欄の場合、フォームが未編集の場合、そしてデータの読み書き中の場合は、ボタンを押せなくしている。
まとめ
そんなわけで、CRUD アプリを無理やり完成させました。
かれこれ1週間半くらいこの話を続けてきましたが、@ngrx/data ネタはいったんここで一旦おしまいです。
えー。
まだ、親子関係のある複数エンティティを同時更新したり、Web API の要件に合わせて URL をカスタマイズしたりといった応用技が残っているじゃん。
もともと、この連載のゴールは @ngrx/data でお手軽に CRUD を体験するところまでだったんだ。
ここまでくれば、あとは独力でドキュメントを読んで先に進めるとボクは思ってる。 それに──
たしかに。
ソースも毎回ちょっとずつ修正だから、これ以上長くなると写経する人が完走できなくなっちゃうかもね。
思った以上に簡単だった。 Angular 側から見ると、配列操作を行う感覚に近い。
いきなり売り物に組み込む前に、自分で簡単なアプリに組み込んで、手に馴染んだら本格的に使ってみたいと思った。