diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index eba5cef23d..1f3986eeaf 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -437,6 +437,11 @@ namespace Umbraco.Core /// public const string EmailAddressAlias = "Umbraco.EmailAddress"; + /// + /// Alias for the nested content property editor. + /// + public const string NestedContentAlias = "Umbraco.NestedContent"; + public static class PreValueKeys { /// diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtended.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtended.cs index 56745ece66..209dbcc521 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtended.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtended.cs @@ -28,7 +28,7 @@ namespace Umbraco.Core.Models.PublishedContent if (_index.HasValue) return _index.Value; // slow -- and don't cache, not in a set - if (_contentSet == null) return Content.GetIndex(); + if (_contentSet == null) return WrappedContentInternal.GetIndex(); // slow -- but cache for next time var index = _contentSet.FindIndex(x => x.Id == Id); @@ -147,7 +147,7 @@ namespace Umbraco.Core.Models.PublishedContent public override IEnumerable ContentSet { - get { return _contentSet ?? Content.ContentSet; } + get { return _contentSet ?? WrappedContentInternal.ContentSet; } } #endregion @@ -161,8 +161,8 @@ namespace Umbraco.Core.Models.PublishedContent get { return _properties == null - ? Content.Properties - : Content.Properties.Union(_properties).ToList(); + ? WrappedContentInternal.Properties + : WrappedContentInternal.Properties.Union(_properties).ToList(); } } @@ -175,15 +175,15 @@ namespace Umbraco.Core.Models.PublishedContent var property = _properties.FirstOrDefault(prop => prop.PropertyTypeAlias.InvariantEquals(alias)); if (property != null) return property.HasValue ? property.Value : null; } - return Content[alias]; + return WrappedContentInternal[alias]; } } public override IPublishedProperty GetProperty(string alias) { return _properties == null - ? Content.GetProperty(alias) - : _properties.FirstOrDefault(prop => prop.PropertyTypeAlias.InvariantEquals(alias)) ?? Content.GetProperty(alias); + ? WrappedContentInternal.GetProperty(alias) + : _properties.FirstOrDefault(prop => prop.PropertyTypeAlias.InvariantEquals(alias)) ?? WrappedContentInternal.GetProperty(alias); } #endregion diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyExtended.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyExtended.cs index 492fd79796..4386c5daaf 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyExtended.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyExtended.cs @@ -9,6 +9,6 @@ namespace Umbraco.Core.Models.PublishedContent : base(content) { } - public Guid Key { get { return ((IPublishedContentWithKey) Content).Key; } } + public Guid Key { get { return ((IPublishedContentWithKey) WrappedContentInternal).Key; } } } } diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyModel.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyModel.cs index 4761a52617..1ee7c13daf 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyModel.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyModel.cs @@ -8,6 +8,6 @@ namespace Umbraco.Core.Models.PublishedContent : base (content) { } - public Guid Key { get { return ((IPublishedContentWithKey) Content).Key; } } + public Guid Key { get { return ((IPublishedContentWithKey) WrappedContentInternal).Key; } } } } diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyWrapped.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyWrapped.cs index 35d7dd6f1f..45d28a0086 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyWrapped.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWithKeyWrapped.cs @@ -12,6 +12,6 @@ namespace Umbraco.Core.Models.PublishedContent : base(content) { } - public virtual Guid Key { get { return ((IPublishedContentWithKey) Content).Key; } } + public virtual Guid Key { get { return ((IPublishedContentWithKey) WrappedContentInternal).Key; } } } } diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs index 2767dc6b8b..d5793c3b4d 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs @@ -27,7 +27,7 @@ namespace Umbraco.Core.Models.PublishedContent /// public abstract class PublishedContentWrapped : IPublishedContent { - protected readonly IPublishedContent Content; + protected readonly IPublishedContent WrappedContentInternal; /// /// Initialize a new instance of the class @@ -36,7 +36,7 @@ namespace Umbraco.Core.Models.PublishedContent /// The content to wrap and extend. protected PublishedContentWrapped(IPublishedContent content) { - Content = content; + WrappedContentInternal = content; } /// @@ -45,21 +45,21 @@ namespace Umbraco.Core.Models.PublishedContent /// The wrapped content, that was passed as an argument to the constructor. public IPublishedContent Unwrap() { - return Content; + return WrappedContentInternal; } #region ContentSet public virtual IEnumerable ContentSet { - get { return Content.ContentSet; } + get { return WrappedContentInternal.ContentSet; } } #endregion #region ContentType - public virtual PublishedContentType ContentType { get { return Content.ContentType; } } + public virtual PublishedContentType ContentType { get { return WrappedContentInternal.ContentType; } } #endregion @@ -67,102 +67,102 @@ namespace Umbraco.Core.Models.PublishedContent public virtual int Id { - get { return Content.Id; } + get { return WrappedContentInternal.Id; } } public virtual int TemplateId { - get { return Content.TemplateId; } + get { return WrappedContentInternal.TemplateId; } } public virtual int SortOrder { - get { return Content.SortOrder; } + get { return WrappedContentInternal.SortOrder; } } public virtual string Name { - get { return Content.Name; } + get { return WrappedContentInternal.Name; } } public virtual string UrlName { - get { return Content.UrlName; } + get { return WrappedContentInternal.UrlName; } } public virtual string DocumentTypeAlias { - get { return Content.DocumentTypeAlias; } + get { return WrappedContentInternal.DocumentTypeAlias; } } public virtual int DocumentTypeId { - get { return Content.DocumentTypeId; } + get { return WrappedContentInternal.DocumentTypeId; } } public virtual string WriterName { - get { return Content.WriterName; } + get { return WrappedContentInternal.WriterName; } } public virtual string CreatorName { - get { return Content.CreatorName; } + get { return WrappedContentInternal.CreatorName; } } public virtual int WriterId { - get { return Content.WriterId; } + get { return WrappedContentInternal.WriterId; } } public virtual int CreatorId { - get { return Content.CreatorId; } + get { return WrappedContentInternal.CreatorId; } } public virtual string Path { - get { return Content.Path; } + get { return WrappedContentInternal.Path; } } public virtual DateTime CreateDate { - get { return Content.CreateDate; } + get { return WrappedContentInternal.CreateDate; } } public virtual DateTime UpdateDate { - get { return Content.UpdateDate; } + get { return WrappedContentInternal.UpdateDate; } } public virtual Guid Version { - get { return Content.Version; } + get { return WrappedContentInternal.Version; } } public virtual int Level { - get { return Content.Level; } + get { return WrappedContentInternal.Level; } } public virtual string Url { - get { return Content.Url; } + get { return WrappedContentInternal.Url; } } public virtual PublishedItemType ItemType { - get { return Content.ItemType; } + get { return WrappedContentInternal.ItemType; } } public virtual bool IsDraft { - get { return Content.IsDraft; } + get { return WrappedContentInternal.IsDraft; } } public virtual int GetIndex() { - return Content.GetIndex(); + return WrappedContentInternal.GetIndex(); } #endregion @@ -171,12 +171,12 @@ namespace Umbraco.Core.Models.PublishedContent public virtual IPublishedContent Parent { - get { return Content.Parent; } + get { return WrappedContentInternal.Parent; } } public virtual IEnumerable Children { - get { return Content.Children; } + get { return WrappedContentInternal.Children; } } #endregion @@ -185,22 +185,22 @@ namespace Umbraco.Core.Models.PublishedContent public virtual ICollection Properties { - get { return Content.Properties; } + get { return WrappedContentInternal.Properties; } } public virtual object this[string alias] { - get { return Content[alias]; } + get { return WrappedContentInternal[alias]; } } public virtual IPublishedProperty GetProperty(string alias) { - return Content.GetProperty(alias); + return WrappedContentInternal.GetProperty(alias); } public virtual IPublishedProperty GetProperty(string alias, bool recurse) { - return Content.GetProperty(alias, recurse); + return WrappedContentInternal.GetProperty(alias, recurse); } #endregion diff --git a/src/Umbraco.Core/PropertyEditors/PropertyEditorResolver.cs b/src/Umbraco.Core/PropertyEditors/PropertyEditorResolver.cs index 5c0227247e..ba1df58879 100644 --- a/src/Umbraco.Core/PropertyEditors/PropertyEditorResolver.cs +++ b/src/Umbraco.Core/PropertyEditors/PropertyEditorResolver.cs @@ -42,7 +42,16 @@ namespace Umbraco.Core.PropertyEditors internal PropertyEditorResolver(IServiceProvider serviceProvider, ILogger logger, Func> typeListProducerList, ManifestBuilder builder) : base(serviceProvider, logger, typeListProducerList, ObjectLifetimeScope.Application) { - _unioned = new Lazy>(() => Values.Union(builder.PropertyEditors).ToList()); + _unioned = new Lazy>(() => SanitizeNames(Values.Union(builder.PropertyEditors).ToList())); + } + + private static List SanitizeNames(List editors) + { + var nestedContentEditorFromPackage = editors.FirstOrDefault(x => x.Alias == "Our.Umbraco.NestedContent"); + if (nestedContentEditorFromPackage != null) + nestedContentEditorFromPackage.Name = "(Obsolete) " + nestedContentEditorFromPackage.Name; + return editors; + } private readonly Lazy> _unioned; diff --git a/src/Umbraco.Tests/PublishedContent/StronglyTypedModels/TypedModelBase.cs b/src/Umbraco.Tests/PublishedContent/StronglyTypedModels/TypedModelBase.cs index 0de99c76ad..3af65457d3 100644 --- a/src/Umbraco.Tests/PublishedContent/StronglyTypedModels/TypedModelBase.cs +++ b/src/Umbraco.Tests/PublishedContent/StronglyTypedModels/TypedModelBase.cs @@ -48,7 +48,7 @@ namespace Umbraco.Tests.PublishedContent.StronglyTypedModels protected T Resolve(string propertyTypeAlias) { - return Content.GetPropertyValue(propertyTypeAlias); + return WrappedContentInternal.GetPropertyValue(propertyTypeAlias); } protected T Resolve(MethodBase methodBase, T ifCannotConvert) @@ -59,7 +59,7 @@ namespace Umbraco.Tests.PublishedContent.StronglyTypedModels protected T Resolve(string propertyTypeAlias, T ifCannotConvert) { - return Content.GetPropertyValue(propertyTypeAlias, false, ifCannotConvert); + return WrappedContentInternal.GetPropertyValue(propertyTypeAlias, false, ifCannotConvert); } protected T Resolve(MethodBase methodBase, bool recursive, T ifCannotConvert) @@ -70,7 +70,7 @@ namespace Umbraco.Tests.PublishedContent.StronglyTypedModels protected T Resolve(string propertyTypeAlias, bool recursive, T ifCannotConvert) { - return Content.GetPropertyValue(propertyTypeAlias, recursive, ifCannotConvert); + return WrappedContentInternal.GetPropertyValue(propertyTypeAlias, recursive, ifCannotConvert); } #endregion @@ -81,7 +81,7 @@ namespace Umbraco.Tests.PublishedContent.StronglyTypedModels if (constructorInfo == null) throw new Exception("No valid constructor found"); - return (T) constructorInfo.Invoke(new object[] {Content.Parent}); + return (T) constructorInfo.Invoke(new object[] {WrappedContentInternal.Parent}); } protected IEnumerable Children(MethodBase methodBase) where T : TypedModelBase @@ -98,7 +98,7 @@ namespace Umbraco.Tests.PublishedContent.StronglyTypedModels string singularizedDocTypeAlias = docTypeAlias.ToSingular(); - return Content.Children.Where(x => x.DocumentTypeAlias == singularizedDocTypeAlias) + return WrappedContentInternal.Children.Where(x => x.DocumentTypeAlias == singularizedDocTypeAlias) .Select(x => (T)constructorInfo.Invoke(new object[] { x })); } @@ -116,7 +116,7 @@ namespace Umbraco.Tests.PublishedContent.StronglyTypedModels string singularizedDocTypeAlias = docTypeAlias.ToSingular(); - return Content.Ancestors().Where(x => x.DocumentTypeAlias == singularizedDocTypeAlias) + return WrappedContentInternal.Ancestors().Where(x => x.DocumentTypeAlias == singularizedDocTypeAlias) .Select(x => (T)constructorInfo.Invoke(new object[] { x })); } @@ -134,7 +134,7 @@ namespace Umbraco.Tests.PublishedContent.StronglyTypedModels string singularizedDocTypeAlias = docTypeAlias.ToSingular(); - return Content.Descendants().Where(x => x.DocumentTypeAlias == singularizedDocTypeAlias) + return WrappedContentInternal.Descendants().Where(x => x.DocumentTypeAlias == singularizedDocTypeAlias) .Select(x => (T)constructorInfo.Invoke(new object[] { x })); } #endregion diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbnestedcontent.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbnestedcontent.directive.js new file mode 100644 index 0000000000..b8b1776dfe --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbnestedcontent.directive.js @@ -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 +// } +//}); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/filters/nestedcontent.filter.js b/src/Umbraco.Web.UI.Client/src/common/filters/nestedcontent.filter.js new file mode 100644 index 0000000000..76e4e4a822 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/filters/nestedcontent.filter.js @@ -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]; + }; + +}); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/nestedcontent.resources.js b/src/Umbraco.Web.UI.Client/src/common/resources/nestedcontent.resources.js new file mode 100644 index 0000000000..b3488fdff4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/resources/nestedcontent.resources.js @@ -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' + ); + }, + }; + }); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index cfe82ff3ea..4d3d42b1f0 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -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"; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less new file mode 100644 index 0000000000..38de65d7d8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less @@ -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%); +} diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js new file mode 100644 index 0000000000..10b9cffc8f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js @@ -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(); + }; + } + +]); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html new file mode 100644 index 0000000000..5a7d4edfdd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html @@ -0,0 +1,58 @@ +
+
+ + + + + + + + + + + + + + + + + +
+ + Document Type + + Tab + + Name Template + +
+ + + + + + + + Remove +
+
+ Add + +
+
+
+
+

