로메오의 블로그

[Angular] Rest API Service 구현하기 본문

Frontend/angular

[Angular] Rest API Service 구현하기

romeoh 2020. 1. 12. 23:45
반응형

ionic 차례

 

모듈추가

src/app/app.module.ts

....
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  ....
  imports: [
    ....
    HttpClientModule,
    ....
  ],
  ....
})

httpClientModule을 주입합니다.

 

전체소스

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { DragDropModule } from '@angular/cdk/drag-drop';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule,
    ReactiveFormsModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    BrowserAnimationsModule,
    DragDropModule,
    ScrollingModule
  ],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

product.ts 모델 추가

$ touch src/app/product.ts
export class Product {
    _id: number;
    prod_name: string;
    prod_desc: string;
    prod_price: number;
    updated_at: Date;
}

service 추가

$ ionic g service api

api.service.ts

api.service.spec.ts

두 개의 파일이 생성되었습니다.

 

api.service.ts

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ApiService {

  constructor() { }
}

아래와 같이 코드를 수정합니다.

import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { catchError, tap, map } from 'rxjs/operators';
import { Product } from './product';

const httpOptions = {
  headers: new HttpHeaders({'Content-Type': 'application/json'})
};
const apiUrl = 'http://localhost:3000/api/v1/products';

@Injectable({
  providedIn: 'root'
})
export class ApiService {

  constructor(private http: HttpClient) { }

  private handleError<T>(operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {
      console.error(error); // log to console instead
      return of(result as T);
    };
  }

  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(apiUrl)
      .pipe(
        tap(product => console.log('fetched products')),
        catchError(this.handleError('getProducts', []))
      );
  }
  
  getProduct(id: any): Observable<Product> {
    const url = `${apiUrl}/${id}`;
    return this.http.get<Product>(url).pipe(
      tap(_ => console.log(`fetched product id=${id}`)),
      catchError(this.handleError<Product>(`getProduct id=${id}`))
    );
  }
  
  addProduct(product: Product): Observable<Product> {
    return this.http.post<Product>(apiUrl, product, httpOptions).pipe(
      tap((prod: Product) => console.log(`added product w/ id=${prod._id}`)),
      catchError(this.handleError<Product>('addProduct'))
    );
  }
  
  updateProduct(id: any, product: any): Observable<any> {
    const url = `${apiUrl}/${id}`;
    return this.http.put(url, product, httpOptions).pipe(
      tap(_ => console.log(`updated product id=${id}`)),
      catchError(this.handleError<any>('updateProduct'))
    );
  }
  
  deleteProduct(id: any): Observable<Product> {
    const url = `${apiUrl}/${id}`;
  
    return this.http.delete<Product>(url, httpOptions).pipe(
      tap(_ => console.log(`deleted product id=${id}`)),
      catchError(this.handleError<Product>('deleteProduct'))
    );
  }
}

 

 

 

데이터 LIST 구현

src/app/home/home.page.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {

  constructor() {}

}

아래와 같이 코드를 수정합니다.

import { Component, OnInit } from '@angular/core';
import { LoadingController } from '@ionic/angular';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../api.service';
import { Product } from '../product';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})

export class HomePage implements OnInit {

  products: Product[] = [];

  constructor(
    public api: ApiService,
    public loadingController: LoadingController,
    public router: Router,
    public route: ActivatedRoute
  ) {}

  ngOnInit() {
    this.getProducts();
  }

  async getProducts() {
    const loading = await this.loadingController.create({
      message: 'Loading...'
    });
    await loading.present();
    await this.api.getProducts()
      .subscribe(res => {
        this.products = res;
        console.log(this.products);
        loading.dismiss();
      }, err => {
        console.log(err);
        loading.dismiss();
      });
  }

  drop(event: CdkDragDrop<string[]>) {
    moveItemInArray(this.products, event.previousIndex, event.currentIndex);
  }
}

 

src/app/home/home.module.ts

