Merge remote-tracking branch 'origin/temp-u4-8604-1' into dev-v7.7

This commit is contained in:
Shannon
2017-06-05 09:50:17 +02:00
28 changed files with 2008 additions and 80 deletions

View File

@@ -0,0 +1,97 @@
angular.module("umbraco.directives").directive('nestedContentEditor', [
function () {
var link = function ($scope) {
// Clone the model because some property editors
// do weird things like updating and config values
// so we want to ensure we start from a fresh every
// time, we'll just sync the value back when we need to
$scope.model = angular.copy($scope.ngModel);
$scope.nodeContext = $scope.model;
// Find the selected tab
var selectedTab = $scope.model.tabs[0];
if ($scope.tabAlias) {
angular.forEach($scope.model.tabs, function (tab) {
if (tab.alias.toLowerCase() === $scope.tabAlias.toLowerCase()) {
selectedTab = tab;
return;
}
});
}
$scope.tab = selectedTab;
// Listen for sync request
var unsubscribe = $scope.$on("ncSyncVal", function (ev, args) {
if (args.key === $scope.model.key) {
// Tell inner controls we are submitting
$scope.$broadcast("formSubmitting", { scope: $scope });
// Sync the values back
angular.forEach($scope.ngModel.tabs, function (tab) {
if (tab.alias.toLowerCase() === selectedTab.alias.toLowerCase()) {
var localPropsMap = selectedTab.properties.reduce(function (map, obj) {
map[obj.alias] = obj;
return map;
}, {});
angular.forEach(tab.properties, function (prop) {
if (localPropsMap.hasOwnProperty(prop.alias)) {
prop.value = localPropsMap[prop.alias].value;
}
});
}
});
}
});
$scope.$on('$destroy', function () {
unsubscribe();
});
};
return {
restrict: "E",
replace: true,
templateUrl: Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath + "/views/propertyeditors/nestedcontent/nestedcontent.editor.html",
scope: {
ngModel: '=',
tabAlias: '='
},
link: link
};
}
]);
//angular.module("umbraco.directives").directive('nestedContentSubmitWatcher', function () {
// var link = function (scope) {
// // call the load callback on scope to obtain the ID of this submit watcher
// var id = scope.loadCallback();
// scope.$on("formSubmitting", function (ev, args) {
// // on the "formSubmitting" event, call the submit callback on scope to notify the nestedContent controller to do it's magic
// if (id === scope.activeSubmitWatcher) {
// scope.submitCallback();
// }
// });
// }
// return {
// restrict: "E",
// replace: true,
// template: "",
// scope: {
// loadCallback: '=',
// submitCallback: '=',
// activeSubmitWatcher: '='
// },
// link: link
// }
//});

View File

@@ -0,0 +1,47 @@
// Filter to take a node id and grab it's name instead
// Usage: {{ pickerAlias | ncNodeName }}
// Cache for node names so we don't make a ton of requests
var ncNodeNameCache = {
id: "",
keys: {}
};
angular.module("umbraco.filters").filter("ncNodeName", function (editorState, entityResource) {
return function (input) {
// Check we have a value at all
if (input === "" || input.toString() === "0") {
return "";
}
var currentNode = editorState.getCurrent();
// Ensure a unique cache per editor instance
var key = "ncNodeName_" + currentNode.key;
if (ncNodeNameCache.id !== key) {
ncNodeNameCache.id = key;
ncNodeNameCache.keys = {};
}
// See if there is a value in the cache and use that
if (ncNodeNameCache.keys[input]) {
return ncNodeNameCache.keys[input];
}
// No value, so go fetch one
// We'll put a temp value in the cache though so we don't
// make a load of requests while we wait for a response
ncNodeNameCache.keys[input] = "Loading...";
entityResource.getById(input, "Document")
.then(function (ent) {
ncNodeNameCache.keys[input] = ent.name;
});
// Return the current value for now
return ncNodeNameCache.keys[input];
};
});

View File

@@ -0,0 +1,12 @@
angular.module('umbraco.resources').factory('Umbraco.PropertyEditors.NestedContent.Resources',
function ($q, $http, umbRequestHelper) {
return {
getContentTypes: function () {
var url = Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath + "/backoffice/UmbracoApi/NestedContent/GetContentTypes";
return umbRequestHelper.resourcePromise(
$http.get(url),
'Failed to retrieve content types'
);
},
};
});

