diff --git a/build/build.ps1 b/build/build.ps1 index c2c5bdd232..15d455b976 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -125,7 +125,23 @@ $error.Clear() Write-Output "### gulp build for version $($this.Version.Release)" >> $log 2>&1 - npx gulp build --buildversion=$this.Version.Release >> $log 2>&1 + npm run build --buildversion=$this.Version.Release >> $log 2>&1 + + # We can ignore this warning, we need to update to node 12 at some point - https://github.com/jsdom/jsdom/issues/2939 + $indexes = [System.Collections.ArrayList]::new() + $index = 0; + $error | ForEach-Object { + # Find which of the errors is the ExperimentalWarning + if($_.ToString().Contains("ExperimentalWarning: The fs.promises API is experimental")) { + [void]$indexes.Add($index) + } + $index++ + } + $indexes | ForEach-Object { + # Loop through the list of indexes and remove the errors that we expect and feel confident we can ignore + $error.Remove($error[$_]) + } + if (-not $?) { throw "Failed to build" } # that one is expected to work } finally { Pop-Location diff --git a/src/Umbraco.Core/ContentExtensions.cs b/src/Umbraco.Core/ContentExtensions.cs index 7bce23e98e..eb6339741a 100644 --- a/src/Umbraco.Core/ContentExtensions.cs +++ b/src/Umbraco.Core/ContentExtensions.cs @@ -119,6 +119,15 @@ namespace Umbraco.Core return false; } + /// + /// Returns all properties based on the editorAlias + /// + /// + /// + /// + public static IEnumerable GetPropertiesByEditor(this IContentBase content, string editorAlias) + => content.Properties.Where(x => x.PropertyType.PropertyEditorAlias == editorAlias); + /// /// Returns properties that do not belong to a group /// diff --git a/src/Umbraco.Core/Models/Blocks/BlockEditorDataConverter.cs b/src/Umbraco.Core/Models/Blocks/BlockEditorDataConverter.cs index 22e364c0f8..802e8c2ee3 100644 --- a/src/Umbraco.Core/Models/Blocks/BlockEditorDataConverter.cs +++ b/src/Umbraco.Core/Models/Blocks/BlockEditorDataConverter.cs @@ -18,10 +18,35 @@ namespace Umbraco.Core.Models.Blocks _propertyEditorAlias = propertyEditorAlias; } + public BlockEditorData ConvertFrom(JToken json) + { + var value = json.ToObject(); + return Convert(value); + } + + public bool TryDeserialize(string json, out BlockEditorData blockEditorData) + { + try + { + var value = JsonConvert.DeserializeObject(json); + blockEditorData = Convert(value); + return true; + } + catch (System.Exception) + { + blockEditorData = null; + return false; + } + } + public BlockEditorData Deserialize(string json) { var value = JsonConvert.DeserializeObject(json); + return Convert(value); + } + private BlockEditorData Convert(BlockValue value) + { if (value.Layout == null) return BlockEditorData.Empty; diff --git a/src/Umbraco.Core/Models/Blocks/BlockListItem.cs b/src/Umbraco.Core/Models/Blocks/BlockListItem.cs index f4b5c489e7..620c3d9fe0 100644 --- a/src/Umbraco.Core/Models/Blocks/BlockListItem.cs +++ b/src/Umbraco.Core/Models/Blocks/BlockListItem.cs @@ -5,41 +5,126 @@ using Umbraco.Core.Models.PublishedContent; namespace Umbraco.Core.Models.Blocks { /// - /// Represents a layout item for the Block List editor + /// Represents a layout item for the Block List editor. /// + /// [DataContract(Name = "block", Namespace = "")] public class BlockListItem : IBlockReference { + /// + /// Initializes a new instance of the class. + /// + /// The content UDI. + /// The content. + /// The settings UDI. + /// The settings. + /// contentUdi + /// or + /// content public BlockListItem(Udi contentUdi, IPublishedElement content, Udi settingsUdi, IPublishedElement settings) { - ContentUdi = contentUdi ?? throw new ArgumentNullException(nameof(contentUdi)); + ContentUdi = contentUdi ?? throw new ArgumentNullException(nameof(contentUdi)); Content = content ?? throw new ArgumentNullException(nameof(content)); - Settings = settings; // can be null - SettingsUdi = settingsUdi; // can be null + SettingsUdi = settingsUdi; + Settings = settings; } /// - /// The Id of the content data item + /// Gets the content UDI. /// + /// + /// The content UDI. + /// [DataMember(Name = "contentUdi")] public Udi ContentUdi { get; } /// - /// The Id of the settings data item - /// - [DataMember(Name = "settingsUdi")] - public Udi SettingsUdi { get; } - - /// - /// The content data item referenced + /// Gets the content. /// + /// + /// The content. + /// [DataMember(Name = "content")] public IPublishedElement Content { get; } /// - /// The settings data item referenced + /// Gets the settings UDI. /// + /// + /// The settings UDI. + /// + [DataMember(Name = "settingsUdi")] + public Udi SettingsUdi { get; } + + /// + /// Gets the settings. + /// + /// + /// The settings. + /// [DataMember(Name = "settings")] public IPublishedElement Settings { get; } } + + /// + /// Represents a layout item with a generic content type for the Block List editor. + /// + /// The type of the content. + /// + public class BlockListItem : BlockListItem + where T : IPublishedElement + { + /// + /// Initializes a new instance of the class. + /// + /// The content UDI. + /// The content. + /// The settings UDI. + /// The settings. + public BlockListItem(Udi contentUdi, T content, Udi settingsUdi, IPublishedElement settings) + : base(contentUdi, content, settingsUdi, settings) + { + Content = content; + } + + /// + /// Gets the content. + /// + /// + /// The content. + /// + public new T Content { get; } + } + + /// + /// Represents a layout item with generic content and settings types for the Block List editor. + /// + /// The type of the content. + /// The type of the settings. + /// + public class BlockListItem : BlockListItem + where TContent : IPublishedElement + where TSettings : IPublishedElement + { + /// + /// Initializes a new instance of the class. + /// + /// The content udi. + /// The content. + /// The settings udi. + /// The settings. + public BlockListItem(Udi contentUdi, TContent content, Udi settingsUdi, TSettings settings) + : base(contentUdi, content, settingsUdi, settings) + { + Settings = settings; + } + + /// + /// Gets the settings. + /// + /// + /// The settings. + /// + public new TSettings Settings { get; } + } } diff --git a/src/Umbraco.Core/Models/Blocks/BlockListLayoutItem.cs b/src/Umbraco.Core/Models/Blocks/BlockListLayoutItem.cs index 3453ff2a78..5de44e16c1 100644 --- a/src/Umbraco.Core/Models/Blocks/BlockListLayoutItem.cs +++ b/src/Umbraco.Core/Models/Blocks/BlockListLayoutItem.cs @@ -12,7 +12,7 @@ namespace Umbraco.Core.Models.Blocks [JsonConverter(typeof(UdiJsonConverter))] public Udi ContentUdi { get; set; } - [JsonProperty("settingsUdi")] + [JsonProperty("settingsUdi", NullValueHandling = NullValueHandling.Ignore)] [JsonConverter(typeof(UdiJsonConverter))] public Udi SettingsUdi { get; set; } } diff --git a/src/Umbraco.Core/Models/Blocks/BlockListModel.cs b/src/Umbraco.Core/Models/Blocks/BlockListModel.cs index 9a5a3af22a..9a3a26ab30 100644 --- a/src/Umbraco.Core/Models/Blocks/BlockListModel.cs +++ b/src/Umbraco.Core/Models/Blocks/BlockListModel.cs @@ -1,64 +1,63 @@ using System; -using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Runtime.Serialization; -using Umbraco.Core.Models.PublishedContent; namespace Umbraco.Core.Models.Blocks { /// - /// The strongly typed model for the Block List editor + /// The strongly typed model for the Block List editor. /// + /// [DataContract(Name = "blockList", Namespace = "")] - public class BlockListModel : IReadOnlyList + public class BlockListModel : ReadOnlyCollection { - private readonly IReadOnlyList _layout = new List(); - + /// + /// Gets the empty . + /// + /// + /// The empty . + /// public static BlockListModel Empty { get; } = new BlockListModel(); + /// + /// Prevents a default instance of the class from being created. + /// private BlockListModel() - { - } - - public BlockListModel(IEnumerable layout) - { - _layout = layout.ToList(); - } - - public int Count => _layout.Count; + : this(new List()) + { } /// - /// Get the block by index + /// Initializes a new instance of the class. /// - /// - /// - public BlockListItem this[int index] => _layout[index]; + /// The list to wrap. + public BlockListModel(IList list) + : base(list) + { } /// - /// Get the block by content Guid + /// Gets the with the specified content key. /// - /// - /// - public BlockListItem this[Guid contentKey] => _layout.FirstOrDefault(x => x.Content.Key == contentKey); + /// + /// The . + /// + /// The content key. + /// + /// The with the specified content key. + /// + public BlockListItem this[Guid contentKey] => this.FirstOrDefault(x => x.Content.Key == contentKey); /// - /// Get the block by content element Udi + /// Gets the with the specified content UDI. /// - /// - /// - public BlockListItem this[Udi contentUdi] - { - get - { - if (!(contentUdi is GuidUdi guidUdi)) return null; - return _layout.FirstOrDefault(x => x.Content.Key == guidUdi.Guid); - } - } - - public IEnumerator GetEnumerator() => _layout.GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - + /// + /// The . + /// + /// The content UDI. + /// + /// The with the specified content UDI. + /// + public BlockListItem this[Udi contentUdi] => contentUdi is GuidUdi guidUdi ? this.FirstOrDefault(x => x.Content.Key == guidUdi.Guid) : null; } } diff --git a/src/Umbraco.Core/Models/Blocks/ContentAndSettingsReference.cs b/src/Umbraco.Core/Models/Blocks/ContentAndSettingsReference.cs index 523a964c7b..f7222fe140 100644 --- a/src/Umbraco.Core/Models/Blocks/ContentAndSettingsReference.cs +++ b/src/Umbraco.Core/Models/Blocks/ContentAndSettingsReference.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using System; +using System; +using System.Collections.Generic; namespace Umbraco.Core.Models.Blocks { @@ -12,26 +12,16 @@ namespace Umbraco.Core.Models.Blocks } public Udi ContentUdi { get; } + public Udi SettingsUdi { get; } - public override bool Equals(object obj) - { - return obj is ContentAndSettingsReference reference && Equals(reference); - } + public override bool Equals(object obj) => obj is ContentAndSettingsReference reference && Equals(reference); - public bool Equals(ContentAndSettingsReference other) - { - return EqualityComparer.Default.Equals(ContentUdi, other.ContentUdi) && - EqualityComparer.Default.Equals(SettingsUdi, other.SettingsUdi); - } + public bool Equals(ContentAndSettingsReference other) => other != null + && EqualityComparer.Default.Equals(ContentUdi, other.ContentUdi) + && EqualityComparer.Default.Equals(SettingsUdi, other.SettingsUdi); - public override int GetHashCode() - { - var hashCode = 272556606; - hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(ContentUdi); - hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(SettingsUdi); - return hashCode; - } + public override int GetHashCode() => (ContentUdi, SettingsUdi).GetHashCode(); public static bool operator ==(ContentAndSettingsReference left, ContentAndSettingsReference right) { diff --git a/src/Umbraco.Core/Models/Blocks/IBlockReference.cs b/src/Umbraco.Core/Models/Blocks/IBlockReference.cs index 8d8ddd47f0..7f5c835b3c 100644 --- a/src/Umbraco.Core/Models/Blocks/IBlockReference.cs +++ b/src/Umbraco.Core/Models/Blocks/IBlockReference.cs @@ -1,26 +1,37 @@ namespace Umbraco.Core.Models.Blocks { - /// - /// Represents a data item reference for a Block editor implementation - /// - /// - /// - /// see: https://github.com/umbraco/rfcs/blob/907f3758cf59a7b6781296a60d57d537b3b60b8c/cms/0011-block-data-structure.md#strongly-typed - /// - public interface IBlockReference : IBlockReference - { - TSettings Settings { get; } - } - - /// - /// Represents a data item reference for a Block Editor implementation + /// Represents a data item reference for a Block Editor implementation. /// /// - /// see: https://github.com/umbraco/rfcs/blob/907f3758cf59a7b6781296a60d57d537b3b60b8c/cms/0011-block-data-structure.md#strongly-typed + /// See: https://github.com/umbraco/rfcs/blob/907f3758cf59a7b6781296a60d57d537b3b60b8c/cms/0011-block-data-structure.md#strongly-typed /// public interface IBlockReference { + /// + /// Gets the content UDI. + /// + /// + /// The content UDI. + /// Udi ContentUdi { get; } } + + /// + /// Represents a data item reference with settings for a Block editor implementation. + /// + /// The type of the settings. + /// + /// See: https://github.com/umbraco/rfcs/blob/907f3758cf59a7b6781296a60d57d537b3b60b8c/cms/0011-block-data-structure.md#strongly-typed + /// + public interface IBlockReference : IBlockReference + { + /// + /// Gets the settings. + /// + /// + /// The settings. + /// + TSettings Settings { get; } + } } diff --git a/src/Umbraco.Core/Models/PropertyCollection.cs b/src/Umbraco.Core/Models/PropertyCollection.cs index c587a45424..13fbf949d6 100644 --- a/src/Umbraco.Core/Models/PropertyCollection.cs +++ b/src/Umbraco.Core/Models/PropertyCollection.cs @@ -7,6 +7,7 @@ using System.Runtime.Serialization; namespace Umbraco.Core.Models { + /// /// Represents a collection of property values. /// diff --git a/src/Umbraco.Core/PropertyEditors/ComplexPropertyEditorContentEventHandler.cs b/src/Umbraco.Core/PropertyEditors/ComplexPropertyEditorContentEventHandler.cs new file mode 100644 index 0000000000..2b819d4555 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ComplexPropertyEditorContentEventHandler.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core.Events; +using Umbraco.Core.Models; +using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; + +namespace Umbraco.Core.PropertyEditors +{ + /// + /// Utility class for dealing with Copying/Saving events for complex editors + /// + internal class ComplexPropertyEditorContentEventHandler : IDisposable + { + private readonly string _editorAlias; + private readonly Func _formatPropertyValue; + private bool _disposedValue; + + public ComplexPropertyEditorContentEventHandler(string editorAlias, + Func formatPropertyValue) + { + _editorAlias = editorAlias; + _formatPropertyValue = formatPropertyValue; + ContentService.Copying += ContentService_Copying; + ContentService.Saving += ContentService_Saving; + } + + /// + /// Copying event handler + /// + /// + /// + private void ContentService_Copying(IContentService sender, CopyEventArgs e) + { + var props = e.Copy.GetPropertiesByEditor(_editorAlias); + UpdatePropertyValues(props, false); + } + + /// + /// Saving event handler + /// + /// + /// + private void ContentService_Saving(IContentService sender, ContentSavingEventArgs e) + { + foreach (var entity in e.SavedEntities) + { + var props = entity.GetPropertiesByEditor(_editorAlias); + UpdatePropertyValues(props, true); + } + } + + private void UpdatePropertyValues(IEnumerable props, bool onlyMissingKeys) + { + foreach (var prop in props) + { + // A Property may have one or more values due to cultures + var propVals = prop.Values; + foreach (var cultureVal in propVals) + { + // Remove keys from published value & any nested properties + var updatedPublishedVal = _formatPropertyValue(cultureVal.PublishedValue?.ToString(), onlyMissingKeys); + cultureVal.PublishedValue = updatedPublishedVal; + + // Remove keys from edited/draft value & any nested properties + var updatedEditedVal = _formatPropertyValue(cultureVal.EditedValue?.ToString(), onlyMissingKeys); + cultureVal.EditedValue = updatedEditedVal; + } + } + } + + /// + /// Unbinds from events + /// + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + ContentService.Copying -= ContentService_Copying; + ContentService.Saving -= ContentService_Saving; + } + _disposedValue = true; + } + } + + /// + /// Unbinds from events + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 19f9d25369..f80784f12a 100755 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -148,6 +148,7 @@ + diff --git a/src/Umbraco.Tests/PropertyEditors/BlockEditorComponentTests.cs b/src/Umbraco.Tests/PropertyEditors/BlockEditorComponentTests.cs new file mode 100644 index 0000000000..bfd8b8c77b --- /dev/null +++ b/src/Umbraco.Tests/PropertyEditors/BlockEditorComponentTests.cs @@ -0,0 +1,261 @@ +using Newtonsoft.Json; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core; +using Umbraco.Web.Compose; + +namespace Umbraco.Tests.PropertyEditors +{ + [TestFixture] + public class BlockEditorComponentTests + { + private readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, + + }; + + private const string _contentGuid1 = "036ce82586a64dfba2d523a99ed80f58"; + private const string _contentGuid2 = "48288c21a38a40ef82deb3eda90a58f6"; + private const string _settingsGuid1 = "ffd35c4e2eea4900abfa5611b67b2492"; + private const string _subContentGuid1 = "4c44ce6b3a5c4f5f8f15e3dc24819a9e"; + private const string _subContentGuid2 = "a062c06d6b0b44ac892b35d90309c7f8"; + private const string _subSettingsGuid1 = "4d998d980ffa4eee8afdc23c4abd6d29"; + + [Test] + public void Cannot_Have_Null_Udi() + { + var component = new BlockEditorComponent(); + var json = GetBlockListJson(null, string.Empty); + Assert.Throws(() => component.ReplaceBlockListUdis(json)); + } + + [Test] + public void No_Nesting() + { + var guids = Enumerable.Range(0, 3).Select(x => Guid.NewGuid()).ToList(); + var guidCounter = 0; + Func guidFactory = () => guids[guidCounter++]; + + var json = GetBlockListJson(null); + + var expected = ReplaceGuids(json, guids, _contentGuid1, _contentGuid2, _settingsGuid1); + + var component = new BlockEditorComponent(); + var result = component.ReplaceBlockListUdis(json, guidFactory); + + var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); + var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); + Console.WriteLine(expectedJson); + Console.WriteLine(resultJson); + Assert.AreEqual(expectedJson, resultJson); + } + + [Test] + public void One_Level_Nesting_Escaped() + { + var guids = Enumerable.Range(0, 6).Select(x => Guid.NewGuid()).ToList(); + + var guidCounter = 0; + Func guidFactory = () => guids[guidCounter++]; + + var innerJson = GetBlockListJson(null, _subContentGuid1, _subContentGuid2, _subSettingsGuid1); + + // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing + // and this is how to do that, the result will also include quotes around it. + var innerJsonEscaped = JsonConvert.ToString(innerJson); + + // get the json with the subFeatures as escaped + var json = GetBlockListJson(innerJsonEscaped); + + var component = new BlockEditorComponent(); + var result = component.ReplaceBlockListUdis(json, guidFactory); + + // the expected result is that the subFeatures data is no longer escaped + var expected = ReplaceGuids(GetBlockListJson(innerJson), guids, + _contentGuid1, _contentGuid2, _settingsGuid1, + _subContentGuid1, _subContentGuid2, _subSettingsGuid1); + + var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); + var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); + Console.WriteLine(expectedJson); + Console.WriteLine(resultJson); + Assert.AreEqual(expectedJson, resultJson); + } + + [Test] + public void One_Level_Nesting_Unescaped() + { + var guids = Enumerable.Range(0, 6).Select(x => Guid.NewGuid()).ToList(); + var guidCounter = 0; + Func guidFactory = () => guids[guidCounter++]; + + // nested blocks without property value escaping used in the conversion + var innerJson = GetBlockListJson(null, _subContentGuid1, _subContentGuid2, _subSettingsGuid1); + + // get the json with the subFeatures as unescaped + var json = GetBlockListJson(innerJson); + + var expected = ReplaceGuids(GetBlockListJson(innerJson), guids, + _contentGuid1, _contentGuid2, _settingsGuid1, + _subContentGuid1, _subContentGuid2, _subSettingsGuid1); + + var component = new BlockEditorComponent(); + var result = component.ReplaceBlockListUdis(json, guidFactory); + + var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); + var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); + Console.WriteLine(expectedJson); + Console.WriteLine(resultJson); + Assert.AreEqual(expectedJson, resultJson); + } + + [Test] + public void Nested_In_Complex_Editor_Escaped() + { + var guids = Enumerable.Range(0, 6).Select(x => Guid.NewGuid()).ToList(); + var guidCounter = 0; + Func guidFactory = () => guids[guidCounter++]; + + var innerJson = GetBlockListJson(null, _subContentGuid1, _subContentGuid2, _subSettingsGuid1); + + // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing + // and this is how to do that, the result will also include quotes around it. + var innerJsonEscaped = JsonConvert.ToString(innerJson); + + // Complex editor such as the grid + var complexEditorJsonEscaped = GetGridJson(innerJsonEscaped); + + var json = GetBlockListJson(complexEditorJsonEscaped); + + var component = new BlockEditorComponent(); + var result = component.ReplaceBlockListUdis(json, guidFactory); + + // the expected result is that the subFeatures data is no longer escaped + var expected = ReplaceGuids(GetBlockListJson(GetGridJson(innerJson)), guids, + _contentGuid1, _contentGuid2, _settingsGuid1, + _subContentGuid1, _subContentGuid2, _subSettingsGuid1); + + var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); + var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); + Console.WriteLine(expectedJson); + Console.WriteLine(resultJson); + Assert.AreEqual(expectedJson, resultJson); + } + + private string GetBlockListJson(string subFeatures, + string contentGuid1 = _contentGuid1, + string contentGuid2 = _contentGuid2, + string settingsGuid1 = _settingsGuid1) + { + return @"{ + ""layout"": + { + ""Umbraco.BlockList"": [ + { + ""contentUdi"": """ + (contentGuid1.IsNullOrWhiteSpace() ? string.Empty : GuidUdi.Create(Constants.UdiEntityType.Element, Guid.Parse(contentGuid1)).ToString()) + @""" + }, + { + ""contentUdi"": ""umb://element/" + contentGuid2 + @""", + ""settingsUdi"": ""umb://element/" + settingsGuid1 + @""" + } + ] + }, + ""contentData"": [ + { + ""contentTypeKey"": ""d6ce4a86-91a2-45b3-a99c-8691fc1fb020"", + ""udi"": """ + (contentGuid1.IsNullOrWhiteSpace() ? string.Empty : GuidUdi.Create(Constants.UdiEntityType.Element, Guid.Parse(contentGuid1)).ToString()) + @""", + ""featureName"": ""Hello"", + ""featureDetails"": ""World"" + }, + { + ""contentTypeKey"": ""d6ce4a86-91a2-45b3-a99c-8691fc1fb020"", + ""udi"": ""umb://element/" + contentGuid2 + @""", + ""featureName"": ""Another"", + ""featureDetails"": ""Feature""" + (subFeatures == null ? string.Empty : (@", ""subFeatures"": " + subFeatures)) + @" + } + ], + ""settingsData"": [ + { + ""contentTypeKey"": ""d6ce4a86-91a2-45b3-a99c-8691fc1fb020"", + ""udi"": ""umb://element/" + settingsGuid1 + @""", + ""featureName"": ""Setting 1"", + ""featureDetails"": ""Setting 2"" + }, + ] +}"; + } + + private string GetGridJson(string subBlockList) + { + return @"{ + ""name"": ""1 column layout"", + ""sections"": [ + { + ""grid"": ""12"", + ""rows"": [ + { + ""name"": ""Article"", + ""id"": ""b4f6f651-0de3-ef46-e66a-464f4aaa9c57"", + ""areas"": [ + { + ""grid"": ""4"", + ""controls"": [ + { + ""value"": ""I am quote"", + ""editor"": { + ""alias"": ""quote"", + ""view"": ""textstring"" + }, + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }, + { + ""grid"": ""8"", + ""controls"": [ + { + ""value"": ""Header"", + ""editor"": { + ""alias"": ""headline"", + ""view"": ""textstring"" + }, + ""styles"": null, + ""config"": null + }, + { + ""value"": " + subBlockList + @", + ""editor"": { + ""alias"": ""madeUpNestedContent"", + ""view"": ""madeUpNestedContentInGrid"" + }, + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }] + }] +}"; + } + + private string ReplaceGuids(string json, List newGuids, params string[] oldGuids) + { + for (var i = 0; i < oldGuids.Length; i++) + { + var old = oldGuids[i]; + json = json.Replace(old, newGuids[i].ToString("N")); + } + return json; + } + + } +} diff --git a/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs b/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs index 1b83c048d2..5b7e220123 100644 --- a/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs +++ b/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs @@ -9,6 +9,7 @@ using Umbraco.Web.Compose; namespace Umbraco.Tests.PropertyEditors { + [TestFixture] public class NestedContentPropertyComponentTests { diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index f803f1cd1d..004945bb46 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -147,6 +147,7 @@ + diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 0057a47383..4a4544a771 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -72,11 +72,11 @@ "gulp-wrap": "0.15.0", "gulp-wrap-js": "0.4.1", "jasmine-core": "3.5.0", + "jsdom": "16.4.0", "karma": "4.4.1", - "karma-chrome-launcher": "^3.1.0", + "karma-jsdom-launcher": "^8.0.2", "karma-jasmine": "2.0.1", "karma-junit-reporter": "2.0.1", - "karma-phantomjs-launcher": "1.0.4", "karma-spec-reporter": "0.0.32", "less": "3.10.3", "lodash": "4.17.19", diff --git a/src/Umbraco.Web.UI.Client/src/common/filters/compareArrays.filter.js b/src/Umbraco.Web.UI.Client/src/common/filters/compareArrays.filter.js index 56bba61c52..0cbf3077e6 100644 --- a/src/Umbraco.Web.UI.Client/src/common/filters/compareArrays.filter.js +++ b/src/Umbraco.Web.UI.Client/src/common/filters/compareArrays.filter.js @@ -2,6 +2,10 @@ angular.module("umbraco.filters") .filter('compareArrays', function() { return function inArray(array, compareArray, compareProperty) { + if (!compareArray || !compareArray.length) { + return [...array]; + } + var result = []; array.forEach(function(arrayItem){ diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js index ffb1971169..dfa0eae297 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js @@ -4,39 +4,113 @@ * * @description * Added in Umbraco 8.7. Service for dealing with Block Editors. - * + * * Block Editor Service provides the basic features for a block editor. * The main feature is the ability to create a Model Object which takes care of your data for your Block Editor. - * - * + * + * * ##Samples * * ####Instantiate a Model Object for your property editor: - * + * *
  *     modelObject = blockEditorService.createModelObject(vm.model.value, vm.model.editor, vm.model.config.blocks, $scope);
  *     modelObject.load().then(onLoaded);
  * 
