Uploader un fichier avec Angular et Material

Feb 28, 2018 · 1103 words · 6 minutes read angular material typescript spring

Pour l’un des projet sur lequel je developpe, nous avons eu a mettre en place un upload de fichier. Projet basé sur Angular 4, et material pour la partie cliente et Spring 4.X pour la partie serveur. Quoi de plus facile ? Effectivement, c’est simple, a part si vous vous prenez en compte le framework de presentation Material Design de Google et son implémentation angular Angular Material.

Car Angular Material ne presente pas de composant d’upload de fichier, et utiliser le composant standard HTML de selection et d’upload de fichier ruine completement el design.

Dans cet exemple, je vais developper la solution, qui sans être extraordinaire, est assez élégante :)

le serveur

Il faut d’abord créer une API REST pour uploader le fichier. Pour mon test, il renvera seulement une structure avec le nom du fichier et la taille de celui-ci.

Je crée un endpoint sur le verbe POST :

@PostMapping(value = "/upload", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<FileInformation> uploadFile(
  @RequestParam(name = "data") MultipartFile multipartFile
) throws UploadFileException {
  if (multipartFile == null || multipartFile.isEmpty()) {
    throw new UploadFileException();
  }
  return new ResponseEntity<>(new FileInformation(multipartFile.getOriginalFilename(), multipartFile.getSize()), HttpStatus.CREATED);
}

Cet endpoint accept des données sous la forme multipart/form-data, qui permet la reception de données sous forme binaire de grande taille. Et c’est évidement un POST, car l’upload de fichier va créer de l’information sur le serveur. J’ai un peu dérogé à mes principes, le POST renvoi une information sur la donnée, mais c’est pour le test ;)

Et pour tester l’upload, c’est simple :

Le client

Le client est une application réalisée avec le framework Angular. Dans un premier temps, je monte l’exemple de base sans intégrer Anular/Material que j’exposerai dans la 3eme partie du post.

Donc pour notre client de base, il faut un champs de selection de fichier :

<form [formGroup]="uploadForm" > (1)
  <input type="file" id="userFile" (change)="onSelectFile($event)"> (2)
  <input type="button" (click)="sendFile()" value="Envoyer"> (3)
</form>
  1. Déclaration du formulaire

  2. champ input de type file

  3. Envoi du fichier

J’ai mis en place une action ((change)="onSelectFile($event)") sur l’input afin de mémoriser le fichier directement sur l’evenement de changement de l’input.

  onSelectFile(event) {
    if(event.target.files && event.target.files.length > 0) {
      this.file = event.target.files[0];
      this.fileInformation = null;
    }
  }

L’envoi du fichier se fait sans coup férir via le bouton :

  sendFile() {
    const data: FormData = new FormData();
    data.append(`data`, this.file, this.file.name );
    // Pas d'ajout d'header exposant le content-type, le framework le fait pour vous.
    this.httpClient.post(
      '/api/upload',
      data
    ).subscribe(value => {
        this.fileInformation = value as FileInformation;
      })
  }

Je n’ajoute pas les headers décrivant le content-type, le framework le fait automatiquement en fonction du type de fichiers.

C’est simple, en tout cas, en ce qui concerne l’upload de fichier en "vanilla" HTML.

Client Material

Par contre, si je veux appliquer le framework de presentation Material en incluant la possibilité d’upload, cela se corse un peu…​ Car Material ne propose pas d’input d’upload de fichier. Il va falloir en créer un de toute pièce.

Je commence par ajouter Angular à mon projet avec yarn add @angular/material @angular/cdk

J’ai donc Material ajouté comme dépendance (1) à mon package.json :

  "dependencies": {
    "@angular/animations": "^5.2.0",
    "@angular/cdk": "^5.2.4",
    "@angular/common": "^5.2.0",
    "@angular/compiler": "^5.2.0",
    "@angular/core": "^5.2.0",
    "@angular/forms": "^5.2.0",
    "@angular/http": "^5.2.0",
    "@angular/material": "^5.2.4", (1)
    "@angular/platform-browser": "^5.2.0",
    "@angular/platform-browser-dynamic": "^5.2.0",
    "@angular/router": "^5.2.0",
    "core-js": "^2.4.1",
    "rxjs": "^5.5.6",
    "zone.js": "^0.8.19"
  },

