slowly getting the tree node model decoupled from being passed in to menu actions, updated more of the appState and clarified a few things, created a new umboptionsmenu directive to be used for the options drop down on editors

This commit is contained in:
Shannon
2013-11-14 16:09:53 +11:00
parent 8dab2148f1
commit 43f001280e
21 changed files with 337 additions and 183 deletions

View File

@@ -1,10 +1,10 @@
angular.module("umbraco.directives")
.directive('umbContextMenu', function ($injector, navigationService) {
.directive('umbContextMenu', function ($injector, umbModelMapper, treeService) {
return {
scope: {
menuDialogTitle: "@",
currentSection: "@",
currentEntity: "=",
currentNode: "=",
menuActions: "="
},
restrict: 'E',
@@ -14,7 +14,7 @@ angular.module("umbraco.directives")
//adds a handler to the context menu item click, we need to handle this differently
//depending on what the menu item is supposed to do.
scope.executeMenuItem = function (currentNode, action, currentSection) {
scope.executeMenuItem = function (action) {
if (action.metaData && action.metaData["jsAction"] && angular.isString(action.metaData["jsAction"])) {
@@ -41,9 +41,13 @@ angular.module("umbraco.directives")
}
method.apply(this, [{
treeNode: currentNode,
//map our content object to a basic entity to pass in to the menu handlers,
//this is required for consistency since a menu item needs to be decoupled from a tree node since the menu can
//exist standalone in the editor for which it can only pass in an entity (not tree node).
entity: umbModelMapper.convertToEntityBasic(scope.currentNode),
action: action,
section: currentSection
section: scope.currentSection,
treeAlias: treeService.getTreeAlias(scope.currentNode)
}]);
}
}
@@ -54,7 +58,7 @@ angular.module("umbraco.directives")
// the problem with all these dialogs is were passing other object's scopes around which isn't nice at all.
// Each of these passed scopes expects a .nav property assigned to it which is a reference to the navigationService,
// which should not be happenning... should simply be using the navigation service, no ?!
scope.$parent.openDialog(currentNode, action, currentSection);
scope.$parent.openDialog(scope.currentNode, action, scope.currentSection);
}
};

View File

@@ -0,0 +1,80 @@
angular.module("umbraco.directives")
.directive('umbOptionsMenu', function ($injector, treeService, navigationService, umbModelMapper) {
return {
scope: {
content: "=",
currentSection: "@",
treeAlias: "@"
},
restrict: 'E',
replace: true,
templateUrl: 'views/directives/umb-optionsmenu.html',
link: function (scope, element, attrs, ctrl) {
//adds a handler to the context menu item click, we need to handle this differently
//depending on what the menu item is supposed to do.
scope.executeMenuItem = function (action) {
//map our content object to a basic entity to pass in to the handlers
var currentEntity = umbModelMapper.convertToEntityBasic(scope.content);
if (action.metaData && action.metaData["jsAction"] && angular.isString(action.metaData["jsAction"])) {
//we'll try to get the jsAction from the injector
var menuAction = action.metaData["jsAction"].split('.');
if (menuAction.length !== 2) {
//if it is not two parts long then this most likely means that it's a legacy action
var js = action.metaData["jsAction"].replace("javascript:", "");
//there's not really a different way to acheive this except for eval
eval(js);
}
else {
var service = $injector.get(menuAction[0]);
if (!service) {
throw "The angular service " + menuAction[0] + " could not be found";
}
var method = service[menuAction[1]];
if (!method) {
throw "The method " + menuAction[1] + " on the angular service " + menuAction[0] + " could not be found";
}
method.apply(this, [{
entity: currentEntity,
action: action,
section: scope.currentSection,
treeAlias: scope.treeAlias
}]);
}
}
else {
//by default we launch the dialog
//TODO: This is temporary using $parent, now that this is an isolated scope
// the problem with all these dialogs is were passing other object's scopes around which isn't nice at all.
// Each of these passed scopes expects a .nav property assigned to it which is a reference to the navigationService,
// which should not be happenning... should simply be using the navigation service, no ?!
scope.$parent.openDialog(currentEntity, action, scope.currentSection);
}
};
//callback method to go and get the options async
scope.getOptions = function () {
if (!scope.content.id) {
return;
}
if (!scope.actions) {
treeService.getMenu({ treeNode: navigationService.ui.currentNode })
.then(function (data) {
scope.actions = data.menuItems;
});
}
};
}
};
});

View File

@@ -25,8 +25,11 @@ function appState($rootScope) {
};
var treeState = {
//The currently selected/edited entity
currentEntity: null
//The currently selected node
selectedNode: null,
//The currently loaded root node reference - depending on the section loaded this could be a section root or a normal root.
//We keep this reference so we can lookup nodes to interact with in the UI via the tree service
currentRootNode: null
};
var menuState = {
@@ -34,8 +37,8 @@ function appState($rootScope) {
menuActions: null,
//the title to display in the context menu dialog
dialogTitle: null,
//The basic entity that is having an action performed on it
currentEntity: null,
//The tree node that the ctx menu is launched for
currentNode: null,
//Whether the menu's dialog is being shown or not
showMenuDialog: null,
//Whether the context menu is being shown or not

View File

@@ -8,7 +8,7 @@
* @description
* Defines the methods that are called when menu items declare only an action to execute
*/
function umbracoMenuActions($q, treeService, $location, navigationService) {
function umbracoMenuActions($q, treeService, $location, navigationService, appState) {
return {
@@ -21,11 +21,32 @@ function umbracoMenuActions($q, treeService, $location, navigationService) {
* @description
* Clears all node children and then gets it's up-to-date children from the server and re-assigns them
* @param {object} args An arguments object
* @param {object} args.treeNode The tree node
* @param {object} args.entity The basic entity being acted upon
* @param {object} args.treeAlias The tree alias associated with this entity
* @param {object} args.section The current section
*/
"RefreshNode": function (args) {
treeService.loadNodeChildren({ node: args.treeNode, section: args.section });
//just in case clear any tree cache for this node/section
treeService.clearCache({
cacheKey: "__" + args.section, //each item in the tree cache is cached by the section name
childrenOf: args.entity.parentId //clear the children of the parent
});
//since we're dealing with an entity, we need to attempt to find it's tree node, in the main tree
// this action is purely a UI thing so if for whatever reason there is no loaded tree node in the UI
// we can safely ignore this process.
//to find a visible tree node, we'll go get the currently loaded root node from appState
var treeRoot = appState.getTreeState("currentRootNode");
if (treeRoot) {
var treeNode = treeService.getDescendantNode(treeRoot, args.entity.id, args.treeAlias);
if (treeNode) {
treeService.loadNodeChildren({ node: treeNode, section: args.section });
}
}
},
/**
@@ -37,14 +58,15 @@ function umbracoMenuActions($q, treeService, $location, navigationService) {
* @description
* This will re-route to a route for creating a new entity as a child of the current node
* @param {object} args An arguments object
* @param {object} args.treeNode The tree node
* @param {object} args.entity The basic entity being acted upon
* @param {object} args.treeAlias The tree alias associated with this entity
* @param {object} args.section The current section
*/
"CreateChildEntity": function (args) {
navigationService.hideNavigation();
var route = "/" + args.section + "/" + treeService.getTreeAlias(args.treeNode) + "/edit/" + args.treeNode.id;
var route = "/" + args.section + "/" + args.treeAlias + "/edit/" + args.entity.id;
//change to new path
$location.path(route).search({ create: true });

View File

@@ -198,6 +198,11 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo
setupTreeEvents: function(treeEventHandler, scope) {
mainTreeEventHandler = treeEventHandler;
//when a tree is loaded into a section, we need to put it into appState
mainTreeEventHandler.bind("treeLoaded", function(ev, args) {
appState.setTreeState("currentRootNode", args.tree);
});
//when a tree node is synced this event will fire, this allows us to set the currentNode
mainTreeEventHandler.bind("treeSynced", function (ev, args) {
@@ -218,8 +223,7 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo
ev.preventDefault();
//Set the current action node (this is not the same as the current selected node!)
//TODO: Convert this to basic entity , not tree node
appState.setMenuState("currentEntity", args.node);
appState.setMenuState("currentNode", args.node);
args.scope = scope;
@@ -248,6 +252,8 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo
ev.stopPropagation();
ev.preventDefault();
//put this into the app state
appState.setTreeState("selectedNode", args.node);
if (n.metaData && n.metaData["jsClickCallback"] && angular.isString(n.metaData["jsClickCallback"]) && n.metaData["jsClickCallback"] !== "") {
//this is a legacy tree node!
@@ -439,8 +445,7 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo
if (found) {
//NOTE: This is assigning the current action node - this is not the same as the currently selected node!
//TODO: Change this to an entity instead of a node!
appState.setMenuState("currentEntity", args.node);
appState.setMenuState("currentNode", args.node);
//ensure the current dialog is cleared before creating another!
if (currentDialog) {
@@ -464,8 +469,7 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo
setMode("menu");
//TODO: Change this to an entity instead of a node!
appState.setMenuState("currentEntity", args.node);
appState.setMenuState("currentNode", args.node);
appState.setMenuState("menuActions", data.menuItems);
appState.setMenuState("dialogTitle", args.node.name);
@@ -486,7 +490,7 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo
*/
hideMenu: function() {
//SD: Would we ever want to access the last action'd node instead of clearing it here?
appState.setMenuState("currentEntity", null);
appState.setMenuState("currentNode", null);
appState.setMenuState("menuActions", []);
setMode("tree");
},
@@ -666,8 +670,8 @@ function navigationService($rootScope, $routeParams, $log, $location, $q, $timeo
* @description
* hides the currently open dialog
*/
hideDialog: function() {
this.showMenu(undefined, { skipDefault: true, node: appState.getMenuState("currentEntity") });
hideDialog: function () {
this.showMenu(undefined, { skipDefault: true, node: appState.getMenuState("currentNode") });
},
/**
* @ngdoc method

View File

@@ -147,10 +147,12 @@ function treeService($q, treeResource, iconHelper, notificationsService, $rootSc
cacheKey: args.cacheKey,
filter: function(cc) {
//get the new parent node from the tree cache
var parent = self.getDescendantNode(cc.root, args.childrenOf);
//clear it's children and set to not expanded
parent.children = null;
parent.expanded = false;
var parent = self.getDescendantNode(cc.root, args.childrenOf);
if (parent) {
//clear it's children and set to not expanded
parent.children = null;
parent.expanded = false;
}
//return the cache to be saved
return cc;
}
@@ -277,7 +279,33 @@ function treeService($q, treeResource, iconHelper, notificationsService, $rootSc
},
/** Gets a descendant node by id */
getDescendantNode: function(treeNode, id) {
getDescendantNode: function(treeNode, id, treeAlias) {
//validate if it is a section container since we'll need a treeAlias if it is one
if (treeNode.isContainer === true && !treeAlias) {
throw "Cannot get a descendant node from a section container node without a treeAlias specified";
}
//if it is a section container, we need to find the tree to be searched
if (treeNode.isContainer) {
var foundRoot = null;
for (var c = 0; c < treeNode.children.length; c++) {
if (this.getTreeAlias(treeNode.children[c]) === treeAlias) {
foundRoot = treeNode.children[c];
break;
}
}
if (!foundRoot) {
throw "Could not find a tree in the current section with alias " + treeAlias;
}
treeNode = foundRoot;
}
//check this node
if (treeNode.id === id) {
return treeNode;
}
//check the first level
var found = this.getChildNode(treeNode, id);
if (found) {

View File

@@ -1,5 +1,35 @@
/*Contains multiple services for various helper tasks */
/**
* @ngdoc function
* @name umbraco.services.umbModelMapper
* @function
*
* @description
* Utility class to map/convert models
*/
function umbModelMapper() {
return {
/** This converts the source model to a basic entity model, it will throw an exception if there isn't enough data to create the model */
convertToEntityBasic: function (source) {
var required = ["id", "name", "icon", "parentId", "path"];
_.each(required, function (k) {
if (!_.has(source, k)) {
throw "The source object does not contain the property " + k;
}
});
var optional = ["metaData", "key", "alias"];
//now get the basic object
var result = _.pick(source, required.concat(optional));
return result;
}
};
}
angular.module('umbraco.services').factory('umbModelMapper', umbModelMapper);
/**
* @ngdoc function
* @name umbraco.services.umbSessionStorage

View File

@@ -27,7 +27,7 @@ function NavigationController($scope, $rootScope, $location, $log, $routeParams,
$scope.showSearchResults = false;
$scope.menuDialogTitle = null;
$scope.menuActions = [];
$scope.menuEntity = null;
$scope.menuNode = null;
$scope.currentSection = appState.getSectionState("currentSection");
$scope.showNavigation = appState.getGlobalState("showNavigation");
@@ -66,8 +66,8 @@ function NavigationController($scope, $rootScope, $location, $log, $routeParams,
if (args.key === "menuActions") {
$scope.menuActions = args.value;
}
if (args.key === "currentEntity") {
$scope.menuEntity = args.value;
if (args.key === "currentNode") {
$scope.menuNode = args.value;
}
});

View File

@@ -221,19 +221,6 @@ function ContentEditController($scope, $routeParams, $q, $timeout, $window, appS
}
};
$scope.options = function(content){
if(!content.id){
return;
}
if(!$scope.actions){
treeService.getMenu({ treeNode: $scope.nav.ui.currentNode })
.then(function(data) {
$scope.actions = data.menuItems;
});
}
};
/** this method is called for all action buttons and then we proxy based on the btn definition */
$scope.performAction = function(btn) {
if (!btn || !angular.isFunction(btn.handler)) {

View File

@@ -21,27 +21,9 @@
<div class="btn-group" ng-animate="'fade'" ng-show="formStatus">
<p class="btn btn-link umb-status-label">{{formStatus}}</p>
</div>
<div class="btn-group" ng-class="{dimmed: content.id === 0}">
<!-- options button -->
<a class="btn" href="#" ng-click="options(content)" prevent-default data-toggle="dropdown">
<localize key="general_actions">Actions</localize> <span class="caret"></span>
</a>
<!-- actions -->
<ul class="dropdown-menu umb-actions" role="menu" aria-labelledby="dLabel">
<li class="action" ng-class="{sep:action.seperator}" ng-repeat="action in actions">
<!-- How does this reference executeMenuItem() i really don't think that this is very clear -->
<a prevent-default
ng-click="executeMenuItem(nav.ui.currentNode,action,currentSection)">
<i class="icon icon-{{action.cssclass}}"></i>
<span class="menu-label">{{action.name}}</span>
</a>
</li>
</ul>
</div>
<umb-options-menu content="content" current-section="{{currentSection}}"></umb-options-menu>
</div>
</div>
</umb-header>

View File

@@ -87,19 +87,6 @@ function DataTypeEditController($scope, $routeParams, $location, appState, navig
}
});
$scope.options = function(content){
if(!content.id){
return;
}
if(!$scope.actions){
treeService.getMenu({ treeNode: $scope.nav.ui.currentNode })
.then(function(data) {
$scope.actions = data.menuItems;
});
}
};
$scope.save = function() {
if (formHelper.submitForm({ scope: $scope, statusMessage: "Saving..." })) {

View File

@@ -17,30 +17,9 @@
<div class="span8">
<div class="btn-toolbar pull-right umb-btn-toolbar">
<div class="btn-group" ng-class="{dimmed: content.id === 0}">
<!-- options button -->
<a class="btn" href="#" ng-click="options(content)" prevent-default data-toggle="dropdown">
<i class="icon-settings" style="line-height: 14px"></i>
<localize key="general_actions">Actions</localize>
</a>
<a class="btn dropdown-toggle" ng-click="options(content)" data-toggle="dropdown">
<span class="caret"></span>
</a>
<!-- actions -->
<ul class="dropdown-menu umb-actions" role="menu" aria-labelledby="dLabel">
<li class="action" ng-class="{sep:action.seperator}" ng-repeat="action in actions">
<!-- How does this reference executeMenuItem() i really don't think that this is very clear -->
<a prevent-default
ng-click="executeMenuItem(nav.ui.currentNode,action,currentSection)">
<i class="icon icon-{{action.cssclass}}"></i>
<span class="menu-label">{{action.name}}</span>
</a>
</li>
</ul>
</div>
<umb-options-menu content="content" current-section="{{currentSection}}"></umb-options-menu>
</div>
</div>
</umb-header>

View File

@@ -7,7 +7,7 @@
<ul class="umb-actions">
<li class="action" ng-class="{sep:action.seperator}" ng-repeat="action in menuActions">
<a prevent-default
ng-click="executeMenuItem(currentEntity,action,currentSection)">
ng-click="executeMenuItem(action)">
<i class="icon icon-{{action.cssclass}}"></i>
<span class="menu-label">{{action.name}}</span>
</a>

View File

@@ -78,7 +78,7 @@
<umb-context-menu
menu-dialog-title="{{menuDialogTitle}}"
current-section="{{currentSection}}"
current-entity="menuEntity"
current-node="menuNode"
menu-actions="menuActions">
</umb-context-menu>
</div>

View File

@@ -0,0 +1,21 @@
<div class="btn-group" ng-class="{dimmed: content.id === 0}">
<!-- options button -->
<a class="btn" href="#" ng-click="getOptions()" prevent-default data-toggle="dropdown">
<localize key="general_actions">Actions</localize>
<span class="caret"></span>
</a>
<!-- actions -->
<ul class="dropdown-menu umb-actions" role="menu" aria-labelledby="dLabel">
<li class="action" ng-class="{sep:action.seperator}" ng-repeat="action in actions">
<!-- How does this reference executeMenuItem() i really don't think that this is very clear -->
<a prevent-default
ng-click="executeMenuItem(action)">
<i class="icon icon-{{action.cssclass}}"></i>
<span class="menu-label">{{action.name}}</span>
</a>
</li>
</ul>
</div>

View File

@@ -20,30 +20,8 @@
<p class="btn btn-link umb-status-label">{{formStatus}}</p>
</div>
<div class="btn-group" ng-class="{dimmed: content.id === 0}">
<!-- options button -->
<a class="btn" href="#" ng-click="options(content)" prevent-default data-toggle="dropdown">
<i class="icon-settings" style="line-height: 14px"></i>
<localize key="general_actions">Actions</localize>
</a>
<a class="btn dropdown-toggle" ng-click="options(content)" data-toggle="dropdown">
<span class="caret"></span>
</a>
<!-- actions -->
<ul class="dropdown-menu umb-actions" role="menu" aria-labelledby="dLabel">
<li class="action" ng-class="{sep:action.seperator}" ng-repeat="action in actions">
<!-- How does this reference executeMenuItem() i really don't think that this is very clear -->
<a prevent-default
ng-click="executeMenuItem(nav.ui.currentNode,action,currentSection)">
<i class="icon icon-{{action.cssclass}}"></i>
<span class="menu-label">{{action.name}}</span>
</a>
</li>
</ul>
</div>
<umb-options-menu content="content" current-section="{{currentSection}}"></umb-options-menu>
</div>
</div>
</umb-header>

View File

@@ -38,19 +38,6 @@ function mediaEditController($scope, $routeParams, appState, mediaResource, navi
});
}
$scope.options = function(content){
if(!content.id){
return;
}
if(!$scope.actions){
treeService.getMenu({ treeNode: $scope.nav.ui.currentNode })
.then(function(data) {
$scope.actions = data.menuItems;
});
}
};
$scope.save = function () {
if (formHelper.submitForm({ scope: $scope, statusMessage: "Saving..." })) {

View File

@@ -20,30 +20,8 @@
<p class="btn btn-link umb-status-label">{{formStatus}}</p>
</div>
<div class="btn-group" ng-class="{dimmed: content.id === 0}">
<!-- options button -->
<a class="btn" href="#" ng-click="options(content)" prevent-default data-toggle="dropdown">
<i class="icon-settings" style="line-height: 14px"></i>
<localize key="general_actions">Actions</localize>
</a>
<a class="btn dropdown-toggle" ng-click="options(content)" data-toggle="dropdown">
<span class="caret"></span>
</a>
<!-- actions -->
<ul class="dropdown-menu umb-actions" role="menu" aria-labelledby="dLabel">
<li class="action" ng-class="{sep:action.seperator}" ng-repeat="action in actions">
<!-- How does this reference executeMenuItem() i really don't think that this is very clear -->
<a prevent-default
ng-click="executeMenuItem(nav.ui.currentNode,action,currentSection)">
<i class="icon icon-{{action.cssclass}}"></i>
<span class="menu-label">{{action.name}}</span>
</a>
</li>
</ul>
</div>
<umb-options-menu content="content" current-section="{{currentSection}}"></umb-options-menu>
</div>
</div>
</umb-header>

View File

@@ -54,22 +54,7 @@ function MemberEditController($scope, $routeParams, $location, $q, $window, appS
}
}
$scope.options = function(content){
if(!content.id){
return;
}
if(!$scope.actions){
treeService.getMenu({ treeNode: $scope.nav.ui.currentNode })
.then(function(data) {
$scope.actions = data.menuItems;
});
}
};
$scope.save = function() {
if (formHelper.submitForm({ scope: $scope, statusMessage: "Saving..." })) {

View File

@@ -44,8 +44,8 @@ describe('appState tests', function () {
describe('Tree state', function () {
it('Can get/set state', function () {
appState.setTreeState("currentEntity", true);
expect(appState.getTreeState("currentEntity")).toBe(true);
appState.setTreeState("selectedNode", true);
expect(appState.getTreeState("selectedNode")).toBe(true);
});
it('Throws when invalid key', function () {
function setInvalidKey() {

View File

@@ -0,0 +1,99 @@
describe('model mapper tests', function () {
var umbModelMapper;
beforeEach(module('umbraco.services'));
beforeEach(inject(function ($injector) {
umbModelMapper = $injector.get('umbModelMapper');
}));
describe('maps basic entity', function () {
it('can map content object', function () {
var content = {
id: "1",
name: "test",
icon: "icon",
key: "some key",
parentId: "-1",
alias: "test alias",
path: "-1,1",
metaData: {hello:"world"},
publishDate: null,
releaseDate: null,
removeDate: false,
template: "test",
urls: [],
allowedActions: [],
contentTypeName: null,
notifications: [],
ModelState: {},
tabs: [],
properties: [],
};
var mapped = umbModelMapper.convertToEntityBasic(content);
var keys = _.keys(mapped);
expect(keys.length).toBe(8);
expect(mapped.id).toBe("1");
expect(mapped.name).toBe("test");
expect(mapped.icon).toBe("icon");
expect(mapped.key).toBe("some key");
expect(mapped.parentId).toBe("-1");
expect(mapped.alias).toBe("test alias");
expect(mapped.metaData.hello).toBe("world");
});
it('throws when info is missing', function () {
var content = {
id: "1",
//name: "test", //removed
icon: "icon",
key: "some key",
parentId: "-1",
alias: "test alias",
path: "-1,1",
metaData: { hello: "world" },
publishDate: null,
releaseDate: null,
removeDate: false,
template: "test",
urls: [],
allowedActions: [],
contentTypeName: null,
notifications: [],
ModelState: {},
tabs: [],
properties: [],
};
function doMap() {
umbModelMapper.convertToEntityBasic(content);
}
expect(doMap).toThrow();
});
it('can map the minimum props', function () {
var content = {
id: "1",
name: "test",
icon: "icon",
parentId: "-1",
path: "-1,1"
};
var mapped = umbModelMapper.convertToEntityBasic(content);
var keys = _.keys(mapped);
expect(keys.length).toBe(5);
expect(mapped.id).toBe("1");
expect(mapped.name).toBe("test");
expect(mapped.icon).toBe("icon");
expect(mapped.parentId).toBe("-1");
});
});
});