View File

@@ -120,6 +120,7 @@
@import "components/umb-querybuilder.less";
@import "components/umb-pagination.less";
@import "components/umb-mini-list-view.less";
@import "components/umb-nested-content.less";
@import "components/buttons/umb-button.less";
@import "components/buttons/umb-button-group.less";

View File

@@ -0,0 +1,192 @@
.nested-content
{
text-align: center;
}
.nested-content__item
{
position: relative;
text-align: left;
border-top: solid 1px transparent;
background: white;
}
.nested-content__item--active:not(.nested-content__item--single)
{
background: #f8f8f8;
}
.nested-content__item.ui-sortable-placeholder
{
background: #f8f8f8;
border: 1px dashed #d9d9d9;
visibility: visible !important;
height: 55px;
margin-top: -1px;
}
.nested-content__item--single > .nested-content__content
{
border: 0;
}
.nested-content__item--single > .nested-content__content > .umb-pane
{
margin: 0;
}
.nested-content__header-bar
{
padding: 15px 20px;
border-bottom: 1px dashed #e0e0e0;
text-align: right;
cursor: pointer;
background-color: white;
-moz-user-select: none;
-khtml-user-select: none;
-webkit-user-select: none;
-o-user-select: none;
}
.nested-content__heading
{
float: left;
line-height: 20px;
}
.nested-content__heading i
{
vertical-align: text-top;
color: #999; /* same icon color as the icons in the item type picker */
margin-right: 10px;
}
.nested-content__icons
{
margin: -6px 0;
opacity: 0;
transition: opacity .15s ease-in-out;
-moz-transition: opacity .15s ease-in-out;
-webkit-transition: opacity .15s ease-in-out;
}
.nested-content__header-bar:hover .nested-content__icons,
.nested-content__item--active > .nested-content__header-bar .nested-content__icons
{
opacity: 1;
}
.nested-content__icon,
.nested-content__icon.nested-content__icon--disabled:hover
{
display: inline-block;
padding: 4px 6px;
margin: 2px;
cursor: pointer;
background: #fff;
border: 1px solid #b6b6b6;
border-radius: 200px;
text-decoration: none !important;
}
.nested-content__icon:hover,
.nested-content__icon--active
{
color: white;
background: #2e8aea;
border-color: #2e8aea;
text-decoration: none;
}
.nested-content__icon .icon,
.nested-content__icon.nested-content__icon--disabled:hover .icon
{
display: block;
font-size: 16px !important;
color: #5f5f5f;
}
.nested-content__icon:hover .icon,
.nested-content__icon--active .icon
{
color: white;
}
.nested-content__icon--disabled
{
opacity: 0.3;
}
.nested-content__footer-bar
{
text-align: center;
padding-top: 20px;
}
.nested-content__content
{
border-bottom: 1px dashed #e0e0e0;
}
.nested-content__content .umb-control-group {
padding-bottom: 0;
}
.nested-content__item.ui-sortable-helper .nested-content__content
{
display: none !important;
}
.nested-content__help-text
{
display: inline-block;
padding: 10px 20px 10px 20px;
clear: both;
font-size: 14px;
color: #555;
background: #f8f8f8;
border-radius: 15px;
}
.nested-content__doctypepicker table input, .nested-content__doctypepicker table select {
width: 100%;
padding-right: 0;
}
.nested-content__doctypepicker table td.icon-navigation, .nested-content__doctypepicker i.nested-content__help-icon {
vertical-align: middle;
color: #CCC;
}
.nested-content__doctypepicker table td.icon-navigation:hover, .nested-content__doctypepicker i.nested-content__help-icon:hover {
color: #343434;
}
.nested-content__doctypepicker i.nested-content__help-icon {
margin-left: 10px;
}
.form-horizontal .nested-content--narrow .controls-row
{
margin-left: 40% !important;
}
.form-horizontal .nested-content--narrow .controls-row .umb-textstring,
.form-horizontal .nested-content--narrow .controls-row .umb-textarea
{
width: 95%;
}
.form-horizontal .nested-content--narrow .controls-row .umb-dropdown {
width: 99%;
}
.usky-grid.nested-content__node-type-picker .cell-tools-menu {
position: relative;
transform: translate(-50%, -25%);
}

