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
diff --git a/src/maasserver/static/js/angular/directives/machines_table.js b/src/maasserver/static/js/angular/directives/machines_table.js
index 672fb66..ffc2938 100644
--- a/src/maasserver/static/js/angular/directives/machines_table.js
+++ b/src/maasserver/static/js/angular/directives/machines_table.js
@@ -9,7 +9,7 @@
9/* @ngInject */9/* @ngInject */
10function maasMachinesTable(10function maasMachinesTable(
11 MachinesManager, NotificationsManager, UsersManager,11 MachinesManager, NotificationsManager, UsersManager,
12 GeneralManager, $document, $window, $log) {12 GeneralManager, $filter, $document, $window, $log) {
13 return {13 return {
14 restrict: "E",14 restrict: "E",
15 scope: {15 scope: {
@@ -90,8 +90,11 @@ function maasMachinesTable(
90 "lock",90 "lock",
91 "unlock"91 "unlock"
92 ];92 ];
93
93 $scope.openMenu = "";94 $scope.openMenu = "";
9495
96 $scope.closedGroups = [];
97
95 $scope.groupBy = (list, keyGetter) => {98 $scope.groupBy = (list, keyGetter) => {
96 const map = new Map();99 const map = new Map();
97 list.forEach((item) => {100 list.forEach((item) => {
@@ -200,6 +203,32 @@ function maasMachinesTable(
200 $scope.updateAllChecked();203 $scope.updateAllChecked();
201 };204 };
202205
206 $scope.toggleCheckGroup = groupLabel => {
207 const machineGroup = $scope.groupedMachines.find(group => {
208 return group.label === groupLabel
209 });
210 if ($scope.getGroupSelectedState(groupLabel)) {
211 machineGroup.machines.forEach(machine => {
212 MachinesManager.unselectItem(machine.system_id);
213 });
214 } else {
215 machineGroup.machines.forEach(machine => {
216 MachinesManager.selectItem(machine.system_id);
217 });
218 }
219 $scope.updateAllChecked();
220 };
221
222 $scope.toggleOpenGroup = groupLabel => {
223 if ($scope.closedGroups.includes(groupLabel)) {
224 $scope.closedGroups = $scope.closedGroups.filter(group => (
225 group !== groupLabel
226 ));
227 } else {
228 $scope.closedGroups = [...$scope.closedGroups, groupLabel];
229 }
230 };
231
203 // Sorts the table by predicate.232 // Sorts the table by predicate.
204 $scope.sortTable = function(predicate) {233 $scope.sortTable = function(predicate) {
205 $scope.table.predicate = predicate;234 $scope.table.predicate = predicate;
@@ -308,6 +337,29 @@ function maasMachinesTable(
308 }337 }
309 };338 };
310339
340 $scope.getGroupSelectedState = groupLabel => {
341 const machineGroup = $scope.groupedMachines.find(group => {
342 return group.label === groupLabel
343 });
344 return !machineGroup.machines.some(machine => !machine.$selected);
345 };
346
347 $scope.getGroupCountString = groupLabel => {
348 const machineGroup = $scope.groupedMachines.find(group => {
349 return group.label === groupLabel
350 });
351 const machines = machineGroup.machines.length;
352 const selected
353 = machineGroup.machines.filter(item => item.$selected).length;
354 const machinesString
355 = `${machines} ${machines === 1 ? "machine" : "machines"}`;
356
357 if (selected && selected === machines) {
358 return `${machinesString} selected`;
359 }
360 return `${machinesString}${selected ? `, ${selected} selected` : ""}`;
361 };
362
311 $scope.updateGroupedMachines = function(field) {363 $scope.updateGroupedMachines = function(field) {
312 if ($scope.table.filteredMachines.length === 0) { return; }364 if ($scope.table.filteredMachines.length === 0) { return; }
313365
@@ -393,7 +445,7 @@ function maasMachinesTable(
393 ...(machines.get('Reserved') || [])445 ...(machines.get('Reserved') || [])
394 ]446 ]
395 }447 }
396 ]448 ];
397 return;449 return;
398 }450 }
399451
@@ -424,7 +476,7 @@ function maasMachinesTable(
424 label: 'none',476 label: 'none',
425 machines: $scope.table.filteredMachines477 machines: $scope.table.filteredMachines
426 }478 }
427 ]479 ];
428 return;480 return;
429 }481 }
430482
@@ -439,19 +491,18 @@ function maasMachinesTable(
439 $scope.updateGroupedMachines($scope.groupByLabel);491 $scope.updateGroupedMachines($scope.groupByLabel);
440 });492 });
441493
442 // When the list of machines changes update grouping.494 $scope.$watch("search", function() {
443 $scope.$watch("table.machines", function() {495 $scope.table.filteredMachines
444 if ($scope.groupByLabel !== 'none') {496 = $filter('nodesFilter')($scope.table.machines, $scope.search);
445 $scope.updateGroupedMachines($scope.groupByLabel);497 });
446 }
447 }, true);
448498
449 // Watch a simplified list of machines for changes to power state,499 // Watch simplified list of machines for changes to power state and status,
450 // then set transitional state accordingly.500 // then make changes accordingly.
451 $scope.$watch(501 $scope.$watch(
452 scope =>502 scope =>
453 scope.table.machines.map(machine => ({503 scope.table.machines.map(machine => ({
454 id: machine.id,504 id: machine.id,
505 status: machine.status,
455 state: machine.power_state506 state: machine.power_state
456 })),507 })),
457 (newMachines, oldMachines) => {508 (newMachines, oldMachines) => {
@@ -460,11 +511,19 @@ function maasMachinesTable(
460 oldMachines.find(511 oldMachines.find(
461 machine => machine.id === newMachine.id512 machine => machine.id === newMachine.id
462 ) || {};513 ) || {};
514
515 // Check if power state has changed, then set transitional state
463 if (newMachine.state !== oldMachine.state) {516 if (newMachine.state !== oldMachine.state) {
464 $scope.table.machines.find(517 $scope.table.machines.find(
465 machine => machine.id === newMachine.id518 machine => machine.id === newMachine.id
466 ).powerTransition = undefined;519 ).powerTransition = undefined;
467 }520 }
521
522 // Check if status has changed, then run function to regroup machines
523 if (newMachine.status !== oldMachine.status
524 && $scope.groupByLabel !== "none") {
525 $scope.updateGroupedMachines($scope.groupByLabel);
526 }
468 });527 });
469 },528 },
470 true529 true
diff --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
index c1e7f2b..72ed5e7 100644
--- a/src/maasserver/static/js/angular/directives/tests/test_machines_table.js
+++ b/src/maasserver/static/js/angular/directives/tests/test_machines_table.js
@@ -170,6 +170,51 @@ describe("maasMachinesTable", function() {
170 });170 });
171 });171 });
172172
173 describe("toggleCheckGroup", () => {
174 it("selects all unselected machines in a group", () => {
175 const directive = compileDirective();
176 const scope = directive.isolateScope();
177
178 const machines = [makeMachine(), makeMachine(), makeMachine()];
179 machines[0].status = 'New';
180 machines[1].status = 'Broken';
181 machines[2].status = 'New';
182 scope.table.filteredMachines = machines;
183 scope.groupedMachines = [
184 { label: 'New', machines: [machines[0], machines[2]] },
185 { label: 'Broken', machines: [machines[1]]}
186 ];
187 scope.toggleCheckGroup('New');
188
189 expect(machines[0].$selected).toBe(true);
190 expect(machines[1].$selected).toBe(false);
191 expect(machines[2].$selected).toBe(true);
192 });
193
194 it("unselects all machines in a group if all are selected", () => {
195 const directive = compileDirective();
196 const scope = directive.isolateScope();
197
198 const machines = [makeMachine(), makeMachine(), makeMachine()];
199 machines[0].status = 'New';
200 machines[0].$selected = true;
201 machines[1].status = 'Broken';
202 machines[1].$selected = true;
203 machines[2].status = 'New';
204 machines[2].$selected = true;
205 scope.table.filteredMachines = machines;
206 scope.groupedMachines = [
207 { label: 'New', machines: [machines[0], machines[2]] },
208 { label: 'Broken', machines: [machines[1]]}
209 ];
210 scope.toggleCheckGroup('New');
211
212 expect(machines[0].$selected).toBe(false);
213 expect(machines[1].$selected).toBe(true);
214 expect(machines[2].$selected).toBe(false);
215 });
216 });
217
173 describe("toggleChecked", function() {218 describe("toggleChecked", function() {
174219
175 it("selects machine", function() {220 it("selects machine", function() {
@@ -194,6 +239,26 @@ describe("maasMachinesTable", function() {
194 });239 });
195 });240 });
196241
242 describe("toggleOpenGroup", () => {
243 it("removes group from scope.closedGroups if present", () => {
244 const directive = compileDirective();
245 const scope = directive.isolateScope();
246 const group = makeName("group");
247 scope.closedGroups = [group];
248 scope.toggleOpenGroup(group);
249 expect(scope.closedGroups).toEqual([]);
250 });
251
252 it("adds group to scope.closedGroups if not present", () => {
253 const directive = compileDirective();
254 const scope = directive.isolateScope();
255 const group = makeName("group");
256 scope.closedGroups = [];
257 scope.toggleOpenGroup(group);
258 expect(scope.closedGroups).toEqual([group]);
259 });
260 });
261
197 describe("sortTable", function() {262 describe("sortTable", function() {
198263
199 it("sets predicate", function() {264 it("sets predicate", function() {
@@ -922,4 +987,67 @@ describe("maasMachinesTable", function() {
922 { label: 'user1', machines: [machines[1]]}]);987 { label: 'user1', machines: [machines[1]]}]);
923 });988 });
924 });989 });
990
991 describe("getGroupSelectedState", () => {
992 it("returns true if all machines in a group are selected", () => {
993 const directive = compileDirective();
994 const scope = directive.isolateScope();
995
996 const machines = [makeMachine(), makeMachine()];
997 machines[0].status = 'New';
998 machines[0].$selected = true;
999 machines[1].status = 'New';
1000 machines[1].$selected = true;
1001 scope.table.filteredMachines = machines;
1002 scope.groupedMachines = [
1003 { label: 'New', machines: [machines[0], machines[1]] }
1004 ];
1005 scope.getGroupSelectedState('New');
1006
1007 expect(scope.getGroupSelectedState('New')).toBe(true);
1008 });
1009
1010 it("returns false if not all machines in a group are selected", () => {
1011 const directive = compileDirective();
1012 const scope = directive.isolateScope();
1013
1014 const machines = [makeMachine(), makeMachine()];
1015 machines[0].status = 'New';
1016 machines[0].$selected = true;
1017 machines[1].status = 'New';
1018 scope.table.filteredMachines = machines;
1019 scope.groupedMachines = [
1020 { label: 'New', machines: [machines[0], machines[1]] }
1021 ];
1022 scope.getGroupSelectedState('New');
1023
1024 expect(scope.getGroupSelectedState('New')).toBe(false);
1025 });
1026 });
1027
1028 describe("getGroupCountString", () => {
1029 it(`correctly returns a string of the number of machines in a group,
1030 and the selected machines in that group`, () => {
1031 const directive = compileDirective();
1032 const scope = directive.isolateScope();
1033 const machines = Array.from(Array(6)).map(makeMachine);
1034 machines[0].$selected = true;
1035 machines[1].$selected = true;
1036 machines[2].$selected = true;
1037 scope.groupedMachines = [
1038 { label: 'New', machines: [machines[0], machines[1]] },
1039 { label: 'Ready', machines: [machines[2], machines[3]] },
1040 { label: 'Failed', machines: [machines[4], machines[5]] }
1041 ];
1042
1043 expect(
1044 scope.getGroupCountString('New')).toBe("2 machines selected");
1045 expect(
1046 scope.getGroupCountString('Ready')).toBe(
1047 "2 machines, 1 selected");
1048 expect(
1049 scope.getGroupCountString('Failed')).toBe(
1050 "2 machines");
1051 });
1052 });
925});1053});
diff --git a/src/maasserver/static/partials/machines-table.html b/src/maasserver/static/partials/machines-table.html
index abd1366..a23d462 100644
--- a/src/maasserver/static/partials/machines-table.html
+++ b/src/maasserver/static/partials/machines-table.html
@@ -1,6 +1,6 @@
1<table data-ng-repeat="group in groupedMachines" data-ng-if="group.machines.length" class="p-table--sortable p-table--machines" role="grid">1<table class="p-table--sortable p-table--machines" role="grid">
2 <thead>2 <thead>
3 <tr class="p-table__header p-table__row" data-ng-if="$first">3 <tr class="p-table__header p-table__row">
4 <th class="p-table__col--name p-double-row u-align--left">4 <th class="p-table__col--name p-double-row u-align--left">
5 <div class="p-double-row__checkbox" data-ng-if="!hideActions">5 <div class="p-double-row__checkbox" data-ng-if="!hideActions">
6 <input class="checkbox" type="checkbox" data-ng-click="toggleCheckAll()" data-ng-checked="table.allViewableChecked" id="check-all" data-ng-disabled="ngDisabled()" />6 <input class="checkbox" type="checkbox" data-ng-click="toggleCheckAll()" data-ng-checked="table.allViewableChecked" id="check-all" data-ng-disabled="ngDisabled()" />
@@ -50,11 +50,32 @@
50 <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>50 <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>
51 <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>51 <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>
52 </tr>52 </tr>
53 <tr class="p-table__group p-table__row" data-ng-if="group.label != 'none' && group.machines.length && !actionOption && search != 'in:(Selected)'">53 </thead>
54</table>
55<table data-ng-repeat="group in groupedMachines" data-ng-if="group.machines.length" class="p-table--machines" role="grid">
56 <thead>
57 <tr
58 class="p-table__group p-table__row"
59 data-ng-class="{
60 'is-active': getGroupSelectedState(group.label),
61 'p-table__placeholder': group.label === 'none'
62 }"
63 >
54 <th class="p-table__group-label p-table__col--name p-double-row u-align--left">64 <th class="p-table__group-label p-table__col--name p-double-row u-align--left">
55 {$ group.label $}65 <input type="checkbox" id="{$ group.label $}" data-ng-click="toggleCheckGroup(group.label)" data-ng-checked="getGroupSelectedState(group.label)" />
66 <label class="p-checkbox--action u-no-margin--bottom" for="{$ group.label $}">{$ group.label $}</label>
67 <div class="p-muted-text">{$ getGroupCountString(group.label) $}</div>
68 </th>
69 <th class="p-table__col--power" role="columnheader">
70 <button class="p-table__group-toggle p-button--base p-button--narrow" data-ng-click="toggleOpenGroup(group.label)">
71 <i data-ng-class="{
72 'p-icon--minus': !closedGroups.includes(group.label),
73 'p-icon--plus': closedGroups.includes(group.label)
74 }">
75 Toggle
76 </i>
77 </button>
56 </th>78 </th>
57 <th class="p-table__col--power" role="columnheader"></th>
58 <th class="p-table__col--status p-double-row" title="Status"></th>79 <th class="p-table__col--status p-double-row" title="Status"></th>
59 <th class="p-table__col--owner p-double-row" title="Owner, Tags"></th>80 <th class="p-table__col--owner p-double-row" title="Owner, Tags"></th>
60 <th class="p-table__col--pool" title="Pool" role="columnheader"></th>81 <th class="p-table__col--pool" title="Pool" role="columnheader"></th>
@@ -66,7 +87,7 @@
66 <th class="p-table__col--storage u-align--right" role="columnheader"></th>87 <th class="p-table__col--storage u-align--right" role="columnheader"></th>
67 </tr>88 </tr>
68 </thead>89 </thead>
69 <tbody vs-repeat>90 <tbody vs-repeat data-ng-if="!closedGroups.includes(group.label)">
70 <tr class="p-table__row" data-ng-repeat="node in group.machines | nodesFilter:search | orderBy:table.predicate:table.reverse track by node.system_id"91 <tr class="p-table__row" data-ng-repeat="node in group.machines | nodesFilter:search | orderBy:table.predicate:table.reverse track by node.system_id"
71 data-ng-class="{ 'table--error': machineHasError({ $machine: node }),92 data-ng-class="{ 'table--error': machineHasError({ $machine: node }),
72 'is-active': node.$selected,93 'is-active': node.$selected,
diff --git a/src/maasserver/static/scss/_base_forms.scss b/src/maasserver/static/scss/_base_forms.scss
index 79be0e7..f8c3c38 100644
--- a/src/maasserver/static/scss/_base_forms.scss
+++ b/src/maasserver/static/scss/_base_forms.scss
@@ -57,7 +57,7 @@
57 }57 }
58 }58 }
5959
60 th & + label {60 th:not(.p-table__group-label) & + label {
61 &::before {61 &::before {
62 top: .65em;62 top: .65em;
63 }63 }
diff --git a/src/maasserver/static/scss/_tables.scss b/src/maasserver/static/scss/_tables.scss
index 007cc4d..aa5459d 100644
--- a/src/maasserver/static/scss/_tables.scss
+++ b/src/maasserver/static/scss/_tables.scss
@@ -374,23 +374,38 @@
374374
375 .p-table__group {375 .p-table__group {
376 border: 0;376 border: 0;
377 position: relative;
377378
378 .p-table__group-label {379 .p-table__group-label {
379 color: $color-dark;380 color: $color-dark;
380 font-size: 1rem;381 font-size: 1rem;
381 padding: $spv-inter--regular 0 $spv-inter--regular $group-padding-left;382 padding: $spv-intra 0 $spv-intra $sph-intra--condensed;
382 text-transform: none;383 text-transform: none;
383 }384 }
385
386 .p-table__group-toggle {
387 padding: 0 $sp-x-small;
388 position: absolute;
389 right: $spv-intra--expanded;
390 top: $spv-inter--regular;
391 }
392
393 &.is-open {
394 @include vf-icon-minus($color-mid-dark);
395 }
384 }396 }
385397
386 .p-table__row {398 .p-table__row {
387 position: relative;399 position: relative;
388400
401 &::after {
402 content: '';
403 }
404
389 &.is-grouped {405 &.is-grouped {
390 border: 0;406 border: 0;
391407
392 &::after {408 &::after {
393 content: '';
394 position: absolute;409 position: absolute;
395 left: $group-padding-left;410 left: $group-padding-left;
396 right: 0;411 right: 0;
@@ -463,6 +478,15 @@
463 }478 }
464 }479 }
465480
481 // Needed to keep widths of machine table correct when no grouping selected
482 .p-table__placeholder {
483 * {
484 height: 0 !important;
485 padding: 0 !important;
486 visibility: hidden;
487 }
488 }
489
466 .p-icon--placeholder {490 .p-icon--placeholder {
467 @extend %icon;491 @extend %icon;
468 height: map-get($icon-sizes, default);492 height: map-get($icon-sizes, default);
@@ -474,6 +498,10 @@
474 max-width: 500px;498 max-width: 500px;
475 white-space: inherit;499 white-space: inherit;
476 }500 }
501
502 &:last-of-type::after {
503 display: none;
504 }
477 }505 }
478506
479 .p-table--controller-interfaces {507 .p-table--controller-interfaces {
@@ -705,6 +733,11 @@
705 color: $color-mid-dark;733 color: $color-mid-dark;
706 margin: 0;734 margin: 0;
707 padding: 0;735 padding: 0;
736
737 .p-table__group-label & {
738 font-weight: 300;
739 padding-left: $sp-x-large;
740 }
708 }741 }
709742
710 .p-link--muted {743 .p-link--muted {

Subscribers

People subscribed via source and target branches