I have recently come up with an idea to retrieve additional data through Pipe, and I am not sure if I created a cool solution or terrible abomination. I will try to present the problem and my solution. I will include most important parts of the code, but should something be unclear please let me know.
Angular version is 4.2.3
Problem
There is a model of a BaseModel
class that I retrieve from database in a traditional way:
export class BaseModel {
// some properties
googlePlaceId: string = '';
}
In my application, I want to display details about the given place (the one with googlePlaceId
) that I get from GooglePlacesAPI. Now, what I would normally do, I would create an additional property inside a component to which I would write a result of a call, like that:
// part of the Component
getBaseModel() {
this.baseModelService.get()
.subscribe(model => {
this.baseModel = model;
this.googlePlacesService.getDetails(model.googlePlaceId)
.subscribe(googlePlaceDetails => {
this.googlePlaceModel = googlePlaceDetails;
})
});
}
There is a problem with that solution, and it is quite ugly. There is a call inside result of another call, and I need to track another property (googlePlaceModel
) in the component.
Solution
There is a LocationModel
class that consists of place details:
export class LocationModel {
placeId: string;
latitude?: number;
longitude?: number;
formattedAddress?: string;
url?: string;
static fromGooglePlaces(object: any): LocationModel {
const locationModel: LocationModel = {
placeId: object.place_id,
latitude: object.geometry.location.lat(),
longitude: object.geometry.location.lng(),
formattedAddress: object.formatted_address,
url: object.url,
};
return locationModel;
}
}
There is a GooglePlacesService
that retrieves data from the Google API:
// part of GooglePlacesService
public locations: LocationModel[] = [];
public getLocation(id: string, subject: Subject<any>): LocationModel {
const index = this.locations.findIndex(x => x.placeId === id);
if (index === -1) {
const locationModel: LocationModel = { placeId: id };
const newIndex = this.locations.push(locationModel) - 1;
this.getDetails(id)
.subscribe(val => {
this.locations[newIndex] = LocationModel.fromGooglePlaces(val);
subject.next();
})
return this.locations[newIndex]
}
return this.locations[index];
}
Let's take a while to inspect what is happening here. The getLocation
method returns details of a place with given id. First, it inspects the locations
array, and if the place already is inside that array it returns it. Otherwise, it creates a LocationModel
with just placeId
, returns it, and calls the method that retrieves all the details (this.getDetails
). It is important to notice that return this.locations[newIndex]
will be executed BEFORE the code within subscribe
method, so it will return almost empty model. Inside the subscribe
method there is also subject.next();
which informs the caller that the object has been retrieved.
Last but not least, there is a GetLocationPipe
:
// GetLocationPipe
@Pipe({
name: 'getLocation',
pure: false
})
export class GetLocationPipe implements PipeTransform {
result: LocationModel;
subject = new Subject();
constructor(
private googlePlacesService: GooglePlacesService,
private ref: ChangeDetectorRef) {
this.subject.subscribe(() => this.ref.detectChanges())
}
transform(placeId: string): LocationModel {
if (placeId == '') {
return null;
}
return this.result = this.googlePlacesService.getLocation(placeId, this.subject);
}
}
From now on it is pretty straightforward. There is the result
that is retrieved from getLocation
method. At the beginning, it will be almost empty as discussed earlier, but we expect to get more details from that call inside the getLocation
method. Now, to know when the result
is retrieved, the subject
is passed inside the getLocation
method. In the constructor, we subscribe to the subject
and perform refresh when the subject
is raised.
Theoretically, it would work without subject
and our smart refresh as the pipe is NOT pure and will refresh with time. Unfortunately Angular doesn't detect the change of our result
right away, and it would take something like 10 - 20 seconds to see property refresh. The subject
subscription comes to help with that, by letting getLocation
method inform us when the result
is retrieved (it has all the details).
Usage
Finally, how it actually works in practice, here is fragment of a template:
{{(baseModel.googlePlaceId | getLocation).formattedAddress}}
It is that simple, component itself doesn't even know we are retrieving additional data. Of course, we would still have to wait for information being retrieved, I haven't done any serious tests, but there is absolutely no visible latency with this method comparing to the method presented in the "Problem" part.
Summary
I hope I have presented the problem and solution well enough. I am interested to know what to do think. Is it viable, cool and clean solution, or simply overkill and I should avoid such experiments? What are your thoughts on performance and usability?
-
\$\begingroup\$ I've removed the old solutions and kept the updated ones since there's only a need for one version. \$\endgroup\$Jamal– Jamal2017年06月17日 23:41:48 +00:00Commented Jun 17, 2017 at 23:41
1 Answer 1
There are some problems with your implementation, first of all you're creating subscriptions without unsubscribing from them when the pipe get's destroyed or the request finishes. This could lead to some unwanted behavior.
Second you're using a class but implementing a static method and using the class only as an interface, why not creating a constructor and actually creating instances?
For the LocationModel
i would go with this one:
export class GooglePlacesLocation {
public readonly id: string;
public readonly latitude?: number;
public readonly longitude?: number;
public readonly formattedAddress?: string;
public readonly url?: string;
constructor(placesObj: any) {
this.id = object.place_id;
this.latitude = object.geometry.location.lat();
this.longitude = object.geometry.location.lng();
this.formattedAddress = object.formatted_address;
this.url = object.url;
}
}
Inside your service you're using a array to cache the locations, just use a simple object, it's faster. And your the method to get the location should return an observable, to make return it inside your pipe. For the subscription just use the angular build-in async pipe
.
GooglePlacesService
@Injectable()
export class GooglePlacesService {
private locationCache: { [id: string]: GooglePlacesLocation } = {};
public getDetails$(id: string): Observable<any> {
// api implementation
}
public getLocation$(id: string): Observable<GooglePlacesLocation> {
const cachedLocation = this.locations[id];
if(cachedLocation) {
return Observable.of(cachedLocation);
} else {
return this.getDetails(id).map(value => {
const location = new GooglePlacesLocation(id);
this.locations[id] = location;
return location;
})
}
}
}
And inside your pipe just return the observable:
@Pipe({
name: 'googlePlacesLocation',
pure: false
})
export class GooglePlacesLocationPipe implements PipeTransform {
constructor(private googlePlacesService: GooglePlacesService) { }
transform(id: string): Observable<GooglePlacesLocation> {
if(id) {
return this.googlePlacesService.getLocation$(id);
}
return Observable.of(null);
}
}
Now if you're using it, you have to use it together with the async
pipe.
{{ id | googlePlacesLocation | async }}
This code is currently unteste, made it up on the spot, so if you could test it and tell me if it works would be nice.
Note: The $
at the end of the method name indicates that it's returning an observable.
-
\$\begingroup\$ Thank you for your response. 1. As for the LocationModel. It is being used in many places and can be retrieved from different sources. I think that in this case, static method working as a constructor is better as it clearly states from what source object is being created. 2. Using object instead of array may be better to store the data, thanks. I still can't get used to this js object like dictionary thing. 3. As for returning observable, I was trying to do this for quite some time but without success. The value was refreshing with a big delay. Maybe I will try to do it again. \$\endgroup\$Bielik– Bielik2017年08月01日 23:02:23 +00:00Commented Aug 1, 2017 at 23:02