View File

@@ -0,0 +1,417 @@
angular.module("umbraco").controller("Umbraco.PropertyEditors.NestedContent.DocTypePickerController", [
"$scope",
"Umbraco.PropertyEditors.NestedContent.Resources",
function ($scope, ncResources) {
$scope.add = function () {
$scope.model.value.push({
// As per PR #4, all stored content type aliases must be prefixed "nc" for easier recognition.
// For good measure we'll also prefix the tab alias "nc"
ncAlias: "",
ncTabAlias: "",
nameTemplate: ""
}
);
}
$scope.remove = function (index) {
$scope.model.value.splice(index, 1);
}
$scope.sortableOptions = {
axis: 'y',
cursor: "move",
handle: ".icon-navigation"
};
$scope.selectedDocTypeTabs = {};
ncResources.getContentTypes().then(function (docTypes) {
$scope.model.docTypes = docTypes;
// Populate document type tab dictionary
docTypes.forEach(function (value) {
$scope.selectedDocTypeTabs[value.alias] = value.tabs;
});
});
if (!$scope.model.value) {
$scope.model.value = [];
$scope.add();
}
}
]);
angular.module("umbraco").controller("Umbraco.PropertyEditors.NestedContent.PropertyEditorController", [
"$scope",
"$interpolate",
"$filter",
"$timeout",
"contentResource",
"localizationService",
"iconHelper",
"Umbraco.PropertyEditors.NestedContent.Resources",
function ($scope, $interpolate, $filter, $timeout, contentResource, localizationService, iconHelper, ncResources) {
//$scope.model.config.contentTypes;
//$scope.model.config.minItems;
//$scope.model.config.maxItems;
//console.log($scope);
var inited = false;
_.each($scope.model.config.contentTypes, function (contentType) {
contentType.nameExp = !!contentType.nameTemplate
? $interpolate(contentType.nameTemplate)
: undefined;
});
$scope.editIconTitle = '';
$scope.moveIconTitle = '';
$scope.deleteIconTitle = '';
// localize the edit icon title
localizationService.localize('general_edit').then(function (value) {
$scope.editIconTitle = value;
});
// localize the delete icon title
localizationService.localize('general_delete').then(function (value) {
$scope.deleteIconTitle = value;
});
// localize the move icon title
localizationService.localize('actions_move').then(function (value) {
$scope.moveIconTitle = value;
});
$scope.nodes = [];
$scope.currentNode = undefined;
$scope.realCurrentNode = undefined;
$scope.scaffolds = undefined;
$scope.sorting = false;
$scope.minItems = $scope.model.config.minItems || 0;
$scope.maxItems = $scope.model.config.maxItems || 0;
if ($scope.maxItems == 0)
$scope.maxItems = 1000;
$scope.singleMode = $scope.minItems == 1 && $scope.maxItems == 1;
$scope.showIcons = $scope.model.config.showIcons || true;
$scope.wideMode = $scope.model.config.hideLabel == "1";
$scope.overlayMenu = {
show: false,
style: {}
};
// helper to force the current form into the dirty state
$scope.setDirty = function () {
if ($scope.propertyForm) {
$scope.propertyForm.$setDirty();
}
};
$scope.addNode = function (alias) {
var scaffold = $scope.getScaffold(alias);
var newNode = initNode(scaffold, null);
$scope.currentNode = newNode;
$scope.setDirty();
$scope.closeNodeTypePicker();
};
$scope.openNodeTypePicker = function (event) {
if ($scope.nodes.length >= $scope.maxItems) {
return;
}
// this could be used for future limiting on node types
$scope.overlayMenu.scaffolds = [];
_.each($scope.scaffolds, function (scaffold) {
$scope.overlayMenu.scaffolds.push({
alias: scaffold.contentTypeAlias,
name: scaffold.contentTypeName,
icon: iconHelper.convertFromLegacyIcon(scaffold.icon)
});
});
if ($scope.overlayMenu.scaffolds.length == 0) {
return;
}
if ($scope.overlayMenu.scaffolds.length == 1) {
// only one scaffold type - no need to display the picker
$scope.addNode($scope.scaffolds[0].contentTypeAlias);
return;
}
$scope.overlayMenu.show = true;
};
$scope.closeNodeTypePicker = function () {
$scope.overlayMenu.show = false;
};
$scope.editNode = function (idx) {
if ($scope.currentNode && $scope.currentNode.key == $scope.nodes[idx].key) {
$scope.currentNode = undefined;
} else {
$scope.currentNode = $scope.nodes[idx];
}
};
$scope.deleteNode = function (idx) {
if ($scope.nodes.length > $scope.model.config.minItems) {
if ($scope.model.config.confirmDeletes && $scope.model.config.confirmDeletes == 1) {
if (confirm("Are you sure you want to delete this item?")) {
$scope.nodes.splice(idx, 1);
$scope.setDirty();
updateModel();
}
} else {
$scope.nodes.splice(idx, 1);
$scope.setDirty();
updateModel();
}
}
};
$scope.getName = function (idx) {
var name = "Item " + (idx + 1);
if ($scope.model.value[idx]) {
var contentType = $scope.getContentTypeConfig($scope.model.value[idx].ncContentTypeAlias);
if (contentType != null && contentType.nameExp) {
// Run the expression against the stored dictionary value, NOT the node object
var item = $scope.model.value[idx];
// Add a temporary index property
item['$index'] = (idx + 1);
var newName = contentType.nameExp(item);
if (newName && (newName = $.trim(newName))) {
name = newName;
}
// Delete the index property as we don't want to persist it
delete item['$index'];
}
}
// Update the nodes actual name value
if ($scope.nodes[idx].name !== name) {
$scope.nodes[idx].name = name;
}
return name;
};
$scope.getIcon = function (idx) {
var scaffold = $scope.getScaffold($scope.model.value[idx].ncContentTypeAlias);
return scaffold && scaffold.icon ? iconHelper.convertFromLegacyIcon(scaffold.icon) : "icon-folder";
}
$scope.sortableOptions = {
axis: 'y',
cursor: "move",
handle: ".nested-content__icon--move",
start: function (ev, ui) {
// Yea, yea, we shouldn't modify the dom, sue me
$("#nested-content--" + $scope.model.id + " .umb-rte textarea").each(function () {
tinymce.execCommand('mceRemoveEditor', false, $(this).attr('id'));
$(this).css("visibility", "hidden");
});
$scope.$apply(function () {
$scope.sorting = true;
});
},
update: function (ev, ui) {
$scope.setDirty();
},
stop: function (ev, ui) {
$("#nested-content--" + $scope.model.id + " .umb-rte textarea").each(function () {
tinymce.execCommand('mceAddEditor', true, $(this).attr('id'));
$(this).css("visibility", "visible");
});
$scope.$apply(function () {
$scope.sorting = false;
updateModel();
});
}
};
$scope.getScaffold = function (alias) {
return _.find($scope.scaffolds, function (scaffold) {
return scaffold.contentTypeAlias == alias;
});
}
$scope.getContentTypeConfig = function (alias) {
return _.find($scope.model.config.contentTypes, function (contentType) {
return contentType.ncAlias == alias;
});
}
// Initialize
var scaffoldsLoaded = 0;
$scope.scaffolds = [];
_.each($scope.model.config.contentTypes, function (contentType) {
contentResource.getScaffold(-20, contentType.ncAlias).then(function (scaffold) {
// remove all tabs except the specified tab
var tab = _.find(scaffold.tabs, function (tab) {
return tab.id != 0 && (tab.alias.toLowerCase() == contentType.ncTabAlias.toLowerCase() || contentType.ncTabAlias == "");
});
scaffold.tabs = [];
if (tab) {
scaffold.tabs.push(tab);
}
// Store the scaffold object
$scope.scaffolds.push(scaffold);
scaffoldsLoaded++;
initIfAllScaffoldsHaveLoaded();
}, function (error) {
scaffoldsLoaded++;
initIfAllScaffoldsHaveLoaded();
});
});
var initIfAllScaffoldsHaveLoaded = function () {
// Initialize when all scaffolds have loaded
if ($scope.model.config.contentTypes.length == scaffoldsLoaded) {
// Because we're loading the scaffolds async one at a time, we need to
// sort them explicitly according to the sort order defined by the data type.
var contentTypeAliases = [];
_.each($scope.model.config.contentTypes, function (contentType) {
contentTypeAliases.push(contentType.ncAlias);
});
$scope.scaffolds = $filter('orderBy')($scope.scaffolds, function (s) {
return contentTypeAliases.indexOf(s.contentTypeAlias);
});
// Convert stored nodes
if ($scope.model.value) {
for (var i = 0; i < $scope.model.value.length; i++) {
var item = $scope.model.value[i];
var scaffold = $scope.getScaffold(item.ncContentTypeAlias);
if (scaffold == null) {
// No such scaffold - the content type might have been deleted. We need to skip it.
continue;
}
initNode(scaffold, item);
}
}
// Enforce min items
if ($scope.nodes.length < $scope.model.config.minItems) {
for (var i = $scope.nodes.length; i < $scope.model.config.minItems; i++) {
$scope.addNode($scope.scaffolds[0].contentTypeAlias);
}
}
// If there is only one item, set it as current node
if ($scope.singleMode || ($scope.nodes.length == 1 && $scope.maxItems == 1)) {
$scope.currentNode = $scope.nodes[0];
}
inited = true;
}
}
var initNode = function (scaffold, item) {
var node = angular.copy(scaffold);
node.key = guid();
node.ncContentTypeAlias = scaffold.contentTypeAlias;
for (var t = 0; t < node.tabs.length; t++) {
var tab = node.tabs[t];
for (var p = 0; p < tab.properties.length; p++) {
var prop = tab.properties[p];
prop.propertyAlias = prop.alias;
prop.alias = $scope.model.alias + "___" + prop.alias;
// Force validation to occur server side as this is the
// only way we can have consistancy between mandatory and
// regex validation messages. Not ideal, but it works.
prop.validation = {
mandatory: false,
pattern: ""
};
if (item) {
if (item[prop.propertyAlias]) {
prop.value = item[prop.propertyAlias];
}
}
}
}
$scope.nodes.push(node);
return node;
}
var updateModel = function () {
if ($scope.realCurrentNode) {
$scope.$broadcast("ncSyncVal", { key: $scope.realCurrentNode.key });
}
if (inited) {
var newValues = [];
for (var i = 0; i < $scope.nodes.length; i++) {
var node = $scope.nodes[i];
var newValue = {
key: node.key,
name: node.name,
ncContentTypeAlias: node.ncContentTypeAlias
};
for (var t = 0; t < node.tabs.length; t++) {
var tab = node.tabs[t];
for (var p = 0; p < tab.properties.length; p++) {
var prop = tab.properties[p];
if (typeof prop.value !== "function") {
newValue[prop.propertyAlias] = prop.value;
}
}
}
newValues.push(newValue);
}
$scope.model.value = newValues;
}
}
$scope.$watch("currentNode", function (newVal) {
updateModel();
$scope.realCurrentNode = newVal;
});
var unsubscribe = $scope.$on("formSubmitting", function (ev, args) {
updateModel();
});
$scope.$on('$destroy', function () {
unsubscribe();
});
var guid = function () {
function _p8(s) {
var p = (Math.random().toString(16) + "000000000").substr(2, 8);
return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p;
}
return _p8() + _p8(true) + _p8(true) + _p8();
};
}
]);

