I'm writing my first web application with Spring and Angular. I used PHP for about 10 years, then discovered and used Rails for the last 10 years, and have done a couple ASP projects to boot, so I'm no stranger to web development, in general. I'm trying to create my first complete set of CRUD actions, but I cannot find documentation on how to create an edit form for a "complex" object (with parents and/or children). The official Angular guide stops just short of this, and I can't find a single tutorial anywhere on the internet that covers this comprehensively. In my example, I want an edit component for a "Product" where I can change the currently selected "Engine".
In Spring, I have classes, repositories, and controllers configured for my two initial models, which looks like this:
Engine (parent) -> Product (child)
Product.java
:
package com.xyz.cddm_ng;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import java.time.LocalDateTime;
import static javax.persistence.GenerationType.IDENTITY;
@Entity
@Getter @Setter
@NoArgsConstructor
@ToString @EqualsAndHashCode
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class Product {
@Id
@GeneratedValue(strategy=IDENTITY)
Long id;
@NonNull String title;
String note;
@CreationTimestamp
LocalDateTime createDateTime;
@UpdateTimestamp
LocalDateTime updateDateTime;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "engine_id")
@JsonManagedReference
Engine engine;
}
Engine.java
(snippet):
@OneToMany(fetch = FetchType.LAZY, mappedBy = "engine")
@JsonBackReference
Collection<Product> products;
The controller and repositories are just stubbed out.
ProductController.java
:
package com.xyz.cddm_ng;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("products")
@CrossOrigin(origins = "http://localhost:4200")
class ProductController { }
ProductRepository.java
:
package com.xyz.cddm_ng;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import org.springframework.web.bind.annotation.CrossOrigin;
@RepositoryRestResource
@CrossOrigin(origins = "http://localhost:4200")
interface ProductRepository extends JpaRepository<Product, Long> { }
On the Angular side, I have product.service.ts
:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ProductService {
public API = '//localhost:8080';
public PRODUCT_API = this.API + '/products';
constructor(private http: HttpClient) { }
getAll(): Observable<any> {
return this.http.get(this.PRODUCT_API);
}
get(id: string) {
return this.http.get(this.PRODUCT_API + '/' + id);
}
save(product: any): Observable<any> {
let result: Observable<Object>;
if (product['href']) {
result = this.http.put(product.href, product);
} else {
result = this.http.post(this.PRODUCT_API, product);
}
return result;
}
remove(href: string) {
return this.http.delete(href);
}
}
product-edit.component.ts
:
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { ProductService } from '../shared/product/product.service';
import { EngineService } from '../shared/engine/engine.service';
import { NgForm } from '@angular/forms';
@Component({
selector: 'app-product-edit',
templateUrl: './product-edit.component.html',
styleUrls: ['./product-edit.component.css']
})
export class ProductEditComponent implements OnInit, OnDestroy {
prod: any = { };
sub: Subscription;
engines: any[] = [ ];
constructor(private route: ActivatedRoute,
private router: Router,
private productService: ProductService,
private engineService: EngineService) { }
ngOnInit() {
this.sub = this.route.params.subscribe( params => {
const id = params['id'];
if (id) {
this.productService.get(id)
.subscribe(
data => {
this.prod = data;
},
error => {
console.log(`Product with '${id}' not found, returning to list. Error was '${error}'.`);
this.gotoList();
});
this.engineService.getAll().subscribe(
data => {
this.engines = data;
});
}
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
gotoList() {
this.router.navigate(['/product-list']);
}
save(form: NgForm) {
this.productService.save(form).subscribe(result => {
this.gotoList();
}, error => console.error(error));
}
remove(href) {
this.productService.remove(href).subscribe(result => {
this.gotoList();
}, error => console.error(error));
}
}
product-edit.component.html
:
<form #f="ngForm" (ngSubmit)="save(f.value)">
<div class="form-group">
<label class="form-control-label" for="title">Title:</label>
<input type="text" class="form-control" id="title" required [(ngModel)]="prod.title" name="title">
</div>
<div class="form-group">
<label class="form-control-label" for="note">Note:</label>
<input type="text" class="form_control" id="note" required [(ngModel)]="prod.note" name="note">
</div>
<div class="form-group">
<label class="form-control-label" for="engine" #selectedValue>Engine</label>
<select class="form-control" id="engine" name="engine" [(ngModel)]="prod.engine">
<option [value]="null"></option>
<option *ngFor="let engine of engines" [ngValue]="engine">{{engine.name}}</option>
</select>
</div>
<button type="submit" class="btn btn-success">Submit</button>
</form>
My current problem is with the generation of the correct JSON from the Spring side for consumption on the Angular side. I believe I need to include the nested engine with the product so that the current one can be shown in the dropdown list. (It's my understanding that the [(ngModel)]="prod.engine"
part should take care of this.)
In my attempt to create a bi-directional association between Product and Engine, I created a recursive loop. If I hit http://localhost:8080/products/1
, Product loads Engines, which loads Products, which... wait for it... overflows the stack, and causes the browser to error out with 500. Trying to fix this problem lead me to this question: Jackson bidirectional relationship (One-to-many) not working, which is all about @JsonIdentityInfo
and its associated annotations.
So I put those annotations on my model, but then I got errors about "not finding property with name 'id'". Trying to fix this problem leads to this popular question: Spring boot @ResponseBody doesn't serialize entity id. There was discussion in there about exposing ID's being the "wrong" thing to do in a REST back end, but fixing the recursive eager loading problem with @JsonIdentityInfo
seems to NEED the ID's. So now I've fixed this problem, and I get the ID's in my JSON, and I don't get a recursive loop, but I still don't get the associated engine data in the JSON for a product:
{
"id" : 1,
"title" : "eowir",
"note" : "noerw",
"createDateTime" : "2018-08-22T16:10:07.349752",
"updateDateTime" : "2018-08-22T16:10:07.349752",
"_links" : {
"self" : {
"href" : "http://localhost:8080/products/1"
},
"product" : {
"href" : "http://localhost:8080/products/1"
},
"engine" : {
"href" : "http://localhost:8080/products/1/engine"
}
}
}
Hitting http://localhost:8080/products/1/engine
works, as does http://localhost:8080/engines/1/products
. So the model works, is associated correctly, and the routing is working as intended. What is preventing me from getting the engine dumped as part of the product JSON?
Also, if I fix this problem, and get the engine stanza along with the product JSON, will that make the edit form work? Will that automatically populate the engine dropdown with the currently-associated engine?