....
import { ScrollingModule } from '@angular/cdk/scrolling';
import { DragDropModule } from '@angular/cdk/drag-drop';
....
@NgModule({
  imports: [
    ....
    ScrollingModule,
    DragDropModule,
    ....
  ],
  ....
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { RouterModule } from '@angular/router';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { DragDropModule } from '@angular/cdk/drag-drop';

import { HomePage } from './home.page';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    ScrollingModule,
    DragDropModule,
    RouterModule.forChild([
      {
        path: '',
        component: HomePage
      }
    ])
  ],
  declarations: [HomePage]
})
export class HomePageModule {}

src/app/home/home.module.html

home.module.html 파일을 열어서 아래 코드로 대체합니다.

<ion-header>
  <ion-toolbar>
    <ion-title>Home</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <cdk-virtual-scroll-viewport cdkDropList itemSize="20" class="example-viewport" (cdkDropListDropped)="drop($event)">
    <ion-item *cdkVirtualFor="let p of products" class="example-item" href="/tabs/(details:{{p._id}})" cdkDrag>
      <ion-icon name="desktop" slot="start"></ion-icon>
      {{p.prod_name}}
      <div class="item-note" slot="end">
        {{p.prod_price | currency}}
      </div>
    </ion-item>
  </cdk-virtual-scroll-viewport>
</ion-content>

src/app/home/home.page.sass

.example-viewport {
  height: 100%;
  width: 100%;
  border: none;
}

.example-item {
  min-height: 50px;
}

sass 파일도 위 코드로 대체합니다.

 

데이터 DETAIL 구현

src/app/product-detail/product-detail.page.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-product-detail',
  templateUrl: './product-detail.page.html',
  styleUrls: ['./product-detail.page.scss'],
})
export class ProductDetailPage implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

아래와 같이 코드를 수정합니다.

import { Component, OnInit } from '@angular/core';
import { AlertController } from '@ionic/angular';
import { ApiService } from '../api.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Product } from '../product';

@Component({
  selector: 'app-product-detail',
  templateUrl: './product-detail.page.html',
  styleUrls: ['./product-detail.page.scss'],
})
export class ProductDetailPage implements OnInit {

  product: Product = { _id: null, prod_name: '', prod_desc: '', prod_price: null, updated_at: null };
  isLoadingResults = false;

  constructor(
    public api: ApiService,
    public alertController: AlertController,
    public route: ActivatedRoute,
    public router: Router
  ) {}

  ngOnInit() {
    this.getProduct();
  }

  async getProduct() {
    if (this.route.snapshot.paramMap.get('id') === 'null') {
      this.presentAlertConfirm('You are not choosing an item from the list');
    } else {
      this.isLoadingResults = true;
      await this.api.getProduct(this.route.snapshot.paramMap.get('id'))
        .subscribe(res => {
          console.log(res);
          this.product = res;
          this.isLoadingResults = false;
        }, err => {
          console.log(err);
          this.isLoadingResults = false;
        });
    }
  }

  async presentAlertConfirm(msg: string) {
    const alert = await this.alertController.create({
      header: 'Warning!',
      message: msg,
      buttons: [
        {
          text: 'Okay',
          handler: () => {
            this.router.navigate(['']);
          }
        }
      ]
    });
  
    await alert.present();
  }

  editProduct(id: any) {
    this.router.navigate([ '/product-edit', id ]);
  }
}

 

src/app/product-detail/product-detail.page.html

<ion-header>
  <ion-toolbar>
    <ion-title>product-detail</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

</ion-content>

