Add Multi Url Picker to core (#2323)

This commit is contained in:
Rasmus John Pedersen
2019-01-20 21:12:00 +01:00
committed by Sebastiaan Janssen
parent 76bece07ef
commit e1c9b1818e
12 changed files with 707 additions and 3 deletions

View File

@@ -447,6 +447,11 @@ namespace Umbraco.Core
/// </summary>
public const string NestedContentAlias = "Umbraco.NestedContent";
/// <summary>
/// Alias for the multi url picker editor.
/// </summary>
public const string MultiUrlPickerAlias = "Umbraco.MultiUrlPicker";
public static class PreValueKeys
{
/// <summary>

View File

@@ -53,6 +53,12 @@ namespace Umbraco.Core.PropertyEditors
nestedContentEditorFromPackage.Name = "(Obsolete) " + nestedContentEditorFromPackage.Name;
nestedContentEditorFromPackage.IsDeprecated = true;
}
var multiUrlPickerEditorFromPackage = editors.FirstOrDefault(x => x.Alias == "RJP.MultiUrlPicker");
if (multiUrlPickerEditorFromPackage != null)
{
multiUrlPickerEditorFromPackage.Name = "(Obsolete) " + multiUrlPickerEditorFromPackage.Name;
multiUrlPickerEditorFromPackage.IsDeprecated = true;
}
return editors;
}

View File

@@ -0,0 +1,133 @@
function multiUrlPickerController($scope, angularHelper, localizationService, entityResource, iconHelper) {
$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 () {
if ($scope.model.config && $scope.model.config.minNumber) {
$scope.multiUrlPickerForm.minCount.$setValidity(
"minCount",
+$scope.model.config.minNumber <= $scope.renderModel.length
);
}
if ($scope.model.config && $scope.model.config.maxNumber) {
$scope.multiUrlPickerForm.maxCount.$setValidity(
"maxCount",
+$scope.model.config.maxNumber >= $scope.renderModel.length
);
}
$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;
$scope.linkPickerOverlay = {
view: "linkpicker",
currentTarget: target,
show: true,
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();
}
$scope.linkPickerOverlay.show = false;
$scope.linkPickerOverlay = null;
}
};
};
}
angular.module("umbraco").controller("Umbraco.PropertyEditors.MultiUrlPickerController", multiUrlPickerController);

View File