* - * + * * See {@link umbraco.services.blockEditorModelObject BlockEditorModelObject} for more samples. - * + * */ (function () { 'use strict'; + + /** + * When performing a runtime copy of Block Editors entries, we copy the ElementType Data Model and inner IDs are kept identical, to ensure new IDs are changed on paste we need to provide a resolver for the ClipboardService. + */ + angular.module('umbraco').run(['clipboardService', 'udiService', function (clipboardService, udiService) { + + function replaceUdi(obj, key, dataObject) { + var udi = obj[key]; + var newUdi = udiService.create("element"); + obj[key] = newUdi; + dataObject.forEach((data) => { + if (data.udi === udi) { + data.udi = newUdi; + } + }); + } + function replaceUdisOfObject(obj, propValue) { + for (var k in obj) { + if(k === "contentUdi") { + replaceUdi(obj, k, propValue.contentData); + } else if(k === "settingsUdi") { + replaceUdi(obj, k, propValue.settingsData); + } else { + // lets crawl through all properties of layout to make sure get captured all `contentUdi` and `settingsUdi` properties. + var propType = typeof obj[k]; + if(propType === "object" || propType === "array") { + replaceUdisOfObject(obj[k], propValue) + } + } + } + } + function replaceElementTypeBlockListUDIsResolver(obj, propClearingMethod) { + replaceRawBlockListUDIsResolver(obj.value, propClearingMethod); + } + + clipboardService.registerPastePropertyResolver(replaceElementTypeBlockListUDIsResolver, clipboardService.TYPES.ELEMENT_TYPE); + + + function replaceRawBlockListUDIsResolver(value, propClearingMethod) { + if (typeof value === "object") { + + // we got an object, and it has these three props then we are most likely dealing with a Block Editor. + if ((value.layout !== undefined && value.contentData !== undefined && value.settingsData !== undefined)) { + + replaceUdisOfObject(value.layout, value); + + // replace UDIs for inner properties of this Block Editors content data. + if(value.contentData.length > 0) { + value.contentData.forEach((item) => { + for (var k in item) { + propClearingMethod(item[k], clipboardService.TYPES.RAW); + } + }); + } + // replace UDIs for inner properties of this Block Editors settings data. + if(value.settingsData.length > 0) { + value.settingsData.forEach((item) => { + for (var k in item) { + propClearingMethod(item[k], clipboardService.TYPES.RAW); + } + }); + } + + } + } + } + + clipboardService.registerPastePropertyResolver(replaceRawBlockListUDIsResolver, clipboardService.TYPES.RAW); + + }]); + + + + function blockEditorService(blockEditorModelObject) { /** * @ngdocs function * @name createModelObject * @methodOf umbraco.services.blockEditorService - * + * * @description * Create a new Block Editor Model Object. * See {@link umbraco.services.blockEditorModelObject blockEditorModelObject} - * + * * @see umbraco.services.blockEditorModelObject * @param {object} propertyModelValue data object of the property editor, usually model.value. * @param {string} propertyEditorAlias alias of the property. diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 920ba1c58d..868b8baba7 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -13,8 +13,7 @@ (function () { 'use strict'; - - function blockEditorModelObjectFactory($interpolate, $q, udiService, contentResource, localizationService, umbRequestHelper) { + function blockEditorModelObjectFactory($interpolate, $q, udiService, contentResource, localizationService, umbRequestHelper, clipboardService) { /** * Simple mapping from property model content entry to editing model, @@ -231,7 +230,8 @@ var notSupportedProperties = [ "Umbraco.Tags", "Umbraco.UploadField", - "Umbraco.ImageCropper" + "Umbraco.ImageCropper", + "Umbraco.NestedContent" ]; @@ -524,12 +524,11 @@ } var blockConfiguration = this.getBlockConfiguration(dataModel.contentTypeKey); - var contentScaffold; + var contentScaffold = null; if (blockConfiguration === null) { - console.error("The block of " + contentUdi + " is not being initialized because its contentTypeKey('" + dataModel.contentTypeKey + "') is not allowed for this PropertyEditor"); - } - else { + console.warn("The block of " + contentUdi + " is not being initialized because its contentTypeKey('" + dataModel.contentTypeKey + "') is not allowed for this PropertyEditor"); + } else { contentScaffold = this.getScaffoldFromKey(blockConfiguration.contentElementTypeKey); if (contentScaffold === null) { console.error("The block of " + contentUdi + " is not begin initialized cause its Element Type was not loaded."); @@ -539,10 +538,9 @@ if (blockConfiguration === null || contentScaffold === null) { blockConfiguration = { - label: "Unsupported Block", + label: "Unsupported", unsupported: true }; - contentScaffold = {}; } var blockObject = {}; @@ -567,10 +565,14 @@ , 10); // make basics from scaffold - blockObject.content = Utilities.copy(contentScaffold); - ensureUdiAndKey(blockObject.content, contentUdi); + if(contentScaffold !== null) {// We might not have contentScaffold + blockObject.content = Utilities.copy(contentScaffold); + ensureUdiAndKey(blockObject.content, contentUdi); - mapToElementModel(blockObject.content, dataModel); + mapToElementModel(blockObject.content, dataModel); + } else { + blockObject.content = null; + } blockObject.data = dataModel; blockObject.layout = layoutEntry; @@ -672,11 +674,8 @@ * @param {Object} blockObject The BlockObject to be removed and destroyed. */ removeDataAndDestroyModel: function (blockObject) { - var udi = blockObject.content.udi; - var settingsUdi = null; - if (blockObject.settings) { - settingsUdi = blockObject.settings.udi; - } + var udi = blockObject.layout.contentUdi; + var settingsUdi = blockObject.layout.settingsUdi || null; this.destroyBlockObject(blockObject); this.removeDataByUdi(udi); if (settingsUdi) { @@ -745,7 +744,7 @@ */ createFromElementType: function (elementTypeDataModel) { - elementTypeDataModel = Utilities.copy(elementTypeDataModel); + elementTypeDataModel = clipboardService.parseContentForPaste(elementTypeDataModel, clipboardService.TYPES.ELEMENT_TYPE); var contentElementTypeKey = elementTypeDataModel.contentTypeKey; diff --git a/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js b/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js index 0d2ca6623b..58ed07367e 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js @@ -13,7 +13,28 @@ function clipboardService(notificationsService, eventsService, localStorageService, iconHelper) { - var clearPropertyResolvers = []; + const TYPES = {}; + TYPES.ELEMENT_TYPE = "elementType"; + TYPES.RAW = "raw"; + + var clearPropertyResolvers = {}; + var pastePropertyResolvers = {}; + var clipboardTypeResolvers = {}; + + clipboardTypeResolvers[TYPES.ELEMENT_TYPE] = function(data, propMethod) { + for (var t = 0; t < data.variants[0].tabs.length; t++) { + var tab = data.variants[0].tabs[t]; + for (var p = 0; p < tab.properties.length; p++) { + var prop = tab.properties[p]; + propMethod(prop, TYPES.ELEMENT_TYPE); + } + } + } + clipboardTypeResolvers[TYPES.RAW] = function(data, propMethod) { + for (var p = 0; p < data.length; p++) { + propMethod(data[p], TYPES.RAW); + } + } var STORAGE_KEY = "umbClipboardService"; @@ -57,28 +78,29 @@ function clipboardService(notificationsService, eventsService, localStorageServi } - function clearPropertyForStorage(prop) { + function resolvePropertyForStorage(prop, type) { - for (var i=0; i prepareEntryForStorage(data, firstLevelClearupMethod)); + var copiedDatas = datas.map(data => prepareEntryForStorage(type, data, firstLevelClearupMethod)); // remove previous copies of this entry: storage.entries = storage.entries.filter( diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockeditor/blockeditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockeditor/blockeditor.controller.js index a08a05b0f7..88cda027a8 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockeditor/blockeditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockeditor/blockeditor.controller.js @@ -7,8 +7,8 @@ angular.module("umbraco") vm.tabs = []; localizationService.localizeMany([ - vm.model.liveEditing ? "prompt_discardChanges" : "general_close", - vm.model.liveEditing ? "buttons_confirmActionConfirm" : "buttons_submitChanges" + vm.model.createFlow ? "general_cancel" : (vm.model.liveEditing ? "prompt_discardChanges" : "general_close"), + vm.model.createFlow ? "general_create" : (vm.model.liveEditing ? "buttons_confirmActionConfirm" : "buttons_submitChanges") ]).then(function (data) { vm.closeLabel = data[0]; vm.submitLabel = data[1]; @@ -68,16 +68,16 @@ angular.module("umbraco") // * It would have a 'commit' method to commit the removed errors - which we would call in the formHelper.submitForm when it's successful // * It would have a 'rollback' method to reset the removed errors - which we would call here - - if (vm.blockForm.$dirty === true) { - localizationService.localizeMany(["prompt_discardChanges", "blockEditor_blockHasChanges"]).then(function (localizations) { + if (vm.model.createFlow === true || vm.blockForm.$dirty === true) { + var labels = vm.model.createFlow === true ? ["blockEditor_confirmCancelBlockCreationHeadline", "blockEditor_confirmCancelBlockCreationMessage"] : ["prompt_discardChanges", "blockEditor_blockHasChanges"]; + localizationService.localizeMany(labels).then(function (localizations) { const confirm = { title: localizations[0], view: "default", content: localizations[1], submitButtonLabelKey: "general_discard", submitButtonStyle: "danger", - closeButtonLabelKey: "general_cancel", + closeButtonLabelKey: "prompt_stay", submit: function () { overlayService.close(); vm.model.close(vm.model); @@ -88,11 +88,10 @@ angular.module("umbraco") }; overlayService.open(confirm); }); - - return; + } else { + vm.model.close(vm.model); } - // TODO: check if content/settings has changed and ask user if they are sure. - vm.model.close(vm.model); + } } diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js index 487a73f948..0e6dc76725 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js @@ -2,7 +2,7 @@ * @ngdoc controller * @name Umbraco.Editors.Content.CreateController * @function - * + * * @description * The controller for the content creation dialog */ @@ -22,20 +22,17 @@ function contentCreateController($scope, function initialize() { $scope.loading = true; $scope.allowedTypes = null; - + var getAllowedTypes = contentTypeResource.getAllowedTypes($scope.currentNode.id).then(function (data) { $scope.allowedTypes = iconHelper.formatContentTypeIcons(data); - if ($scope.allowedTypes.length === 0) { - contentTypeResource.getCount().then(function(count) { - $scope.countTypes = count; - }); - } }); var getCurrentUser = authResource.getCurrentUser().then(function (currentUser) { - if (currentUser.allowedSections.indexOf("settings") > -1) { - $scope.hasSettingsAccess = true; + + $scope.hasSettingsAccess = currentUser.allowedSections.indexOf("settings") > -1; + if ($scope.hasSettingsAccess) { + if ($scope.currentNode.id > -1) { - contentResource.getById($scope.currentNode.id).then(function (data) { + return contentResource.getById($scope.currentNode.id).then(function (data) { $scope.contentTypeId = data.contentTypeId; }); } @@ -43,6 +40,12 @@ function contentCreateController($scope, }); $q.all([getAllowedTypes, getCurrentUser]).then(function() { + if ($scope.hasSettingsAccess === true && $scope.allowedTypes.length === 0) { + return contentTypeResource.getCount().then(function(count) { + $scope.countTypes = count; + }); + } + }).then(function() { $scope.loading = false; }); @@ -60,13 +63,13 @@ function contentCreateController($scope, .path("/content/content/edit/" + $scope.currentNode.id) .search("doctype", docType.alias) .search("create", "true") - /* when we create a new node we want to make sure it uses the same + /* when we create a new node we want to make sure it uses the same language as what is selected in the tree */ .search("cculture", mainCulture) - /* when we create a new node we must make sure that any previously + /* when we create a new node we must make sure that any previously opened segments is reset */ .search("csegment", null) - /* when we create a new node we must make sure that any previously + /* when we create a new node we must make sure that any previously used blueprint is reset */ .search("blueprintId", null); close(); @@ -92,8 +95,6 @@ function contentCreateController($scope, } else { createBlank(docType); } - - navigationService.hideDialog(); } function createFromBlueprint(blueprintId) { @@ -127,7 +128,7 @@ function contentCreateController($scope, $scope.createOrSelectBlueprintIfAny = createOrSelectBlueprintIfAny; $scope.createFromBlueprint = createFromBlueprint; - // the current node changes behind the scenes when the context menu is clicked without closing + // the current node changes behind the scenes when the context menu is clicked without closing // the default menu first, so we must watch the current node and re-initialize accordingly var unbindModelWatcher = $scope.$watch("currentNode", initialize); $scope.$on('$destroy', function () { diff --git a/src/Umbraco.Web.UI.Client/src/views/macros/macros.create.controller.js b/src/Umbraco.Web.UI.Client/src/views/macros/macros.create.controller.js index e8c5c550d0..baae8e085a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/macros/macros.create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/macros/macros.create.controller.js @@ -13,7 +13,7 @@ function MacrosCreateController($scope, $location, macroResource, navigationServ function createItem() { - if (formHelper.submitForm({ scope: $scope, formCtrl: this.createMacroForm })) { + if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createMacroForm })) { var node = $scope.currentNode; @@ -25,7 +25,7 @@ function MacrosCreateController($scope, $location, macroResource, navigationServ navigationService.syncTree({ tree: "macros", path: currPath + "," + data, forceReload: true, activate: true }); // reset form state - formHelper.resetForm({ scope: $scope, formCtrl: this.createMacroForm }); + formHelper.resetForm({ scope: $scope, formCtrl: $scope.createMacroForm }); // navigate to edit view var currentSection = appState.getSectionState("currentSection"); @@ -33,7 +33,7 @@ function MacrosCreateController($scope, $location, macroResource, navigationServ }, function (err) { - formHelper.resetForm({ scope: $scope, formCtrl: this.createMacroForm, hasErrors: true }); + formHelper.resetForm({ scope: $scope, formCtrl: $scope.createMacroForm, hasErrors: true }); if (err.data && err.data.message) { notificationsService.error(err.data.message); navigationService.hideMenu(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklistentryeditors/unsupportedblock/unsupportedblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklistentryeditors/unsupportedblock/unsupportedblock.editor.html index d860b44b60..0317105a66 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklistentryeditors/unsupportedblock/unsupportedblock.editor.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklistentryeditors/unsupportedblock/unsupportedblock.editor.html @@ -4,9 +4,11 @@ {{block.label}}
- This Block is no longer supported in this context.
+ This content is no longer supported in this context.
You might want to remove this block, or contact your developer to take actions for making this block available again.

- Learn about this circumstance +
Block data:

     
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js index ba0d4415f5..7c608cb3f4 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js @@ -50,7 +50,7 @@ unsubscribe.push(eventsService.on("editors.documentType.saved", updateUsedElementTypes)); vm.requestRemoveBlockByIndex = function (index) { - localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockMessage", "blockEditor_confirmDeleteBlockNotice"]).then(function (data) { + localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockTypeMessage", "blockEditor_confirmDeleteBlockTypeNotice"]).then(function (data) { var contentElementType = vm.getElementTypeByKey($scope.model.value[index].contentElementTypeKey); overlayService.confirmDelete({ title: data[0], diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-property-editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-property-editor.less index a2c124a6ea..5a8c21250b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-property-editor.less +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-property-editor.less @@ -38,6 +38,45 @@ opacity: 1; } } + + &.--show-validation { + ng-form.ng-invalid-val-server-match-content > & { + border: 2px solid @formErrorText; + border-radius: @baseBorderRadius; + &::after { + content: "!"; + position: absolute; + top: -12px; + right: -12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 13px; + text-align: center; + font-weight: bold; + background-color: @errorBackground; + color: @errorText; + border: 2px solid @white; + font-weight: 900; + + animation-duration: 1.4s; + animation-iteration-count: infinite; + animation-name: umb-block-list__block--content--badge-bounce; + animation-timing-function: ease; + @keyframes umb-block-list__block--content--badge-bounce { + 0% { transform: translateY(0); } + 20% { transform: translateY(-6px); } + 40% { transform: translateY(0); } + 55% { transform: translateY(-3px); } + 70% { transform: translateY(0); } + 100% { transform: translateY(0); } + } + } + } + } } ng-form.ng-invalid-val-server-match-settings > .umb-block-list__block > .umb-block-list__block--actions { opacity: 1; @@ -109,44 +148,6 @@ ng-form.ng-invalid-val-server-match-settings > .umb-block-list__block > .umb-blo box-sizing: border-box; } - &.--show-validation { - ng-form.ng-invalid-val-server-match-content > .umb-block-list__block > & > div { - border: 2px solid @formErrorText; - border-radius: @baseBorderRadius; - &::after { - content: "!"; - position: absolute; - top: -12px; - right: -12px; - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 50%; - font-size: 13px; - text-align: center; - font-weight: bold; - background-color: @errorBackground; - color: @errorText; - border: 2px solid @white; - font-weight: 900; - - animation-duration: 1.4s; - animation-iteration-count: infinite; - animation-name: umb-block-list__block--content--badge-bounce; - animation-timing-function: ease; - @keyframes umb-block-list__block--content--badge-bounce { - 0% { transform: translateY(0); } - 20% { transform: translateY(-6px); } - 40% { transform: translateY(0); } - 55% { transform: translateY(-3px); } - 70% { transform: translateY(0); } - 100% { transform: translateY(0); } - } - } - } - } } .blockelement__draggable-element { @@ -187,25 +188,29 @@ ng-form.ng-invalid-val-server-match-settings > .umb-block-list__block > .umb-blo > .__plus { position: absolute; - pointer-events: none; // lets stop avoiding the mouse values in JS move event. - width: 24px; - height: 24px; - padding: 0; - border-radius: 3em; - border: 2px solid @blueMid; display: flex; justify-content: center; align-items: center; + pointer-events: none; // lets stop avoiding the mouse values in JS move event. + box-sizing: border-box; + width: 28px; + height: 28px; + margin-left: -16px - 8px; + margin-top: -16px; + padding: 0; + border-radius: 3em; + border: 2px solid @blueMid; color: @blueMid; + line-height: 22px; font-size: 20px; font-weight: 800; background-color: rgba(255, 255, 255, .96); box-shadow: 0 0 0 2px rgba(255, 255, 255, .96); - transform: scale(0) translate(-80%, -50%); + transform: scale(0); transition: transform 240ms ease-in; - animation: umb-block-list__block--create-button_after 800ms ease-in-out infinite; + animation: umb-block-list__block--create-button__plus 400ms ease-in-out infinite; - @keyframes umb-block-list__block--create-button_after { + @keyframes umb-block-list__block--create-button__plus { 0% { color: rgba(@blueMid, 0.8); } 50% { color: rgba(@blueMid, 1); } 100% { color: rgba(@blueMid, 0.8); } @@ -223,7 +228,7 @@ ng-form.ng-invalid-val-server-match-settings > .umb-block-list__block > .umb-blo transition-duration: 120ms; > .__plus { - transform: scale(1) translate(-80%, -50%); + transform: scale(1); transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275); } } diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-row.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-row.html index c2657985cf..c692c81eaf 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-row.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umb-block-list-row.html @@ -1,9 +1,9 @@ -
+
entry.contentUdi === block.content.udi); + var layoutIndex = vm.layout.findIndex(entry => entry.contentUdi === block.layout.contentUdi); if (layoutIndex === -1) { - throw new Error("Could not find layout entry of block with udi: "+block.content.udi) + throw new Error("Could not find layout entry of block with udi: "+block.layout.contentUdi) } setDirty(); @@ -310,7 +326,9 @@ blockObject.active = true; } - function editBlock(blockObject, openSettings, blockIndex, parentForm) { + function editBlock(blockObject, openSettings, blockIndex, parentForm, options) { + + options = options || {}; // this must be set if (blockIndex === undefined) { @@ -344,6 +362,7 @@ $parentForm: parentForm || vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form) hideContent: blockObject.hideContentInOverlay, openSettings: openSettings === true, + createFlow: options.createFlow === true, liveEditing: liveEditing, title: blockObject.label, view: "views/common/infiniteeditors/blockeditor/blockeditor.html", @@ -358,15 +377,17 @@ blockObject.active = false; editorService.close(); }, - close: function() { - - if (liveEditing === true) { - // revert values when closing in liveediting mode. - blockObject.retrieveValuesFrom(blockContentClone, blockSettingsClone); - } - - if (wasNotActiveBefore === true) { - blockObject.active = false; + close: function(blockEditorModel) { + if (blockEditorModel.createFlow) { + deleteBlock(blockObject); + } else { + if (liveEditing === true) { + // revert values when closing in liveediting mode. + blockObject.retrieveValuesFrom(blockContentClone, blockSettingsClone); + } + if (wasNotActiveBefore === true) { + blockObject.active = false; + } } editorService.close(); } @@ -406,7 +427,7 @@ size: (amountOfAvailableTypes > 8 ? "medium" : "small"), filter: (amountOfAvailableTypes > 8), clickPasteItem: function(item, mouseEvent) { - if (item.type === "elementTypeArray") { + if (Array.isArray(item.pasteData)) { var indexIncrementor = 0; item.pasteData.forEach(function (entry) { if (requestPasteFromClipboard(createIndex + indexIncrementor, entry)) { @@ -432,7 +453,7 @@ if (inlineEditing === true) { activateBlock(vm.layout[createIndex].$block); } else if (inlineEditing === false && vm.layout[createIndex].$block.hideContentInOverlay !== true) { - editBlock(vm.layout[createIndex].$block, false, createIndex, blockPickerModel.$parentForm); + editBlock(vm.layout[createIndex].$block, false, createIndex, blockPickerModel.$parentForm, {createFlow: true}); } } } @@ -448,42 +469,28 @@ }; blockPickerModel.clickClearClipboard = function ($event) { - clipboardService.clearEntriesOfType("elementType", vm.availableContentTypesAliases); - clipboardService.clearEntriesOfType("elementTypeArray", vm.availableContentTypesAliases); + clipboardService.clearEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, vm.availableContentTypesAliases); }; blockPickerModel.clipboardItems = []; - var singleEntriesForPaste = clipboardService.retriveEntriesOfType("elementType", vm.availableContentTypesAliases); - singleEntriesForPaste.forEach(function (entry) { - blockPickerModel.clipboardItems.push( - { - type: "elementType", - date: entry.date, - pasteData: entry.data, - blockConfigModel: modelObject.getScaffoldFromAlias(entry.alias), - elementTypeModel: { - name: entry.label, - icon: entry.icon - } + var entriesForPaste = clipboardService.retriveEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, vm.availableContentTypesAliases); + entriesForPaste.forEach(function (entry) { + var pasteEntry = { + type: clipboardService.TYPES.ELEMENT_TYPE, + date: entry.date, + pasteData: entry.data, + elementTypeModel: { + name: entry.label, + icon: entry.icon } - ); - }); - - var arrayEntriesForPaste = clipboardService.retriveEntriesOfType("elementTypeArray", vm.availableContentTypesAliases); - arrayEntriesForPaste.forEach(function (entry) { - blockPickerModel.clipboardItems.push( - { - type: "elementTypeArray", - date: entry.date, - pasteData: entry.data, - blockConfigModel: {}, // no block configuration for paste items of elementTypeArray. - elementTypeModel: { - name: entry.label, - icon: entry.icon - } - } - ); + } + if(Array.isArray(pasteEntry.data) === false) { + pasteEntry.blockConfigModel = modelObject.getScaffoldFromAlias(entry.alias); + } else { + pasteEntry.blockConfigModel = {}; + } + blockPickerModel.clipboardItems.push(pasteEntry); }); blockPickerModel.clipboardItems.sort( (a, b) => { @@ -513,11 +520,11 @@ } localizationService.localize("clipboard_labelForArrayOfItemsFrom", [vm.model.label, contentNodeName]).then(function(localizedLabel) { - clipboardService.copyArray("elementTypeArray", aliases, elementTypesToCopy, localizedLabel, "icon-thumbnail-list", vm.model.id); + clipboardService.copyArray(clipboardService.TYPES.ELEMENT_TYPE, aliases, elementTypesToCopy, localizedLabel, "icon-thumbnail-list", vm.model.id); }); } function copyBlock(block) { - clipboardService.copy("elementType", block.content.contentTypeAlias, block.content, block.label); + clipboardService.copy(clipboardService.TYPES.ELEMENT_TYPE, block.content.contentTypeAlias, block.content, block.label); } function requestPasteFromClipboard(index, pasteEntry) { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js index 206fb0bb3a..4531612cba 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js @@ -41,6 +41,9 @@ // Guess we'll leave it for now but means all things need to be copied to the $scope and then all // primitives need to be watched. + // let the Block know about its form + model.block.setParentForm(model.parentForm); + $scope.block = model.block; $scope.api = model.api; $scope.index = model.index; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.controller.js index 10668808a5..0d55a75233 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.controller.js @@ -1,5 +1,5 @@ angular.module("umbraco").controller("Umbraco.PropertyEditors.CheckboxListController", - function ($scope) { + function ($scope, validationMessageService) { var vm = this; @@ -8,6 +8,8 @@ angular.module("umbraco").controller("Umbraco.PropertyEditors.CheckboxListContro vm.change = change; function init() { + + vm.uniqueId = String.CreateGuid(); // currently the property editor will onyl work if our input is an object. if (Utilities.isObject($scope.model.config.items)) { @@ -35,6 +37,12 @@ angular.module("umbraco").controller("Umbraco.PropertyEditors.CheckboxListContro //watch the model.value in case it changes so that we can keep our view model in sync $scope.$watchCollection("model.value", updateViewModel); } + + // Set the message to use for when a mandatory field isn't completed. + // Will either use the one provided on the property type or a localised default. + validationMessageService.getMandatoryMessage($scope.model.validation).then(function (value) { + $scope.mandatoryMessage = value; + }); } diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.html index 20f24728dd..4207a327ad 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/checkboxlist/checkboxlist.html @@ -1,7 +1,16 @@ 
-
    -
  • - -
  • -
+ + + +
    +
  • + +
  • +
+ +
+

{{mandatoryMessage}}

+
+ +
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 index 9212019f4a..ffd22bcfe6 100644 --- 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 @@ -22,18 +22,18 @@ // Loop through all inner properties: for (var k in obj) { - propClearingMethod(obj[k]); + propClearingMethod(obj[k], clipboardService.TYPES.RAW); } } } } - clipboardService.registrerClearPropertyResolver(clearNestedContentPropertiesForStorage) + clipboardService.registerClearPropertyResolver(clearNestedContentPropertiesForStorage, clipboardService.TYPES.ELEMENT_TYPE) function clearInnerNestedContentPropertiesForStorage(prop, propClearingMethod) { - // if we got an array, and it has a entry with ncContentTypeAlias this meants that we are dealing with a NestedContent property inside a NestedContent property. + // if we got an array, and it has a entry with ncContentTypeAlias this meants that we are dealing with a NestedContent property data. if ((Array.isArray(prop) && prop.length > 0 && prop[0].ncContentTypeAlias !== undefined)) { for (var i = 0; i < prop.length; i++) { @@ -44,13 +44,13 @@ // Loop through all inner properties: for (var k in obj) { - propClearingMethod(obj[k]); + propClearingMethod(obj[k], clipboardService.TYPES.RAW); } } } } - clipboardService.registrerClearPropertyResolver(clearInnerNestedContentPropertiesForStorage) + clipboardService.registerClearPropertyResolver(clearInnerNestedContentPropertiesForStorage, clipboardService.TYPES.RAW) }]); angular @@ -130,7 +130,7 @@ } localizationService.localize("clipboard_labelForArrayOfItemsFrom", [model.label, nodeName]).then(function (data) { - clipboardService.copyArray("elementTypeArray", aliases, vm.nodes, data, "icon-thumbnail-list", model.id, clearNodeForCopy); + clipboardService.copyArray(clipboardService.TYPES.ELEMENT_TYPE, aliases, vm.nodes, data, "icon-thumbnail-list", model.id, clearNodeForCopy); }); } @@ -210,7 +210,7 @@ size: availableItems.length > 6 ? "medium" : "small", availableItems: availableItems, clickPasteItem: function (item) { - if (item.type === "elementTypeArray") { + if (Array.isArray(item.data)) { _.each(item.data, function (entry) { pasteFromClipboard(entry); }); @@ -238,10 +238,9 @@ dialog.pasteItems = []; - var singleEntriesForPaste = clipboardService.retriveEntriesOfType("elementType", contentTypeAliases); - _.each(singleEntriesForPaste, function (entry) { + var entriesForPaste = clipboardService.retriveEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, contentTypeAliases); + _.each(entriesForPaste, function (entry) { dialog.pasteItems.push({ - type: "elementType", date: entry.date, name: entry.label, data: entry.data, @@ -249,18 +248,7 @@ }); }); - var arrayEntriesForPaste = clipboardService.retriveEntriesOfType("elementTypeArray", contentTypeAliases); - _.each(arrayEntriesForPaste, function (entry) { - dialog.pasteItems.push({ - type: "elementTypeArray", - date: entry.date, - name: entry.label, - data: entry.data, - icon: entry.icon - }); - }); - - vm.overlayMenu.pasteItems.sort( (a, b) => { + dialog.pasteItems.sort( (a, b) => { return b.date - a.date }); @@ -269,8 +257,7 @@ dialog.clickClearPaste = function ($event) { $event.stopPropagation(); $event.preventDefault(); - clipboardService.clearEntriesOfType("elementType", contentTypeAliases); - clipboardService.clearEntriesOfType("elementTypeArray", contentTypeAliases); + clipboardService.clearEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, contentTypeAliases); dialog.pasteItems = [];// This dialog is not connected via the clipboardService events, so we need to update manually. dialog.overlayMenu.hideHeader = false; }; @@ -463,7 +450,7 @@ syncCurrentNode(); - clipboardService.copy("elementType", node.contentTypeAlias, node, null, null, null, clearNodeForCopy); + clipboardService.copy(clipboardService.TYPES.ELEMENT_TYPE, node.contentTypeAlias, node, null, null, null, clearNodeForCopy); $event.stopPropagation(); } @@ -474,6 +461,8 @@ return; } + newNode = clipboardService.parseContentForPaste(newNode, clipboardService.TYPES.ELEMENT_TYPE); + // generate a new key. newNode.key = String.CreateGuid(); @@ -485,7 +474,7 @@ } function checkAbilityToPasteContent() { - vm.showPaste = clipboardService.hasEntriesOfType("elementType", contentTypeAliases) || clipboardService.hasEntriesOfType("elementTypeArray", contentTypeAliases); + vm.showPaste = clipboardService.hasEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, contentTypeAliases); } eventsService.on("clipboardService.storageUpdate", checkAbilityToPasteContent); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.controller.js index 6bfde10e9c..b30c04fdb9 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.controller.js @@ -7,6 +7,8 @@ angular.module("umbraco").controller("Umbraco.PropertyEditors.RadioButtonsContro function init() { + vm.uniqueId = String.CreateGuid(); + //we can't really do anything if the config isn't an object if (Utilities.isObject($scope.model.config.items)) { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.html index 82952193e7..88f8e676a5 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/radiobuttons/radiobuttons.html @@ -3,7 +3,7 @@
  • -
-
+

{{mandatoryMessage}}

diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.controller.js index 4a7fff99f8..4f3cb6770b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.controller.js @@ -1,42 +1,38 @@ function textAreaController($scope, validationMessageService) { // macro parameter editor doesn't contains a config object, - // so we create a new one to hold any properties + // so we create a new one to hold any properties if (!$scope.model.config) { $scope.model.config = {}; } - $scope.model.count = 0; - if (!$scope.model.config.maxChars) { - $scope.model.config.maxChars = false; - } - - $scope.model.maxlength = false; - if ($scope.model.config && $scope.model.config.maxChars) { - $scope.model.maxlength = true; - } + $scope.maxChars = $scope.model.config.maxChars || 0; + $scope.maxCharsLimit = ($scope.model.config && $scope.model.config.maxChars > 0); + $scope.charsCount = 0; + $scope.nearMaxLimit = false; + $scope.validLength = true; $scope.$on("formSubmitting", function() { - if ($scope.isLengthValid()) { + if ($scope.validLength) { $scope.textareaFieldForm.textarea.$setValidity("maxChars", true); } else { $scope.textareaFieldForm.textarea.$setValidity("maxChars", false); } }); - $scope.isLengthValid = function() { - if (!$scope.model.maxlength) { - return true; - } - return $scope.model.config.maxChars >= $scope.model.count; + function checkLengthVadility() { + $scope.validLength = !($scope.maxCharsLimit === true && $scope.charsCount > $scope.maxChars); } - $scope.model.change = function () { + $scope.change = function () { if ($scope.model.value) { - $scope.model.count = $scope.model.value.length; + $scope.charsCount = $scope.model.value.length; + checkLengthVadility(); + $scope.nearMaxLimit = $scope.maxCharsLimit === true && $scope.validLength === true && $scope.charsCount > Math.max($scope.maxChars*.8, $scope.maxChars-50); } } - $scope.model.change(); + $scope.model.onValueChanged = $scope.change; + $scope.change(); // Set the message to use for when a mandatory field isn't completed. // Will either use the one provided on the property type or a localised default. diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.html index 87f6ffeac9..2f183c29f0 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textarea/textarea.html @@ -1,17 +1,29 @@
- + {{mandatoryMessage}} {{textareaFieldForm.textarea.errorMsg}} -
- %0% characters left. +
+

{{model.label}} %0% characters left.

+
-
- Maximum %0% characters, %1% too many. +
+

{{model.label}} Maximum %0% characters, %1% too many.

+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js index b47c3584b3..b7c740e749 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.controller.js @@ -4,37 +4,41 @@ function textboxController($scope, validationMessageService) { if (!$scope.model.config) { $scope.model.config = {}; } - $scope.model.count = 0; - if (!$scope.model.config.maxChars) { - // 500 is the maximum number that can be stored - // in the database, so set it to the max, even - // if no max is specified in the config - $scope.model.config.maxChars = 500; - } + + // 512 is the maximum number that can be stored + // in the database, so set it to the max, even + // if no max is specified in the config + $scope.maxChars = Math.min($scope.model.config.maxChars || 512, 512); + $scope.charsCount = 0; + $scope.nearMaxLimit = false; + $scope.validLength = true; $scope.$on("formSubmitting", function() { - if ($scope.isLengthValid()) { + if ($scope.validLength === true) { $scope.textboxFieldForm.textbox.$setValidity("maxChars", true); } else { $scope.textboxFieldForm.textbox.$setValidity("maxChars", false); } }); - $scope.isLengthValid = function() { - return $scope.model.config.maxChars >= $scope.model.count; + function checkLengthVadility() { + $scope.validLength = $scope.charsCount <= $scope.maxChars; } - $scope.model.change = function () { + $scope.change = function () { if ($scope.model.value) { - $scope.model.count = $scope.model.value.length; + $scope.charsCount = $scope.model.value.length; + checkLengthVadility(); + $scope.nearMaxLimit = $scope.validLength && $scope.charsCount > Math.max($scope.maxChars*.8, $scope.maxChars-25); } } - $scope.model.change(); + $scope.model.onValueChanged = $scope.change; + $scope.change(); // Set the message to use for when a mandatory field isn't completed. // Will either use the one provided on the property type or a localised default. validationMessageService.getMandatoryMessage($scope.model.validation).then(function(value) { $scope.mandatoryMessage = value; - }); + }); } angular.module('umbraco').controller("Umbraco.PropertyEditors.textboxController", textboxController); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html index 5d86259e93..5e135ea7d9 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/textbox/textbox.html @@ -1,27 +1,30 @@
- - + ng-keyup="change()" /> +

{{model.label}} {{textboxFieldForm.textbox.errorMsg}}

{{mandatoryMessage}}

-
-

{{model.label}} %0% characters left.

-

%0% characters left.

+
+

{{model.label}} %0% characters left.

+
-
-

{{model.label}} Maximum %0% characters, %1% too many.

- +
+

{{model.label}} Maximum %0% characters, %1% too many.

+
diff --git a/src/Umbraco.Web.UI.Client/src/views/relationtypes/create.controller.js b/src/Umbraco.Web.UI.Client/src/views/relationtypes/create.controller.js index b2fbee4b36..bd990961bf 100644 --- a/src/Umbraco.Web.UI.Client/src/views/relationtypes/create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/relationtypes/create.controller.js @@ -26,7 +26,7 @@ function RelationTypeCreateController($scope, $location, relationTypeResource, n } function createRelationType() { - if (formHelper.submitForm({ scope: $scope, formCtrl: this.createRelationTypeForm, statusMessage: "Creating relation type..." })) { + if (formHelper.submitForm({ scope: $scope, formCtrl: $scope.createRelationTypeForm, statusMessage: "Creating relation type..." })) { var node = $scope.currentNode; relationTypeResource.create(vm.relationType).then(function (data) { @@ -36,12 +36,12 @@ function RelationTypeCreateController($scope, $location, relationTypeResource, n var currentPath = node.path ? node.path : "-1"; navigationService.syncTree({ tree: "relationTypes", path: currentPath + "," + data, forceReload: true, activate: true }); - formHelper.resetForm({ scope: $scope, formCtrl: this.createRelationTypeForm }); + formHelper.resetForm({ scope: $scope, formCtrl: $scope.createRelationTypeForm }); var currentSection = appState.getSectionState("currentSection"); $location.path("/" + currentSection + "/relationTypes/edit/" + data); }, function (err) { - formHelper.resetForm({ scope: $scope, formCtrl: this.createRelationTypeForm, hasErrors: true }); + formHelper.resetForm({ scope: $scope, formCtrl: $scope.createRelationTypeForm, hasErrors: true }); if (err.data && err.data.message) { notificationsService.error(err.data.message); navigationService.hideMenu(); diff --git a/src/Umbraco.Web.UI.Client/test/config/karma.conf.js b/src/Umbraco.Web.UI.Client/test/config/karma.conf.js index d0f59c110f..c039e59ee1 100644 --- a/src/Umbraco.Web.UI.Client/test/config/karma.conf.js +++ b/src/Umbraco.Web.UI.Client/test/config/karma.conf.js @@ -100,14 +100,7 @@ module.exports = function (config) { // - PhantomJS // - IE (only Windows) // CLI --browsers Chrome,Firefox,Safari - browsers: ['ChromeHeadless'], - - customLaunchers: { - ChromeDebugging: { - base: 'Chrome', - flags: ['--remote-debugging-port=9333'] - } - }, + browsers: ['jsdom'], // allow waiting a bit longer, some machines require this @@ -123,11 +116,9 @@ module.exports = function (config) { plugins: [ require('karma-jasmine'), - require('karma-phantomjs-launcher'), - require('karma-chrome-launcher'), + require('karma-jsdom-launcher'), require('karma-junit-reporter'), require('karma-spec-reporter') - ], // the default configuration diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml index dff3615883..9610d7ab57 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml @@ -1845,8 +1845,9 @@ Mange hilsner fra Umbraco robotten Tilføj speciel visning Tilføj instillinger Overskriv label form - %0%.]]> - Indhold der benytter sig af denne blok vil gå bort. + %0%.]]> + %0%.]]> + Indholdet vil stadigt eksistere, men redigering af dette indhold vil ikke være muligt. Indholdet vil blive vist som ikke understøttet indhold. Billede Tilføj billede @@ -1856,6 +1857,8 @@ Mange hilsner fra Umbraco robotten Avanceret Skjuld indholds editoren Du har lavet ændringer til dette indhold. Er du sikker på at du vil kassere dem? + Annuller oprettelse? + diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml index 13f076a492..a7479a3392 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml @@ -2475,8 +2475,9 @@ To manage your website, simply open the Umbraco back office and start adding con Add custom view Add settings Overwrite label template - %0%.]]> - Content using this block will be lost. + %0%.]]> + %0%.]]> + The content of this block will still be present, editing of this content will no longer be available and will be shown as unsupported content. Thumbnail Add thumbnail @@ -2486,6 +2487,8 @@ To manage your website, simply open the Umbraco back office and start adding con Advanced Force hide content editor You have made changes to this content. Are you sure you want to discard them? + Discard creation? + What are Content Templates? diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml index c44b7d15a8..d00af6fe5e 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml @@ -2497,8 +2497,9 @@ To manage your website, simply open the Umbraco back office and start adding con Add custom view Add settings Overwrite label template - %0%.]]> - Content using this block will be lost. + %0%.]]> + %0%.]]> + The content of this block will still be present, editing of this content will no longer be available and will be shown as unsupported content. Thumbnail Add thumbnail @@ -2508,6 +2509,8 @@ To manage your website, simply open the Umbraco back office and start adding con Advanced Force hide content editor You have made changes to this content. Are you sure you want to discard them? + Discard creation? + What are Content Templates? diff --git a/src/Umbraco.Web/Compose/BlockEditorComponent.cs b/src/Umbraco.Web/Compose/BlockEditorComponent.cs new file mode 100644 index 0000000000..a8b4cfb8ca --- /dev/null +++ b/src/Umbraco.Web/Compose/BlockEditorComponent.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.Core.Models.Blocks; +using Umbraco.Core.PropertyEditors; + +namespace Umbraco.Web.Compose +{ + /// + /// A component for Block editors used to bind to events + /// + public class BlockEditorComponent : IComponent + { + private ComplexPropertyEditorContentEventHandler _handler; + private readonly BlockListEditorDataConverter _converter = new BlockListEditorDataConverter(); + + public void Initialize() + { + _handler = new ComplexPropertyEditorContentEventHandler( + Constants.PropertyEditors.Aliases.BlockList, + ReplaceBlockListUdis); + } + + public void Terminate() => _handler?.Dispose(); + + private string ReplaceBlockListUdis(string rawJson, bool onlyMissingUdis) + { + // the block editor doesn't ever have missing UDIs so when this is true there's nothing to process + if (onlyMissingUdis) return rawJson; + + return ReplaceBlockListUdis(rawJson, null); + } + + // internal for tests + internal string ReplaceBlockListUdis(string rawJson, Func createGuid = null) + { + // used so we can test nicely + if (createGuid == null) + createGuid = () => Guid.NewGuid(); + + if (string.IsNullOrWhiteSpace(rawJson) || !rawJson.DetectIsJson()) + return rawJson; + + // Parse JSON + // This will throw a FormatException if there are null UDIs (expected) + var blockListValue = _converter.Deserialize(rawJson); + + UpdateBlockListRecursively(blockListValue, createGuid); + + return JsonConvert.SerializeObject(blockListValue.BlockValue); + } + + private void UpdateBlockListRecursively(BlockEditorData blockListData, Func createGuid) + { + var oldToNew = new Dictionary(); + MapOldToNewUdis(oldToNew, blockListData.BlockValue.ContentData, createGuid); + MapOldToNewUdis(oldToNew, blockListData.BlockValue.SettingsData, createGuid); + + for (var i = 0; i < blockListData.References.Count; i++) + { + var reference = blockListData.References[i]; + var hasContentMap = oldToNew.TryGetValue(reference.ContentUdi, out var contentMap); + Udi settingsMap = null; + var hasSettingsMap = reference.SettingsUdi != null && oldToNew.TryGetValue(reference.SettingsUdi, out settingsMap); + + if (hasContentMap) + { + // replace the reference + blockListData.References.RemoveAt(i); + blockListData.References.Insert(i, new ContentAndSettingsReference(contentMap, hasSettingsMap ? settingsMap : null)); + } + } + + // build the layout with the new UDIs + var layout = (JArray)blockListData.Layout; + layout.Clear(); + foreach (var reference in blockListData.References) + { + layout.Add(JObject.FromObject(new BlockListLayoutItem + { + ContentUdi = reference.ContentUdi, + SettingsUdi = reference.SettingsUdi + })); + } + + + RecursePropertyValues(blockListData.BlockValue.ContentData, createGuid); + RecursePropertyValues(blockListData.BlockValue.SettingsData, createGuid); + } + + private void RecursePropertyValues(IEnumerable blockData, Func createGuid) + { + foreach (var data in blockData) + { + // check if we need to recurse (make a copy of the dictionary since it will be modified) + foreach (var propertyAliasToBlockItemData in new Dictionary(data.RawPropertyValues)) + { + if (propertyAliasToBlockItemData.Value is JToken jtoken) + { + if (ProcessJToken(jtoken, createGuid, out var result)) + { + // need to re-save this back to the RawPropertyValues + data.RawPropertyValues[propertyAliasToBlockItemData.Key] = result; + } + } + else + { + var asString = propertyAliasToBlockItemData.Value?.ToString(); + + if (asString != null && asString.DetectIsJson()) + { + // this gets a little ugly because there could be some other complex editor that contains another block editor + // and since we would have no idea how to parse that, all we can do is try JSON Path to find another block editor + // of our type + var json = JToken.Parse(asString); + if (ProcessJToken(json, createGuid, out var result)) + { + // need to re-save this back to the RawPropertyValues + data.RawPropertyValues[propertyAliasToBlockItemData.Key] = result; + } + } + } + } + } + } + + private bool ProcessJToken(JToken json, Func createGuid, out JToken result) + { + var updated = false; + result = json; + + // select all tokens (flatten) + var allProperties = json.SelectTokens("$..*").Select(x => x.Parent as JProperty).WhereNotNull().ToList(); + foreach (var prop in allProperties) + { + if (prop.Name == Constants.PropertyEditors.Aliases.BlockList) + { + // get it's parent 'layout' and it's parent's container + var layout = prop.Parent?.Parent as JProperty; + if (layout != null && layout.Parent is JObject layoutJson) + { + // recurse + var blockListValue = _converter.ConvertFrom(layoutJson); + UpdateBlockListRecursively(blockListValue, createGuid); + + // set new value + if (layoutJson.Parent != null) + { + // we can replace the object + layoutJson.Replace(JObject.FromObject(blockListValue.BlockValue)); + updated = true; + } + else + { + // if there is no parent it means that this json property was the root, in which case we just return + result = JObject.FromObject(blockListValue.BlockValue); + return true; + } + } + } + else if (prop.Name != "layout" && prop.Name != "contentData" && prop.Name != "settingsData" && prop.Name != "contentTypeKey") + { + // this is an arbitrary property that could contain a nested complex editor + var propVal = prop.Value?.ToString(); + // check if this might contain a nested Block Editor + if (!propVal.IsNullOrWhiteSpace() && propVal.DetectIsJson() && propVal.InvariantContains(Constants.PropertyEditors.Aliases.BlockList)) + { + if (_converter.TryDeserialize(propVal, out var nestedBlockData)) + { + // recurse + UpdateBlockListRecursively(nestedBlockData, createGuid); + // set the value to the updated one + prop.Value = JObject.FromObject(nestedBlockData.BlockValue); + updated = true; + } + } + } + } + + return updated; + } + + private void MapOldToNewUdis(Dictionary oldToNew, IEnumerable blockData, Func createGuid) + { + foreach (var data in blockData) + { + // This should never happen since a FormatException will be thrown if one is empty but we'll keep this here + if (data.Udi == null) + throw new InvalidOperationException("Block data cannot contain a null UDI"); + + // replace the UDIs + var newUdi = GuidUdi.Create(Constants.UdiEntityType.Element, createGuid()); + oldToNew[data.Udi] = newUdi; + data.Udi = newUdi; + } + } + } +} diff --git a/src/Umbraco.Web/Compose/BlockEditorComposer.cs b/src/Umbraco.Web/Compose/BlockEditorComposer.cs new file mode 100644 index 0000000000..e281bcb19f --- /dev/null +++ b/src/Umbraco.Web/Compose/BlockEditorComposer.cs @@ -0,0 +1,12 @@ +using Umbraco.Core; +using Umbraco.Core.Composing; + +namespace Umbraco.Web.Compose +{ + /// + /// A composer for Block editors to run a component + /// + [RuntimeLevel(MinLevel = RuntimeLevel.Run)] + public class BlockEditorComposer : ComponentComposer, ICoreComposer + { } +} diff --git a/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs b/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs index 5794a2734e..633e814bd9 100644 --- a/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs +++ b/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs @@ -6,67 +6,32 @@ using Umbraco.Core; using Umbraco.Core.Composing; using Umbraco.Core.Events; using Umbraco.Core.Models; +using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; using Umbraco.Web.PropertyEditors; namespace Umbraco.Web.Compose { + + /// + /// A component for NestedContent used to bind to events + /// public class NestedContentPropertyComponent : IComponent { + private ComplexPropertyEditorContentEventHandler _handler; + public void Initialize() { - ContentService.Copying += ContentService_Copying; - ContentService.Saving += ContentService_Saving; + _handler = new ComplexPropertyEditorContentEventHandler( + Constants.PropertyEditors.Aliases.NestedContent, + CreateNestedContentKeys); } - private void ContentService_Copying(IContentService sender, CopyEventArgs e) - { - // When a content node contains nested content property - // Check if the copied node contains a nested content - var nestedContentProps = e.Copy.Properties.Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.NestedContent); - UpdateNestedContentProperties(nestedContentProps, false); - } + public void Terminate() => _handler?.Dispose(); - private void ContentService_Saving(IContentService sender, ContentSavingEventArgs e) - { - // One or more content nodes could be saved in a bulk publish - foreach (var entity in e.SavedEntities) - { - // When a content node contains nested content property - // Check if the copied node contains a nested content - var nestedContentProps = entity.Properties.Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.NestedContent); - UpdateNestedContentProperties(nestedContentProps, true); - } - } + private string CreateNestedContentKeys(string rawJson, bool onlyMissingKeys) => CreateNestedContentKeys(rawJson, onlyMissingKeys, null); - public void Terminate() - { - ContentService.Copying -= ContentService_Copying; - ContentService.Saving -= ContentService_Saving; - } - - private void UpdateNestedContentProperties(IEnumerable nestedContentProps, bool onlyMissingKeys) - { - // Each NC Property on a doctype - foreach (var nestedContentProp in nestedContentProps) - { - // A NC Prop may have one or more values due to cultures - var propVals = nestedContentProp.Values; - foreach (var cultureVal in propVals) - { - // Remove keys from published value & any nested NC's - var updatedPublishedVal = CreateNestedContentKeys(cultureVal.PublishedValue?.ToString(), onlyMissingKeys); - cultureVal.PublishedValue = updatedPublishedVal; - - // Remove keys from edited/draft value & any nested NC's - var updatedEditedVal = CreateNestedContentKeys(cultureVal.EditedValue?.ToString(), onlyMissingKeys); - cultureVal.EditedValue = updatedEditedVal; - } - } - } - - // internal for tests internal string CreateNestedContentKeys(string rawJson, bool onlyMissingKeys, Func createGuid = null) { @@ -77,7 +42,7 @@ namespace Umbraco.Web.Compose if (string.IsNullOrWhiteSpace(rawJson) || !rawJson.DetectIsJson()) return rawJson; - // Parse JSON + // Parse JSON var complexEditorValue = JToken.Parse(rawJson); UpdateNestedContentKeysRecursively(complexEditorValue, onlyMissingKeys, createGuid); @@ -98,7 +63,6 @@ namespace Umbraco.Web.Compose { // get it's sibling 'key' property var ncKeyVal = prop.Parent["key"] as JValue; - // TODO: This bool seems odd, if the key is null, shouldn't we fill it in regardless of onlyMissingKeys? if ((onlyMissingKeys && ncKeyVal == null) || (!onlyMissingKeys && ncKeyVal != null)) { // create or replace diff --git a/src/Umbraco.Web/Compose/NestedContentPropertyComposer.cs b/src/Umbraco.Web/Compose/NestedContentPropertyComposer.cs index 4c9d9dee1c..8e4cfbfffc 100644 --- a/src/Umbraco.Web/Compose/NestedContentPropertyComposer.cs +++ b/src/Umbraco.Web/Compose/NestedContentPropertyComposer.cs @@ -3,6 +3,9 @@ using Umbraco.Core.Composing; namespace Umbraco.Web.Compose { + /// + /// A composer for nested content to run a component + /// [RuntimeLevel(MinLevel = RuntimeLevel.Run)] public class NestedContentPropertyComposer : ComponentComposer, ICoreComposer { } diff --git a/src/Umbraco.Web/Install/InstallHelper.cs b/src/Umbraco.Web/Install/InstallHelper.cs index b74a20c27c..a998a172fc 100644 --- a/src/Umbraco.Web/Install/InstallHelper.cs +++ b/src/Umbraco.Web/Install/InstallHelper.cs @@ -30,6 +30,18 @@ namespace Umbraco.Web.Install private readonly IInstallationService _installationService; private InstallationType? _installationType; + + [Obsolete("Use the constructor with IInstallationService injected.")] + public InstallHelper( + IUmbracoContextAccessor umbracoContextAccessor, + DatabaseBuilder databaseBuilder, + ILogger logger, + IGlobalSettings globalSettings) + : this(umbracoContextAccessor, databaseBuilder, logger, globalSettings, Current.Factory.GetInstance()) + { + + } + public InstallHelper(IUmbracoContextAccessor umbracoContextAccessor, DatabaseBuilder databaseBuilder, ILogger logger, IGlobalSettings globalSettings, IInstallationService installationService) diff --git a/src/Umbraco.Web/PropertyEditors/TextboxConfiguration.cs b/src/Umbraco.Web/PropertyEditors/TextboxConfiguration.cs index c9f15e2e2f..641cc8e42a 100644 --- a/src/Umbraco.Web/PropertyEditors/TextboxConfiguration.cs +++ b/src/Umbraco.Web/PropertyEditors/TextboxConfiguration.cs @@ -7,7 +7,7 @@ namespace Umbraco.Web.PropertyEditors /// public class TextboxConfiguration { - [ConfigurationField("maxChars", "Maximum allowed characters", "textstringlimited", Description = "If empty, 500 character limit")] + [ConfigurationField("maxChars", "Maximum allowed characters", "textstringlimited", Description = "If empty, 512 character limit")] public int? MaxChars { get; set; } } } diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs index 0c90a41fbd..f46c118174 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs @@ -120,7 +120,9 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters settingsData = null; } - var layoutRef = new BlockListItem(contentGuidUdi, contentData, settingGuidUdi, settingsData); + var layoutType = typeof(BlockListItem<,>).MakeGenericType(contentData.GetType(), settingsData?.GetType() ?? typeof(IPublishedElement)); + var layoutRef = (BlockListItem)Activator.CreateInstance(layoutType, contentGuidUdi, contentData, settingGuidUdi, settingsData); + layout.Add(layoutRef); } diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index b2068290f6..33051671ee 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -133,6 +133,8 @@ + +