Merge lp:~julian-edwards/maas/better-search into lp:~maas-committers/maas/trunk

Proposed by Julian Edwards
Status: Merged
Approved by: Julian Edwards
Approved revision: no longer in the source branch.
Merged at revision: 3328
Proposed branch: lp:~julian-edwards/maas/better-search
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 448 lines (+225/-31)
4 files modified
src/maasserver/static/css/components/search_box.css (+51/-9)
src/maasserver/templates/maasserver/node_list.html (+13/-6)
src/maasserver/views/nodes.py (+24/-3)
src/maasserver/views/tests/test_nodes.py (+137/-13)
To merge this branch: bzr merge lp:~julian-edwards/maas/better-search
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Christian Reis (community) Needs Information
Review via email: mp+239793@code.launchpad.net

Commit message

Add an extra search box to the node listing page that allows users to do substring searches that match on hostname, architecture and tags.

Description of the change

I suck at CSS and it shows, so please help me out here! There's a couple of obvious problems with the new layout of the boxes (please run up the branch to see).

 1. The magnifying glass icon moves up a pixel when you hover over it! But only on one of the search boxes... :/

 2. The cross-to-cancel click doesn't work on the "test constraints" box, I think I messed up with the layout somehow but I don't know how.

Otherwise, this does exactly what it says on the tin.

To post a comment you must log in.
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Pretty please someone?

Revision history for this message
Raphaël Badin (rvb) wrote :

The new search box is really badly positioned on Firefox (it overlaps with the other search box): http://people.canonical.com/~rvb/search_overlap.png

Revision history for this message
Raphaël Badin (rvb) wrote :

This is the result of searching for 'u' on the sample data: http://people.canonical.com/~rvb/multiple_nodes.png. Some lines are duplicated!

review: Needs Fixing
Revision history for this message
Raphaël Badin (rvb) wrote :

Not sure if it's a regression this branch introduced or not but the sorting parameters are not preserved when you use the search box:
- sort on a field
- search for something
- the ordering is lost

Revision history for this message
Christian Reis (kiko) wrote :

Is this a part of the UX work that is being planned post-1.7?

review: Needs Information
Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Thursday 30 Oct 2014 11:50:31 you wrote:
> Review: Needs Information
>
> Is this a part of the UX work that is being planned post-1.7?

No, it was a bug that Andres wanted fixed.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Thursday 30 Oct 2014 11:10:40 you wrote:
> Review: Needs Fixing
>
> This is the result of searching for 'u' on the sample data:
> http://people.canonical.com/~rvb/multiple_nodes.png. Some lines are
> duplicated!

Woops, needs a unique! Thanks for spotting.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Thursday 30 Oct 2014 10:49:42 you wrote:
> The new search box is really badly positioned on Firefox (it overlaps with
> the other search box): http://people.canonical.com/~rvb/search_overlap.png

I hate CSS. I mean, I really hate it. :/

Revision history for this message
Christian Reis (kiko) wrote :

> No, it was a bug that Andres wanted fixed.

Okay, then we shouldn't do this without aligning with the wider UX plan.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Thursday 30 Oct 2014 13:25:30 you wrote:
> > No, it was a bug that Andres wanted fixed.
>
> Okay, then we shouldn't do this without aligning with the wider UX plan.

I really don't think that should block this, we have had people screaming for
a proper search for a long time. The aesthetics are orthogonal to the
underlying search and can be trivially tweaked later - by someone who knows
what they're doing with CSS of course :)

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Thursday 30 October 2014 11:10:40 you wrote:
> Review: Needs Fixing
>
> This is the result of searching for 'u' on the sample data:
> http://people.canonical.com/~rvb/multiple_nodes.png. Some lines are
> duplicated!

FWIW, I can't get the sampledata to work when I load it from fresh, it looks
like it needs fixing, it causes the appserver to crash when clicking on nodes,
and the names in the node list are all "#### INVALID STRING ####"

