Skip to content

Commit

Permalink
feat(core): refactor insight menu from dropdown to buttons (#8424)
Browse files Browse the repository at this point in the history
The motivation for this change is to better surface an action for the user to
take when they are on the search, project, and application pages. The current
UX obscures the next appropriate action behind a drop down menu that visually
blends into the page, making it difficult for users to know what to do next.

The proposed change is to replace the drop down menu with buttons rendered
appropriately for each page. When on the search page, both create project and
application buttons are rendered, with a call to action for create application.
When on project or application pages, only render those buttons. Finally, if a
cache refresh is needed, render this on all pages when appropriate.

At a technical level, this unifies the three dropdowns which currently depend
on 2 separate angular controllers and a react component. The component has been
used in favor of the angular implementations on both search and project pages.
  • Loading branch information
dogonthehorizon committed Jul 23, 2020
1 parent 0fa6896 commit 425d2e1
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 111 deletions.
84 changes: 84 additions & 0 deletions app/scripts/modules/core/src/insight/InsightMenu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';
import { mock } from 'angular';
import { ReactWrapper, mount } from 'enzyme';
import { IInsightMenuProps, IInsightMenuState, InsightMenu } from './InsightMenu';
import { IModalService } from 'angular-ui-bootstrap';
import { OverrideRegistry } from '../overrideRegistry/override.registry';
import { CacheInitializerService } from '../cache/cacheInitializer.service';
import { Button } from 'react-bootstrap';

beforeEach(() => {
mock.module(($provide: any) => {
$provide.value('$uibModal', {} as IModalService);
$provide.value('overrideRegistry', {} as OverrideRegistry);
$provide.value('cacheInitializer', {} as CacheInitializerService);
});
});

describe('<InsightMenu />', () => {
let component: ReactWrapper<IInsightMenuProps, IInsightMenuState>;

function getNewMenu(params: object): ReactWrapper<IInsightMenuProps, any> {
// Set defaults to zero so we only need to pass in the prop we want rendered
const mergedParams = { ...{ createApp: false, createProject: false, refreshCaches: false }, ...params };
return mount(
<InsightMenu
createApp={mergedParams.createApp}
createProject={mergedParams.createProject}
refreshCaches={mergedParams.refreshCaches}
/>,
);
}

it('should only render create application button when initialized', () => {
component = getNewMenu({ createApp: true });
const btn = component.find(Button);

expect(btn.length).toBe(1);
expect(btn.text()).toEqual('Create Application');
// Button should always be primary for create application
// FIXME: when this project moves to v1+ of react-bootstrap this prop will need to change.
expect(btn.prop('bsStyle')).toEqual('primary');
});

it('should only render create project button when initialized', () => {
component = getNewMenu({ createProject: true });

const btn = component.find(Button);

expect(btn.length).toBe(1);
expect(btn.text()).toEqual('Create Project');
// If project is the only button rendered, it should be primary.
// FIXME: when this project moves to v1+ of react-bootstrap this prop will need to change.
expect(btn.prop('bsStyle')).toEqual('primary');
});

it('should only render refresh cache button when initialized', () => {
// note: this test doesn't validate the state changes that could occur w/
// the refresh button in particular.
component = getNewMenu({ refreshCaches: true });

const btn = component.find(Button);

expect(btn.length).toBe(1);
expect(btn.text()).toMatch('Refresh');
});

it('should only render create application as primary when multiple buttons are rendered', () => {
component = getNewMenu({ createApp: true, createProject: true });

const btns = component.find(Button);
const proj = btns.at(0);
const app = btns.at(1);

expect(btns.length).toBe(2);
// Project button should be first
expect(proj.text()).toEqual('Create Project');
// FIXME: when this project moves to v1+ of react-bootstrap this prop will need to change.
expect(proj.prop('bsStyle')).toEqual('default');
// Application button should be second, so that it renders furthest to the right
expect(app.text()).toEqual('Create Application');
// FIXME: when this project moves to v1+ of react-bootstrap this prop will need to change.
expect(app.prop('bsStyle')).toEqual('primary');
});
});
36 changes: 24 additions & 12 deletions app/scripts/modules/core/src/insight/InsightMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { IScope } from 'angular';
import { DropdownButton, MenuItem } from 'react-bootstrap';
import { Button } from 'react-bootstrap';
import { StateService } from '@uirouter/core';
import { IModalService } from 'angular-ui-bootstrap';

Expand Down Expand Up @@ -87,23 +87,35 @@ export class InsightMenu extends React.Component<IInsightMenuProps, IInsightMenu
);