View File

@@ -0,0 +1,58 @@
<div id="{{model.alias}}" class="nested-content__doctypepicker" ng-controller="Umbraco.PropertyEditors.NestedContent.DocTypePickerController">
<div>
<table class="table table-striped">
<thead>
<tr>
<th/>
<th>
Document Type
</th>
<th>
Tab
</th>
<th>
Name Template
</th>
<th />
</tr>
</thead>
<tbody ui-sortable="sortableOptions" ng-model="model.value">
<tr ng-repeat="config in model.value">
<td class="icon icon-navigation">
</td>
<td>
<select id="{{model.alias}}_doctype_select"
ng-options="dt.alias as dt.name for dt in model.docTypes | orderBy: 'name'"
ng-model="config.ncAlias" required></select>
</td>
<td>
<select id="{{model.alias}}_tab_select"
ng-options="t for t in selectedDocTypeTabs[config.ncAlias]"
ng-model="config.ncTabAlias" required></select>
</td>
<td>
<input type="text" ng-model="config.nameTemplate" />
</td>
<td>
<a class="btn btn-danger" ng-click="remove($index)" ng-show="model.value.length > 1">Remove</a>
</td>
</tr>
</tbody>
</table>
<div>
<a class="btn" ng-click="add()">Add</a>
<i class="icon icon-help-alt medium nested-content__help-icon" ng-click="showHelpText = !showHelpText"></i>
</div>
</div>
<br/>
<div class="nested-content__help-text" ng-show="showHelpText">
<p>
<b>Tab:</b><br/>
Select the tab who's properties should be displayed. If left blank, the first tab on the doc type will be used.
</p>
<p>
<b>Name template:</b><br/>
Enter an angular expression to evaluate against each item for its name. Use <code ng-non-bindable>{{$index}}</code> to display the item index
</p>
</div>
</div>