Revision history for this message
Julian Edwards (julian-edwards) wrote :

Also regarding sorting being preserved, it's like that in trunk AFAICT.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

Ok Raph, I've fixed the duplication, but I'll be buggered if I can get the CSS working. When I change it to fix one browser, the other screws up. I think I need some help here, if you are able?

Revision history for this message
Christian Reis (kiko) wrote :

Julian, have you looked at Blake's search work which he shared via the list? Looks like the two of you should sync.

Revision history for this message
Raphaël Badin (rvb) wrote :

> Woops, needs a unique! Thanks for spotting.

It needs a unique indeed. But it has nothing to do with the union:
This is a way to trigger the problem: http://paste.ubuntu.com/8803036/ (fwiw, this is the query this generates http://paste.ubuntu.com/8803039/).

The problem is that querying across manytomany relationships gives you an effective join. Hence the duplication. This makes perfect sense from an SQL point of view but from an ORM point of view (i.e. Nodes.objects.filter()), it's a bit weird, I'll give you that.

Also, you can make the code is a tad nicer: http://paste.ubuntu.com/8803384/

Revision history for this message
Raphaël Badin (rvb) wrote :

> When I change it to fix one browser, the other screws up. I think I need some help here, if you are able?

I don't see the overlap I was seeing last week, I guess you've fixed this.

The only UI problem that I see is the jumping magnifying glass… and the only way I managed to fix the problem is by doing this: http://paste.ubuntu.com/8804644/.

Revision history for this message
Raphaël Badin (rvb) wrote :

This looks good to me. This is a massive improvement to the usability of MAAS. I'm not entirely sure we should have the two search boxes though. It's looks weird and I'm not sure anyone uses the constraints search box since it isn't documented anywhere.

So I'd be +1 on removing it after we make sure its usage is non-existant. But this is something we can do after this lands.

>> Okay, then we shouldn't do this without aligning with the wider UX plan.

> [...] The aesthetics are orthogonal to the underlying search.

Agreed. I even think it's best if we land this now to force the UX redesign to take this into account.

review: Approve
Revision history for this message
Christian Reis (kiko) wrote :

On Mon, Nov 03, 2014 at 04:06:07PM -0000, Raphaël Badin wrote:
> Agreed. I even think it's best if we land this now to force the UX
> redesign to take this into account.

Won't this directly conflict with what Blake is working on?

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Monday 03 November 2014 12:50:53 you wrote:
> Julian, have you looked at Blake's search work which he shared via the list?
> Looks like the two of you should sync.

I did. My change is vastly simpler and I'd like to land this now because it
solves a specific problem without requiring a new JS toolkit and UI redesign.

It doesn't preclude any work that Blake is doing, however.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Monday 03 November 2014 18:18:21 you wrote:
> On Mon, Nov 03, 2014 at 04:06:07PM -0000, Raphaël Badin wrote:
> > Agreed. I even think it's best if we land this now to force the UX
> > redesign to take this into account.
>
> Won't this directly conflict with what Blake is working on?

Not really, his is nowhere near landing.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Monday 03 November 2014 16:00:34 you wrote:
> > When I change it to fix one browser, the other screws up. I think I need
> > some help here, if you are able?
> I don't see the overlap I was seeing last week, I guess you've fixed this.

I did. Copious overriding of CSS properties!

> The only UI problem that I see is the jumping magnifying glass… and the only
> way I managed to fix the problem is by doing this:

> http://paste.ubuntu.com/8804644/.

I worked out what's causing it in the end, I can just remove the padding in
the input that it's inheriting from some top-level CSS class. The hover makes
the padding move! Weird.

Also the other problem is that the input boxes are different lengths on FF and
Chrom*. FF increases the size of the box when you left-pad the text to make
room for the icon; Chrom* does not. Consistency eh?!

