Skip to content

Commit 19bb907

Browse files
committed
spring-projects#41 - Added minimal web UI for Starbucks example app.
Added a minimalistic HTML5 web front-end based on Thymeleaf, Bootstrap, jQuery, URI.js and Google Maps JavaScript API. The required JavaScript dependencies are referenced via Webjars. For details see the README. Original pull request: spring-projects#47.
1 parent b7263b4 commit 19bb907

File tree

8 files changed

+343
-6
lines changed

8 files changed

+343
-6
lines changed

rest/starbucks/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ This sample app exposes 10843 Starbucks coffee shops via a RESTful API that allo
77
1. Install MongoDB (http://www.mongodb.org/downloads, unzip, run `bin/mongod --dbpath=data`)
88
2. Build and run the app (`mvn spring-boot:run`)
99
3. Access the root resource (`curl http://localhost:8080`) and traverse hyperlinks.
10-
4. Or access the location search directly (e.g. `localhost:8080/stores/search/findByAddressLocationNear?location=40.740337,-73.995146&distance=0.5miles`)
10+
4. Or access the location search directly (e.g. `http://localhost:8080/stores/search/findByAddressLocationNear?location=40.740337,-73.995146&distance=0.5miles`)
1111

12-
## API exploration
12+
## Web UI
1313

14-
The module uses the HAL Browser module of Spring Data REST which serves a UI to explore the resources exposed. Point your browser to `http://localhost:8080` to see it.
14+
The application provides a custom web UI using the exposed REST API to display the search result on a Google Map. Point you browser to `http://localhost:8080`. The UI is rendered using Thymeleaf, driven by the `StoresController`. A tiny JavaScript progressively enhances the view by picking up and enhancing a URI template rendered into the view (`<div id="map" data-uri="…" />`).
15+
16+
![Starbucks Web UI](webui.png "Starbucks Web UI")
17+
18+
The API itself can be discovered using the HAL browser pulled in through the corresponding Spring Data REST module (`spring-data-rest-hal-browser`). It's exposed at the API root at `http://localhost:8080/api`.
1519

1620
## Technologies used
1721

1822
- Spring Data REST & Spring Data MongoDB
1923
- MongoDB
2024
- Spring Batch (to read the CSV file containing the store data and pipe it into MongoDB)
21-
- Spring Boot
25+
- Spring Boot

rest/starbucks/pom.xml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@
4040
<scope>runtime</scope>
4141
</dependency>
4242

43+
<!-- Traditional frontend -->
44+
45+
<dependency>
46+
<groupId>org.springframework.boot</groupId>
47+
<artifactId>spring-boot-starter-thymeleaf</artifactId>
48+
<scope>runtime</scope>
49+
</dependency>
50+
51+
<dependency>
52+
<groupId>org.webjars</groupId>
53+
<artifactId>jquery</artifactId>
54+
<version>2.1.3</version>
55+
<scope>runtime</scope>
56+
</dependency>
57+
58+
<dependency>
59+
<groupId>org.webjars</groupId>
60+
<artifactId>bootstrap</artifactId>
61+
<version>3.3.4</version>
62+
<scope>runtime</scope>
63+
</dependency>
64+
65+
<dependency>
66+
<groupId>org.webjars</groupId>
67+
<artifactId>URI.js</artifactId>
68+
<version>1.14.1</version>
69+
<scope>runtime</scope>
70+
</dependency>
71+
4372
</dependencies>
4473

4574
<build>
@@ -54,4 +83,4 @@
5483
</plugins>
5584
</build>
5685

57-
</project>
86+
</project>

rest/starbucks/src/main/java/example/stores/Address.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2014 the original author or authors.
2+
* Copyright 2014-2015 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -30,4 +30,12 @@ public class Address {
3030

3131
private final String street, city, zip;
3232
private final @GeoSpatialIndexed Point location;
33+
34+
/*
35+
* (non-Javadoc)
36+
* @see java.lang.Object#toString()
37+
*/
38+
public String toString() {
39+
return String.format("%s, %s %s", street, zip, city);
40+
}
3341
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2014-2015 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package example.stores.web;
17+
18+
import static org.springframework.data.geo.Metrics.*;
19+
20+
import java.util.Arrays;
21+
import java.util.Collections;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Optional;
26+
27+
import lombok.RequiredArgsConstructor;
28+
29+
import org.springframework.beans.factory.annotation.Autowired;
30+
import org.springframework.data.domain.Page;
31+
import org.springframework.data.domain.Pageable;
32+
import org.springframework.data.geo.Distance;
33+
import org.springframework.data.geo.Metrics;
34+
import org.springframework.data.geo.Point;
35+
import org.springframework.data.rest.webmvc.support.RepositoryEntityLinks;
36+
import org.springframework.stereotype.Controller;
37+
import org.springframework.ui.Model;
38+
import org.springframework.web.bind.annotation.RequestMapping;
39+
import org.springframework.web.bind.annotation.RequestMethod;
40+
import org.springframework.web.bind.annotation.RequestParam;
41+
42+
import example.stores.Store;
43+
import example.stores.StoreRepository;
44+
45+
/**
46+
* A Spring MVC controller to produce an HTML frontend.
47+
*
48+
* @author Oliver Gierke
49+
*/
50+
@Controller
51+
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
52+
class StoresController {
53+
54+
private static final List<Distance> DISTANCES = Arrays.asList(new Distance(0.5, MILES), new Distance(1, MILES),
55+
new Distance(2, MILES));
56+
private static final Distance DEFAULT_DISTANCE = new Distance(1, Metrics.MILES);
57+
private static final Map<String, Point> KNOWN_LOCATIONS;
58+
59+
static {
60+
61+
Map<String, Point> locations = new HashMap<>();
62+
63+
locations.put("Pivotal SF", new Point(-122.4041764, 37.7819286));
64+
locations.put("Timesquare NY", new Point(-73.995146, 40.740337));
65+
66+
KNOWN_LOCATIONS = Collections.unmodifiableMap(locations);
67+
}
68+
69+
private final StoreRepository repository;
70+
private final RepositoryEntityLinks entityLinks;
71+
72+
/**
73+
* Looks up the stores in the given distance around the given location.
74+
*
75+
* @param model the {@link Model} to populate.
76+
* @param location the optional location, if none is given, no search results will be returned.
77+
* @param distance the distance to use, if none is given the {@link #DEFAULT_DISTANCE} is used.
78+
* @param pageable the pagination information
79+
* @return
80+
*/
81+
@RequestMapping(value = "/", method = RequestMethod.GET)
82+
String index(Model model, @RequestParam Optional<Point> location, @RequestParam Optional<Distance> distance,
83+
Pageable pageable) {
84+
85+
Point point = location.orElse(KNOWN_LOCATIONS.get("Timesquare NY"));
86+
87+
Page<Store> stores = repository.findByAddressLocationNear(point, distance.orElse(DEFAULT_DISTANCE), pageable);
88+
89+
model.addAttribute("stores", stores);
90+
model.addAttribute("distances", DISTANCES);
91+
model.addAttribute("selectedDistance", distance.orElse(DEFAULT_DISTANCE));
92+
model.addAttribute("location", point);
93+
model.addAttribute("locations", KNOWN_LOCATIONS);
94+
model.addAttribute("api", entityLinks.linkToSearchResource(Store.class, "by-location", pageable).getHref());
95+
96+
return "index";
97+
}
98+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
spring.data.rest.base-path=/api
Loading
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<!DOCTYPE html>
2+
<html lang="en" xmlns:th="http://www.thymeleaf.org" >
3+
<head>
4+
<title>Starbucks Storefinder</title>
5+
6+
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1" />
8+
9+
<link rel="stylesheet" href="/webjars/bootstrap/3.3.4/css/bootstrap.min.css"/>
10+
11+
<style type="text/css">
12+
13+
#map {
14+
height: 400px;
15+
width: 400px;
16+
background-image: url("/img/map.png");
17+
background-size: 400px 300px;
18+
background-repeat: no-repeat;
19+
}
20+
21+
</style>
22+
</head>
23+
24+
<body>
25+
26+
<div class="container-fluid">
27+
28+
<h1>Starbucks Storefinder</h1>
29+
30+
<div class="panel panel-default">
31+
<div class="panel-heading">
32+
<h3 class="panel-title">Search</h3>
33+
</div>
34+
<div class="panel-body">
35+
36+
<form action="/" class="form-horizontal">
37+
38+
<div class="form-group">
39+
<label class="col-sm-2 control-label">Predefined locations:</label>
40+
<div class="col-sm-10">
41+
<a class="btn btn-default" th:each="location : ${locations}" th:href="@{/(location=${{location.value}},distance=${{selectedDistance}})}" th:text="${location.key}">Foo</a>
42+
</div>
43+
</div>
44+
45+
<div class="form-group">
46+
<label for="location" class="col-sm-2 control-label">Location:</label>
47+
<div class="col-sm-2">
48+
<input id="location" name="location" th:value="${{location}}" type="text" class="form-control" placeholder="lat,long" />
49+
</div>
50+
</div>
51+
52+
53+
<div class="form-group">
54+
<label for="distance" class="col-sm-2 control-label">Distance:</label>
55+
<div class="col-sm-2">
56+
<select id="distance" name="distance" class="form-control">
57+
<option th:each="distance : ${distances}" th:value="${{distance}}" th:text="${distance}" th:selected="${distance == selectedDistance}">
58+
Distance
59+
</option>
60+
</select>
61+
</div>
62+
</div>
63+
64+
<div class="form-group">
65+
<div class="col-sm-offset-2 col-sm-10">
66+
<button id="submit" type="submit" class="btn btn-default">Submit</button>
67+
</div>
68+
</div>
69+
70+
</form>
71+
</div>
72+
</div>
73+
74+
<div class="panel panel-default">
75+
76+
<div id="resultList" class="panel-heading">
77+
<h3 class="panel-title">Results</h3>
78+
</div>
79+
80+
<div class="panel-body">
81+
82+
<div id="map" class="col-md-4" th:attr="data-uri=${api}"></div>
83+
84+
<div class="col-sm-8" style="margin-left: 1em">
85+
<div th:if="${stores.hasContent()}">
86+
<p th:text="'Showing ' + ${stores.numberOfElements} + ' of ' + ${stores.totalElements} + ' results.'">Found 1 results.</p>
87+
<ol class="search-results">
88+
<li class="search-result" th:each="store : ${stores}" th:text="${store.name} + ' - ' + ${store.address}">Store name</li>
89+
</ol>
90+
</div>
91+
<p class="search-result no-results" th:unless="${stores.hasContent()}">No Results</p>
92+
</div>
93+
94+
</div>
95+
96+
</div>
97+
</div>
98+
99+
<script type="text/javascript"
100+
src="/webjars/jquery/2.1.3/jquery.min.js"></script>
101+
102+
<script type="text/javascript"
103+
src="/webjars/bootstrap/3.3.4/js/bootstrap.min.js"></script>
104+
105+
<script type="text/javascript"
106+
src="/webjars/URI.js/1.14.1/URI.min.js"></script>
107+
108+
<script type="text/javascript"
109+
src="//maps.google.com/maps/api/js?sensor=false"></script>
110+
111+
<script type="text/javascript">
112+
(function () {
113+
"use strict";
114+
115+
function initApp() {
116+
117+
function handleSearchResult(searchResponse) {
118+
119+
120+
}
121+
122+
window.starbucks = {
123+
124+
ui: {
125+
markers: [],
126+
map: null
127+
},
128+
129+
performStoreSearch: function () {
130+
131+
// Maps enabled (online)?
132+
133+
if (!starbucks.ui.map) {
134+
return;
135+
}
136+
137+
// Get location
138+
139+
var location = $("#location").val();
140+
141+
// Center map
142+
143+
var coordinate = {
144+
lat: parseFloat(location.split(",")[0]),
145+
lng: parseFloat(location.split(",")[1])
146+
}
147+
148+
starbucks.ui.map.setCenter(coordinate);
149+
150+
new google.maps.Marker({
151+
position: coordinate,
152+
map: starbucks.ui.map
153+
});
154+
155+
// Expand template and execute search
156+
157+
var template = new URITemplate($("#map").attr("data-uri"));
158+
159+
$.get(template.expand({
160+
"location": location,
161+
"distance": $("#distance").val(),
162+
"size": 100,
163+
"page": 0
164+
}), function(response) {
165+
166+
while (starbucks.ui.markers.length) {
167+
starbucks.ui.markers.pop().setMap(null);
168+
}
169+
170+
// Create marker for store
171+
172+
response._embedded["stores"].forEach(function (store) {
173+
starbucks.ui.markers.push(new google.maps.Marker({
174+
position: {
175+
lat: store.address.location.y,
176+
lng: store.address.location.x
177+
},
178+
map: starbucks.ui.map
179+
}));
180+
});
181+
})
182+
},
183+
184+
init: function() {
185+
starbucks.ui.map = new google.maps.Map($("#map")[0], { zoom: 14 });
186+
starbucks.performStoreSearch();
187+
}
188+
};
189+
190+
window.starbucks.init();
191+
}
192+
193+
$(initApp);
194+
})();
195+
</script>
196+
</body>
197+
</html>

rest/starbucks/webui.png

404 KB
Loading

0 commit comments

Comments
 (0)