From e1175b814ed6df6a47ad015dac7b9e6b15d6eafa Mon Sep 17 00:00:00 2001 From: Rasmus John Pedersen Date: Mon, 21 Jan 2019 20:08:09 +0100 Subject: [PATCH] Add Multi Url Picker --- src/Umbraco.Core/Constants-PropertyEditors.cs | 5 + .../multiurlpicker.controller.js | 135 ++++++++++++++ .../multiurlpicker/multiurlpicker.html | 77 ++++++++ .../Models/ContentEditing/LinkDisplay.cs | 36 ++++ src/Umbraco.Web/Models/Link.cs | 13 ++ src/Umbraco.Web/Models/LinkType.cs | 9 + .../MultiUrlPickerConfiguration.cs | 13 ++ .../MultiUrlPickerConfigurationEditor.cs | 8 + .../MultiUrlPickerPropertyEditor.cs | 26 +++ .../MultiUrlPickerValueEditor.cs | 176 ++++++++++++++++++ .../MultiUrlPickerValueConverter.cs | 93 +++++++++ src/Umbraco.Web/Umbraco.Web.csproj | 8 + 12 files changed, 599 insertions(+) create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.html create mode 100644 src/Umbraco.Web/Models/ContentEditing/LinkDisplay.cs create mode 100644 src/Umbraco.Web/Models/Link.cs create mode 100644 src/Umbraco.Web/Models/LinkType.cs create mode 100644 src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs create mode 100644 src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfigurationEditor.cs create mode 100644 src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs create mode 100644 src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs create mode 100644 src/Umbraco.Web/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index b09987ad90..b9f20fb449 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -178,6 +178,11 @@ namespace Umbraco.Core /// Nested Content. /// public const string NestedContent = "Umbraco.NestedContent"; + + /// + /// Alias for the multi url picker editor. + /// + public const string MultiUrlPicker = "Umbraco.MultiUrlPicker"; } /// diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js new file mode 100644 index 0000000000..af53dc86aa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js @@ -0,0 +1,135 @@ +function multiUrlPickerController($scope, angularHelper, localizationService, entityResource, iconHelper, editorService) { + + $scope.renderModel = []; + + if ($scope.preview) { + return; + } + + if (!Array.isArray($scope.model.value)) { + $scope.model.value = []; + } + + var currentForm = angularHelper.getCurrentForm($scope); + + $scope.sortableOptions = { + distance: 10, + tolerance: "pointer", + scroll: true, + zIndex: 6000, + update: function () { + currentForm.$setDirty(); + } + }; + + $scope.model.value.forEach(function (link) { + link.icon = iconHelper.convertFromLegacyIcon(link.icon); + $scope.renderModel.push(link); + }); + + $scope.$on("formSubmitting", function () { + $scope.model.value = $scope.renderModel; + }); + + $scope.$watch( + function () { + return $scope.renderModel.length; + }, + function () { + //Validate! + if ($scope.model.config && $scope.model.config.minNumber && parseInt($scope.model.config.minNumber) > $scope.renderModel.length) { + $scope.multiUrlPickerForm.minCount.$setValidity("minCount", false); + } + else { + $scope.multiUrlPickerForm.minCount.$setValidity("minCount", true); + } + + if ($scope.model.config && $scope.model.config.maxNumber && parseInt($scope.model.config.maxNumber) < $scope.renderModel.length) { + $scope.multiUrlPickerForm.maxCount.$setValidity("maxCount", false); + } + else { + $scope.multiUrlPickerForm.maxCount.$setValidity("maxCount", true); + } + $scope.sortableOptions.disabled = $scope.renderModel.length === 1; + } + ); + + $scope.remove = function ($index) { + $scope.renderModel.splice($index, 1); + + currentForm.$setDirty(); + }; + + $scope.openLinkPicker = function (link, $index) { + var target = link ? { + name: link.name, + anchor: link.queryString, + // the linkPicker breaks if it get an udi for media + udi: link.isMedia ? null : link.udi, + url: link.url, + target: link.target + } : null; + + var linkPicker = { + currentTarget: target, + submit: function (model) { + if (model.target.url) { + // if an anchor exists, check that it is appropriately prefixed + if (model.target.anchor && model.target.anchor[0] !== '?' && model.target.anchor[0] !== '#') { + model.target.anchor = (model.target.anchor.indexOf('=') === -1 ? '#' : '?') + model.target.anchor; + } + if (link) { + if (link.isMedia && link.url === model.target.url) { + // we can assume the existing media item is changed and no new file has been selected + // so we don't need to update the udi and isMedia fields + } else { + link.udi = model.target.udi; + link.isMedia = model.target.isMedia; + } + + link.name = model.target.name || model.target.url; + link.queryString = model.target.anchor; + link.target = model.target.target; + link.url = model.target.url; + } else { + link = { + isMedia: model.target.isMedia, + name: model.target.name || model.target.url, + queryString: model.target.anchor, + target: model.target.target, + udi: model.target.udi, + url: model.target.url + }; + $scope.renderModel.push(link); + } + + if (link.udi) { + var entityType = link.isMedia ? "media" : "document"; + + entityResource.getById(link.udi, entityType).then(function (data) { + link.icon = iconHelper.convertFromLegacyIcon(data.icon); + link.published = (data.metaData && data.metaData.IsPublished === false && entityType === "Document") ? false : true; + link.trashed = data.trashed; + if (link.trashed) { + item.url = localizationService.dictionary.general_recycleBin; + } + }); + } else { + link.icon = "icon-link"; + link.published = true; + } + + currentForm.$setDirty(); + } + editorService.close(); + }, + close: function () { + editorService.close(); + } + }; + editorService.linkPicker(linkPicker); + }; +} + +angular.module("umbraco").controller("Umbraco.PropertyEditors.MultiUrlPickerController", multiUrlPickerController); + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.html new file mode 100644 index 0000000000..ca79c7faa0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.html @@ -0,0 +1,77 @@ +
+