return (
<DropdownButton pullRight={true} title="Actions" id="insight-menu">
{createApp && (
<MenuItem href="javascript:void(0)" onClick={this.createApplication}>
Create Application
</MenuItem>
)}
<div id="insight-menu">
{createProject && (
<MenuItem href="javascript:void(0)" onClick={this.createProject}>
<Button
bsStyle={createApp ? 'default' : 'primary'}
href="javascript:void(0)"
onClick={this.createProject}
style={{ marginRight: createApp ? '5px' : '' }}
>
Create Project
</MenuItem>
</Button>
)}

{createApp && (
<Button
bsStyle="primary"
href="javascript:void(0)"
onClick={this.createApplication}
style={{ marginRight: refreshCaches ? '5px' : '' }}
>
Create Application
</Button>
)}

{refreshCaches && (
<MenuItem href="javascript:void(0)" onClick={this.refreshAllCaches}>
<Button href="javascript:void(0)" onClick={this.refreshAllCaches}>
{refreshMarkup}
</MenuItem>
</Button>
)}
</DropdownButton>
</div>
);
}
}
148 changes: 77 additions & 71 deletions app/scripts/modules/core/src/projects/projects.controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use strict';
import { module } from 'angular';

import { react2angular } from 'react2angular';

import { ANY_FIELD_FILTER } from '../presentation/anyFieldFilter/anyField.filter';
import { INSIGHT_MENU_DIRECTIVE } from '../insight/insightmenu.directive';
import { ViewStateCache } from 'core/cache';
Expand All @@ -10,91 +12,95 @@ import { ProjectReader } from './service/ProjectReader';
import { CORE_PRESENTATION_SORTTOGGLE_SORTTOGGLE_DIRECTIVE } from '../presentation/sortToggle/sorttoggle.directive';
import UIROUTER_ANGULARJS from '@uirouter/angularjs';

import { InsightMenu as ProjectInsightMenu } from 'core/insight/InsightMenu';

export const CORE_PROJECTS_PROJECTS_CONTROLLER = 'spinnaker.projects.controller';
export const name = CORE_PROJECTS_PROJECTS_CONTROLLER; // for backwards compatibility
module(CORE_PROJECTS_PROJECTS_CONTROLLER, [
UIROUTER_ANGULARJS,
ANY_FIELD_FILTER,
CORE_PRESENTATION_SORTTOGGLE_SORTTOGGLE_DIRECTIVE,
INSIGHT_MENU_DIRECTIVE,
]).controller('ProjectsCtrl', [
'$scope',
'$uibModal',
'$log',
'$filter',
function($scope, $uibModal, $log, $filter) {
const projectsViewStateCache =
ViewStateCache.get('projects') || ViewStateCache.createCache('projects', { version: 1 });

function cacheViewState() {
projectsViewStateCache.put('#global', $scope.viewState);
}

function initializeViewState() {
$scope.viewState = projectsViewStateCache.get('#global') || {
sortModel: { key: 'name' },
projectFilter: '',
};
}
])
.component('projectInsightMenu', react2angular(ProjectInsightMenu, ['createApp', 'createProject', 'refreshCaches']))
.controller('ProjectsCtrl', [
'$scope',
'$uibModal',
'$log',
'$filter',
function($scope, $uibModal, $log, $filter) {
const projectsViewStateCache =
ViewStateCache.get('projects') || ViewStateCache.createCache('projects', { version: 1 });

function cacheViewState() {
projectsViewStateCache.put('#global', $scope.viewState);
}

function initializeViewState() {
$scope.viewState = projectsViewStateCache.get('#global') || {
sortModel: { key: 'name' },
projectFilter: '',
};
}

$scope.projectsLoaded = false;
$scope.projectsLoaded = false;

$scope.projectFilter = '';
$scope.projectFilter = '';

$scope.menuActions = [
{
displayName: 'Create Project',
action: function() {
ConfigureProjectModal.show().catch(() => {});
$scope.menuActions = [
{
displayName: 'Create Project',
action: function() {
ConfigureProjectModal.show().catch(() => {});
},
},
},
];
];

this.filterProjects = function filterProjects() {
const filtered = $filter('anyFieldFilter')($scope.projects, {
name: $scope.viewState.projectFilter,
email: $scope.viewState.projectFilter,
});
const sorted = $filter('orderBy')(filtered, $scope.viewState.sortModel.key);
$scope.filteredProjects = sorted;
this.resetPaginator();
};

this.resultPage = function resultPage() {
const pagination = $scope.pagination;
const allFiltered = $scope.filteredProjects;
const start = (pagination.currentPage - 1) * pagination.itemsPerPage;
const end = pagination.currentPage * pagination.itemsPerPage;
if (!allFiltered || !allFiltered.length) {
return [];
}
if (allFiltered.length < pagination.itemsPerPage) {
return allFiltered;
}
if (allFiltered.length < end) {
return allFiltered.slice(start);
}
return allFiltered.slice(start, end);
};

this.resetPaginator = function resetPaginator() {
$scope.pagination = {
currentPage: 1,
itemsPerPage: 12,
maxSize: 12,
this.filterProjects = function filterProjects() {
const filtered = $filter('anyFieldFilter')($scope.projects, {
name: $scope.viewState.projectFilter,
email: $scope.viewState.projectFilter,
});
const sorted = $filter('orderBy')(filtered, $scope.viewState.sortModel.key);
$scope.filteredProjects = sorted;
this.resetPaginator();
};

this.resultPage = function resultPage() {
const pagination = $scope.pagination;
const allFiltered = $scope.filteredProjects;
const start = (pagination.currentPage - 1) * pagination.itemsPerPage;
const end = pagination.currentPage * pagination.itemsPerPage;
if (!allFiltered || !allFiltered.length) {
return [];
}
if (allFiltered.length < pagination.itemsPerPage) {
return allFiltered;
}
if (allFiltered.length < end) {
return allFiltered.slice(start);
}
return allFiltered.slice(start, end);
};

this.resetPaginator = function resetPaginator() {
$scope.pagination = {
currentPage: 1,
itemsPerPage: 12,
maxSize: 12,
};
};
};

const ctrl = this;
const ctrl = this;

ProjectReader.listProjects().then(function(projects) {
$scope.projects = projects;
ctrl.filterProjects();
$scope.projectsLoaded = true;
});
ProjectReader.listProjects().then(function(projects) {
$scope.projects = projects;
ctrl.filterProjects();
$scope.projectsLoaded = true;
});

$scope.$watch('viewState', cacheViewState, true);
$scope.$watch('viewState', cacheViewState, true);

initializeViewState();
},
]);
initializeViewState();
},
]);
2 changes: 1 addition & 1 deletion app/scripts/modules/core/src/projects/projects.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ <h2 class="header-section">
/>
</h2>
<div class="header-actions">
<insight-menu data-purpose="projects-menu" actions="menuActions" right-align="true"> </insight-menu>
<project-insight-menu create-app="false" create-project="true" refresh-caches="false" />
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import _ from 'lodash';