@@ -0,0 +1,79 @@
<div ng-controller="Umbraco.PropertyEditors.MultiUrlPickerController" class="umb-editor umb-contentpicker">
<p ng-if="(renderModel|filter:{trashed:true}).length == 1"><localize key="contentPicker_pickedTrashedItem"></localize></p>
<p ng-if="(renderModel|filter:{trashed:true}).length > 1"><localize key="contentPicker_pickedTrashedItems"></localize></p>
<ng-form name="multiUrlPickerForm">
<div ui-sortable="sortableOptions" ng-model="renderModel">
<umb-node-preview
ng-repeat="link in renderModel"
icon="link.icon"
name="link.name"
published="link.published"
description="link.url + (link.queryString ? link.queryString : '')"
sortable="!sortableOptions.disabled"
allow-remove="true"
allow-edit="true"
on-remove="remove($index)"
on-edit="openLinkPicker(link, $index)">
</umb-node-preview>
</div>
<a ng-show="!model.config.maxNumber || renderModel.length < model.config.maxNumber"
class="umb-node-preview-add"
href
ng-click="openLinkPicker()"
prevent-default>
<localize key="general_add">Add</localize>
</a>
<div class="umb-contentpicker__min-max-help">
<!-- Both min and max items -->
<span ng-if="model.config.minNumber && model.config.maxNumber && model.config.minNumber !== model.config.maxNumber">
<span ng-if="renderModel.length < model.config.maxNumber">Add between {{model.config.minNumber}} and {{model.config.maxNumber}} items</span>
<span ng-if="renderModel.length > model.config.maxNumber">
<localize key="validation_maxCount">You can only have</localize> {{model.config.maxNumber}} <localize key="validation_itemsSelected"> items selected</localize>
</span>
</span>
<!-- Equal min and max -->
<span ng-if="model.config.minNumber && model.config.maxNumber && model.config.minNumber === model.config.maxNumber">
<span ng-if="renderModel.length < model.config.maxNumber">Add {{model.config.minNumber - renderModel.length}} item(s)</span>
<span ng-if="renderModel.length > model.config.maxNumber">
<localize key="validation_maxCount">You can only have</localize> {{model.config.maxNumber}} <localize key="validation_itemsSelected"> items selected</localize>
</span>
</span>
<!-- Only max -->
<span ng-if="!model.config.minNumber && model.config.maxNumber">
<span ng-if="renderModel.length < model.config.maxNumber">Add up to {{model.config.maxNumber}} items</span>
<span ng-if="renderModel.length > model.config.maxNumber">
<localize key="validation_maxCount">You can only have</localize> {{model.config.maxNumber}} <localize key="validation_itemsSelected">items selected</localize>
</span>
</span>
<!-- Only min -->
<span ng-if="model.config.minNumber && !model.config.maxNumber && renderModel.length < model.config.minNumber">
Add at least {{model.config.minNumber}} item(s)
</span>
</div>
<!--These are here because we need ng-form fields to validate against-->
<input type="hidden" name="minCount" ng-model="renderModel" />
<input type="hidden" name="maxCount" ng-model="renderModel" />
<div class="help-inline" val-msg-for="minCount" val-toggle-msg="minCount">
<localize key="validation_minCount">You need to add at least</localize> {{model.config.minNumber}} <localize key="validation_items">items</localize>
</div>
<div class="help-inline" val-msg-for="maxCount" val-toggle-msg="maxCount">
<localize key="validation_maxCount">You can only have</localize> {{model.config.maxNumber}} <localize key="validation_itemsSelected">items selected</localize>
</div>
</ng-form>
<umb-overlay ng-if="linkPickerOverlay.show"
model="linkPickerOverlay"
view="linkPickerOverlay.view"
position="right">
</umb-overlay>
</div>

View File

@@ -117,6 +117,7 @@ namespace Umbraco.Web.Cache
LegacyMediaPickerPropertyConverter.ClearCaches();
SliderValueConverter.ClearCaches();
MediaPickerPropertyConverter.ClearCaches();
MultiUrlPickerPropertyConverter.ClearCaches();
base.Refresh(jsonPayload);

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Web.Models
{
public enum LinkType
{
Content,
Media,
External
}
}

View File