아래와 같이 코드를 수정합니다.

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/home"></ion-back-button>
    </ion-buttons>
    <ion-title>Product Details</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <div class="example-container mat-elevation-z8">
    <div class="example-loading-shade"
          *ngIf="isLoadingResults">
      <mat-spinner *ngIf="isLoadingResults"></mat-spinner>
    </div>
    <mat-card class="example-card">
      <mat-card-header>
        <mat-card-title><h2>{{product.prod_name}}</h2></mat-card-title>
        <mat-card-subtitle>{{product.prod_desc}}</mat-card-subtitle>
      </mat-card-header>
      <mat-card-content>
        <dl>
          <dt>Product Price:</dt>
          <dd>{{product.prod_price}}</dd>
          <dt>Updated At:</dt>
          <dd>{{product.updated_at | date}}</dd>
        </dl>
      </mat-card-content>
      <mat-card-actions>
        <a mat-flat-button color="primary" (click)="editProduct(product._id)"><mat-icon>edit</mat-icon></a>
        <a mat-flat-button color="warn" (click)="deleteProduct(product._id)"><mat-icon>delete</mat-icon></a>
      </mat-card-actions>
    </mat-card>
  </div>
</ion-content>

src/app/product-detail/product-detail.page.scss

.example-container {
  position: relative;
  padding: 5px;
  height: 100%;
  background-color: aqua;
}

.example-loading-shade {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 56px;
  right: 0;
  background: rgba(0, 0, 0, 0.15);
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

.mat-flat-button {
  margin: 5px;
}

Add Data 구현

src/app/product-add/product-add.page.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-product-add',
  templateUrl: './product-add.page.html',
  styleUrls: ['./product-add.page.scss'],
})
export class ProductAddPage implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

아래와 같이 코드를 수정합니다.

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { ApiService } from '../api.service';
import { FormControl, FormGroupDirective, FormBuilder, FormGroup, NgForm, Validators } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';

/** Error when invalid control is dirty, touched, or submitted. */
export class MyErrorStateMatcher implements ErrorStateMatcher {
  isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
    const isSubmitted = form && form.submitted;
    return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted));
  }
}

@Component({
  selector: 'app-product-add',
  templateUrl: './product-add.page.html',
  styleUrls: ['./product-add.page.scss'],
})
export class ProductAddPage implements OnInit {

  productForm: FormGroup;
  prod_name = '';
  prod_desc = '';
  prod_price: number = null;
  isLoadingResults = false;
  matcher = new MyErrorStateMatcher();

  constructor(
    private router: Router,
    private api: ApiService,
    private formBuilder: FormBuilder
  ) { }

  ngOnInit() {
    this.productForm = this.formBuilder.group({
      'prod_name': [null, Validators.required],
      'prod_desc': [null, Validators.required],
      'prod_price': [null, Validators.required]
    })
  }

  onFormSubmit() {
    this.isLoadingResults = true;
    this.api.addProduct(this.productForm.value)
      .subscribe((res: any) => {
          const id = res._id;
          this.isLoadingResults = false;
          this.router.navigate(['/product-details', id]);
        }, (err: any) => {
          console.log(err);
          this.isLoadingResults = false;
        });
  }

}

src/app/product-add/product-add.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { ProductAddPageRoutingModule } from './product-add-routing.module';

import { ProductAddPage } from './product-add.page';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    ProductAddPageRoutingModule
  ],
  declarations: [ProductAddPage]
})
export class ProductAddPageModule {}

아래와 같이 코드를 수정합니다.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';

import { IonicModule } from '@ionic/angular';

import { ProductAddPageRoutingModule } from './product-add-routing.module';

import { ProductAddPage } from './product-add.page';

import {
  MatInputModule,
  MatPaginatorModule,
  MatProgressSpinnerModule,
  MatSortModule,
  MatTableModule,
  MatIconModule,
  MatButtonModule,
  MatCardModule,
  MatFormFieldModule } from '@angular/material';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    IonicModule,
    MatInputModule,
    MatPaginatorModule,
    MatProgressSpinnerModule,
    MatSortModule,
    MatTableModule,
    MatIconModule,
    MatButtonModule,
    MatCardModule,
    MatFormFieldModule
  ],
  declarations: [ProductAddPage]
})
export class ProductAddPageModule {}

src/app/product-add/product-add.page.html

<ion-header>
  <ion-toolbar>
    <ion-title>product-add</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

</ion-content>

아래와 같이 코드를 수정합니다.

