Add Multi Url Picker

This commit is contained in:
Rasmus John Pedersen
2019-01-21 20:08:09 +01:00
committed by Sebastiaan Janssen
parent 9b9c9ef455
commit e1175b814e
12 changed files with 599 additions and 0 deletions

View File

@@ -178,6 +178,11 @@ namespace Umbraco.Core
/// Nested Content.
/// </summary>
public const string NestedContent = "Umbraco.NestedContent";
/// <summary>
/// Alias for the multi url picker editor.
/// </summary>
public const string MultiUrlPicker = "Umbraco.MultiUrlPicker";
}
/// <summary>

View File

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

View File

@@ -0,0 +1,77 @@
<div ng-controller="Umbraco.PropertyEditors.MultiUrlPickerController" class="umb-property-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="openContentEditor(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 ng-messages="contentPickerForm.minCount.$error" show-validation-on-submit>
<div class="help-inline" ng-message="minCount">
<localize key="validation_minCount">You need to add at least</localize> {{model.config.minNumber}} <localize key="validation_items">items</localize>
</div>
</div>
<div ng-messages="contentPickerForm.maxCount.$error" show-validation-on-submit>
<div class="help-inline" ng-message="maxCount">
<localize key="validation_maxCount">You can only have</localize> {{model.config.maxNumber}} <localize key="validation_itemsSelected">items selected</localize>
</div>
</div>
</ng-form>
</div>

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

View File

@@ -0,0 +1,8 @@
using Umbraco.Core.PropertyEditors;
namespace Umbraco.Web.PropertyEditors
{
public class MultiUrlPickerConfigurationEditor : ConfigurationEditor<MultiUrlPickerConfiguration>
{
}
}

View File

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

View File

@@ -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<object>();
}
try
{
var links = JsonConvert.DeserializeObject<List<MultiUrlPickerValueEditor.LinkDto>>(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<IEntitySlim>();
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<LinkDisplay>();
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<MultiUrlPickerValueEditor>("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<List<LinkDisplay>>(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<MultiUrlPickerValueEditor>("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; }
}
}
}

View File

@@ -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<MultiUrlPickerConfiguration>().MaxNumber == 1 ?
typeof(Link) :
typeof(IEnumerable<Link>);
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<MultiUrlPickerValueConverter>($"ConvertPropertyToLinks ({propertyType.DataType.Id})"))
{
var maxNumber = propertyType.DataType.ConfigurationAs<MultiUrlPickerConfiguration>().MaxNumber;
if (inter == null)
{
return maxNumber == 1 ? null : Enumerable.Empty<Link>();
}
var links = new List<Link>();
var dtos = JsonConvert.DeserializeObject<IEnumerable<MultiUrlPickerValueEditor.LinkDto>>(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;
}
}
}
}

View File

@@ -167,8 +167,16 @@
<Compile Include="Media\TypeDetector\SvgDetector.cs" />
<Compile Include="Media\TypeDetector\TIFFDetector.cs" />
<Compile Include="Media\UploadAutoFillProperties.cs" />
<Compile Include="Models\ContentEditing\LinkDisplay.cs" />
<Compile Include="Models\ContentEditing\MacroDisplay.cs" />
<Compile Include="Models\ContentEditing\MacroParameterDisplay.cs" />
<Compile Include="Models\Link.cs" />
<Compile Include="Models\LinkType.cs" />
<Compile Include="PropertyEditors\MultiUrlPickerConfiguration.cs" />
<Compile Include="PropertyEditors\MultiUrlPickerConfigurationEditor.cs" />
<Compile Include="PropertyEditors\MultiUrlPickerPropertyEditor.cs" />
<Compile Include="PropertyEditors\MultiUrlPickerValueEditor.cs" />
<Compile Include="PropertyEditors\ValueConverters\MultiUrlPickerValueConverter.cs" />
<Compile Include="Trees\BackOfficeSectionCollectionBuilder.cs" />
<Compile Include="Trees\MediaBackOfficeSection.cs" />
<Compile Include="Trees\MembersBackOfficeSection.cs" />