View File

@@ -0,0 +1,9 @@
<div class="umb-pane">
<umb-property
property="property"
ng-repeat="property in tab.properties">
<umb-editor model="property"></umb-editor>
</umb-property>
</div>

View File

@@ -0,0 +1,62 @@
<div id="nested-content--{{model.id}}" class="nested-content"
ng-controller="Umbraco.PropertyEditors.NestedContent.PropertyEditorController"
ng-class="{'nested-content--narrow':!wideMode, 'nested-content--wide':wideMode}">
<ng-form>
<div class="nested-content__items" ng-hide="nodes.length == 0" ui-sortable="sortableOptions" ng-model="nodes">
<div class="nested-content__item" ng-repeat="node in nodes" ng-class="{ 'nested-content__item--active' : $parent.realCurrentNode.key == node.key, 'nested-content__item--single' : $parent.singleMode }">
<div class="nested-content__header-bar" ng-click="$parent.editNode($index)" ng-hide="$parent.singleMode">
<div class="nested-content__heading"><i ng-if="showIcons" class="icon" ng-class="$parent.getIcon($index)"></i><span ng-bind="$parent.getName($index)"></span></div>
<div class="nested-content__icons">
<a class="nested-content__icon nested-content__icon--edit" title="{{editIconTitle}}" ng-class="{ 'nested-content__icon--active' : $parent.realCurrentNode.id == node.id }" ng-click="$parent.editNode($index); $event.stopPropagation();" ng-show="$parent.maxItems > 1" prevent-default>
<i class="icon icon-edit"></i>
</a>
<a class="nested-content__icon nested-content__icon--move" title="{{moveIconTitle}}" ng-click="$event.stopPropagation();" ng-show="$parent.nodes.length > 1" prevent-default>
<i class="icon icon-navigation"></i>
</a>
<a class="nested-content__icon nested-content__icon--delete" title="{{deleteIconTitle}}" ng-class="{ 'nested-content__icon--disabled': $parent.nodes.length <= $parent.minItems }" ng-click="$parent.deleteNode($index); $event.stopPropagation();" prevent-default>
<i class="icon icon-trash"></i>
</a>
</div>
</div>
<div class="nested-content__content" ng-if="$parent.realCurrentNode.key == node.key && !$parent.sorting">
<nested-content-editor ng-model="node" tab-alias="ncTabAlias" />
</div>
</div>
</div>
<div class="nested-content__help-text" ng-show="nodes.length == 0">
<localize key="grid_addElement"></localize>
</div>
<div class="nested-content__footer-bar" ng-hide="nodes.length >= maxItems">
<a class="nested-content__icon" ng-click="openNodeTypePicker($event)" prevent-default>
<i class="icon icon-add"></i>
</a>
</div>
<div class="usky-grid nested-content__node-type-picker" ng-if="overlayMenu.show">
<div class="cell-tools-menu" ng-style="overlayMenu.style" style="margin: 0;" delayed-mouseleave="closeNodeTypePicker()" on-delayed-mouseleave="closeNodeTypePicker()">
<h5>
<localize key="grid_insertControl" />
</h5>
<ul class="elements">
<li ng-repeat="scaffold in overlayMenu.scaffolds">
<a ng-click="addNode(scaffold.alias)" href>
<i class="icon {{scaffold.icon}}"></i>
{{scaffold.name}}
</a>
</li>
</ul>
</div>
</div>
</ng-form>
</div>