@@ -0,0 +1,249 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Umbraco.Core;
using Umbraco.Core.Logging;
using Umbraco.Core.Models;
using Umbraco.Core.Models.Editors;
using Umbraco.Core.Models.EntityBase;
using Umbraco.Core.PropertyEditors;
using Umbraco.Core.Services;
using Umbraco.Web.Models.ContentEditing;
using Umbraco.Web.Routing;
namespace Umbraco.Web.PropertyEditors
{
[PropertyEditor(Constants.PropertyEditors.MultiUrlPickerAlias, "Multi Url Picker", PropertyEditorValueTypes.Json, "multiurlpicker", Group = "pickers", Icon = "icon-link", IsParameterEditor = true)]
public class MultiUrlPickerPropertyEditor : PropertyEditor
{
protected override PreValueEditor CreatePreValueEditor()
{
return new MultiUrlPickerPreValueEditor();
}
protected override PropertyValueEditor CreateValueEditor()
{
return new MultiUrlPickerPropertyValueEditor(base.CreateValueEditor());
}
private class MultiUrlPickerPreValueEditor : PreValueEditor
{
public MultiUrlPickerPreValueEditor()
{
Fields.Add(new PreValueField
{
Key = "minNumber",
View = "number",
Name = "Minimum number of items"
});
Fields.Add(new PreValueField
{
Key = "maxNumber",
View = "number",
Name = "Maximum number of items"
});
}
}
private class MultiUrlPickerPropertyValueEditor : PropertyValueEditorWrapper
{
public MultiUrlPickerPropertyValueEditor(PropertyValueEditor wrapped) : base(wrapped)
{
}
public override object ConvertDbToEditor(Property property, PropertyType propertyType, IDataTypeService dataTypeService)
{
if (property.Value == null)
return Enumerable.Empty<object>();
var value = property.Value.ToString();
if (string.IsNullOrEmpty(value))
return Enumerable.Empty<object>();
try
{
var umbHelper = new UmbracoHelper(UmbracoContext.Current);
var services = ApplicationContext.Current.Services;
var entityService = services.EntityService;
var contentTypeService = services.ContentTypeService;
string deletedLocalization = null;
string recycleBinLocalization = null;
var dtos = JsonConvert.DeserializeObject<List<LinkDto>>(value);
var documentLinks = dtos.FindAll(link =>
link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Document
);
var mediaLinks = dtos.FindAll(link =>
link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Media
);
var entities = new List<IUmbracoEntity>();
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 links = new List<LinkDisplay>();
foreach (var dto in dtos)
{
var link = new LinkDisplay
{
Icon = "icon-link",
IsMedia = false,
Name = dto.Name,
Published = true,
QueryString = dto.QueryString,
Target = dto.Target,
Trashed = false,
Udi = dto.Udi,
Url = dto.Url,
};
links.Add(link);
if (dto.Udi == null)
continue;
var entity = entities.Find(e => e.Key == dto.Udi.Guid);
if (entity == null)
{
if (deletedLocalization == null)
deletedLocalization = services.TextService.Localize("general/deleted");
link.Published = false;
link.Trashed = true;
link.Url = deletedLocalization;
}
else
{
var entityType =
Equals(entity.AdditionalData["NodeObjectTypeId"], Constants.ObjectTypes.MediaGuid)
? Constants.UdiEntityType.Media
: Constants.UdiEntityType.Document;
var udi = new GuidUdi(entityType, entity.Key);
var contentTypeAlias = (string)entity.AdditionalData["ContentTypeAlias"];
if (entity.Trashed)
{
if (recycleBinLocalization == null)
recycleBinLocalization = services.TextService.Localize("general/recycleBin");
link.Trashed = true;
link.Url = recycleBinLocalization;
}
if (udi.EntityType == Constants.UdiEntityType.Document)
{
var contentType = contentTypeService.GetContentType(contentTypeAlias);
if (contentType == null)
continue;
link.Icon = contentType.Icon;
link.Published = Equals(entity.AdditionalData["IsPublished"], true);
if (link.Trashed == false)
link.Url = umbHelper.Url(entity.Id, UrlProviderMode.Relative);
}
else
{
link.IsMedia = true;
var mediaType = contentTypeService.GetMediaType(contentTypeAlias);
if (mediaType == null)
continue;
link.Icon = mediaType.Icon;
if (link.Trashed)
continue;
var media = umbHelper.TypedMedia(entity.Id);
if (media != null)
link.Url = media.Url;
}
}
}
return links;
}
catch (Exception ex)
{
ApplicationContext.Current.ProfilingLogger.Logger.Error<MultiUrlPickerPropertyValueEditor>($"Error getting links.\r\n{property.Value}", ex);
}
return base.ConvertDbToEditor(property, propertyType, dataTypeService);
}
public override object ConvertEditorToDb(ContentPropertyData editorValue, object currentValue)
{
if (editorValue.Value == null)
return null;
var value = editorValue.Value.ToString();
if (string.IsNullOrEmpty(value))
return null;
try
{
return JsonConvert.SerializeObject(
from link in JsonConvert.DeserializeObject<List<LinkDisplay>>(value)
select new LinkDto
{
Name = link.Name,
QueryString = link.QueryString,
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)
{
ApplicationContext.Current.ProfilingLogger.Logger.Error<MultiUrlPickerPropertyValueEditor>($"Error saving links.\r\n{editorValue.Value}", ex);
}
return base.ConvertEditorToDb(editorValue, currentValue);
}
}
[DataContract]
internal class LinkDto
{
[DataMember(Name = "name")]
public string Name { get; set; }
[DataMember(Name = "queryString")]
public string QueryString { 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; }
}
}
}

View File

@@ -1,10 +1,13 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using Umbraco.Core;
using Umbraco.Core.PropertyEditors;
namespace Umbraco.Web.PropertyEditors
{
[PropertyEditor(Constants.PropertyEditors.RelatedLinks2Alias, "Related links", "relatedlinks", ValueType = PropertyEditorValueTypes.Json, Icon = "icon-thumbnail-list", Group = "pickers")]
// TODO: Remove in V8
[Obsolete("This editor is obsolete, use MultiUrlPickerPropertyEditor instead")]
[PropertyEditor(Constants.PropertyEditors.RelatedLinks2Alias, "Related links", "relatedlinks", ValueType = PropertyEditorValueTypes.Json, Icon = "icon-thumbnail-list", Group = "pickers", IsDeprecated = true)]
public class RelatedLinks2PropertyEditor : PropertyEditor
{
public RelatedLinks2PropertyEditor()

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using Umbraco.Core;
using Umbraco.Core.Logging;
using Umbraco.Core.Models.PublishedContent;
using Umbraco.Core.PropertyEditors;
using Umbraco.Core.PropertyEditors.ValueConverters;
using Umbraco.Core.Services;
using Umbraco.Web.Models;
namespace Umbraco.Web.PropertyEditors.ValueConverters
{
[DefaultPropertyValueConverter(typeof(JsonValueConverter))]
public class MultiUrlPickerPropertyConverter : PropertyValueConverterBase, IPropertyValueConverterMeta
{
private readonly IDataTypeService _dataTypeService;
public MultiUrlPickerPropertyConverter(IDataTypeService dataTypeService)
{
if (dataTypeService == null) throw new ArgumentNullException("dataTypeService");
_dataTypeService = dataTypeService;
}
//TODO: Remove this ctor in v8 since the other one will use IoC
public MultiUrlPickerPropertyConverter() : this(ApplicationContext.Current.Services.DataTypeService)
{
}
public override bool IsConverter(PublishedPropertyType propertyType)
{
return propertyType.PropertyEditorAlias.Equals(Constants.PropertyEditors.MultiUrlPickerAlias);
}
public override object ConvertDataToSource(PublishedPropertyType propertyType, object source, bool preview)
{
if (source == null)
return null;
if (source.ToString().Trim().StartsWith("[") == false)
return null;
try
{
return JArray.Parse(source.ToString());
}
catch (Exception ex)
{
LogHelper.Error<MultiUrlPickerPropertyConverter>("Error parsing JSON", ex);
}
return null;
}
public override object ConvertSourceToObject(PublishedPropertyType propertyType, object source, bool preview)
{
var isMultiple = IsMultipleDataType(propertyType.DataTypeId, out var maxNumber);
if (source == null)
return isMultiple
? Enumerable.Empty<Link>()
: null;
//TODO: Inject an UmbracoHelper and create a GetUmbracoHelper method based on either injected or singleton
if (UmbracoContext.Current == null)
return source;
var umbHelper = new UmbracoHelper(UmbracoContext.Current);
var links = new List<Link>();
var dtos = ((JArray) source).ToObject<IEnumerable<MultiUrlPickerPropertyEditor.LinkDto>>();
foreach (var dto in dtos)
{
var type = LinkType.External;
var url = dto.Url;
if (dto.Udi != null)
{
type = dto.Udi.EntityType == Constants.UdiEntityType.Media
? LinkType.Media
: LinkType.Content;
if (type == LinkType.Media)
{
var media = umbHelper.TypedMedia(dto.Udi);
if (media == null)
continue;
url = media.Url;
}
else
{
var content = umbHelper.TypedContent(dto.Udi);
if (content == null)
continue;
url = content.Url;
}
}
var link = new Link
{
Name = dto.Name,
Target = dto.Target,
Type = type,
Udi = dto.Udi,
Url = url + dto.QueryString,
};
links.Add(link);
}
if (isMultiple == false)
return links.FirstOrDefault();
if (maxNumber > 0)
return links.Take(maxNumber);
return links;
}
public Type GetPropertyValueType(PublishedPropertyType propertyType)
{
return IsMultipleDataType(propertyType.DataTypeId, out var maxNumber)
? typeof(IEnumerable<Link>)
: typeof(Link);
}
public PropertyCacheLevel GetPropertyCacheLevel(PublishedPropertyType propertyType, PropertyCacheValue cacheValue)
{
switch (cacheValue)
{
case PropertyCacheValue.Source:
return PropertyCacheLevel.Content;
case PropertyCacheValue.Object:
case PropertyCacheValue.XPath:
return PropertyCacheLevel.ContentCache;
}
return PropertyCacheLevel.None;
}
private bool IsMultipleDataType(int dataTypeId, out int maxNumber)
{
// GetPreValuesCollectionByDataTypeId is cached at repository level;
// still, the collection is deep-cloned so this is kinda expensive,
// better to cache here + trigger refresh in DataTypeCacheRefresher
maxNumber = Storages.GetOrAdd(dataTypeId, id =>
{
var preValues = _dataTypeService.GetPreValuesCollectionByDataTypeId(id).PreValuesAsDictionary;
return preValues.TryGetValue("maxNumber", out var maxNumberPreValue)
? maxNumberPreValue.Value.TryConvertTo<int>().Result
: 0;
});
return maxNumber != 1;
}
private static readonly ConcurrentDictionary<int, int> Storages = new ConcurrentDictionary<int, int>();
internal static void ClearCaches()
{
Storages.Clear();
}
}
}

View File

@@ -419,6 +419,7 @@
<Compile Include="Models\ContentEditing\ContentTypeSave.cs" />
<Compile Include="Models\ContentEditing\DocumentTypeSave.cs" />
<Compile Include="Models\ContentEditing\InstalledPackageModel.cs" />
<Compile Include="Models\ContentEditing\LinkDisplay.cs" />
<Compile Include="Models\ContentEditing\MediaTypeDisplay.cs" />
<Compile Include="Models\ContentEditing\MediaTypeSave.cs" />
<Compile Include="Models\ContentEditing\MemberPropertyTypeBasic.cs" />
@@ -444,6 +445,8 @@
<Compile Include="Models\DetachedPublishedProperty.cs" />
<Compile Include="Models\ContentEditing\UserInvite.cs" />
<Compile Include="Models\ContentEditing\UserSave.cs" />
<Compile Include="Models\Link.cs" />
<Compile Include="Models\LinkType.cs" />
<Compile Include="Models\LocalPackageInstallModel.cs" />
<Compile Include="Models\Mapping\CodeFileDisplayMapper.cs" />
<Compile Include="Models\Mapping\ContentTypeModelMapperExtensions.cs" />
@@ -485,6 +488,7 @@
<Compile Include="PropertyEditors\MediaPicker2PropertyEditor.cs" />
<Compile Include="PropertyEditors\MemberPicker2PropertyEditor.cs" />
<Compile Include="PropertyEditors\MultiNodeTreePicker2PropertyEditor.cs" />
<Compile Include="PropertyEditors\MultiUrlPickerPropertyEditor.cs" />
<Compile Include="PropertyEditors\NestedContentController.cs" />
<Compile Include="PropertyEditors\NestedContentHelper.cs" />
<Compile Include="PropertyEditors\NestedContentPropertyEditor.cs" />
@@ -499,6 +503,7 @@
<Compile Include="PropertyEditors\ValueConverters\MemberPickerPropertyConverter.cs" />
<Compile Include="PropertyEditors\ValueConverters\MultiNodeTreePickerPropertyConverter.cs" />
<Compile Include="PropertyEditors\ValueConverters\LegacyMediaPickerPropertyConverter.cs" />
<Compile Include="PropertyEditors\ValueConverters\MultiUrlPickerPropertyConverter.cs" />
<Compile Include="PropertyEditors\ValueConverters\NestedContentSingleValueConverter.cs" />
<Compile Include="PropertyEditors\ValueConverters\NestedContentManyValueConverter.cs" />
<Compile Include="PropertyEditors\ValueConverters\NestedContentPublishedPropertyTypeExtensions.cs" />