Mais pour que cela fonctionne, il faut importer dans le module de votre application les modules material correspondant au différents éléments :

...
import {MatButtonModule, MatCardModule, MatInputModule} from '@angular/material';
...
@NgModule({
  ...
  imports: [
    ...
    MatCardModule, MatButtonModule, MatInputModule,
    ...
  ],
  ...
})
export class AppModule { }

Sans oublier l’import du style material dans le fichier de style global. Vous pourrez trouver toutes les explication ici: theming Material (docmentation officielle)

Le but est d’utiliser le composant HTML standard de selection de fichier, ou du moins sa capacité a ouvrir une fenetre système de selection de fichier. Le but étant de l’utiliser sans le montrer et de le remplacer par un input standard material en lecture seul et d’y joindre un bouton permettant d’appeler la fonction de selection de fichier de l’input de type fichier.

<mat-card class="example-card" style="margin: 3em; background-color: gainsboro">
  <mat-card-header>
    <h2>Upload de fichier</h2>
  </mat-card-header>
  <mat-card-content>
    <form [formGroup]="uploadForm" >
      <input type="file" id="userFile" (change)="onSelectFile($event)" #fileInput style="display: none"> (1)
      <mat-form-field >
        <input matInput formControlName="filename" placeholder="Fichier" readonly> (2)
      </mat-form-field>
      <button mat-button (click)="selectFile()">Selectionner</button> (3)
    </form>
    <button mat-button (click)="sendFile()" >Envoyer</button>
  </mat-card-content>
</mat-card>

Regardons le code du template. Le champ imput de type ficher (1) est bien present et display à none. Je lui ai aussi assigné une variable locale (fileInput), aussi appelé variable de template, qui sera bien utile pour la suite. J’ai inclus un input en lecture seule (2) qui affichera le nom du fichier. Et enfin j’ajoute un bouton (3) qui permettra d’appeler le fonctionnement par défaut de l’input de type ficher afin de selectionner un fichier.

Maintenant, soulevons le capot du composant. Toute l’astuce consiste à executer le comportement par défaut d’un champs de type fichier (ouverture de la fenetre systeme de selection de ficher). Pour cela, nous avons affecté une variable au champs, voir ci dessus ( #fileInput). Cette variable de template va être inspecté par Angular, et le framework va l’injecter au niveau du composant via l’annotation @ViewChild. Injection qui affectera une variable de type ElementRef, c’est une classe Angular permettant de référencer des élément natif du DOM et d’acceder à la propriété et fonctions. Le bouton de selection (3) appele une méthode qui ne fait que déclencher le comportement par défaut :

export class AppComponent implements OnInit {
  ...
  @ViewChild('fileInput')
  fileInput: ElementRef;
  ...
  selectFile(): void {
    this.fileInput.nativeElement.click();
  }
  ...
}

J’ai aussi legerement modfié la gestion de l’evenement de changement de l’input de type fichier, pour y mettre à jour l’input (material) chargé d’afficher le nom du fichier selectionné. (1)

onSelectFile(event) {
  if(event.target.files && event.target.files.length > 0) {
    this.file = event.target.files[0];
    this.uploadForm.get('filename').setValue(this.file.name); (1)
    this.fileInformation = null;
  }
}

Le reste fonctionne exactement de la même manière que precedement.

J’ai maintenant un système d’upload de fichier avec le framework Angular/Material parfaitement fonctionnel.

Toutes les sources de cet exemple se trouve sur mon repository github : ptitbob/spring-angular-demo-upload. L’exemple d’upload simple, est sous le tag ptitbob/spring-angular-demo-upload - upload-html-simple. Et l’exemple final Material : ptitbob/spring-angular-demo-upload - upload-material.

Pour executer l’exemple, cloner le projet en local. Le repertoire server contient le serveur Spring Boot, que vous pouvez lancer avec la commande mvn spring-boot:run. Pour la partie cliente, placez vous dans le repertoire client et lancer la commande npm start (ne faite pas un ng start car j’ai mis en place une configuration de proxy pour que la webapp appelle le serveur sans problème de CORS). La partie cliente est accessible à l’adresse http://localhost:4200/

have fun