+ Tab:
+ Select the tab who's properties should be displayed. If left blank, the first tab on the doc type will be used. +

+

+ Name template:
+ Enter an angular expression to evaluate against each item for its name. Use {{$index}} to display the item index +

+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.editor.html new file mode 100644 index 0000000000..b3d338feb3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.editor.html @@ -0,0 +1,9 @@ +
+ + + + + +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.html new file mode 100644 index 0000000000..4799167452 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.html @@ -0,0 +1,62 @@ +
+ + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ +
+ +
+ +
+ + + +
+
+
+ +
+ +
+
+ +
+
diff --git a/src/Umbraco.Web/Cache/DataTypeCacheRefresher.cs b/src/Umbraco.Web/Cache/DataTypeCacheRefresher.cs index 34ca2ceec0..391fd09dd1 100644 --- a/src/Umbraco.Web/Cache/DataTypeCacheRefresher.cs +++ b/src/Umbraco.Web/Cache/DataTypeCacheRefresher.cs @@ -6,6 +6,7 @@ using System.Linq; using Umbraco.Core.Models; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.PropertyEditors.ValueConverters; +using Umbraco.Web.PropertyEditors; using Umbraco.Web.PropertyEditors.ValueConverters; @@ -13,12 +14,12 @@ namespace Umbraco.Web.Cache { /// /// A cache refresher to ensure member cache is updated when members change - /// + ///
public sealed class DataTypeCacheRefresher : JsonCacheRefresherBase { #region Static helpers - + /// /// Converts the json to a JsonPayload object /// @@ -29,7 +30,7 @@ namespace Umbraco.Web.Cache var serializer = new JavaScriptSerializer(); var jsonObject = serializer.Deserialize(json); return jsonObject; - } + } /// /// Creates the custom Json payload used to refresh cache amongst the servers @@ -43,7 +44,7 @@ namespace Umbraco.Web.Cache var json = serializer.Serialize(items); return json; } - + /// /// Converts a macro to a jsonPayload object /// @@ -58,7 +59,7 @@ namespace Umbraco.Web.Cache }; return payload; } - + #endregion #region Sub classes @@ -93,7 +94,7 @@ namespace Umbraco.Web.Cache //we need to clear the ContentType runtime cache since that is what caches the // db data type to store the value against and anytime a datatype changes, this also might change // we basically need to clear all sorts of runtime caches here because so many things depend upon a data type - + ClearAllIsolatedCacheByEntityType(); ClearAllIsolatedCacheByEntityType(); ClearAllIsolatedCacheByEntityType(); @@ -104,14 +105,15 @@ namespace Umbraco.Web.Cache ApplicationContext.Current.ApplicationCache.RuntimeCache.ClearCacheByKeySearch(CacheKeys.KeyToIdCacheKey); var dataTypeCache = ApplicationContext.Current.ApplicationCache.IsolatedRuntimeCache.GetCache(); - payloads.ForEach(payload => + foreach (var payload in payloads) { //clears the prevalue cache if (dataTypeCache) dataTypeCache.Result.ClearCacheByKeySearch(string.Format("{0}_{1}", CacheKeys.DataTypePreValuesCacheKey, payload.Id)); PublishedContentType.ClearDataType(payload.Id); - }); + NestedContentHelper.ClearCache(payload.Id); + } TagsValueConverter.ClearCaches(); MultipleMediaPickerPropertyConverter.ClearCaches(); diff --git a/src/Umbraco.Web/Models/DetachedPublishedContent.cs b/src/Umbraco.Web/Models/DetachedPublishedContent.cs new file mode 100644 index 0000000000..b088d9be3c --- /dev/null +++ b/src/Umbraco.Web/Models/DetachedPublishedContent.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.Web.Models +{ + public class DetachedPublishedContent : PublishedContentWithKeyBase + { + private readonly Guid _key; + private readonly string _name; + private readonly PublishedContentType _contentType; + private readonly IEnumerable _properties; + private readonly int _sortOrder; + private readonly bool _isPreviewing; + private readonly IPublishedContent _containerNode; + + public DetachedPublishedContent( + Guid key, + string name, + PublishedContentType contentType, + IEnumerable properties, + IPublishedContent containerNode = null, + int sortOrder = 0, + bool isPreviewing = false) + { + _key = key; + _name = name; + _contentType = contentType; + _properties = properties; + _sortOrder = sortOrder; + _isPreviewing = isPreviewing; + _containerNode = containerNode; + } + + public override Guid Key + { + get { return _key; } + } + + public override int Id + { + get { return 0; } + } + + public override string Name + { + get { return _name; } + } + + public override bool IsDraft + { + get { return _isPreviewing; } + } + + public override PublishedItemType ItemType + { + get { return PublishedItemType.Content; } + } + + public override PublishedContentType ContentType + { + get { return _contentType; } + } + + public override string DocumentTypeAlias + { + get { return _contentType.Alias; } + } + + public override int DocumentTypeId + { + get { return _contentType.Id; } + } + + public override ICollection Properties + { + get { return _properties.ToArray(); } + } + + public override IPublishedProperty GetProperty(string alias) + { + return _properties.FirstOrDefault(x => x.PropertyTypeAlias.InvariantEquals(alias)); + } + + public override IPublishedProperty GetProperty(string alias, bool recurse) + { + if (recurse) + throw new NotSupportedException(); + + return GetProperty(alias); + } + + public override IPublishedContent Parent + { + get { return null; } + } + + public override IEnumerable Children + { + get { return Enumerable.Empty(); } + } + + public override int TemplateId + { + get { return 0; } + } + + public override int SortOrder + { + get { return _sortOrder; } + } + + public override string UrlName + { + get { return null; } + } + + public override string WriterName + { + get { return _containerNode != null ? _containerNode.WriterName : null; } + } + + public override string CreatorName + { + get { return _containerNode != null ? _containerNode.CreatorName : null; } + } + + public override int WriterId + { + get { return _containerNode != null ? _containerNode.WriterId : 0; } + } + + public override int CreatorId + { + get { return _containerNode != null ? _containerNode.CreatorId : 0; } + } + + public override string Path + { + get { return null; } + } + + public override DateTime CreateDate + { + get { return _containerNode != null ? _containerNode.CreateDate : DateTime.MinValue; } + } + + public override DateTime UpdateDate + { + get { return _containerNode != null ? _containerNode.UpdateDate : DateTime.MinValue; } + } + + public override Guid Version + { + get { return _containerNode != null ? _containerNode.Version : Guid.Empty; } + } + + public override int Level + { + get { return 0; } + } + } +} diff --git a/src/Umbraco.Web/Models/DetachedPublishedProperty.cs b/src/Umbraco.Web/Models/DetachedPublishedProperty.cs new file mode 100644 index 0000000000..4a76942dd6 --- /dev/null +++ b/src/Umbraco.Web/Models/DetachedPublishedProperty.cs @@ -0,0 +1,52 @@ +using System; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.Web.Models +{ + internal class DetachedPublishedProperty : IPublishedProperty + { + private readonly PublishedPropertyType _propertyType; + private readonly object _rawValue; + private readonly Lazy _sourceValue; + private readonly Lazy _objectValue; + private readonly Lazy _xpathValue; + private readonly bool _isPreview; + + public DetachedPublishedProperty(PublishedPropertyType propertyType, object value) + : this(propertyType, value, false) + { + } + + public DetachedPublishedProperty(PublishedPropertyType propertyType, object value, bool isPreview) + { + _propertyType = propertyType; + _isPreview = isPreview; + + _rawValue = value; + + _sourceValue = new Lazy(() => _propertyType.ConvertDataToSource(_rawValue, _isPreview)); + _objectValue = new Lazy(() => _propertyType.ConvertSourceToObject(_sourceValue.Value, _isPreview)); + _xpathValue = new Lazy(() => _propertyType.ConvertSourceToXPath(_sourceValue.Value, _isPreview)); + } + + public string PropertyTypeAlias + { + get + { + return _propertyType.PropertyTypeAlias; + } + } + + public bool HasValue + { + get { return DataValue != null && DataValue.ToString().Trim().Length > 0; } + } + + public object DataValue { get { return _rawValue; } } + + public object Value { get { return _objectValue.Value; } } + + public object XPathValue { get { return _xpathValue.Value; } } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentController.cs b/src/Umbraco.Web/PropertyEditors/NestedContentController.cs new file mode 100644 index 0000000000..3e8b0027fd --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/NestedContentController.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.Web.Editors; +using Umbraco.Web.Mvc; + +namespace Umbraco.Web.PropertyEditors +{ + [PluginController("UmbracoApi")] + public class NestedContentController : UmbracoAuthorizedJsonController + { + [System.Web.Http.HttpGet] + public IEnumerable GetContentTypes() + { + return Services.ContentTypeService.GetAllContentTypes() + .OrderBy(x => x.SortOrder) + .Select(x => new + { + id = x.Id, + guid = x.Key, + name = x.Name, + alias = x.Alias, + icon = x.Icon, + tabs = x.CompositionPropertyGroups.Select(y => y.Name).Distinct() + }); + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentHelper.cs b/src/Umbraco.Web/PropertyEditors/NestedContentHelper.cs new file mode 100644 index 0000000000..079a2e33a4 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/NestedContentHelper.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Newtonsoft.Json.Linq; +using Umbraco.Core; +using Umbraco.Core.Models; + +namespace Umbraco.Web.PropertyEditors +{ + internal static class NestedContentHelper + { + private const string CacheKeyPrefix = "Umbraco.Web.PropertyEditors.NestedContent.GetPreValuesCollectionByDataTypeId_"; + + public static PreValueCollection GetPreValuesCollectionByDataTypeId(int dtdId) + { + var preValueCollection = (PreValueCollection)ApplicationContext.Current.ApplicationCache.RuntimeCache.GetCacheItem( + string.Concat(CacheKeyPrefix, dtdId), + () => ApplicationContext.Current.Services.DataTypeService.GetPreValuesCollectionByDataTypeId(dtdId)); + + return preValueCollection; + } + + public static void ClearCache(int id) + { + ApplicationContext.Current.ApplicationCache.RuntimeCache.ClearCacheItem( + string.Concat(CacheKeyPrefix, id)); + } + + public static string GetContentTypeAliasFromItem(JObject item) + { + var contentTypeAliasProperty = item[NestedContentPropertyEditor.ContentTypeAliasPropertyKey]; + if (contentTypeAliasProperty == null) + { + return null; + } + + return contentTypeAliasProperty.ToObject(); + } + + public static IContentType GetContentTypeFromItem(JObject item) + { + var contentTypeAlias = GetContentTypeAliasFromItem(item); + if (string.IsNullOrEmpty(contentTypeAlias)) + { + return null; + } + + return ApplicationContext.Current.Services.ContentTypeService.GetContentType(contentTypeAlias); + } + + #region Conversion from v0.1.1 data formats + + public static void ConvertItemValueFromV011(JObject item, int dtdId, ref PreValueCollection preValues) + { + var contentTypeAlias = GetContentTypeAliasFromItem(item); + if (contentTypeAlias != null) + { + // the item is already in >v0.1.1 format + return; + } + + // old style (v0.1.1) data, let's attempt a conversion + // - get the prevalues (if they're not loaded already) + preValues = preValues ?? GetPreValuesCollectionByDataTypeId(dtdId); + + // - convert the prevalues (if necessary) + ConvertPreValueCollectionFromV011(preValues); + + // - get the content types prevalue as JArray + var preValuesAsDictionary = preValues.PreValuesAsDictionary.ToDictionary(x => x.Key, x => x.Value.Value); + if (!preValuesAsDictionary.ContainsKey(ContentTypesPreValueKey) || string.IsNullOrEmpty(preValuesAsDictionary[ContentTypesPreValueKey]) != false) + { + return; + } + + var preValueContentTypes = JArray.Parse(preValuesAsDictionary[ContentTypesPreValueKey]); + if (preValueContentTypes.Any()) + { + // the only thing we can really do is assume that the item is the first available content type + item[NestedContentPropertyEditor.ContentTypeAliasPropertyKey] = preValueContentTypes.First().Value("ncAlias"); + } + } + + public static void ConvertPreValueCollectionFromV011(PreValueCollection preValueCollection) + { + if (preValueCollection == null) + { + return; + } + + var persistedPreValuesAsDictionary = preValueCollection.PreValuesAsDictionary.ToDictionary(x => x.Key, x => x.Value.Value); + + // do we have a "docTypeGuid" prevalue and no "contentTypes" prevalue? + if (persistedPreValuesAsDictionary.ContainsKey("docTypeGuid") == false || persistedPreValuesAsDictionary.ContainsKey(ContentTypesPreValueKey)) + { + // the prevalues are already in >v0.1.1 format + return; + } + + // attempt to parse the doc type guid + Guid guid; + if (Guid.TryParse(persistedPreValuesAsDictionary["docTypeGuid"], out guid) == false) + { + // this shouldn't happen... but just in case. + return; + } + + // find the content type + var contentType = ApplicationContext.Current.Services.ContentTypeService.GetAllContentTypes().FirstOrDefault(c => c.Key == guid); + if (contentType == null) + { + return; + } + + // add a prevalue in the format expected by the new (>0.1.1) content type picker/configurator + preValueCollection.PreValuesAsDictionary[ContentTypesPreValueKey] = new PreValue( + string.Format(@"[{{""ncAlias"": ""{0}"", ""ncTabAlias"": ""{1}"", ""nameTemplate"": ""{2}"", }}]", + contentType.Alias, + persistedPreValuesAsDictionary["tabAlias"], + persistedPreValuesAsDictionary["nameTemplate"] + ) + ); + } + + private static string ContentTypesPreValueKey + { + get { return NestedContentPropertyEditor.NestedContentPreValueEditor.ContentTypesPreValueKey; } + } + + #endregion + } +} diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs new file mode 100644 index 0000000000..38944d0a34 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs @@ -0,0 +1,413 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Editors; +using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Services; + +namespace Umbraco.Web.PropertyEditors +{ + [PropertyEditor(Constants.PropertyEditors.NestedContentAlias, "Nested Content", "nestedcontent", ValueType = "JSON", Group = "lists", Icon = "icon-thumbnail-list")] + public class NestedContentPropertyEditor : PropertyEditor + { + internal const string ContentTypeAliasPropertyKey = "ncContentTypeAlias"; + + private IDictionary _defaultPreValues; + public override IDictionary DefaultPreValues + { + get { return _defaultPreValues; } + set { _defaultPreValues = value; } + } + + public NestedContentPropertyEditor() + { + // Setup default values + _defaultPreValues = new Dictionary + { + {NestedContentPreValueEditor.ContentTypesPreValueKey, ""}, + {"minItems", 0}, + {"maxItems", 0}, + {"confirmDeletes", "1"}, + {"showIcons", "1"} + }; + } + + #region Pre Value Editor + + protected override PreValueEditor CreatePreValueEditor() + { + return new NestedContentPreValueEditor(); + } + + internal class NestedContentPreValueEditor : PreValueEditor + { + internal const string ContentTypesPreValueKey = "contentTypes"; + + [PreValueField(ContentTypesPreValueKey, "Doc Types", "views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html", Description = "Select the doc types to use as the data blueprint.")] + public string[] ContentTypes { get; set; } + + [PreValueField("minItems", "Min Items", "number", Description = "Set the minimum number of items allowed.")] + public string MinItems { get; set; } + + [PreValueField("maxItems", "Max Items", "number", Description = "Set the maximum number of items allowed.")] + public string MaxItems { get; set; } + + [PreValueField("confirmDeletes", "Confirm Deletes", "boolean", Description = "Set whether item deletions should require confirming.")] + public string ConfirmDeletes { get; set; } + + [PreValueField("showIcons", "Show Icons", "boolean", Description = "Set whether to show the items doc type icon in the list.")] + public string ShowIcons { get; set; } + + [PreValueField("hideLabel", "Hide Label", "boolean", Description = "Set whether to hide the editor label and have the list take up the full width of the editor window.")] + public string HideLabel { get; set; } + + public override IDictionary ConvertDbToEditor(IDictionary defaultPreVals, PreValueCollection persistedPreVals) + { + // re-format old style (v0.1.1) pre values if necessary + NestedContentHelper.ConvertPreValueCollectionFromV011(persistedPreVals); + + return base.ConvertDbToEditor(defaultPreVals, persistedPreVals); + } + } + + #endregion + + #region Value Editor + + protected override PropertyValueEditor CreateValueEditor() + { + return new NestedContentPropertyValueEditor(base.CreateValueEditor()); + } + + internal class NestedContentPropertyValueEditor : PropertyValueEditorWrapper + { + public NestedContentPropertyValueEditor(PropertyValueEditor wrapped) + : base(wrapped) + { + Validators.Add(new NestedContentValidator()); + } + + internal ServiceContext Services + { + get { return ApplicationContext.Current.Services; } + } + + public override void ConfigureForDisplay(PreValueCollection preValues) + { + base.ConfigureForDisplay(preValues); + + var asDictionary = preValues.PreValuesAsDictionary.ToDictionary(x => x.Key, x => x.Value.Value); + if (asDictionary.ContainsKey("hideLabel")) + { + var boolAttempt = asDictionary["hideLabel"].TryConvertTo(); + if (boolAttempt.Success) + { + HideLabel = boolAttempt.Result; + } + } + } + + #region DB to String + + public override string ConvertDbToString(Property property, PropertyType propertyType, IDataTypeService dataTypeService) + { + // Convert / validate value + if (property.Value == null || string.IsNullOrWhiteSpace(property.Value.ToString())) + return string.Empty; + + var value = JsonConvert.DeserializeObject>(property.Value.ToString()); + if (value == null) + return string.Empty; + + // Process value + PreValueCollection preValues = null; + for (var i = 0; i < value.Count; i++) + { + var o = value[i]; + var propValues = ((JObject)o); + + // convert from old style (v0.1.1) data format if necessary + NestedContentHelper.ConvertItemValueFromV011(propValues, propertyType.DataTypeDefinitionId, ref preValues); + + var contentType = NestedContentHelper.GetContentTypeFromItem(propValues); + if (contentType == null) + { + continue; + } + + var propValueKeys = propValues.Properties().Select(x => x.Name).ToArray(); + + foreach (var propKey in propValueKeys) + { + var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propKey); + if (propType == null) + { + if (IsSystemPropertyKey(propKey) == false) + { + // Property missing so just delete the value + propValues[propKey] = null; + } + } + else + { + try + { + // Create a fake property using the property abd stored value + var prop = new Property(propType, propValues[propKey] == null ? null : propValues[propKey].ToString()); + + // Lookup the property editor + var propEditor = PropertyEditorResolver.Current.GetByAlias(propType.PropertyEditorAlias); + + // Get the editor to do it's conversion, and store it back + propValues[propKey] = propEditor.ValueEditor.ConvertDbToString(prop, propType, dataTypeService); + } + catch (InvalidOperationException) + { + // https://github.com/umco/umbraco-nested-content/issues/111 + // Catch any invalid cast operations as likely means courier failed due to missing + // or trashed item so couldn't convert a guid back to an int + + propValues[propKey] = null; + } + } + + } + } + + // Update the value on the property + property.Value = JsonConvert.SerializeObject(value); + + // Pass the call down + return base.ConvertDbToString(property, propertyType, dataTypeService); + } + + #endregion + + #region DB to Editor + + public override object ConvertDbToEditor(Property property, PropertyType propertyType, IDataTypeService dataTypeService) + { + if (property.Value == null || string.IsNullOrWhiteSpace(property.Value.ToString())) + return string.Empty; + + var value = JsonConvert.DeserializeObject>(property.Value.ToString()); + if (value == null) + return string.Empty; + + // Process value + PreValueCollection preValues = null; + for (var i = 0; i < value.Count; i++) + { + var o = value[i]; + var propValues = ((JObject)o); + + // convert from old style (v0.1.1) data format if necessary + NestedContentHelper.ConvertItemValueFromV011(propValues, propertyType.DataTypeDefinitionId, ref preValues); + + var contentType = NestedContentHelper.GetContentTypeFromItem(propValues); + if (contentType == null) + { + continue; + } + + var propValueKeys = propValues.Properties().Select(x => x.Name).ToArray(); + + foreach (var propKey in propValueKeys) + { + var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propKey); + if (propType == null) + { + if (IsSystemPropertyKey(propKey) == false) + { + // Property missing so just delete the value + propValues[propKey] = null; + } + } + else + { + try + { + // Create a fake property using the property and stored value + var prop = new Property(propType, propValues[propKey] == null ? null : propValues[propKey].ToString()); + + // Lookup the property editor + var propEditor = PropertyEditorResolver.Current.GetByAlias(propType.PropertyEditorAlias); + + // Get the editor to do it's conversion + var newValue = propEditor.ValueEditor.ConvertDbToEditor(prop, propType, dataTypeService); + + // Store the value back + propValues[propKey] = (newValue == null) ? null : JToken.FromObject(newValue); + } + catch (InvalidOperationException) + { + // https://github.com/umco/umbraco-nested-content/issues/111 + // Catch any invalid cast operations as likely means courier failed due to missing + // or trashed item so couldn't convert a guid back to an int + + propValues[propKey] = null; + } + } + + } + } + + // Update the value on the property + property.Value = JsonConvert.SerializeObject(value); + + // Pass the call down + return base.ConvertDbToEditor(property, propertyType, dataTypeService); + } + + #endregion + + #region Editor to DB + + public override object ConvertEditorToDb(ContentPropertyData editorValue, object currentValue) + { + if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString())) + return null; + + var value = JsonConvert.DeserializeObject>(editorValue.Value.ToString()); + if (value == null) + return null; + + // Issue #38 - Keep recursive property lookups working + if (!value.Any()) + return null; + + // Process value + for (var i = 0; i < value.Count; i++) + { + var o = value[i]; + var propValues = ((JObject)o); + + var contentType = NestedContentHelper.GetContentTypeFromItem(propValues); + if (contentType == null) + { + continue; + } + + var propValueKeys = propValues.Properties().Select(x => x.Name).ToArray(); + + foreach (var propKey in propValueKeys) + { + var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propKey); + if (propType == null) + { + if (IsSystemPropertyKey(propKey) == false) + { + // Property missing so just delete the value + propValues[propKey] = null; + } + } + else + { + // Fetch the property types prevalue + var propPreValues = Services.DataTypeService.GetPreValuesCollectionByDataTypeId( + propType.DataTypeDefinitionId); + + // Lookup the property editor + var propEditor = PropertyEditorResolver.Current.GetByAlias(propType.PropertyEditorAlias); + + // Create a fake content property data object + var contentPropData = new ContentPropertyData( + propValues[propKey], propPreValues, + new Dictionary()); + + // Get the property editor to do it's conversion + var newValue = propEditor.ValueEditor.ConvertEditorToDb(contentPropData, propValues[propKey]); + + // Store the value back + propValues[propKey] = (newValue == null) ? null : JToken.FromObject(newValue); + } + + } + } + + return JsonConvert.SerializeObject(value); + } + + #endregion + } + + internal class NestedContentValidator : IPropertyValidator + { + public IEnumerable Validate(object rawValue, PreValueCollection preValues, PropertyEditor editor) + { + var value = JsonConvert.DeserializeObject>(rawValue.ToString()); + if (value == null) + yield break; + + IDataTypeService dataTypeService = ApplicationContext.Current.Services.DataTypeService; + for (var i = 0; i < value.Count; i++) + { + var o = value[i]; + var propValues = ((JObject)o); + + var contentType = NestedContentHelper.GetContentTypeFromItem(propValues); + if (contentType == null) + { + continue; + } + + var propValueKeys = propValues.Properties().Select(x => x.Name).ToArray(); + + foreach (var propKey in propValueKeys) + { + var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propKey); + if (propType != null) + { + PreValueCollection propPrevalues = dataTypeService.GetPreValuesCollectionByDataTypeId(propType.DataTypeDefinitionId); + PropertyEditor propertyEditor = PropertyEditorResolver.Current.GetByAlias(propType.PropertyEditorAlias); + + foreach (IPropertyValidator validator in propertyEditor.ValueEditor.Validators) + { + foreach (ValidationResult result in validator.Validate(propValues[propKey], propPrevalues, propertyEditor)) + { + result.ErrorMessage = "Item " + (i + 1) + " '" + propType.Name + "' " + result.ErrorMessage; + yield return result; + } + } + + // Check mandatory + if (propType.Mandatory) + { + if (propValues[propKey] == null) + yield return new ValidationResult("Item " + (i + 1) + " '" + propType.Name + "' cannot be null", new[] { propKey }); + else if (propValues[propKey].ToString().IsNullOrWhiteSpace()) + yield return new ValidationResult("Item " + (i + 1) + " '" + propType.Name + "' cannot be empty", new[] { propKey }); + } + + // Check regex + if (!propType.ValidationRegExp.IsNullOrWhiteSpace() + && propValues[propKey] != null && !propValues[propKey].ToString().IsNullOrWhiteSpace()) + { + var regex = new Regex(propType.ValidationRegExp); + if (!regex.IsMatch(propValues[propKey].ToString())) + { + yield return new ValidationResult("Item " + (i + 1) + " '" + propType.Name + "' is invalid, it does not match the correct pattern", new[] { propKey }); + } + } + } + } + } + } + } + + #endregion + + private static bool IsSystemPropertyKey(string propKey) + { + return propKey == "name" || propKey == "key" || propKey == ContentTypeAliasPropertyKey; + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs new file mode 100644 index 0000000000..c504601d35 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.PropertyEditors; + +namespace Umbraco.Web.PropertyEditors.ValueConverters +{ + public class NestedContentManyValueConverter : PropertyValueConverterBase, IPropertyValueConverterMeta + { + public override bool IsConverter(PublishedPropertyType propertyType) + { + return propertyType.IsNestedContentProperty() && !propertyType.IsSingleNestedContentProperty(); + } + + public override object ConvertDataToSource(PublishedPropertyType propertyType, object source, bool preview) + { + try + { + return propertyType.ConvertPropertyToNestedContent(source, preview); + } + catch (Exception e) + { + LogHelper.Error("Error converting value", e); + } + + return null; + } + + public virtual Type GetPropertyValueType(PublishedPropertyType propertyType) + { + return typeof(IEnumerable); + } + + public virtual PropertyCacheLevel GetPropertyCacheLevel(PublishedPropertyType propertyType, PropertyCacheValue cacheValue) + { + return PropertyCacheLevel.Content; + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentPublishedPropertyTypeExtensions.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentPublishedPropertyTypeExtensions.cs new file mode 100644 index 0000000000..cb26ef6786 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentPublishedPropertyTypeExtensions.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web.Models; + +namespace Umbraco.Web.PropertyEditors.ValueConverters +{ + internal static class NestedContentPublishedPropertyTypeExtensions + { + public static bool IsNestedContentProperty(this PublishedPropertyType publishedProperty) + { + return publishedProperty.PropertyEditorAlias.InvariantEquals(Constants.PropertyEditors.NestedContentAlias); + } + + public static bool IsSingleNestedContentProperty(this PublishedPropertyType publishedProperty) + { + if (!publishedProperty.IsNestedContentProperty()) + { + return false; + } + + var preValueCollection = NestedContentHelper.GetPreValuesCollectionByDataTypeId(publishedProperty.DataTypeId); + var preValueDictionary = preValueCollection.PreValuesAsDictionary.ToDictionary(x => x.Key, x => x.Value.Value); + + int minItems, maxItems; + return preValueDictionary.ContainsKey("minItems") && + int.TryParse(preValueDictionary["minItems"], out minItems) && minItems == 1 + && preValueDictionary.ContainsKey("maxItems") && + int.TryParse(preValueDictionary["maxItems"], out maxItems) && maxItems == 1; + } + + public static object ConvertPropertyToNestedContent(this PublishedPropertyType propertyType, object source, bool preview) + { + using (DisposableTimer.DebugDuration(string.Format("ConvertPropertyToNestedContent ({0})", propertyType.DataTypeId))) + { + if (source != null && !source.ToString().IsNullOrWhiteSpace()) + { + var rawValue = JsonConvert.DeserializeObject>(source.ToString()); + var processedValue = new List(); + + var preValueCollection = NestedContentHelper.GetPreValuesCollectionByDataTypeId(propertyType.DataTypeId); + var preValueDictionary = preValueCollection.PreValuesAsDictionary.ToDictionary(x => x.Key, x => x.Value.Value); + + for (var i = 0; i < rawValue.Count; i++) + { + var item = (JObject)rawValue[i]; + + // Convert from old style (v.0.1.1) data format if necessary + // - Please note: This call has virtually no impact on rendering performance for new style (>v0.1.1). + // Even so, this should be removed eventually, when it's safe to assume that there is + // no longer any need for conversion. + NestedContentHelper.ConvertItemValueFromV011(item, propertyType.DataTypeId, ref preValueCollection); + + var contentTypeAlias = NestedContentHelper.GetContentTypeAliasFromItem(item); + if (string.IsNullOrEmpty(contentTypeAlias)) + { + continue; + } + + var publishedContentType = PublishedContentType.Get(PublishedItemType.Content, contentTypeAlias); + if (publishedContentType == null) + { + continue; + } + + var propValues = item.ToObject>(); + var properties = new List(); + + foreach (var jProp in propValues) + { + var propType = publishedContentType.GetPropertyType(jProp.Key); + if (propType != null) + { + properties.Add(new DetachedPublishedProperty(propType, jProp.Value, preview)); + } + } + + // Parse out the name manually + object nameObj = null; + if (propValues.TryGetValue("name", out nameObj)) + { + // Do nothing, we just want to parse out the name if we can + } + + object keyObj; + var key = Guid.Empty; + if (propValues.TryGetValue("key", out keyObj)) + { + key = Guid.Parse(keyObj.ToString()); + } + + // Get the current request node we are embedded in + var pcr = UmbracoContext.Current == null ? null : UmbracoContext.Current.PublishedContentRequest; + var containerNode = pcr != null && pcr.HasPublishedContent ? pcr.PublishedContent : null; + + // Create the model based on our implementation of IPublishedContent + IPublishedContent content = new DetachedPublishedContent( + key, + nameObj == null ? null : nameObj.ToString(), + publishedContentType, + properties.ToArray(), + containerNode, + i, + preview); + + if (PublishedContentModelFactoryResolver.HasCurrent) + { + // Let the current model factory create a typed model to wrap our model + content = PublishedContentModelFactoryResolver.Current.Factory.CreateModel(content); + } + + // Add the (typed) model as a result + processedValue.Add(content); + } + + if (propertyType.IsSingleNestedContentProperty()) + { + return processedValue.FirstOrDefault(); + } + + return processedValue; + } + } + + return null; + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs new file mode 100644 index 0000000000..a7af2fd8ae --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs @@ -0,0 +1,40 @@ +using System; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.PropertyEditors; + +namespace Umbraco.Web.PropertyEditors.ValueConverters +{ + public class NestedContentSingleValueConverter : PropertyValueConverterBase, IPropertyValueConverterMeta + { + public override bool IsConverter(PublishedPropertyType propertyType) + { + return propertyType.IsSingleNestedContentProperty(); + } + + public override object ConvertDataToSource(PublishedPropertyType propertyType, object source, bool preview) + { + try + { + return propertyType.ConvertPropertyToNestedContent(source, preview); + } + catch (Exception e) + { + LogHelper.Error("Error converting value", e); + } + + return null; + } + + public virtual Type GetPropertyValueType(PublishedPropertyType propertyType) + { + return typeof(IPublishedContent); + } + + public virtual PropertyCacheLevel GetPropertyCacheLevel(PublishedPropertyType propertyType, PropertyCacheValue cacheValue) + { + return PropertyCacheLevel.Content; + } + } +} diff --git a/src/Umbraco.Web/PublishedContentExtensions.cs b/src/Umbraco.Web/PublishedContentExtensions.cs index 36d1306ab6..8ce0d5c2db 100644 --- a/src/Umbraco.Web/PublishedContentExtensions.cs +++ b/src/Umbraco.Web/PublishedContentExtensions.cs @@ -24,6 +24,12 @@ namespace Umbraco.Web public static Guid GetKey(this IPublishedContent content) { + var wrapped = content as PublishedContentWrapped; + while (wrapped != null) + { + content = wrapped.Unwrap(); + wrapped = content as PublishedContentWrapped; + } var contentWithKey = content as IPublishedContentWithKey; return contentWithKey == null ? Guid.Empty : contentWithKey.Key; } diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 0b5652e509..a6b56e5019 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -304,7 +304,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -366,6 +389,8 @@ + + @@ -402,6 +427,9 @@ + + + @@ -412,6 +440,9 @@ + + + @@ -470,29 +501,6 @@ - - - - - - - - - - - - - - - - - - - - - - -