import * as angular from 'angular';

import { react2angular } from 'react2angular';

import { FirewallLabels } from 'core/securityGroup/label';
import { SEARCH_RANK_FILTER } from '../searchRank.filter';
import { OVERRIDE_REGISTRY } from 'core/overrideRegistry/override.registry';
Expand All @@ -17,6 +19,7 @@ import { ClusterState } from 'core/state';

import { SearchService } from '../search.service';
import { ConfigureProjectModal } from 'core/projects';
import { InsightMenu as SearchInsightMenu } from 'core/insight/InsightMenu';

export const CORE_SEARCH_INFRASTRUCTURE_INFRASTRUCTURE_CONTROLLER = 'spinnaker.search.infrastructure.controller';
export const name = CORE_SEARCH_INFRASTRUCTURE_INFRASTRUCTURE_CONTROLLER; // for backwards compatibility
Expand All @@ -31,6 +34,7 @@ angular
RECENTLY_VIEWED_ITEMS_COMPONENT,
SPINNER_COMPONENT,
])
.component('searchInsightMenu', react2angular(SearchInsightMenu, ['createApp', 'createProject', 'refreshCaches']))
.controller('InfrastructureCtrl', [
'$scope',
'infrastructureSearchService',
Expand Down Expand Up @@ -141,14 +145,14 @@ angular
}

this.menuActions = [
{
displayName: 'Create Application',
action: this.createApplicationForTests,
},
{
displayName: 'Create Project',
action: this.createProject,
},
{
displayName: 'Create Application',
action: this.createApplicationForTests,
},
];

this.hasResults = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,7 @@ <h2 class="header-section">
</div>
</h2>
<div class="header-actions">
<div class="dropdown" uib-dropdown dropdown-append-to-body>
<button type="button" class="btn btn-default dropdown-toggle" uib-dropdown-toggle>
Actions <span class="caret"></span>
</button>
<ul uib-dropdown-menu class="uib-dropdown-menu">
<li ng-repeat="action in ctrl.menuActions">
<a
href
ng-if="action.disableAutoClose"
ng-click="action.action(status)"
ng-bind-html="action.displayName"
></a>
<a
href
ng-if="!action.disableAutoClose"
ng-click="action.action(); status.isOpen = false"
ng-bind-html="action.displayName"
></a>
</li>
</ul>
</div>
<search-insight-menu create-app="true" createProject="true" refresh-caches="false" />
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit 425d2e1

Please sign in to comment.