Merge ~caleb-ellis/maas:grouping-improvements into maas:master

Proposed by Caleb Ellis
Status: Merged
Approved by: Caleb Ellis
Approved revision: b48f6a6f3c309d4747d88412b4adbd24d280fab6
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~caleb-ellis/maas:grouping-improvements
Merge into: maas:master
Diff against target: 459 lines (+261/-20)
5 files modified
src/maasserver/static/js/angular/directives/machines_table.js (+70/-11)
src/maasserver/static/js/angular/directives/tests/test_machines_table.js (+128/-0)
src/maasserver/static/partials/machines-table.html (+27/-6)
src/maasserver/static/scss/_base_forms.scss (+1/-1)
src/maasserver/static/scss/_tables.scss (+35/-2)
Reviewer Review Type Date Requested Status
Anthony Dillon Approve
Review via email: mp+366459@code.launchpad.net

This proposal supersedes a proposal from 2019-04-24.

Commit message

Make machine groups selectable, collapsible and display count

Description of the change

## Done
- Added checkboxes to group headings, which when clicked either selects all unselected machines in a group, or unselects all machines in a group.
- Groups are now collapsible/expandable by clicking the +/- button on the right
- Groups now have a count of the number of machines and the number of selected machines in that group
- Note there is currently a bug when using the "update your selection" link when action can't be performed on all machines, which has been present since the first grouping MP. This will get fixed as part of https://github.com/canonical-web-and-design/maas-squad/issues/1045

## QA
- Check that selecting a group selects all machines in that group and makes the background of the group white
- Check that unselecting a single machine in a group will cause the group checkbox to become unchecked
- Check that you can expand and collapse groups
- Check that the count underneath the group label is correct
- Check that the ungrouped list still looks and works correctly

## Screenshot
https://user-images.githubusercontent.com/25733845/56663433-fe11f880-669d-11e9-9925-7eb33babba93.png

To post a comment you must log in.
Revision history for this message
Anthony Dillon (ya-bo-ng) wrote :

LGTM +1

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
b48f6a6... by Caleb Ellis