I need to fix one more thing too, the margins are different sizes on each box
(again, css inheritance...).

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Monday 03 November 2014 16:06:07 you wrote:
> Review: Approve

Thanks.

> This looks good to me. This is a massive improvement to the usability of
> MAAS. I'm not entirely sure we should have the two search boxes though.
> It's looks weird and I'm not sure anyone uses the constraints search box
> since it isn't documented anywhere.

I'm not sure either, but I left it *just in case*.

> So I'd be +1 on removing it after we make sure its usage is non-existant.
> But this is something we can do after this lands.

Yeah, let's ask around.

> >> Okay, then we shouldn't do this without aligning with the wider UX plan.
> >
> > [...] The aesthetics are orthogonal to the underlying search.
>
> Agreed. I even think it's best if we land this now to force the UX redesign
> to take this into account.

+1

Revision history for this message
Julian Edwards (julian-edwards) wrote :

Bouncing icon fixed - the CSS was being overridden in the HTML page but not for the hover attribute... fun!

Now, I've sorted all of that and the FF page looks great. Chrom* looks good, except the shorter input boxes and that the cursor is now off centre in the box until you start typing in it!

You just can't f'in win.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/static/css/components/search_box.css'
2--- src/maasserver/static/css/components/search_box.css 2014-02-25 07:27:05 +0000
3+++ src/maasserver/static/css/components/search_box.css 2014-11-04 01:15:28 +0000
4@@ -2,21 +2,42 @@
5 position: absolute;
6 top: 0;
7 right: 0;
8+ width: 210px;
9+ height: 33px;
10+ }
11+
12+form.search-test {
13+ position: absolute;
14+ top: 0;
15+ right: 220px;
16 width: 420px;
17- height: 26px;
18+ height: 33px;
19 }
20
21 #header form.search {
22 position: relative;
23- width: 228px;
24+ width: 420px;
25 margin: 18px 15px 0 0;
26 }
27
28+input.search-test {
29+ position: relative;
30+ top: 0;
31+ left: -20px;
32+ width: 420px;
33+ height: 33px;
34+ padding: 0;
35+ padding-left: 22px;
36+ background-color: #fff;
37+ }
38+
39 input.search-input {
40- position: absolute;
41+ position: relative;
42 top: 0;
43- left: 0;
44- width: 420px;
45+ left: 0px;
46+ width: 186px;
47+ height: 33px;
48+ padding: 0;
49 padding-left: 22px;
50 background-color: #fff;
51 }
52@@ -29,10 +50,34 @@
53 border-radius: 4px;
54 }
55
56+#header input.search-test-input {
57+ width: 225px;
58+ border: none;
59+ -moz-border-radius: 4px;
60+ -webkit-border-radius: 4px;
61+ border-radius: 4px;
62+ }
63+
64+input.search-test-submit,
65+input.search-test-submit:hover {
66+ position: absolute;
67+ top: 4px;
68+ margin-top: 3px;
69+ left: -19px;
70+ width: 24px;
71+ height: 24px;
72+ padding: 0;
73+ margin: 0;
74+ background: transparent url(../?img/search_icon.png) no-repeat center center;
75+ border: none;
76+ font-size: 0;
77+ cursor: pointer;
78+ }
79+
80 input.search-submit,
81 input.search-submit:hover {
82 position: absolute;
83- top: 1px;
84+ top: 4px;
85 left: 1px;
86 width: 24px;
87 height: 24px;
88@@ -41,8 +86,5 @@
89 background: transparent url(../?img/search_icon.png) no-repeat center center;
90 border: none;
91 font-size: 0;
92- }
93-
94-input.search-submit:hover {
95 cursor: pointer;
96 }
97
98=== modified file 'src/maasserver/templates/maasserver/node_list.html'
99--- src/maasserver/templates/maasserver/node_list.html 2014-10-08 08:37:42 +0000
100+++ src/maasserver/templates/maasserver/node_list.html 2014-11-04 01:15:28 +0000
101@@ -2,7 +2,7 @@
102
103 {% block nav-active-node-list %}active{% endblock %}
104 {% block title %}Nodes{% endblock %}
105-{% block page-title %}{{ paginator.count }}{% if input_query %} matching{% endif %} node{{ paginator.count|pluralize }} in {% include "maasserver/site_title.html" %}{% endblock %}
106+{% block page-title %}{{ paginator.count }}{% if input_query or input_test %} matching{% endif %} node{{ paginator.count|pluralize }} in {% include "maasserver/site_title.html" %}{% endblock %}
107 {% block header-search %}{% endblock %}
108
109 {% block html_includes %}{% include "maasserver/snippets.html" %}
110@@ -27,14 +27,13 @@
111 display:none;
112 }
113
114- input.search-input {
115+ input.search-input,
116+ input.search-test {
117 line-height: 23px;
118 outline: none;
119 }
120
121- input.search-submit {
122- margin-top: 3px;
123- }
124+
125 </style>
126 <script type="text/javascript">
127 <!--
128@@ -101,12 +100,20 @@
129
130 {% block content %}
131 <div id="nodes" style="position: relative;">
132+ <form id="node_listing_constraint_form"
133+ action="{% url 'node-list' %}?{{preserved_query}}"
134+ method="get" class="search-test">
135+ <input type="search" name="test" placeholder="Test Juju constraints"
136+ class="search-test" value="{{input_test|default_if_none:''}}" />
137+ <input id="search_button" type="submit"
138+ class="search-test-submit" />
139+ </form>
140 <form id="node_listing_search_form"
141 action="{% url 'node-list' %}?{{preserved_query}}"
142 method="get" class="search">
143 <input type="search" name="query" placeholder="Search nodes"
144 class="search-input" value="{{input_query|default_if_none:''}}" />
145- <input id="search_button" type="submit" value="Search"
146+ <input id="search_button" type="submit"
147 class="search-submit" />
148 </form>
149
150
151=== modified file 'src/maasserver/views/nodes.py'
152--- src/maasserver/views/nodes.py 2014-10-31 07:11:08 +0000
153+++ src/maasserver/views/nodes.py 2014-11-04 01:15:28 +0000
154@@ -35,6 +35,7 @@
155 from django.contrib import messages
156 from django.core.exceptions import PermissionDenied
157 from django.core.urlresolvers import reverse
158+from django.db.models import Q
159 from django.http import QueryDict
160 from django.shortcuts import (
161 get_object_or_404,
162@@ -228,6 +229,7 @@
163
164 def populate_modifiers(self, request):
165 self.query = request.GET.get("query")
166+ self.test_query = request.GET.get("test")
167 self.query_error = None
168 self.sort_by = request.GET.get("sort")
169 self.sort_dir = request.GET.get("dir")
170@@ -253,7 +255,7 @@
171 These are sorting and search option we want a POST request to
172 preserve so that the display after a POST request is similar
173 to the display before the request."""
174- return ["dir", "query", "page", "sort"]
175+ return ["dir", "query", "test", "page", "sort"]
176
177 def get_preserved_query(self):
178 params = {
179@@ -323,7 +325,7 @@
180 :param nodes_query: A query set of nodes.
181 :return: A query set of nodes that returns a subset of `nodes_query`.
182 """
183- data = _parse_constraints(self.query)
184+ data = _parse_constraints(self.test_query)
185 form = AcquireNodeForm.Strict(data=data)
186 # Change the field names of the AcquireNodeForm object to
187 # conform to Juju's naming.
188@@ -336,12 +338,30 @@
189 for field, errors in form.errors.items()])
190 return Node.objects.none()
191
192+ def _search_nodes(self, nodes_query):
193+ """Filter the given nodes query by searching field data.
194+
195+ The search query is substring matched non-case sensitively
196+ against some fields in the nodes.
197+
198+ :param nodes_query: A queryset of nodes.
199+ :return: A query set of nodes that returns a subset of `nodes_query`.
200+ """
201+ name_clause = Q(hostname__icontains=self.query)
202+ arch_clause = Q(architecture__icontains=self.query)
203+ tag_clause = Q(tags__name__icontains=self.query)
204+
205+ query = nodes_query.filter(name_clause | arch_clause | tag_clause)
206+ return query.distinct()
207+
208 def get_queryset(self):
209 nodes = Node.objects.get_nodes(
210 user=self.request.user, perm=NODE_PERMISSION.VIEW)
211 nodes = nodes.order_by(*self._compose_sort_order())
212+ if self.test_query:
213+ nodes = self._constrain_nodes(nodes)
214 if self.query:
215- nodes = self._constrain_nodes(nodes)
216+ nodes = self._search_nodes(nodes)
217 nodes = prefetch_nodes_listing(nodes)
218 return configure_macs(nodes)
219
220@@ -391,6 +411,7 @@
221 context["preserved_query"] = self.get_preserved_query()
222 context["form"] = form
223 context["input_query"] = self.query
224+ context["input_test"] = self.test_query
225 context["input_query_error"] = self.query_error
226 links, classes = self._prepare_sort_links()
227 context["sort_links"] = links
228
229=== modified file 'src/maasserver/views/tests/test_nodes.py'
230--- src/maasserver/views/tests/test_nodes.py 2014-10-31 07:11:08 +0000
231+++ src/maasserver/views/tests/test_nodes.py 2014-11-04 01:15:28 +0000
232@@ -94,7 +94,9 @@
233 from provisioningserver.utils.enum import map_enum
234 from provisioningserver.utils.text import normalise_whitespace
235 from testtools.matchers import (
236+ Contains,
237 ContainsAll,
238+ Equals,
239 HasLength,
240 Not,
241 )
242@@ -1029,31 +1031,31 @@
243 "This node is now allocated to you.",
244 '\n'.join(msg.message for msg in response.context['messages']))
245
246- def test_node_list_query_includes_current(self):
247+ def test_node_list_constraint_test_query_includes_current(self):
248 self.client_log_in()
249 qs = factory.make_string()
250- response = self.client.get(reverse('node-list'), {"query": qs})
251+ response = self.client.get(reverse('node-list'), {"test": qs})
252 query_value = fromstring(response.content).xpath(
253- "string(//div[@id='nodes']//input[@name='query']/@value)")
254+ "string(//div[@id='nodes']//input[@name='test']/@value)")
255 self.assertIn(qs, query_value)
256
257- def test_node_list_query_error_on_missing_tag(self):
258+ def test_node_list_constraint_test_query_error_on_missing_tag(self):
259 self.client_log_in()
260 response = self.client.get(
261- reverse('node-list'), {"query": "maas-tags=missing"})
262+ reverse('node-list'), {"test": "maas-tags=missing"})
263 error_string = fromstring(response.content).xpath(
264 "string(//div[@id='nodes']//p[@class='form-errors'])")
265 self.assertIn("No such tag(s): 'missing'", error_string)
266
267- def test_node_list_query_error_on_unknown_constraint(self):
268+ def test_node_list_constraint_test_query_error_on_unknown_constraint(self):
269 self.client_log_in()
270 response = self.client.get(
271- reverse('node-list'), {"query": "color=red"})
272+ reverse('node-list'), {"test": "color=red"})
273 error_string = fromstring(response.content).xpath(
274 "string(//div[@id='nodes']//p[@class='form-errors'])")
275 self.assertEqual("color: No such constraint.", error_string.strip())
276
277- def test_node_list_query_selects_subset(self):
278+ def test_node_list_constraint_test_query_selects_subset(self):
279 self.client_log_in()
280 tag = factory.make_Tag("shiny")
281 node1 = factory.make_Node(cpu_count=1)
282@@ -1063,7 +1065,7 @@
283 node2.tags = [tag]
284 node3.tags = []
285 response = self.client.get(
286- reverse('node-list'), {"query": "maas-tags=shiny cpu=2"})
287+ reverse('node-list'), {"test": "maas-tags=shiny cpu=2"})
288 node2_link = reverse('node-view', args=[node2.system_id])
289 document = fromstring(response.content)
290 node_links = document.xpath(
291@@ -1116,7 +1118,7 @@
292 [(a.text.lower(), a.get("href"))
293 for a in expr_page_anchors(page3)])
294
295- def test_node_list_query_paginates(self):
296+ def test_node_list_constraint_test_query_paginates(self):
297 """Node list query subset is split across multiple pages with links"""
298 self.client_log_in()
299 # Set a very small page size to save creating lots of nodes
300@@ -1130,7 +1132,7 @@
301 last_node_link = reverse('node-view', args=[nodes[0].system_id])
302 response = self.client.get(
303 reverse('node-list'),
304- {"query": "maas-tags=odd", "page": 3})
305+ {"test": "maas-tags=odd", "page": 3})
306 document = fromstring(response.content)
307 self.assertIn("5 matching nodes", document.xpath("string(//h1)"))
308 self.assertEqual(
309@@ -1138,8 +1140,8 @@
310 document.xpath("//div[@id='nodes']/form/table/tr/td[3]/a/@href"))
311 self.assertEqual(
312 [
313- ("first", "?query=maas-tags%3Dodd"),
314- ("previous", "?query=maas-tags%3Dodd&page=2")
315+ ("first", "?test=maas-tags%3Dodd"),
316+ ("previous", "?test=maas-tags%3Dodd&page=2")
317 ],
318 [
319 (a.text.lower(), a.get("href"))
320@@ -1369,6 +1371,128 @@
321 self.assertIn(node_event_list, get_content_links(response))
322
323
324+class TestNodesViewSearch(MAASServerTestCase):
325+
326+ def test_node_list_search_query_includes_current_clause(self):
327+ self.client_log_in()
328+ qs = factory.make_string()
329+ response = self.client.get(reverse('node-list'), {"query": qs})
330+ query_value = fromstring(response.content).xpath(
331+ "string(//div[@id='nodes']//input[@name='query']/@value)")
332+ self.assertIn(qs, query_value)
333+
334+ def test_node_list_search_query_finds_by_hostname(self):
335+ self.client_log_in()
336+ [factory.make_Node() for i in range(10)]
337+ hostname = factory.make_name("hostname")
338+ node = factory.make_Node(hostname=hostname)
339+ node2 = factory.make_Node(hostname=hostname[:-1])
340+ node_link = reverse('node-view', args=[node.system_id])
341+ node2_link = reverse('node-view', args=[node2.system_id])
342+ response = self.client.get(reverse('node-list'), {"query": hostname})
343+ document = fromstring(response.content)
344+ node_links = document.xpath(
345+ "//div[@id='nodes']/form/table/tr/td[3]/a/@href")
346+ self.expectThat(node_links, Equals([node_link]))
347+
348+ # A substring search also matches. Here it also picks up the
349+ # other node with the exact name match.
350+ response = self.client.get(
351+ reverse('node-list'), {"query": hostname[:-1]})
352+ document = fromstring(response.content)
353+ node_links = document.xpath(
354+ "//div[@id='nodes']/form/table/tr/td[3]/a/@href")
355+ self.expectThat(node_links, Equals([node_link, node2_link]))
356+
357+ def test_node_list_search_query_finds_by_arch(self):
358+ self.client_log_in()
359+ amd64_node = factory.make_Node(architecture="amd64/generic")
360+ factory.make_Node(architecture="i386/generic")
361+ factory.make_Node(architecture="armhf/generic")
362+
363+ node_link = reverse('node-view', args=[amd64_node.system_id])
364+ response = self.client.get(reverse('node-list'), {"query": "amd64"})
365+ document = fromstring(response.content)
366+ node_links = document.xpath(
367+ "//div[@id='nodes']/form/table/tr/td[3]/a/@href")
368+ self.expectThat(node_links, Equals([node_link]))
369+
370+ def test_node_list_search_doesnt_show_duplicates(self):
371+ # Unioning several query sets can result in dupes, check that
372+ # the query uses distinct.
373+ self.client_log_in()
374+ node = factory.make_Node(
375+ hostname="arthur", architecture="amd64/generic")
376+ node.tags = [factory.make_Tag() for i in range(3)]
377+ node.save()
378+ nodes = [node]
379+ nodes.append(factory.make_Node(
380+ hostname="andrew", architecture="i386/generic"))
381+ response = self.client.get(reverse('node-list'), {"query": "a"})
382+ document = fromstring(response.content)
383+ expected_node_links = [
384+ reverse('node-view', args=[n.system_id]) for n in nodes]
385+ doc_node_links = document.xpath(
386+ "//div[@id='nodes']/form/table/tr/td[3]/a/@href")
387+ self.assertItemsEqual(expected_node_links, doc_node_links)
388+
389+ def test_node_list_search_query_finds_by_tags(self):
390+ self.client_log_in()
391+ nodes = [factory.make_Node() for i in range(10)]
392+ tag = factory.make_Tag("odd")
393+ for node in nodes[::2]:
394+ node.tags = [tag]
395+ response = self.client.get(
396+ reverse('node-list'), {"query": "odd"})
397+ document = fromstring(response.content)
398+ node_links = document.xpath(
399+ "//div[@id='nodes']/form/table/tr/td[3]/a/@href")
400+ expected_node_links = [
401+ reverse('node-view', args=[node.system_id])
402+ for node in nodes[::2]]
403+ self.expectThat(node_links, ContainsAll(expected_node_links))
404+
405+ # Substrings match tags too.
406+ response = self.client.get(
407+ reverse('node-list'), {"query": "od"})
408+ document = fromstring(response.content)
409+ node_links = document.xpath(
410+ "//div[@id='nodes']/form/table/tr/td[3]/a/@href")
411+ self.expectThat(node_links, ContainsAll(expected_node_links))
412+
413+ def test_node_list_search_query_paginates(self):
414+ """Node list query subset is split across multiple pages with links"""
415+ self.client_log_in()
416+ # Set a very small page size to save creating lots of nodes
417+ self.patch(nodes_views.NodeListView, 'paginate_by', 2)
418+ nodes = [
419+ factory.make_Node(created="2012-10-12 12:00:%02d" % i)
420+ for i in range(10)]
421+ tag = factory.make_Tag("odd")
422+ for node in nodes[::2]:
423+ node.tags = [tag]
424+ last_node_link = reverse('node-view', args=[nodes[0].system_id])
425+ response = self.client.get(
426+ reverse('node-list'), {"query": "odd", "page": 3})
427+ document = fromstring(response.content)
428+ self.expectThat(
429+ document.xpath("string(//h1)"), Contains("5 matching nodes"))
430+ self.expectThat(
431+ [last_node_link],
432+ Equals(
433+ document.xpath(
434+ "//div[@id='nodes']/form/table/tr/td[3]/a/@href")))
435+ self.assertEqual(
436+ [
437+ ("first", "?query=odd"),
438+ ("previous", "?query=odd&page=2")
439+ ],
440+ [
441+ (a.text.lower(), a.get("href"))
442+ for a in document.xpath("//div[@class='pagination']//a")
443+ ])
444+
445+
446 class TestWarnUnconfiguredIPAddresses(MAASServerTestCase):
447
448 def test__warns_for_IPv6_address_on_non_ubuntu_OS(self):