+

+ + +
+ + +
+ + + Add + + +
+ + + + + Add between {{model.config.minNumber}} and {{model.config.maxNumber}} items + + You can only have {{model.config.maxNumber}} items selected + + + + + + Add {{model.config.minNumber - renderModel.length}} item(s) + + You can only have {{model.config.maxNumber}} items selected + + + + + + Add up to {{model.config.maxNumber}} items + + You can only have {{model.config.maxNumber}} items selected + + + + + + Add at least {{model.config.minNumber}} item(s) + + +
+ + + + + +
+
+ You need to add at least {{model.config.minNumber}} items +
+
+
+
+ You can only have {{model.config.maxNumber}} items selected +
+
+
+
diff --git a/src/Umbraco.Web/Models/ContentEditing/LinkDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/LinkDisplay.cs new file mode 100644 index 0000000000..857ae2c318 --- /dev/null +++ b/src/Umbraco.Web/Models/ContentEditing/LinkDisplay.cs @@ -0,0 +1,36 @@ +using System.Runtime.Serialization; +using Umbraco.Core; + +namespace Umbraco.Web.Models.ContentEditing +{ + [DataContract(Name = "link", Namespace = "")] + internal class LinkDisplay + { + [DataMember(Name = "icon")] + public string Icon { get; set; } + + [DataMember(Name = "isMedia")] + public bool IsMedia { get; set; } + + [DataMember(Name = "name")] + public string Name { get; set; } + + [DataMember(Name = "published")] + public bool Published { get; set; } + + [DataMember(Name = "queryString")] + public string QueryString { get; set; } + + [DataMember(Name = "target")] + public string Target { get; set; } + + [DataMember(Name = "trashed")] + public bool Trashed { get; set; } + + [DataMember(Name = "udi")] + public GuidUdi Udi { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } + } +} diff --git a/src/Umbraco.Web/Models/Link.cs b/src/Umbraco.Web/Models/Link.cs new file mode 100644 index 0000000000..74ad4ad2af --- /dev/null +++ b/src/Umbraco.Web/Models/Link.cs @@ -0,0 +1,13 @@ +using Umbraco.Core; + +namespace Umbraco.Web.Models +{ + public class Link + { + public string Name { get; set; } + public string Target { get; set; } + public LinkType Type { get; set; } + public Udi Udi { get; set; } + public string Url { get; set; } + } +} diff --git a/src/Umbraco.Web/Models/LinkType.cs b/src/Umbraco.Web/Models/LinkType.cs new file mode 100644 index 0000000000..3db3165d7f --- /dev/null +++ b/src/Umbraco.Web/Models/LinkType.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Web.Models +{ + public enum LinkType + { + Content, + Media, + External + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs new file mode 100644 index 0000000000..515512eff8 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs @@ -0,0 +1,13 @@ +using Umbraco.Core.PropertyEditors; + +namespace Umbraco.Web.PropertyEditors +{ + public class MultiUrlPickerConfiguration + { + [ConfigurationField("minNumber", "Minimum number of items", "number")] + public int MinNumber { get; set; } + + [ConfigurationField("maxNumber", "Maximum number of items", "number")] + public int MaxNumber { get; set; } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfigurationEditor.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfigurationEditor.cs new file mode 100644 index 0000000000..e780e410a7 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfigurationEditor.cs @@ -0,0 +1,8 @@ +using Umbraco.Core.PropertyEditors; + +namespace Umbraco.Web.PropertyEditors +{ + public class MultiUrlPickerConfigurationEditor : ConfigurationEditor + { + } +} diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs new file mode 100644 index 0000000000..54bf5c4d15 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs @@ -0,0 +1,26 @@ +using System; +using Umbraco.Core; +using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Logging; +using Umbraco.Core.Services; +using Umbraco.Web.PublishedCache; + +namespace Umbraco.Web.PropertyEditors +{ + [DataEditor(Constants.PropertyEditors.Aliases.MultiUrlPicker, EditorType.PropertyValue|EditorType.MacroParameter, "Multi Url Picker", "multiurlpicker", ValueType = "JSON", Group = "pickers", Icon = "icon-link")] + public class MultiUrlPickerPropertyEditor : DataEditor + { + private readonly IEntityService _entityService; + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + + public MultiUrlPickerPropertyEditor(ILogger logger, IEntityService entityService, IPublishedSnapshotAccessor publishedSnapshotAccessor) : base(logger, EditorType.PropertyValue|EditorType.MacroParameter) + { + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); + } + + protected override IConfigurationEditor CreateConfigurationEditor() => new MultiUrlPickerConfigurationEditor(); + + protected override IDataValueEditor CreateValueEditor() => new MultiUrlPickerValueEditor(_entityService, _publishedSnapshotAccessor, Logger, Attribute); + } +} diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs new file mode 100644 index 0000000000..381627eac2 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -0,0 +1,176 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Editors; +using Umbraco.Core.Models.Entities; +using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Services; +using Umbraco.Web.Models.ContentEditing; +using Umbraco.Web.PublishedCache; + +namespace Umbraco.Web.PropertyEditors +{ + public class MultiUrlPickerValueEditor : DataValueEditor + { + private readonly IEntityService _entityService; + private readonly ILogger _logger; + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + + public MultiUrlPickerValueEditor(IEntityService entityService, IPublishedSnapshotAccessor publishedSnapshotAccessor, ILogger logger, DataEditorAttribute attribute) : base(attribute) + { + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override object ToEditor(Property property, IDataTypeService dataTypeService, string culture = null, string segment = null) + { + var value = property.GetValue(culture, segment)?.ToString(); + + if (string.IsNullOrEmpty(value)) + { + return Enumerable.Empty(); + } + + try + { + var links = JsonConvert.DeserializeObject>(value); + + var documentLinks = links.FindAll(link => link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Document); + var mediaLinks = links.FindAll(link => link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Media); + + var entities = new List(); + if (documentLinks.Count > 0) + { + entities.AddRange( + _entityService.GetAll(UmbracoObjectTypes.Document, documentLinks.Select(link => link.Udi.Guid).ToArray()) + ); + } + + if (mediaLinks.Count > 0) + { + entities.AddRange( + _entityService.GetAll(UmbracoObjectTypes.Media, mediaLinks.Select(link => link.Udi.Guid).ToArray()) + ); + } + + var result = new List(); + foreach (var dto in links) + { + GuidUdi udi = null; + var icon = "icon-link"; + var isMedia = false; + var published = true; + var trashed = false; + var url = dto.Url; + + if (dto.Udi != null) + { + IUmbracoEntity entity = entities.Find(e => e.Key == dto.Udi.Guid); + if (entity == null) + { + continue; + } + + if (entity is IDocumentEntitySlim documentEntity) + { + icon = documentEntity.ContentTypeIcon; + published = culture == null ? documentEntity.Published : documentEntity.PublishedCultures.Contains(culture); + udi = new GuidUdi(Constants.UdiEntityType.Document, documentEntity.Key); + url = _publishedSnapshotAccessor.PublishedSnapshot.Content.GetById(entity.Key)?.Url ?? "#"; + trashed = documentEntity.Trashed; + } + else if(entity is IContentEntitySlim contentEntity) + { + icon = contentEntity.ContentTypeIcon; + isMedia = true; + published = !contentEntity.Trashed; + udi = new GuidUdi(Constants.UdiEntityType.Media, contentEntity.Key); + url = _publishedSnapshotAccessor.PublishedSnapshot.Media.GetById(entity.Key)?.Url ?? "#"; + trashed = contentEntity.Trashed; + } + else + { + // Not supported + continue; + } + } + + result.Add(new LinkDisplay + { + Icon = icon, + IsMedia = isMedia, + Name = dto.Name, + Target = dto.Target, + Trashed = trashed, + Published = published, + Udi = udi, + Url = url + }); + } + return result; + } + catch (Exception ex) + { + _logger.Error("Error getting links", ex); + } + + return base.ToEditor(property, dataTypeService, culture, segment); + } + + + public override object FromEditor(ContentPropertyData editorValue, object currentValue) + { + var value = editorValue.Value?.ToString(); + + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + try + { + return JsonConvert.SerializeObject( + from link in JsonConvert.DeserializeObject>(value) + select new MultiUrlPickerValueEditor.LinkDto + { + Name = link.Name, + Target = link.Target, + Udi = link.Udi, + Url = link.Udi == null ? link.Url : null, // only save the url for external links + }, + new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }); + } + catch (Exception ex) + { + _logger.Error("Error saving links", ex); + } + + return base.FromEditor(editorValue, currentValue); + } + + [DataContract] + internal class LinkDto + { + [DataMember(Name = "name")] + public string Name { get; set; } + + [DataMember(Name = "target")] + public string Target { get; set; } + + [DataMember(Name = "udi")] + public GuidUdi Udi { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs new file mode 100644 index 0000000000..c2c604cb7f --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs @@ -0,0 +1,93 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.PropertyEditors; +using Umbraco.Web.Models; +using Umbraco.Web.PublishedCache; + +namespace Umbraco.Web.PropertyEditors.ValueConverters +{ + public class MultiUrlPickerValueConverter : PropertyValueConverterBase + { + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IProfilingLogger _proflog; + + public MultiUrlPickerValueConverter(IPublishedSnapshotAccessor publishedSnapshotAccessor, IProfilingLogger proflog) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); + _proflog = proflog ?? throw new ArgumentNullException(nameof(proflog)); + } + + public override bool IsConverter(PublishedPropertyType propertyType) => Constants.PropertyEditors.Aliases.MultiUrlPicker.Equals(propertyType.EditorAlias); + + public override Type GetPropertyValueType(PublishedPropertyType propertyType) => + propertyType.DataType.ConfigurationAs().MaxNumber == 1 ? + typeof(Link) : + typeof(IEnumerable); + + public override PropertyCacheLevel GetPropertyCacheLevel(PublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + + public override bool? IsValue(object value, PropertyValueLevel level) => value?.ToString() != "[]"; + + public override object ConvertSourceToIntermediate(IPublishedElement owner, PublishedPropertyType propertyType, object source, bool preview) => source?.ToString(); + + public override object ConvertIntermediateToObject(IPublishedElement owner, PublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object inter, bool preview) + { + using (_proflog.DebugDuration($"ConvertPropertyToLinks ({propertyType.DataType.Id})")) + { + var maxNumber = propertyType.DataType.ConfigurationAs().MaxNumber; + + if (inter == null) + { + return maxNumber == 1 ? null : Enumerable.Empty(); + } + + var links = new List(); + var dtos = JsonConvert.DeserializeObject>(inter.ToString()); + + foreach (var dto in dtos) + { + var type = LinkType.External; + var url = dto.Url; + + if (dto.Udi != null) + { + type = dto.Udi.EntityType == Core.Constants.UdiEntityType.Media + ? LinkType.Media + : LinkType.Content; + + var content = type == LinkType.Media ? + _publishedSnapshotAccessor.PublishedSnapshot.Media.GetById(preview, dto.Udi.Guid) : + _publishedSnapshotAccessor.PublishedSnapshot.Content.GetById(preview, dto.Udi.Guid); + + if (content == null) + { + continue; + } + url = content.Url; + } + + links.Add( + new Link + { + Name = dto.Name, + Target = dto.Target, + Type = type, + Udi = dto.Udi, + Url = url, + } + ); + } + + if (maxNumber == 1) return links.FirstOrDefault(); + if (maxNumber > 0) return links.Take(maxNumber); + return links; + } + } + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index a6f380b0dd..6736f7512b 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -167,8 +167,16 @@ + + + + + + + +