fix linting

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasserver/static/js/angular/directives/machines_table.js b/src/maasserver/static/js/angular/directives/machines_table.js
2index 672fb66..ffc2938 100644
3--- a/src/maasserver/static/js/angular/directives/machines_table.js
4+++ b/src/maasserver/static/js/angular/directives/machines_table.js
5@@ -9,7 +9,7 @@
6 /* @ngInject */
7 function maasMachinesTable(
8 MachinesManager, NotificationsManager, UsersManager,
9- GeneralManager, $document, $window, $log) {
10+ GeneralManager, $filter, $document, $window, $log) {
11 return {
12 restrict: "E",
13 scope: {
14@@ -90,8 +90,11 @@ function maasMachinesTable(
15 "lock",
16 "unlock"
17 ];
18+
19 $scope.openMenu = "";
20
21+ $scope.closedGroups = [];
22+
23 $scope.groupBy = (list, keyGetter) => {
24 const map = new Map();
25 list.forEach((item) => {
26@@ -200,6 +203,32 @@ function maasMachinesTable(
27 $scope.updateAllChecked();
28 };
29
30+ $scope.toggleCheckGroup = groupLabel => {
31+ const machineGroup = $scope.groupedMachines.find(group => {
32+ return group.label === groupLabel
33+ });
34+ if ($scope.getGroupSelectedState(groupLabel)) {
35+ machineGroup.machines.forEach(machine => {
36+ MachinesManager.unselectItem(machine.system_id);
37+ });
38+ } else {
39+ machineGroup.machines.forEach(machine => {
40+ MachinesManager.selectItem(machine.system_id);
41+ });
42+ }
43+ $scope.updateAllChecked();
44+ };
45+
46+ $scope.toggleOpenGroup = groupLabel => {
47+ if ($scope.closedGroups.includes(groupLabel)) {
48+ $scope.closedGroups = $scope.closedGroups.filter(group => (
49+ group !== groupLabel
50+ ));
51+ } else {
52+ $scope.closedGroups = [...$scope.closedGroups, groupLabel];
53+ }
54+ };
55+
56 // Sorts the table by predicate.
57 $scope.sortTable = function(predicate) {
58 $scope.table.predicate = predicate;
59@@ -308,6 +337,29 @@ function maasMachinesTable(
60 }
61 };
62
63+ $scope.getGroupSelectedState = groupLabel => {
64+ const machineGroup = $scope.groupedMachines.find(group => {
65+ return group.label === groupLabel
66+ });
67+ return !machineGroup.machines.some(machine => !machine.$selected);
68+ };
69+
70+ $scope.getGroupCountString = groupLabel => {
71+ const machineGroup = $scope.groupedMachines.find(group => {
72+ return group.label === groupLabel
73+ });
74+ const machines = machineGroup.machines.length;
75+ const selected
76+ = machineGroup.machines.filter(item => item.$selected).length;
77+ const machinesString
78+ = `${machines} ${machines === 1 ? "machine" : "machines"}`;
79+
80+ if (selected && selected === machines) {
81+ return `${machinesString} selected`;
82+ }
83+ return `${machinesString}${selected ? `, ${selected} selected` : ""}`;
84+ };
85+
86 $scope.updateGroupedMachines = function(field) {
87 if ($scope.table.filteredMachines.length === 0) { return; }
88
89@@ -393,7 +445,7 @@ function maasMachinesTable(
90 ...(machines.get('Reserved') || [])
91 ]
92 }
93- ]
94+ ];
95 return;
96 }
97
98@@ -424,7 +476,7 @@ function maasMachinesTable(
99 label: 'none',
100 machines: $scope.table.filteredMachines
101 }
102- ]
103+ ];
104 return;
105 }
106
107@@ -439,19 +491,18 @@ function maasMachinesTable(
108 $scope.updateGroupedMachines($scope.groupByLabel);
109 });
110
111- // When the list of machines changes update grouping.
112- $scope.$watch("table.machines", function() {
113- if ($scope.groupByLabel !== 'none') {
114- $scope.updateGroupedMachines($scope.groupByLabel);
115- }
116- }, true);
117+ $scope.$watch("search", function() {
118+ $scope.table.filteredMachines
119+ = $filter('nodesFilter')($scope.table.machines, $scope.search);
120+ });
121
122- // Watch a simplified list of machines for changes to power state,
123- // then set transitional state accordingly.
124+ // Watch simplified list of machines for changes to power state and status,
125+ // then make changes accordingly.
126 $scope.$watch(
127 scope =>
128 scope.table.machines.map(machine => ({
129 id: machine.id,
130+ status: machine.status,
131 state: machine.power_state
132 })),
133 (newMachines, oldMachines) => {
134@@ -460,11 +511,19 @@ function maasMachinesTable(
135 oldMachines.find(
136 machine => machine.id === newMachine.id
137 ) || {};
138+
139+ // Check if power state has changed, then set transitional state
140 if (newMachine.state !== oldMachine.state) {
141 $scope.table.machines.find(
142 machine => machine.id === newMachine.id
143 ).powerTransition = undefined;
144 }
145+
146+ // Check if status has changed, then run function to regroup machines
147+ if (newMachine.status !== oldMachine.status
148+ && $scope.groupByLabel !== "none") {
149+ $scope.updateGroupedMachines($scope.groupByLabel);
150+ }
151 });
152 },
153 true
154diff --git a/src/maasserver/static/js/angular/directives/tests/test_machines_table.js b/src/maasserver/static/js/angular/directives/tests/test_machines_table.js
155index c1e7f2b..72ed5e7 100644
156--- a/src/maasserver/static/js/angular/directives/tests/test_machines_table.js
157+++ b/src/maasserver/static/js/angular/directives/tests/test_machines_table.js
158@@ -170,6 +170,51 @@ describe("maasMachinesTable", function() {
159 });
160 });
161
162+ describe("toggleCheckGroup", () => {
163+ it("selects all unselected machines in a group", () => {
164+ const directive = compileDirective();
165+ const scope = directive.isolateScope();
166+
167+ const machines = [makeMachine(), makeMachine(), makeMachine()];
168+ machines[0].status = 'New';
169+ machines[1].status = 'Broken';
170+ machines[2].status = 'New';
171+ scope.table.filteredMachines = machines;
172+ scope.groupedMachines = [
173+ { label: 'New', machines: [machines[0], machines[2]] },
174+ { label: 'Broken', machines: [machines[1]]}
175+ ];
176+ scope.toggleCheckGroup('New');
177+
178+ expect(machines[0].$selected).toBe(true);
179+ expect(machines[1].$selected).toBe(false);
180+ expect(machines[2].$selected).toBe(true);
181+ });
182+
183+ it("unselects all machines in a group if all are selected", () => {
184+ const directive = compileDirective();
185+ const scope = directive.isolateScope();
186+
187+ const machines = [makeMachine(), makeMachine(), makeMachine()];
188+ machines[0].status = 'New';
189+ machines[0].$selected = true;
190+ machines[1].status = 'Broken';
191+ machines[1].$selected = true;
192+ machines[2].status = 'New';
193+ machines[2].$selected = true;
194+ scope.table.filteredMachines = machines;
195+ scope.groupedMachines = [
196+ { label: 'New', machines: [machines[0], machines[2]] },
197+ { label: 'Broken', machines: [machines[1]]}
198+ ];
199+ scope.toggleCheckGroup('New');
200+
201+ expect(machines[0].$selected).toBe(false);
202+ expect(machines[1].$selected).toBe(true);
203+ expect(machines[2].$selected).toBe(false);
204+ });
205+ });
206+
207 describe("toggleChecked", function() {
208
209 it("selects machine", function() {
210@@ -194,6 +239,26 @@ describe("maasMachinesTable", function() {
211 });
212 });
213
214+ describe("toggleOpenGroup", () => {
215+ it("removes group from scope.closedGroups if present", () => {
216+ const directive = compileDirective();
217+ const scope = directive.isolateScope();
218+ const group = makeName("group");
219+ scope.closedGroups = [group];
220+ scope.toggleOpenGroup(group);
221+ expect(scope.closedGroups).toEqual([]);
222+ });
223+
224+ it("adds group to scope.closedGroups if not present", () => {
225+ const directive = compileDirective();
226+ const scope = directive.isolateScope();
227+ const group = makeName("group");
228+ scope.closedGroups = [];
229+ scope.toggleOpenGroup(group);
230+ expect(scope.closedGroups).toEqual([group]);
231+ });
232+ });
233+
234 describe("sortTable", function() {
235
236 it("sets predicate", function() {
237@@ -922,4 +987,67 @@ describe("maasMachinesTable", function() {
238 { label: 'user1', machines: [machines[1]]}]);
239 });
240 });
241+
242+ describe("getGroupSelectedState", () => {
243+ it("returns true if all machines in a group are selected", () => {
244+ const directive = compileDirective();
245+ const scope = directive.isolateScope();
246+
247+ const machines = [makeMachine(), makeMachine()];
248+ machines[0].status = 'New';
249+ machines[0].$selected = true;
250+ machines[1].status = 'New';
251+ machines[1].$selected = true;
252+ scope.table.filteredMachines = machines;
253+ scope.groupedMachines = [
254+ { label: 'New', machines: [machines[0], machines[1]] }
255+ ];
256+ scope.getGroupSelectedState('New');
257+
258+ expect(scope.getGroupSelectedState('New')).toBe(true);
259+ });
260+
261+ it("returns false if not all machines in a group are selected", () => {
262+ const directive = compileDirective();
263+ const scope = directive.isolateScope();
264+
265+ const machines = [makeMachine(), makeMachine()];
266+ machines[0].status = 'New';
267+ machines[0].$selected = true;
268+ machines[1].status = 'New';
269+ scope.table.filteredMachines = machines;
270+ scope.groupedMachines = [
271+ { label: 'New', machines: [machines[0], machines[1]] }
272+ ];
273+ scope.getGroupSelectedState('New');
274+
275+ expect(scope.getGroupSelectedState('New')).toBe(false);
276+ });
277+ });
278+
279+ describe("getGroupCountString", () => {
280+ it(`correctly returns a string of the number of machines in a group,
281+ and the selected machines in that group`, () => {
282+ const directive = compileDirective();
283+ const scope = directive.isolateScope();
284+ const machines = Array.from(Array(6)).map(makeMachine);
285+ machines[0].$selected = true;
286+ machines[1].$selected = true;
287+ machines[2].$selected = true;
288+ scope.groupedMachines = [
289+ { label: 'New', machines: [machines[0], machines[1]] },
290+ { label: 'Ready', machines: [machines[2], machines[3]] },
291+ { label: 'Failed', machines: [machines[4], machines[5]] }
292+ ];
293+
294+ expect(
295+ scope.getGroupCountString('New')).toBe("2 machines selected");
296+ expect(
297+ scope.getGroupCountString('Ready')).toBe(
298+ "2 machines, 1 selected");
299+ expect(
300+ scope.getGroupCountString('Failed')).toBe(
301+ "2 machines");
302+ });
303+ });
304 });
305diff --git a/src/maasserver/static/partials/machines-table.html b/src/maasserver/static/partials/machines-table.html
306index abd1366..a23d462 100644
307--- a/src/maasserver/static/partials/machines-table.html
308+++ b/src/maasserver/static/partials/machines-table.html
309@@ -1,6 +1,6 @@
310-<table data-ng-repeat="group in groupedMachines" data-ng-if="group.machines.length" class="p-table--sortable p-table--machines" role="grid">
311+<table class="p-table--sortable p-table--machines" role="grid">
312 <thead>
313- <tr class="p-table__header p-table__row" data-ng-if="$first">
314+ <tr class="p-table__header p-table__row">
315 <th class="p-table__col--name p-double-row u-align--left">
316 <div class="p-double-row__checkbox" data-ng-if="!hideActions">
317 <input class="checkbox" type="checkbox" data-ng-click="toggleCheckAll()" data-ng-checked="table.allViewableChecked" id="check-all" data-ng-disabled="ngDisabled()" />
318@@ -50,11 +50,32 @@
319 <th class="p-table__col--disks u-align--right" role="columnheader" data-ng-click="sortTable('physical_disk_count')" data-ng-class="{'is-sorted': table.predicate === 'physical_disk_count', 'sort-asc': table.reverse === false, 'sort-desc': table.reverse === true}" title="Disks">Disks</th>
320 <th class="p-table__col--storage u-align--right" role="columnheader" data-ng-click="sortTable('storage')" data-ng-class="{'is-sorted': table.predicate === 'storage', 'sort-asc': table.reverse === false, 'sort-desc': table.reverse === true}" title="Storage">Storage</th>
321 </tr>
322- <tr class="p-table__group p-table__row" data-ng-if="group.label != 'none' && group.machines.length && !actionOption && search != 'in:(Selected)'">
323+ </thead>
324+</table>
325+<table data-ng-repeat="group in groupedMachines" data-ng-if="group.machines.length" class="p-table--machines" role="grid">
326+ <thead>
327+ <tr
328+ class="p-table__group p-table__row"
329+ data-ng-class="{
330+ 'is-active': getGroupSelectedState(group.label),
331+ 'p-table__placeholder': group.label === 'none'
332+ }"
333+ >
334 <th class="p-table__group-label p-table__col--name p-double-row u-align--left">
335- {$ group.label $}
336+ <input type="checkbox" id="{$ group.label $}" data-ng-click="toggleCheckGroup(group.label)" data-ng-checked="getGroupSelectedState(group.label)" />
337+ <label class="p-checkbox--action u-no-margin--bottom" for="{$ group.label $}">{$ group.label $}</label>
338+ <div class="p-muted-text">{$ getGroupCountString(group.label) $}</div>
339+ </th>
340+ <th class="p-table__col--power" role="columnheader">
341+ <button class="p-table__group-toggle p-button--base p-button--narrow" data-ng-click="toggleOpenGroup(group.label)">
342+ <i data-ng-class="{
343+ 'p-icon--minus': !closedGroups.includes(group.label),
344+ 'p-icon--plus': closedGroups.includes(group.label)
345+ }">
346+ Toggle
347+ </i>
348+ </button>
349 </th>
350- <th class="p-table__col--power" role="columnheader"></th>
351 <th class="p-table__col--status p-double-row" title="Status"></th>
352 <th class="p-table__col--owner p-double-row" title="Owner, Tags"></th>
353 <th class="p-table__col--pool" title="Pool" role="columnheader"></th>
354@@ -66,7 +87,7 @@
355 <th class="p-table__col--storage u-align--right" role="columnheader"></th>
356 </tr>
357 </thead>
358- <tbody vs-repeat>
359+ <tbody vs-repeat data-ng-if="!closedGroups.includes(group.label)">
360 <tr class="p-table__row" data-ng-repeat="node in group.machines | nodesFilter:search | orderBy:table.predicate:table.reverse track by node.system_id"
361 data-ng-class="{ 'table--error': machineHasError({ $machine: node }),
362 'is-active': node.$selected,
363diff --git a/src/maasserver/static/scss/_base_forms.scss b/src/maasserver/static/scss/_base_forms.scss
364index 79be0e7..f8c3c38 100644
365--- a/src/maasserver/static/scss/_base_forms.scss
366+++ b/src/maasserver/static/scss/_base_forms.scss
367@@ -57,7 +57,7 @@
368 }
369 }
370
371- th & + label {
372+ th:not(.p-table__group-label) & + label {
373 &::before {
374 top: .65em;
375 }
376diff --git a/src/maasserver/static/scss/_tables.scss b/src/maasserver/static/scss/_tables.scss
377index 007cc4d..aa5459d 100644
378--- a/src/maasserver/static/scss/_tables.scss
379+++ b/src/maasserver/static/scss/_tables.scss
380@@ -374,23 +374,38 @@
381
382 .p-table__group {
383 border: 0;
384+ position: relative;
385
386 .p-table__group-label {
387 color: $color-dark;
388 font-size: 1rem;
389- padding: $spv-inter--regular 0 $spv-inter--regular $group-padding-left;
390+ padding: $spv-intra 0 $spv-intra $sph-intra--condensed;
391 text-transform: none;
392 }
393+
394+ .p-table__group-toggle {
395+ padding: 0 $sp-x-small;
396+ position: absolute;
397+ right: $spv-intra--expanded;
398+ top: $spv-inter--regular;
399+ }
400+
401+ &.is-open {
402+ @include vf-icon-minus($color-mid-dark);
403+ }
404 }
405
406 .p-table__row {
407 position: relative;
408
409+ &::after {
410+ content: '';
411+ }
412+
413 &.is-grouped {
414 border: 0;
415
416 &::after {
417- content: '';
418 position: absolute;
419 left: $group-padding-left;
420 right: 0;
421@@ -463,6 +478,15 @@
422 }
423 }
424
425+ // Needed to keep widths of machine table correct when no grouping selected
426+ .p-table__placeholder {
427+ * {
428+ height: 0 !important;
429+ padding: 0 !important;
430+ visibility: hidden;
431+ }
432+ }
433+
434 .p-icon--placeholder {
435 @extend %icon;
436 height: map-get($icon-sizes, default);
437@@ -474,6 +498,10 @@
438 max-width: 500px;
439 white-space: inherit;
440 }
441+
442+ &:last-of-type::after {
443+ display: none;
444+ }
445 }
446
447 .p-table--controller-interfaces {
448@@ -705,6 +733,11 @@
449 color: $color-mid-dark;
450 margin: 0;
451 padding: 0;
452+
453+ .p-table__group-label & {
454+ font-weight: 300;
455+ padding-left: $sp-x-large;
456+ }
457 }
458
459 .p-link--muted {

Subscribers

People subscribed via source and target branches