<ion-header>
    <ion-toolbar color="primary">
      <ion-buttons slot="start">
        <ion-back-button defaultHref="/home"></ion-back-button>
      </ion-buttons>
      <ion-title>Product Add</ion-title>
    </ion-toolbar>
  </ion-header>

<ion-content>
  <div class="example-container mat-elevation-z8">
    <div class="example-loading-shade"
         *ngIf="isLoadingResults">
      <mat-spinner *ngIf="isLoadingResults"></mat-spinner>
    </div>
    <mat-card class="example-card">
      <form [formGroup]="productForm" (ngSubmit)="onFormSubmit()">
        <mat-form-field class="example-full-width">
          <input matInput placeholder="Product Name" formControlName="prod_name"
                 [errorStateMatcher]="matcher">
          <mat-error>
            <span *ngIf="!productForm.get('prod_name').valid && productForm.get('prod_name').touched">Please enter Product Name</span>
          </mat-error>
        </mat-form-field>
        <mat-form-field class="example-full-width">
          <input matInput placeholder="Product Desc" formControlName="prod_desc"
                 [errorStateMatcher]="matcher">
          <mat-error>
            <span *ngIf="!productForm.get('prod_desc').valid && productForm.get('prod_desc').touched">Please enter Product Description</span>
          </mat-error>
        </mat-form-field>
        <mat-form-field class="example-full-width">
          <input matInput placeholder="Product Price" formControlName="prod_price"
                 [errorStateMatcher]="matcher">
          <mat-error>
            <span *ngIf="!productForm.get('prod_price').valid && productForm.get('prod_price').touched">Please enter Product Price</span>
          </mat-error>
        </mat-form-field>
        <div class="button-row">
          <button type="submit" [disabled]="!productForm.valid" mat-flat-button color="primary"><mat-icon>save</mat-icon></button>
        </div>
      </form>
    </mat-card>
  </div>
</ion-content>

src/app/product-add/product-add.page.scss

.example-container {
  position: relative;
  padding: 5px;
  height: 100%;
  background-color: aqua;
}

.example-form {
  min-width: 150px;
  max-width: 500px;
  width: 100%;
}

.example-full-width {
  width: 100%;
}

.example-full-width:nth-last-child(0) {
  margin-bottom: 10px;
}

.button-row {
  margin: 10px 0;
}

.mat-flat-button {
  margin: 5px;
}

.example-card {
  margin: 5px;
}

Edit Data 구현

src/app/product-edit/product-edit.page.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.page.html',
  styleUrls: ['./product-edit.page.scss'],
})
export class ProductEditPage implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

아래와 같이 코드를 수정합니다.

import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { ApiService } from '../api.service';
import { FormControl, FormGroupDirective, FormBuilder, FormGroup, NgForm, Validators } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';

/** Error when invalid control is dirty, touched, or submitted. */
export class MyErrorStateMatcher implements ErrorStateMatcher {
  isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
    const isSubmitted = form && form.submitted;
    return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted));
  }
}

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.page.html',
  styleUrls: ['./product-edit.page.scss'],
})

export class ProductEditPage implements OnInit {

  productForm: FormGroup;
  _id = '';
  prod_name = '';
  prod_desc = '';
  prod_price: number = null;
  isLoadingResults = false;
  matcher = new MyErrorStateMatcher();

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private api: ApiService,
    private formBuilder: FormBuilder
  ) { }

  ngOnInit() {
    this.getProduct(this.route.snapshot.params['id']);
    this.productForm = this.formBuilder.group({
      'prod_name' : [null, Validators.required],
      'prod_desc' : [null, Validators.required],
      'prod_price' : [null, Validators.required]
    });
  }

  getProduct(id: any) {
    this.api.getProduct(id).subscribe((data: any) => {
      this._id = data._id;
      this.productForm.setValue({
        prod_name: data.prod_name,
        prod_desc: data.prod_desc,
        prod_price: data.prod_price
      });
    });
  }

  onFormSubmit() {
    this.isLoadingResults = true;
    this.api.updateProduct(this._id, this.productForm.value)
      .subscribe((res: any) => {
          const id = res._id;
          this.isLoadingResults = false;
          this.router.navigate(['/product-details', id]);
        }, (err: any) => {
          console.log(err);
          this.isLoadingResults = false;
        }
      );
  }

  productDetails() {
    this.router.navigate(['/product-details', this._id]);
  }


}

src/app/product-edit/product-edit.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { ProductEditPageRoutingModule } from './product-edit-routing.module';

import { ProductEditPage } from './product-edit.page';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    ProductEditPageRoutingModule
  ],
  declarations: [ProductEditPage]
})
export class ProductEditPageModule {}

아래와 같이 코드를 수정합니다.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { ProductEditPageRoutingModule } from './product-edit-routing.module';

import { ProductEditPage } from './product-edit.page';

import {
  MatInputModule,
  MatPaginatorModule,
  MatProgressSpinnerModule,
  MatSortModule,
  MatTableModule,
  MatIconModule,
  MatButtonModule,
  MatCardModule,
  MatFormFieldModule } from '@angular/material';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    IonicModule,
    ProductEditPageRoutingModule,
    MatInputModule,
    MatPaginatorModule,
    MatProgressSpinnerModule,
    MatSortModule,
    MatTableModule,
    MatIconModule,
    MatButtonModule,
    MatCardModule,
    MatFormFieldModule
  ],
  declarations: [ProductEditPage]
})
export class ProductEditPageModule {}

src/app/product-edit/product-edit.module.html

<ion-header>
  <ion-toolbar>
    <ion-title>product-edit</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

</ion-content>

아래와 같이 코드를 수정합니다.

<ion-header>
    <ion-toolbar color="primary">
      <ion-buttons slot="start">
        <ion-back-button defaultHref="/product-detail/{{_id}}"></ion-back-button>
      </ion-buttons>
      <ion-title>Product Edit</ion-title>
    </ion-toolbar>
  </ion-header>

<ion-content>
  <div class="example-container mat-elevation-z8">
    <div class="example-loading-shade"
         *ngIf="isLoadingResults">
      <mat-spinner *ngIf="isLoadingResults"></mat-spinner>
    </div>
    <mat-card class="example-card">
      <form [formGroup]="productForm" (ngSubmit)="onFormSubmit()">
        <mat-form-field class="example-full-width">
          <input matInput placeholder="Product Name" formControlName="prod_name"
                 [errorStateMatcher]="matcher">
          <mat-error>
            <span *ngIf="!productForm.get('prod_name').valid && productForm.get('prod_name').touched">Please enter Product Name</span>
          </mat-error>
        </mat-form-field>
        <mat-form-field class="example-full-width">
          <input matInput placeholder="Product Desc" formControlName="prod_desc"
                 [errorStateMatcher]="matcher">
          <mat-error>
            <span *ngIf="!productForm.get('prod_desc').valid && productForm.get('prod_desc').touched">Please enter Product Description</span>
          </mat-error>
        </mat-form-field>
        <mat-form-field class="example-full-width">
          <input matInput placeholder="Product Price" formControlName="prod_price"
                 [errorStateMatcher]="matcher">
          <mat-error>
            <span *ngIf="!productForm.get('prod_price').valid && productForm.get('prod_price').touched">Please enter Product Price</span>
          </mat-error>
        </mat-form-field>
        <div class="button-row">
          <button type="submit" [disabled]="!productForm.valid" mat-flat-button color="primary"><mat-icon>save</mat-icon></button>
        </div>
      </form>
    </mat-card>
  </div>
</ion-content>

src/app/product-edit/product-edit.module.scss

.example-container {
  position: relative;
  padding: 5px;
  height: 100%;
  background-color: aqua;
}

.example-form {
  min-width: 150px;
  max-width: 500px;
  width: 100%;
}

.example-full-width {
  width: 100%;
}

.example-full-width:nth-last-child(0) {
  margin-bottom: 10px;
}

.button-row {
  margin: 10px 0;
}

.mat-flat-button {
  margin: 5px;
}

.example-card {
  margin: 5px;
}

 

 

ionic 차례

반응형
Comments