Merge branch 'v8/feature/block-editor-list-validation' into v8/feature/block-list-data-structure-v3
This commit is contained in:
@@ -193,7 +193,7 @@ namespace Umbraco.Core.Migrations.Upgrade
|
||||
|
||||
// to 8.7.0...
|
||||
To<MissingDictionaryIndex>("{a78e3369-8ea3-40ec-ad3f-5f76929d2b20}");
|
||||
|
||||
|
||||
//FINAL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Umbraco.Core.Serialization;
|
||||
|
||||
namespace Umbraco.Core.Models.Blocks
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Converted block data from json
|
||||
/// </summary>
|
||||
public class BlockEditorData
|
||||
{
|
||||
public static BlockEditorData Empty { get; } = new BlockEditorData();
|
||||
|
||||
private BlockEditorData()
|
||||
{
|
||||
}
|
||||
|
||||
public BlockEditorData(JToken layout, IReadOnlyList<Udi> layoutBlockReferences, IReadOnlyList<BlockItemData> blocks)
|
||||
{
|
||||
Layout = layout ?? throw new ArgumentNullException(nameof(layout));
|
||||
LayoutBlockReferences = layoutBlockReferences ?? throw new ArgumentNullException(nameof(layoutBlockReferences));
|
||||
Blocks = blocks ?? throw new ArgumentNullException(nameof(blocks));
|
||||
}
|
||||
|
||||
public JToken Layout { get; }
|
||||
public IReadOnlyList<Udi> LayoutBlockReferences { get; } = new List<Udi>();
|
||||
public IReadOnlyList<BlockItemData> Blocks { get; } = new List<BlockItemData>();
|
||||
|
||||
internal class BlockValue
|
||||
{
|
||||
[JsonProperty("layout")]
|
||||
public IDictionary<string, JToken> Layout { get; set; }
|
||||
|
||||
[JsonProperty("data")]
|
||||
public IEnumerable<BlockItemData> Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single block's data in raw form
|
||||
/// </summary>
|
||||
public class BlockItemData
|
||||
{
|
||||
[JsonProperty("contentTypeKey")]
|
||||
public Guid ContentTypeKey { get; set; }
|
||||
|
||||
[JsonProperty("udi")]
|
||||
[JsonConverter(typeof(UdiJsonConverter))]
|
||||
public Udi Udi { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The remaining properties will be serialized to a dictionary
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The JsonExtensionDataAttribute is used to put the non-typed properties into a bucket
|
||||
/// http://www.newtonsoft.com/json/help/html/DeserializeExtensionData.htm
|
||||
/// NestedContent serializes to string, int, whatever eg
|
||||
/// "stringValue":"Some String","numericValue":125,"otherNumeric":null
|
||||
/// </remarks>
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, object> RawPropertyValues { get; set; } = new Dictionary<string, object>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using static Umbraco.Core.Models.Blocks.BlockEditorData;
|
||||
|
||||
namespace Umbraco.Core.Models.Blocks
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Converts the block json data into objects
|
||||
/// </summary>
|
||||
public abstract class BlockEditorDataConverter
|
||||
{
|
||||
private readonly string _propertyEditorAlias;
|
||||
|
||||
protected BlockEditorDataConverter(string propertyEditorAlias)
|
||||
{
|
||||
_propertyEditorAlias = propertyEditorAlias;
|
||||
}
|
||||
|
||||
public BlockEditorData Convert(string json)
|
||||
{
|
||||
var value = JsonConvert.DeserializeObject<BlockValue>(json);
|
||||
|
||||
if (value.Layout == null)
|
||||
return BlockEditorData.Empty;
|
||||
|
||||
if (!value.Layout.TryGetValue(_propertyEditorAlias, out var layout))
|
||||
return BlockEditorData.Empty;
|
||||
|
||||
var references = GetBlockReferences(layout);
|
||||
var blocks = value.Data.ToList();
|
||||
return new BlockEditorData(layout, references, blocks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the collection of <see cref="IBlockReference"/> from the block editor's Layout (which could be an array or an object depending on the editor)
|
||||
/// </summary>
|
||||
/// <param name="jsonLayout"></param>
|
||||
/// <returns></returns>
|
||||
protected abstract IReadOnlyList<Udi> GetBlockReferences(JToken jsonLayout);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Umbraco.Core.Models.Blocks
|
||||
{
|
||||
/// <summary>
|
||||
/// Data converter for the block list property editor
|
||||
/// </summary>
|
||||
public class BlockListEditorDataConverter : BlockEditorDataConverter
|
||||
{
|
||||
public BlockListEditorDataConverter() : base(Constants.PropertyEditors.Aliases.BlockList)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IReadOnlyList<Udi> GetBlockReferences(JToken jsonLayout)
|
||||
{
|
||||
var blockListLayout = jsonLayout.ToObject<IEnumerable<BlockListLayoutItem>>();
|
||||
return blockListLayout.Select(x => x.Udi).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Newtonsoft.Json;
|
||||
using Umbraco.Core.Serialization;
|
||||
using static Umbraco.Core.Models.Blocks.BlockEditorData;
|
||||
|
||||
namespace Umbraco.Core.Models.Blocks
|
||||
{
|
||||
/// <summary>
|
||||
/// Used for deserializing the block list layout
|
||||
/// </summary>
|
||||
public class BlockListLayoutItem
|
||||
{
|
||||
[JsonProperty("settings")]
|
||||
public BlockItemData Settings { get; set; }
|
||||
|
||||
[JsonProperty("udi")]
|
||||
[JsonConverter(typeof(UdiJsonConverter))]
|
||||
public Udi Udi { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
namespace Umbraco.Core.Models.Blocks
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a data item reference for a Block Editor implementation
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// see: https://github.com/umbraco/rfcs/blob/907f3758cf59a7b6781296a60d57d537b3b60b8c/cms/0011-block-data-structure.md#strongly-typed
|
||||
/// </remarks>
|
||||
public interface IBlockReference
|
||||
{
|
||||
Udi Udi { get; }
|
||||
}
|
||||
|
||||
// TODO: IBlockElement doesn't make sense, this is a reference to an actual element with some settings
|
||||
// and always has to do with the "Layout", should possibly be called IBlockReference or IBlockLayout or IBlockLayoutReference
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Umbraco.Core.Models.Blocks
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a data item reference for a Block Editor implementation
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// see: https://github.com/umbraco/rfcs/blob/907f3758cf59a7b6781296a60d57d537b3b60b8c/cms/0011-block-data-structure.md#strongly-typed
|
||||
/// </remarks>
|
||||
public interface IBlockReference
|
||||
{
|
||||
Udi Udi { get; }
|
||||
}
|
||||
}
|
||||
+29
-15
@@ -4,6 +4,10 @@ using System.Data.SqlClient;
|
||||
|
||||
namespace Umbraco.Core.Persistence.FaultHandling.Strategies
|
||||
{
|
||||
// See https://docs.microsoft.com/en-us/azure/azure-sql/database/troubleshoot-common-connectivity-issues
|
||||
// Also we could just use the nuget package instead https://www.nuget.org/packages/EnterpriseLibrary.TransientFaultHandling/ ?
|
||||
// but i guess that's not netcore so we'll just leave it.
|
||||
|
||||
/// <summary>
|
||||
/// Provides the transient error detection logic for transient faults that are specific to SQL Azure.
|
||||
/// </summary>
|
||||
@@ -71,7 +75,7 @@ namespace Umbraco.Core.Persistence.FaultHandling.Strategies
|
||||
/// Determines whether the specified exception represents a transient failure that can be compensated by a retry.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception object to be verified.</param>
|
||||
/// <returns>True if the specified exception is considered as transient, otherwise false.</returns>
|
||||
/// <returns>true if the specified exception is considered as transient; otherwise, false.</returns>
|
||||
public bool IsTransient(Exception ex)
|
||||
{
|
||||
if (ex != null)
|
||||
@@ -97,40 +101,50 @@ namespace Umbraco.Core.Persistence.FaultHandling.Strategies
|
||||
|
||||
return true;
|
||||
|
||||
// SQL Error Code: 40197
|
||||
// The service has encountered an error processing your request. Please try again.
|
||||
case 40197:
|
||||
// SQL Error Code: 10928
|
||||
// Resource ID: %d. The %s limit for the database is %d and has been reached.
|
||||
case 10928:
|
||||
// SQL Error Code: 10929
|
||||
// Resource ID: %d. The %s minimum guarantee is %d, maximum limit is %d and the current usage for the database is %d.
|
||||
// However, the server is currently too busy to support requests greater than %d for this database.
|
||||
case 10929:
|
||||
// SQL Error Code: 10053
|
||||
// A transport-level error has occurred when receiving results from the server.
|
||||
// An established connection was aborted by the software in your host machine.
|
||||
case 10053:
|
||||
// SQL Error Code: 10054
|
||||
// A transport-level error has occurred when sending the request to the server.
|
||||
// A transport-level error has occurred when sending the request to the server.
|
||||
// (provider: TCP Provider, error: 0 - An existing connection was forcibly closed by the remote host.)
|
||||
case 10054:
|
||||
// SQL Error Code: 10060
|
||||
// A network-related or instance-specific error occurred while establishing a connection to SQL Server.
|
||||
// The server was not found or was not accessible. Verify that the instance name is correct and that SQL Server
|
||||
// is configured to allow remote connections. (provider: TCP Provider, error: 0 - A connection attempt failed
|
||||
// because the connected party did not properly respond after a period of time, or established connection failed
|
||||
// A network-related or instance-specific error occurred while establishing a connection to SQL Server.
|
||||
// The server was not found or was not accessible. Verify that the instance name is correct and that SQL Server
|
||||
// is configured to allow remote connections. (provider: TCP Provider, error: 0 - A connection attempt failed
|
||||
// because the connected party did not properly respond after a period of time, or established connection failed
|
||||
// because connected host has failed to respond.)"}
|
||||
case 10060:
|
||||
// SQL Error Code: 40197
|
||||
// The service has encountered an error processing your request. Please try again.
|
||||
case 40197:
|
||||
// SQL Error Code: 40540
|
||||
// The service has encountered an error processing your request. Please try again.
|
||||
case 40540:
|
||||
// SQL Error Code: 40613
|
||||
// Database XXXX on server YYYY is not currently available. Please retry the connection later. If the problem persists, contact customer
|
||||
// Database XXXX on server YYYY is not currently available. Please retry the connection later. If the problem persists, contact customer
|
||||
// support, and provide them the session tracing ID of ZZZZZ.
|
||||
case 40613:
|
||||
// SQL Error Code: 40143
|
||||
// The service has encountered an error processing your request. Please try again.
|
||||
case 40143:
|
||||
// SQL Error Code: 233
|
||||
// The client was unable to establish a connection because of an error during connection initialization process before login.
|
||||
// Possible causes include the following: the client tried to connect to an unsupported version of SQL Server; the server was too busy
|
||||
// to accept new connections; or there was a resource limitation (insufficient memory or maximum allowed connections) on the server.
|
||||
// The client was unable to establish a connection because of an error during connection initialization process before login.
|
||||
// Possible causes include the following: the client tried to connect to an unsupported version of SQL Server; the server was too busy
|
||||
// to accept new connections; or there was a resource limitation (insufficient memory or maximum allowed connections) on the server.
|
||||
// (provider: TCP Provider, error: 0 - An existing connection was forcibly closed by the remote host.)
|
||||
case 233:
|
||||
// SQL Error Code: 64
|
||||
// A connection was successfully established with the server, but then an error occurred during the login process.
|
||||
// (provider: TCP Provider, error: 0 - The specified network name is no longer available.)
|
||||
// A connection was successfully established with the server, but then an error occurred during the login process.
|
||||
// (provider: TCP Provider, error: 0 - The specified network name is no longer available.)
|
||||
case 64:
|
||||
// DBNETLIB Error Code: 20
|
||||
// The instance of SQL Server you attempted to connect to does not support encryption.
|
||||
|
||||
@@ -12,6 +12,11 @@ namespace Umbraco.Core.Persistence.Repositories
|
||||
/// </summary>
|
||||
void ClearSchedule(DateTime date);
|
||||
|
||||
void ClearSchedule(DateTime date, ContentScheduleAction action);
|
||||
|
||||
bool HasContentForExpiration(DateTime date);
|
||||
bool HasContentForRelease(DateTime date);
|
||||
|
||||
/// <summary>
|
||||
/// Gets <see cref="IContent"/> objects having an expiration date before (lower than, or equal to) a specified date.
|
||||
/// </summary>
|
||||
|
||||
@@ -1017,6 +1017,37 @@ namespace Umbraco.Core.Persistence.Repositories.Implement
|
||||
Database.Execute(sql);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearSchedule(DateTime date, ContentScheduleAction action)
|
||||
{
|
||||
var a = action.ToString();
|
||||
var sql = Sql().Delete<ContentScheduleDto>().Where<ContentScheduleDto>(x => x.Date <= date && x.Action == a);
|
||||
Database.Execute(sql);
|
||||
}
|
||||
|
||||
private Sql GetSqlForHasScheduling(ContentScheduleAction action, DateTime date)
|
||||
{
|
||||
var template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetSqlForHasScheduling", tsql => tsql
|
||||
.SelectCount()
|
||||
.From<ContentScheduleDto>()
|
||||
.Where<ContentScheduleDto>(x => x.Action == SqlTemplate.Arg<string>("action") && x.Date <= SqlTemplate.Arg<DateTime>("date")));
|
||||
|
||||
var sql = template.Sql(action.ToString(), date);
|
||||
return sql;
|
||||
}
|
||||
|
||||
public bool HasContentForExpiration(DateTime date)
|
||||
{
|
||||
var sql = GetSqlForHasScheduling(ContentScheduleAction.Expire, date);
|
||||
return Database.ExecuteScalar<int>(sql) > 0;
|
||||
}
|
||||
|
||||
public bool HasContentForRelease(DateTime date)
|
||||
{
|
||||
var sql = GetSqlForHasScheduling(ContentScheduleAction.Release, date);
|
||||
return Database.ExecuteScalar<int>(sql) > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IContent> GetContentForRelease(DateTime date)
|
||||
{
|
||||
|
||||
@@ -21,12 +21,11 @@ namespace Umbraco.Core.Runtime
|
||||
private const string MainDomKeyPrefix = "Umbraco.Core.Runtime.SqlMainDom";
|
||||
private const string UpdatedSuffix = "_updated";
|
||||
private readonly ILogger _logger;
|
||||
private IUmbracoDatabase _db;
|
||||
private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
|
||||
private SqlServerSyntaxProvider _sqlServerSyntax = new SqlServerSyntaxProvider();
|
||||
private bool _mainDomChanging = false;
|
||||
private readonly UmbracoDatabaseFactory _dbFactory;
|
||||
private bool _hasError;
|
||||
private bool _errorDuringAcquiring;
|
||||
private object _locker = new object();
|
||||
|
||||
public SqlMainDomLock(ILogger logger)
|
||||
@@ -56,25 +55,24 @@ namespace Umbraco.Core.Runtime
|
||||
|
||||
_logger.Debug<SqlMainDomLock>("Acquiring lock...");
|
||||
|
||||
var db = GetDatabase();
|
||||
|
||||
var tempId = Guid.NewGuid().ToString();
|
||||
|
||||
using var db = _dbFactory.CreateDatabase();
|
||||
using var transaction = db.GetTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
try
|
||||
{
|
||||
db.BeginTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
try
|
||||
{
|
||||
// wait to get a write lock
|
||||
_sqlServerSyntax.WriteLock(db, TimeSpan.FromMilliseconds(millisecondsTimeout), Constants.Locks.MainDom);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch(SqlException ex)
|
||||
{
|
||||
if (IsLockTimeoutException(ex))
|
||||
{
|
||||
_logger.Error<SqlMainDomLock>(ex, "Sql timeout occurred, could not acquire MainDom.");
|
||||
_hasError = true;
|
||||
_errorDuringAcquiring = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -82,15 +80,12 @@ namespace Umbraco.Core.Runtime
|
||||
throw;
|
||||
}
|
||||
|
||||
var result = InsertLockRecord(tempId); //we change the row to a random Id to signal other MainDom to shutdown
|
||||
var result = InsertLockRecord(tempId, db); //we change the row to a random Id to signal other MainDom to shutdown
|
||||
if (result == RecordPersistenceType.Insert)
|
||||
{
|
||||
// if we've inserted, then there was no MainDom so we can instantly acquire
|
||||
|
||||
// TODO: see the other TODO, could we just delete the row and that would indicate that we
|
||||
// are MainDom? then we don't leave any orphan rows behind.
|
||||
|
||||
InsertLockRecord(_lockId); // so update with our appdomain id
|
||||
InsertLockRecord(_lockId, db); // so update with our appdomain id
|
||||
_logger.Debug<SqlMainDomLock>("Acquired with ID {LockId}", _lockId);
|
||||
return true;
|
||||
}
|
||||
@@ -100,23 +95,23 @@ namespace Umbraco.Core.Runtime
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ResetDatabase();
|
||||
// unexpected
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error, cannot acquire MainDom");
|
||||
_hasError = true;
|
||||
_errorDuringAcquiring = true;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
db?.CompleteTransaction();
|
||||
transaction.Complete();
|
||||
}
|
||||
|
||||
|
||||
return await WaitForExistingAsync(tempId, millisecondsTimeout);
|
||||
}
|
||||
|
||||
public Task ListenAsync()
|
||||
{
|
||||
if (_hasError)
|
||||
if (_errorDuringAcquiring)
|
||||
{
|
||||
_logger.Warn<SqlMainDomLock>("Could not acquire MainDom, listening is canceled.");
|
||||
return Task.CompletedTask;
|
||||
@@ -142,8 +137,15 @@ namespace Umbraco.Core.Runtime
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
// poll every 1 second
|
||||
Thread.Sleep(1000);
|
||||
// poll every couple of seconds
|
||||
// local testing shows the actual query to be executed from client/server is approx 300ms but would change depending on environment/IO
|
||||
Thread.Sleep(2000);
|
||||
|
||||
if (!_dbFactory.Configured)
|
||||
{
|
||||
// if we aren't configured, we just keep looping since we can't query the db
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_dbFactory.Configured)
|
||||
{
|
||||
@@ -160,20 +162,14 @@ namespace Umbraco.Core.Runtime
|
||||
if (_cancellationTokenSource.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
var db = GetDatabase();
|
||||
|
||||
using var db = _dbFactory.CreateDatabase();
|
||||
using var transaction = db.GetTransaction(IsolationLevel.ReadCommitted);
|
||||
try
|
||||
{
|
||||
db.BeginTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
// get a read lock
|
||||
_sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom);
|
||||
|
||||
// TODO: We could in theory just check if the main dom row doesn't exist, that could indicate that
|
||||
// we are still the maindom. An empty value might be better because then we won't have any orphan rows
|
||||
// if the app is terminated. Could that work?
|
||||
|
||||
if (!IsMainDomValue(_lockId))
|
||||
if (!IsMainDomValue(_lockId, db))
|
||||
{
|
||||
// we are no longer main dom, another one has come online, exit
|
||||
_mainDomChanging = true;
|
||||
@@ -183,38 +179,23 @@ namespace Umbraco.Core.Runtime
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ResetDatabase();
|
||||
// unexpected
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error, listening is canceled.");
|
||||
_hasError = true;
|
||||
return;
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error during listening.");
|
||||
|
||||
// We need to keep on listening unless we've been notified by our own AppDomain to shutdown since
|
||||
// we don't want to shutdown resources controlled by MainDom inadvertently. We'll just keep listening otherwise.
|
||||
if (_cancellationTokenSource.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
db?.CompleteTransaction();
|
||||
transaction.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetDatabase()
|
||||
{
|
||||
if (_db.InTransaction)
|
||||
_db.AbortTransaction();
|
||||
_db.Dispose();
|
||||
_db = null;
|
||||
}
|
||||
|
||||
private IUmbracoDatabase GetDatabase()
|
||||
{
|
||||
if (_db != null)
|
||||
return _db;
|
||||
|
||||
_db = _dbFactory.CreateDatabase();
|
||||
return _db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for any existing MainDom to release so we can continue booting
|
||||
/// </summary>
|
||||
@@ -227,121 +208,131 @@ namespace Umbraco.Core.Runtime
|
||||
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var db = GetDatabase();
|
||||
using var db = _dbFactory.CreateDatabase();
|
||||
|
||||
var watch = new Stopwatch();
|
||||
watch.Start();
|
||||
while(true)
|
||||
while (true)
|
||||
{
|
||||
// poll very often, we need to take over as fast as we can
|
||||
Thread.Sleep(100);
|
||||
// local testing shows the actual query to be executed from client/server is approx 300ms but would change depending on environment/IO
|
||||
Thread.Sleep(1000);
|
||||
|
||||
try
|
||||
{
|
||||
db.BeginTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
// get a read lock
|
||||
_sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom);
|
||||
|
||||
// the row
|
||||
var mainDomRows = db.Fetch<KeyValueDto>("SELECT * FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey });
|
||||
|
||||
if (mainDomRows.Count == 0 || mainDomRows[0].Value == updatedTempId)
|
||||
{
|
||||
// the other main dom has updated our record
|
||||
// Or the other maindom shutdown super fast and just deleted the record
|
||||
// which indicates that we
|
||||
// can acquire it and it has shutdown.
|
||||
|
||||
_sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom);
|
||||
|
||||
// so now we update the row with our appdomain id
|
||||
InsertLockRecord(_lockId);
|
||||
_logger.Debug<SqlMainDomLock>("Acquired with ID {LockId}", _lockId);
|
||||
return true;
|
||||
}
|
||||
else if (mainDomRows.Count == 1 && !mainDomRows[0].Value.StartsWith(tempId))
|
||||
{
|
||||
// in this case, the prefixed ID is different which means
|
||||
// another new AppDomain has come online and is wanting to take over. In that case, we will not
|
||||
// acquire.
|
||||
|
||||
_logger.Debug<SqlMainDomLock>("Cannot acquire, another booting application detected.");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ResetDatabase();
|
||||
|
||||
if (IsLockTimeoutException(ex))
|
||||
{
|
||||
_logger.Error<SqlMainDomLock>(ex, "Sql timeout occurred, waiting for existing MainDom is canceled.");
|
||||
_hasError = true;
|
||||
return false;
|
||||
}
|
||||
// unexpected
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error, waiting for existing MainDom is canceled.");
|
||||
_hasError = true;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
db?.CompleteTransaction();
|
||||
}
|
||||
var acquired = TryAcquire(db, tempId, updatedTempId);
|
||||
if (acquired.HasValue)
|
||||
return acquired.Value;
|
||||
|
||||
if (watch.ElapsedMilliseconds >= millisecondsTimeout)
|
||||
{
|
||||
// if the timeout has elapsed, it either means that the other main dom is taking too long to shutdown,
|
||||
// or it could mean that the previous appdomain was terminated and didn't clear out the main dom SQL row
|
||||
// and it's just been left as an orphan row.
|
||||
// There's really know way of knowing unless we are constantly updating the row for the current maindom
|
||||
// which isn't ideal.
|
||||
// So... we're going to 'just' take over, if the writelock works then we'll assume we're ok
|
||||
|
||||
_logger.Debug<SqlMainDomLock>("Timeout elapsed, assuming orphan row, acquiring MainDom.");
|
||||
|
||||
try
|
||||
{
|
||||
db.BeginTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
_sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom);
|
||||
|
||||
// so now we update the row with our appdomain id
|
||||
InsertLockRecord(_lockId);
|
||||
_logger.Debug<SqlMainDomLock>("Acquired with ID {LockId}", _lockId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ResetDatabase();
|
||||
|
||||
if (IsLockTimeoutException(ex))
|
||||
{
|
||||
// something is wrong, we cannot acquire, not much we can do
|
||||
_logger.Error<SqlMainDomLock>(ex, "Sql timeout occurred, could not forcibly acquire MainDom.");
|
||||
_hasError = true;
|
||||
return false;
|
||||
}
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error, could not forcibly acquire MainDom.");
|
||||
_hasError = true;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
db?.CompleteTransaction();
|
||||
}
|
||||
return AcquireWhenMaxWaitTimeElapsed(db);
|
||||
}
|
||||
}
|
||||
}, _cancellationTokenSource.Token);
|
||||
}
|
||||
|
||||
private bool? TryAcquire(IUmbracoDatabase db, string tempId, string updatedTempId)
|
||||
{
|
||||
using var transaction = db.GetTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
try
|
||||
{
|
||||
// get a read lock
|
||||
_sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom);
|
||||
|
||||
// the row
|
||||
var mainDomRows = db.Fetch<KeyValueDto>("SELECT * FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey });
|
||||
|
||||
if (mainDomRows.Count == 0 || mainDomRows[0].Value == updatedTempId)
|
||||
{
|
||||
// the other main dom has updated our record
|
||||
// Or the other maindom shutdown super fast and just deleted the record
|
||||
// which indicates that we
|
||||
// can acquire it and it has shutdown.
|
||||
|
||||
_sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom);
|
||||
|
||||
// so now we update the row with our appdomain id
|
||||
InsertLockRecord(_lockId, db);
|
||||
_logger.Debug<SqlMainDomLock>("Acquired with ID {LockId}", _lockId);
|
||||
return true;
|
||||
}
|
||||
else if (mainDomRows.Count == 1 && !mainDomRows[0].Value.StartsWith(tempId))
|
||||
{
|
||||
// in this case, the prefixed ID is different which means
|
||||
// another new AppDomain has come online and is wanting to take over. In that case, we will not
|
||||
// acquire.
|
||||
|
||||
_logger.Debug<SqlMainDomLock>("Cannot acquire, another booting application detected.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (IsLockTimeoutException(ex as SqlException))
|
||||
{
|
||||
_logger.Error<SqlMainDomLock>(ex, "Sql timeout occurred, waiting for existing MainDom is canceled.");
|
||||
_errorDuringAcquiring = true;
|
||||
return false;
|
||||
}
|
||||
// unexpected
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error, waiting for existing MainDom is canceled.");
|
||||
_errorDuringAcquiring = true;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
transaction.Complete();
|
||||
}
|
||||
|
||||
return null; // continue
|
||||
}
|
||||
|
||||
private bool AcquireWhenMaxWaitTimeElapsed(IUmbracoDatabase db)
|
||||
{
|
||||
// if the timeout has elapsed, it either means that the other main dom is taking too long to shutdown,
|
||||
// or it could mean that the previous appdomain was terminated and didn't clear out the main dom SQL row
|
||||
// and it's just been left as an orphan row.
|
||||
// There's really know way of knowing unless we are constantly updating the row for the current maindom
|
||||
// which isn't ideal.
|
||||
// So... we're going to 'just' take over, if the writelock works then we'll assume we're ok
|
||||
|
||||
_logger.Debug<SqlMainDomLock>("Timeout elapsed, assuming orphan row, acquiring MainDom.");
|
||||
|
||||
using var transaction = db.GetTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
try
|
||||
{
|
||||
_sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom);
|
||||
|
||||
// so now we update the row with our appdomain id
|
||||
InsertLockRecord(_lockId, db);
|
||||
_logger.Debug<SqlMainDomLock>("Acquired with ID {LockId}", _lockId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (IsLockTimeoutException(ex as SqlException))
|
||||
{
|
||||
// something is wrong, we cannot acquire, not much we can do
|
||||
_logger.Error<SqlMainDomLock>(ex, "Sql timeout occurred, could not forcibly acquire MainDom.");
|
||||
_errorDuringAcquiring = true;
|
||||
return false;
|
||||
}
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error, could not forcibly acquire MainDom.");
|
||||
_errorDuringAcquiring = true;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
transaction.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts or updates the key/value row
|
||||
/// </summary>
|
||||
private RecordPersistenceType InsertLockRecord(string id)
|
||||
private RecordPersistenceType InsertLockRecord(string id, IUmbracoDatabase db)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
return db.InsertOrUpdate(new KeyValueDto
|
||||
{
|
||||
Key = MainDomKey,
|
||||
@@ -354,9 +345,8 @@ namespace Umbraco.Core.Runtime
|
||||
/// Checks if the DB row value is equals the value
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private bool IsMainDomValue(string val)
|
||||
private bool IsMainDomValue(string val, IUmbracoDatabase db)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
return db.ExecuteScalar<int>("SELECT COUNT(*) FROM umbracoKeyValue WHERE [key] = @key AND [value] = @val",
|
||||
new { key = MainDomKey, val = val }) == 1;
|
||||
}
|
||||
@@ -366,7 +356,7 @@ namespace Umbraco.Core.Runtime
|
||||
/// </summary>
|
||||
/// <param name="exception"></param>
|
||||
/// <returns></returns>
|
||||
private bool IsLockTimeoutException(Exception exception) => exception is SqlException sqlException && sqlException.Number == 1222;
|
||||
private bool IsLockTimeoutException(SqlException sqlException) => sqlException?.Number == 1222;
|
||||
|
||||
#region IDisposable Support
|
||||
private bool _disposedValue = false; // To detect redundant calls
|
||||
@@ -385,11 +375,11 @@ namespace Umbraco.Core.Runtime
|
||||
|
||||
if (_dbFactory.Configured)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
using var db = _dbFactory.CreateDatabase();
|
||||
using var transaction = db.GetTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
try
|
||||
{
|
||||
db.BeginTransaction(IsolationLevel.ReadCommitted);
|
||||
|
||||
// get a write lock
|
||||
_sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom);
|
||||
|
||||
@@ -402,24 +392,21 @@ namespace Umbraco.Core.Runtime
|
||||
if (_mainDomChanging)
|
||||
{
|
||||
_logger.Debug<SqlMainDomLock>("Releasing MainDom, updating row, new application is booting.");
|
||||
db.Execute($"UPDATE umbracoKeyValue SET [value] = [value] + '{UpdatedSuffix}' WHERE [key] = @key", new { key = MainDomKey });
|
||||
var count = db.Execute($"UPDATE umbracoKeyValue SET [value] = [value] + '{UpdatedSuffix}' WHERE [key] = @key", new { key = MainDomKey });
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug<SqlMainDomLock>("Releasing MainDom, deleting row, application is shutting down.");
|
||||
db.Execute("DELETE FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey });
|
||||
var count = db.Execute("DELETE FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ResetDatabase();
|
||||
_logger.Error<SqlMainDomLock>(ex, "Unexpected error during dipsose.");
|
||||
_hasError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
db?.CompleteTransaction();
|
||||
ResetDatabase();
|
||||
transaction.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace Umbraco.Core.Services.Implement
|
||||
private IQuery<IContent> _queryNotTrashed;
|
||||
//TODO: The non-lazy object should be injected
|
||||
private readonly Lazy<PropertyValidationService> _propertyValidationService = new Lazy<PropertyValidationService>(() => new PropertyValidationService());
|
||||
|
||||
|
||||
|
||||
#region Constructors
|
||||
|
||||
@@ -879,7 +879,7 @@ namespace Umbraco.Core.Services.Implement
|
||||
throw new NotSupportedException($"Culture \"{culture}\" is not supported by invariant content types.");
|
||||
}
|
||||
|
||||
if(content.Name != null && content.Name.Length > 255)
|
||||
if (content.Name != null && content.Name.Length > 255)
|
||||
{
|
||||
throw new InvalidOperationException("Name cannot be more than 255 characters in length.");
|
||||
}
|
||||
@@ -1247,7 +1247,7 @@ namespace Umbraco.Core.Services.Implement
|
||||
if (culturesUnpublishing != null)
|
||||
{
|
||||
// This will mean that that we unpublished a mandatory culture or we unpublished the last culture.
|
||||
|
||||
|
||||
var langs = string.Join(", ", allLangs
|
||||
.Where(x => culturesUnpublishing.InvariantContains(x.IsoCode))
|
||||
.Select(x => x.CultureName));
|
||||
@@ -1256,7 +1256,7 @@ namespace Umbraco.Core.Services.Implement
|
||||
if (publishResult == null)
|
||||
throw new PanicException("publishResult == null - should not happen");
|
||||
|
||||
switch(publishResult.Result)
|
||||
switch (publishResult.Result)
|
||||
{
|
||||
case PublishResultType.FailedPublishMandatoryCultureMissing:
|
||||
//occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture)
|
||||
@@ -1270,7 +1270,7 @@ namespace Umbraco.Core.Services.Implement
|
||||
Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)");
|
||||
return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, evtMsgs, content);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Audit(AuditType.Unpublish, userId, content.Id);
|
||||
@@ -1290,7 +1290,7 @@ namespace Umbraco.Core.Services.Implement
|
||||
changeType = TreeChangeTypes.RefreshBranch; // whole branch
|
||||
else if (isNew == false && previouslyPublished)
|
||||
changeType = TreeChangeTypes.RefreshNode; // single node
|
||||
|
||||
|
||||
|
||||
// invalidate the node/branch
|
||||
if (!branchOne) // for branches, handled by SaveAndPublishBranch
|
||||
@@ -1364,24 +1364,90 @@ namespace Umbraco.Core.Services.Implement
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PublishResult> PerformScheduledPublish(DateTime date)
|
||||
=> PerformScheduledPublishInternal(date).ToList();
|
||||
|
||||
// beware! this method yields results, so the returned IEnumerable *must* be
|
||||
// enumerated for anything to happen - dangerous, so private + exposed via
|
||||
// the public method above, which forces ToList().
|
||||
private IEnumerable<PublishResult> PerformScheduledPublishInternal(DateTime date)
|
||||
{
|
||||
var allLangs = new Lazy<List<ILanguage>>(() => _languageRepository.GetMany().ToList());
|
||||
var evtMsgs = EventMessagesFactory.Get();
|
||||
var results = new List<PublishResult>();
|
||||
|
||||
using (var scope = ScopeProvider.CreateScope())
|
||||
PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs);
|
||||
PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private void PerformScheduledPublishingExpiration(DateTime date, List<PublishResult> results, EventMessages evtMsgs, Lazy<List<ILanguage>> allLangs)
|
||||
{
|
||||
using var scope = ScopeProvider.CreateScope();
|
||||
|
||||
// do a fast read without any locks since this executes often to see if we even need to proceed
|
||||
if (_documentRepository.HasContentForExpiration(date))
|
||||
{
|
||||
// now take a write lock since we'll be updating
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
var allLangs = _languageRepository.GetMany().ToList();
|
||||
foreach (var d in _documentRepository.GetContentForExpiration(date))
|
||||
{
|
||||
if (d.ContentType.VariesByCulture())
|
||||
{
|
||||
//find which cultures have pending schedules
|
||||
var pendingCultures = d.ContentSchedule.GetPending(ContentScheduleAction.Expire, date)
|
||||
.Select(x => x.Culture)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (pendingCultures.Count == 0)
|
||||
continue; //shouldn't happen but no point in processing this document if there's nothing there
|
||||
|
||||
var saveEventArgs = new ContentSavingEventArgs(d, evtMsgs);
|
||||
if (scope.Events.DispatchCancelable(Saving, this, saveEventArgs, nameof(Saving)))
|
||||
{
|
||||
results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d));
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var c in pendingCultures)
|
||||
{
|
||||
//Clear this schedule for this culture
|
||||
d.ContentSchedule.Clear(c, ContentScheduleAction.Expire, date);
|
||||
//set the culture to be published
|
||||
d.UnpublishCulture(c);
|
||||
}
|
||||
|
||||
var result = CommitDocumentChangesInternal(scope, d, saveEventArgs, allLangs.Value, d.WriterId);
|
||||
if (result.Success == false)
|
||||
Logger.Error<ContentService>(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
|
||||
results.Add(result);
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
//Clear this schedule
|
||||
d.ContentSchedule.Clear(ContentScheduleAction.Expire, date);
|
||||
var result = Unpublish(d, userId: d.WriterId);
|
||||
if (result.Success == false)
|
||||
Logger.Error<ContentService>(null, "Failed to unpublish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
|
||||
results.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
_documentRepository.ClearSchedule(date, ContentScheduleAction.Expire);
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
private void PerformScheduledPublishingRelease(DateTime date, List<PublishResult> results, EventMessages evtMsgs, Lazy<List<ILanguage>> allLangs)
|
||||
{
|
||||
using var scope = ScopeProvider.CreateScope();
|
||||
|
||||
// do a fast read without any locks since this executes often to see if we even need to proceed
|
||||
if (_documentRepository.HasContentForRelease(date))
|
||||
{
|
||||
// now take a write lock since we'll be updating
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
foreach (var d in _documentRepository.GetContentForRelease(date))
|
||||
{
|
||||
PublishResult result;
|
||||
if (d.ContentType.VariesByCulture())
|
||||
{
|
||||
//find which cultures have pending schedules
|
||||
@@ -1391,11 +1457,14 @@ namespace Umbraco.Core.Services.Implement
|
||||
.ToList();
|
||||
|
||||
if (pendingCultures.Count == 0)
|
||||
break; //shouldn't happen but no point in continuing if there's nothing there
|
||||
continue; //shouldn't happen but no point in processing this document if there's nothing there
|
||||
|
||||
var saveEventArgs = new ContentSavingEventArgs(d, evtMsgs);
|
||||
if (scope.Events.DispatchCancelable(Saving, this, saveEventArgs, nameof(Saving)))
|
||||
yield return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d);
|
||||
{
|
||||
results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d));
|
||||
continue; // this document is canceled move next
|
||||
}
|
||||
|
||||
var publishing = true;
|
||||
foreach (var culture in pendingCultures)
|
||||
@@ -1407,94 +1476,51 @@ namespace Umbraco.Core.Services.Implement
|
||||
|
||||
//publish the culture values and validate the property values, if validation fails, log the invalid properties so the develeper has an idea of what has failed
|
||||
Property[] invalidProperties = null;
|
||||
var impact = CultureImpact.Explicit(culture, IsDefaultCulture(allLangs, culture));
|
||||
var impact = CultureImpact.Explicit(culture, IsDefaultCulture(allLangs.Value, culture));
|
||||
var tryPublish = d.PublishCulture(impact) && _propertyValidationService.Value.IsPropertyDataValid(d, out invalidProperties, impact);
|
||||
if (invalidProperties != null && invalidProperties.Length > 0)
|
||||
Logger.Warn<ContentService>("Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}",
|
||||
d.Id, culture, string.Join(",", invalidProperties.Select(x => x.Alias)));
|
||||
|
||||
publishing &= tryPublish; //set the culture to be published
|
||||
if (!publishing) break; // no point continuing
|
||||
if (!publishing) continue; // move to next document
|
||||
}
|
||||
|
||||
PublishResult result;
|
||||
|
||||
if (d.Trashed)
|
||||
result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d);
|
||||
else if (!publishing)
|
||||
result = new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, d);
|
||||
else
|
||||
result = CommitDocumentChangesInternal(scope, d, saveEventArgs, allLangs, d.WriterId);
|
||||
|
||||
result = CommitDocumentChangesInternal(scope, d, saveEventArgs, allLangs.Value, d.WriterId);
|
||||
|
||||
if (result.Success == false)
|
||||
Logger.Error<ContentService>(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
|
||||
|
||||
yield return result;
|
||||
results.Add(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
//Clear this schedule
|
||||
d.ContentSchedule.Clear(ContentScheduleAction.Release, date);
|
||||
|
||||
result = d.Trashed
|
||||
var result = d.Trashed
|
||||
? new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d)
|
||||
: SaveAndPublish(d, userId: d.WriterId);
|
||||
|
||||
if (result.Success == false)
|
||||
Logger.Error<ContentService>(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
|
||||
|
||||
yield return result;
|
||||
results.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var d in _documentRepository.GetContentForExpiration(date))
|
||||
{
|
||||
PublishResult result;
|
||||
if (d.ContentType.VariesByCulture())
|
||||
{
|
||||
//find which cultures have pending schedules
|
||||
var pendingCultures = d.ContentSchedule.GetPending(ContentScheduleAction.Expire, date)
|
||||
.Select(x => x.Culture)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
_documentRepository.ClearSchedule(date, ContentScheduleAction.Release);
|
||||
|
||||
if (pendingCultures.Count == 0)
|
||||
break; //shouldn't happen but no point in continuing if there's nothing there
|
||||
|
||||
var saveEventArgs = new ContentSavingEventArgs(d, evtMsgs);
|
||||
if (scope.Events.DispatchCancelable(Saving, this, saveEventArgs, nameof(Saving)))
|
||||
yield return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d);
|
||||
|
||||
foreach (var c in pendingCultures)
|
||||
{
|
||||
//Clear this schedule for this culture
|
||||
d.ContentSchedule.Clear(c, ContentScheduleAction.Expire, date);
|
||||
//set the culture to be published
|
||||
d.UnpublishCulture(c);
|
||||
}
|
||||
|
||||
result = CommitDocumentChangesInternal(scope, d, saveEventArgs, allLangs, d.WriterId);
|
||||
if (result.Success == false)
|
||||
Logger.Error<ContentService>(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
|
||||
yield return result;
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
//Clear this schedule
|
||||
d.ContentSchedule.Clear(ContentScheduleAction.Expire, date);
|
||||
result = Unpublish(d, userId: d.WriterId);
|
||||
if (result.Success == false)
|
||||
Logger.Error<ContentService>(null, "Failed to unpublish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
|
||||
yield return result;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
_documentRepository.ClearSchedule(date);
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
// utility 'PublishCultures' func used by SaveAndPublishBranch
|
||||
@@ -2650,7 +2676,7 @@ namespace Umbraco.Core.Services.Implement
|
||||
// there will be nothing to publish/unpublish.
|
||||
return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// missing mandatory culture = cannot be published
|
||||
var mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode);
|
||||
@@ -3163,6 +3189,6 @@ namespace Umbraco.Core.Services.Implement
|
||||
#endregion
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Umbraco.Core.Collections;
|
||||
using Umbraco.Core.Composing;
|
||||
using Umbraco.Core.Models;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
@@ -15,19 +13,73 @@ namespace Umbraco.Core.Services
|
||||
{
|
||||
private readonly PropertyEditorCollection _propertyEditors;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly ILocalizedTextService _textService;
|
||||
|
||||
public PropertyValidationService(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService)
|
||||
public PropertyValidationService(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService)
|
||||
{
|
||||
_propertyEditors = propertyEditors;
|
||||
_dataTypeService = dataTypeService;
|
||||
_textService = textService;
|
||||
}
|
||||
|
||||
//TODO: Remove this method in favor of the overload specifying all dependencies
|
||||
public PropertyValidationService()
|
||||
: this(Current.PropertyEditors, Current.Services.DataTypeService)
|
||||
: this(Current.PropertyEditors, Current.Services.DataTypeService, Current.Services.TextService)
|
||||
{
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> ValidatePropertyValue(
|
||||
PropertyType propertyType,
|
||||
object postedValue)
|
||||
{
|
||||
if (propertyType is null) throw new ArgumentNullException(nameof(propertyType));
|
||||
var dataType = _dataTypeService.GetDataType(propertyType.DataTypeId);
|
||||
if (dataType == null) throw new InvalidOperationException("No data type found by id " + propertyType.DataTypeId);
|
||||
|
||||
var editor = _propertyEditors[propertyType.PropertyEditorAlias];
|
||||
if (editor == null) throw new InvalidOperationException("No property editor found by alias " + propertyType.PropertyEditorAlias);
|
||||
|
||||
return ValidatePropertyValue(_textService, editor, dataType, postedValue, propertyType.Mandatory, propertyType.ValidationRegExp, propertyType.MandatoryMessage, propertyType.ValidationRegExpMessage);
|
||||
}
|
||||
|
||||
internal static IEnumerable<ValidationResult> ValidatePropertyValue(
|
||||
ILocalizedTextService textService,
|
||||
IDataEditor editor,
|
||||
IDataType dataType,
|
||||
object postedValue,
|
||||
bool isRequired,
|
||||
string validationRegExp,
|
||||
string isRequiredMessage,
|
||||
string validationRegExpMessage)
|
||||
{
|
||||
// Retrieve default messages used for required and regex validatation. We'll replace these
|
||||
// if set with custom ones if they've been provided for a given property.
|
||||
var requiredDefaultMessages = new[]
|
||||
{
|
||||
textService.Localize("validation", "invalidNull"),
|
||||
textService.Localize("validation", "invalidEmpty")
|
||||
};
|
||||
var formatDefaultMessages = new[]
|
||||
{
|
||||
textService.Localize("validation", "invalidPattern"),
|
||||
};
|
||||
|
||||
var valueEditor = editor.GetValueEditor(dataType.Configuration);
|
||||
foreach (var validationResult in valueEditor.Validate(postedValue, isRequired, validationRegExp))
|
||||
{
|
||||
// If we've got custom error messages, we'll replace the default ones that will have been applied in the call to Validate().
|
||||
if (isRequired && !string.IsNullOrWhiteSpace(isRequiredMessage) && requiredDefaultMessages.Contains(validationResult.ErrorMessage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
validationResult.ErrorMessage = isRequiredMessage;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(validationRegExp) && !string.IsNullOrWhiteSpace(validationRegExpMessage) && formatDefaultMessages.Contains(validationResult.ErrorMessage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
validationResult.ErrorMessage = validationRegExpMessage;
|
||||
}
|
||||
yield return validationResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the content item's properties pass validation rules
|
||||
/// </summary>
|
||||
|
||||
@@ -132,6 +132,11 @@
|
||||
<Compile Include="Migrations\Upgrade\V_8_0_0\Models\ContentTypeDto80.cs" />
|
||||
<Compile Include="Migrations\Upgrade\V_8_0_0\Models\PropertyDataDto80.cs" />
|
||||
<Compile Include="Migrations\Upgrade\V_8_0_0\Models\PropertyTypeDto80.cs" />
|
||||
<Compile Include="Models\Blocks\BlockEditorDataConverter.cs" />
|
||||
<Compile Include="Models\Blocks\BlockEditorData.cs" />
|
||||
<Compile Include="Models\Blocks\BlockListLayoutItem.cs" />
|
||||
<Compile Include="Models\Blocks\BlockListEditorDataConverter.cs" />
|
||||
<Compile Include="Models\Blocks\IBlockReference.cs" />
|
||||
<Compile Include="Models\Blocks\IBlockEditorDataHelper.cs" />
|
||||
<Compile Include="Models\ContentDataIntegrityReport.cs" />
|
||||
<Compile Include="Models\ContentDataIntegrityReportEntry.cs" />
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Examine;
|
||||
using Umbraco.Core.Composing;
|
||||
using Umbraco.Core.Logging;
|
||||
|
||||
namespace Umbraco.Examine
|
||||
{
|
||||
@@ -12,12 +14,20 @@ namespace Umbraco.Examine
|
||||
/// </summary>
|
||||
public class IndexRebuilder
|
||||
{
|
||||
private readonly IProfilingLogger _logger;
|
||||
private readonly IEnumerable<IIndexPopulator> _populators;
|
||||
public IExamineManager ExamineManager { get; }
|
||||
|
||||
[Obsolete("Use constructor with all dependencies")]
|
||||
public IndexRebuilder(IExamineManager examineManager, IEnumerable<IIndexPopulator> populators)
|
||||
: this(Current.ProfilingLogger, examineManager, populators)
|
||||
{
|
||||
}
|
||||
|
||||
public IndexRebuilder(IProfilingLogger logger, IExamineManager examineManager, IEnumerable<IIndexPopulator> populators)
|
||||
{
|
||||
_populators = populators;
|
||||
_logger = logger;
|
||||
ExamineManager = examineManager;
|
||||
}
|
||||
|
||||
@@ -50,8 +60,18 @@ namespace Umbraco.Examine
|
||||
index.CreateIndex(); // clear the index
|
||||
}
|
||||
|
||||
//run the populators in parallel against all indexes
|
||||
Parallel.ForEach(_populators, populator => populator.Populate(indexes));
|
||||
// run each populator over the indexes
|
||||
foreach(var populator in _populators)
|
||||
{
|
||||
try
|
||||
{
|
||||
populator.Populate(indexes);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Error<IndexRebuilder>(e, "Index populating failed for populator {Populator}", populator.GetType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -542,8 +542,10 @@ namespace Umbraco.ModelsBuilder.Embedded
|
||||
if (modelInfos.TryGetValue(typeName, out var modelInfo))
|
||||
throw new InvalidOperationException($"Both types {type.FullName} and {modelInfo.ModelType.FullName} want to be a model type for content type with alias \"{typeName}\".");
|
||||
|
||||
// fixme use Core's ReflectionUtilities.EmitCtor !!
|
||||
// TODO: use Core's ReflectionUtilities.EmitCtor !!
|
||||
// Yes .. DynamicMethod is uber slow
|
||||
// TODO: But perhaps https://docs.microsoft.com/en-us/dotnet/api/system.reflection.emit.constructorbuilder?view=netcore-3.1 is better still?
|
||||
// See CtorInvokeBenchmarks
|
||||
var meth = new DynamicMethod(string.Empty, typeof(IPublishedElement), ctorArgTypes, type.Module, true);
|
||||
var gen = meth.GetILGenerator();
|
||||
gen.Emit(OpCodes.Ldarg_0);
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Linq;
|
||||
using System.Web.Mvc;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Core.Models;
|
||||
using System.Web;
|
||||
using System.Web.Hosting;
|
||||
using System.Web.Routing;
|
||||
using System.Diagnostics;
|
||||
using Umbraco.Core.Composing;
|
||||
using System.Configuration;
|
||||
|
||||
// see https://github.com/Shazwazza/UmbracoScripts/tree/master/src/LoadTesting
|
||||
|
||||
namespace Umbraco.TestData
|
||||
{
|
||||
public class LoadTestController : Controller
|
||||
{
|
||||
public LoadTestController(ServiceContext serviceContext)
|
||||
{
|
||||
_serviceContext = serviceContext;
|
||||
}
|
||||
|
||||
private static readonly Random _random = new Random();
|
||||
private static readonly object _locko = new object();
|
||||
|
||||
private static volatile int _containerId = -1;
|
||||
|
||||
private const string _containerAlias = "LoadTestContainer";
|
||||
private const string _contentAlias = "LoadTestContent";
|
||||
private const int _textboxDefinitionId = -88;
|
||||
private const int _maxCreate = 1000;
|
||||
|
||||
private static readonly string HeadHtml = @"<html>
|
||||
<head>
|
||||
<title>LoadTest</title>
|
||||
<style>
|
||||
body { font-family: arial; }
|
||||
a,a:visited { color: blue; }
|
||||
h1 { margin: 0; padding: 0; font-size: 120%; font-weight: bold; }
|
||||
h1 a { text-decoration: none; }
|
||||
div.block { margin: 20px 0; }
|
||||
ul { margin:0; }
|
||||
div.ver { font-size: 80%; }
|
||||
div.head { padding:0 0 10px 0; margin: 0 0 20px 0; border-bottom: 1px solid #cccccc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=""head"">
|
||||
<h1><a href=""/LoadTest"">LoadTest</a></h1>
|
||||
<div class=""ver"">" + System.Configuration.ConfigurationManager.AppSettings["umbracoConfigurationStatus"] + @"</div>
|
||||
</div>
|
||||
";
|
||||
|
||||
private const string FootHtml = @"</body>
|
||||
</html>";
|
||||
|
||||
private static readonly string _containerTemplateText = @"
|
||||
@inherits Umbraco.Web.Mvc.UmbracoViewPage
|
||||
@{
|
||||
Layout = null;
|
||||
var container = Umbraco.ContentAtRoot().OfTypes(""" + _containerAlias + @""").FirstOrDefault();
|
||||
var contents = container.Children().ToArray();
|
||||
var groups = contents.GroupBy(x => x.Value<string>(""origin""));
|
||||
var id = contents.Length > 0 ? contents[0].Id : -1;
|
||||
var wurl = Request.QueryString[""u""] == ""1"";
|
||||
var missing = contents.Length > 0 && contents[contents.Length - 1].Id - contents[0].Id >= contents.Length;
|
||||
}
|
||||
" + HeadHtml + @"
|
||||
<div class=""block"">
|
||||
<span @Html.Raw(missing ? ""style=\""color:red;\"""" : """")>@contents.Length items</span>
|
||||
<ul>
|
||||
@foreach (var group in groups)
|
||||
{
|
||||
<li>@group.Key: @group.Count()</li>
|
||||
}
|
||||
</ul></div>
|
||||
<div class=""block"">
|
||||
@foreach (var content in contents)
|
||||
{
|
||||
while (content.Id > id)
|
||||
{
|
||||
<div style=""color:red;"">@id :: MISSING</div>
|
||||
id++;
|
||||
}
|
||||
if (wurl)
|
||||
{
|
||||
<div>@content.Id :: @content.Name :: @content.Url</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div>@content.Id :: @content.Name</div>
|
||||
} id++;
|
||||
}
|
||||
</div>
|
||||
" + FootHtml;
|
||||
private readonly ServiceContext _serviceContext;
|
||||
|
||||
private ActionResult ContentHtml(string s)
|
||||
{
|
||||
return Content(HeadHtml + s + FootHtml);
|
||||
}
|
||||
|
||||
public ActionResult Index()
|
||||
{
|
||||
var res = EnsureInitialize();
|
||||
if (res != null) return res;
|
||||
|
||||
var html = @"Welcome. You can:
|
||||
<ul>
|
||||
<li><a href=""/LoadTestContainer"">List existing contents</a> (u:url)</li>
|
||||
<li><a href=""/LoadTest/Create?o=browser"">Create a content</a> (o:origin, r:restart, n:number)</li>
|
||||
<li><a href=""/LoadTest/Clear"">Clear all contents</a></li>
|
||||
<li><a href=""/LoadTest/Domains"">List the current domains in w3wp.exe</a></li>
|
||||
<li><a href=""/LoadTest/Restart"">Restart the current AppDomain</a></li>
|
||||
<li><a href=""/LoadTest/Recycle"">Recycle the AppPool</a></li>
|
||||
<li><a href=""/LoadTest/Die"">Cause w3wp.exe to die</a></li>
|
||||
</ul>
|
||||
";
|
||||
|
||||
return ContentHtml(html);
|
||||
}
|
||||
|
||||
private ActionResult EnsureInitialize()
|
||||
{
|
||||
if (_containerId > 0) return null;
|
||||
|
||||
lock (_locko)
|
||||
{
|
||||
if (_containerId > 0) return null;
|
||||
|
||||
var contentTypeService = _serviceContext.ContentTypeService;
|
||||
var contentType = contentTypeService.Get(_contentAlias);
|
||||
if (contentType == null)
|
||||
return ContentHtml("Not installed, first you must <a href=\"/LoadTest/Install\">install</a>.");
|
||||
|
||||
var containerType = contentTypeService.Get(_containerAlias);
|
||||
if (containerType == null)
|
||||
return ContentHtml("Panic! Container type is missing.");
|
||||
|
||||
var contentService = _serviceContext.ContentService;
|
||||
var container = contentService.GetPagedOfType(containerType.Id, 0, 100, out _, null).FirstOrDefault();
|
||||
if (container == null)
|
||||
return ContentHtml("Panic! Container is missing.");
|
||||
|
||||
_containerId = container.Id;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public ActionResult Install()
|
||||
{
|
||||
var dataTypeService = _serviceContext.DataTypeService;
|
||||
|
||||
//var dataType = dataTypeService.GetAll(Constants.DataTypes.DefaultContentListView);
|
||||
|
||||
|
||||
//if (!dict.ContainsKey("pageSize")) dict["pageSize"] = new PreValue("10");
|
||||
//dict["pageSize"].Value = "200";
|
||||
//dataTypeService.SavePreValues(dataType, dict);
|
||||
|
||||
var contentTypeService = _serviceContext.ContentTypeService;
|
||||
|
||||
var contentType = new ContentType(-1)
|
||||
{
|
||||
Alias = _contentAlias,
|
||||
Name = "LoadTest Content",
|
||||
Description = "Content for LoadTest",
|
||||
Icon = "icon-document"
|
||||
};
|
||||
var def = _serviceContext.DataTypeService.GetDataType(_textboxDefinitionId);
|
||||
contentType.AddPropertyType(new PropertyType(def)
|
||||
{
|
||||
Name = "Origin",
|
||||
Alias = "origin",
|
||||
Description = "The origin of the content.",
|
||||
});
|
||||
contentTypeService.Save(contentType);
|
||||
|
||||
var containerTemplate = ImportTemplate(_serviceContext,
|
||||
"LoadTestContainer", "LoadTestContainer", _containerTemplateText);
|
||||
|
||||
var containerType = new ContentType(-1)
|
||||
{
|
||||
Alias = _containerAlias,
|
||||
Name = "LoadTest Container",
|
||||
Description = "Container for LoadTest content",
|
||||
Icon = "icon-document",
|
||||
AllowedAsRoot = true,
|
||||
IsContainer = true
|
||||
};
|
||||
containerType.AllowedContentTypes = containerType.AllowedContentTypes.Union(new[]
|
||||
{
|
||||
new ContentTypeSort(new Lazy<int>(() => contentType.Id), 0, contentType.Alias),
|
||||
});
|
||||
containerType.AllowedTemplates = containerType.AllowedTemplates.Union(new[] { containerTemplate });
|
||||
containerType.SetDefaultTemplate(containerTemplate);
|
||||
contentTypeService.Save(containerType);
|
||||
|
||||
var contentService = _serviceContext.ContentService;
|
||||
var content = contentService.Create("LoadTestContainer", -1, _containerAlias);
|
||||
contentService.SaveAndPublish(content);
|
||||
|
||||
return ContentHtml("Installed.");
|
||||
}
|
||||
|
||||
public ActionResult Create(int n = 1, int r = 0, string o = null)
|
||||
{
|
||||
var res = EnsureInitialize();
|
||||
if (res != null) return res;
|
||||
|
||||
if (r < 0) r = 0;
|
||||
if (r > 100) r = 100;
|
||||
var restart = GetRandom(0, 100) > (100 - r);
|
||||
|
||||
var contentService = _serviceContext.ContentService;
|
||||
|
||||
if (n < 1) n = 1;
|
||||
if (n > _maxCreate) n = _maxCreate;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var name = Guid.NewGuid().ToString("N").ToUpper() + "-" + (restart ? "R" : "X") + "-" + o;
|
||||
var content = contentService.Create(name, _containerId, _contentAlias);
|
||||
content.SetValue("origin", o);
|
||||
contentService.SaveAndPublish(content);
|
||||
}
|
||||
|
||||
if (restart)
|
||||
DoRestart();
|
||||
|
||||
return ContentHtml("Created " + n + " content"
|
||||
+ (restart ? ", and restarted" : "")
|
||||
+ ".");
|
||||
}
|
||||
|
||||
private int GetRandom(int minValue, int maxValue)
|
||||
{
|
||||
lock (_locko)
|
||||
{
|
||||
return _random.Next(minValue, maxValue);
|
||||
}
|
||||
}
|
||||
|
||||
public ActionResult Clear()
|
||||
{
|
||||
var res = EnsureInitialize();
|
||||
if (res != null) return res;
|
||||
|
||||
var contentType = _serviceContext.ContentTypeService.Get(_contentAlias);
|
||||
_serviceContext.ContentService.DeleteOfType(contentType.Id);
|
||||
|
||||
return ContentHtml("Cleared.");
|
||||
}
|
||||
|
||||
private void DoRestart()
|
||||
{
|
||||
HttpContext.User = null;
|
||||
System.Web.HttpContext.Current.User = null;
|
||||
Thread.CurrentPrincipal = null;
|
||||
HttpRuntime.UnloadAppDomain();
|
||||
}
|
||||
|
||||
public ActionResult Restart()
|
||||
{
|
||||
DoRestart();
|
||||
|
||||
return ContentHtml("Restarted.");
|
||||
}
|
||||
|
||||
public ActionResult Die()
|
||||
{
|
||||
var timer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
throw new Exception("die!");
|
||||
});
|
||||
timer.Change(100, 0);
|
||||
|
||||
return ContentHtml("Dying.");
|
||||
}
|
||||
|
||||
public ActionResult Domains()
|
||||
{
|
||||
var currentDomain = AppDomain.CurrentDomain;
|
||||
var currentName = currentDomain.FriendlyName;
|
||||
var pos = currentName.IndexOf('-');
|
||||
if (pos > 0) currentName = currentName.Substring(0, pos);
|
||||
|
||||
var text = new System.Text.StringBuilder();
|
||||
text.Append("<div class=\"block\">Process ID: " + Process.GetCurrentProcess().Id + "</div>");
|
||||
text.Append("<div class=\"block\">");
|
||||
text.Append("<div>IIS Site: " + HostingEnvironment.ApplicationHost.GetSiteName() + "</div>");
|
||||
text.Append("<div>App ID: " + currentName + "</div>");
|
||||
//text.Append("<div>AppPool: " + Zbu.WebManagement.AppPoolHelper.GetCurrentApplicationPoolName() + "</div>");
|
||||
text.Append("</div>");
|
||||
|
||||
text.Append("<div class=\"block\">Domains:<ul>");
|
||||
text.Append("<li>Not implemented.</li>");
|
||||
/*
|
||||
foreach (var domain in Zbu.WebManagement.AppDomainHelper.GetAppDomains().OrderBy(x => x.Id))
|
||||
{
|
||||
var name = domain.FriendlyName;
|
||||
pos = name.IndexOf('-');
|
||||
if (pos > 0) name = name.Substring(0, pos);
|
||||
text.Append("<li style=\""
|
||||
+ (name != currentName ? "color: #cccccc;" : "")
|
||||
//+ (domain.Id == currentDomain.Id ? "" : "")
|
||||
+ "\">"
|
||||
+"[" + domain.Id + "] " + name
|
||||
+ (domain.IsDefaultAppDomain() ? " (default)" : "")
|
||||
+ (domain.Id == currentDomain.Id ? " (current)" : "")
|
||||
+ "</li>");
|
||||
}
|
||||
*/
|
||||
text.Append("</ul></div>");
|
||||
|
||||
return ContentHtml(text.ToString());
|
||||
}
|
||||
|
||||
public ActionResult Recycle()
|
||||
{
|
||||
return ContentHtml("Not implemented—please use IIS console.");
|
||||
}
|
||||
|
||||
private static Template ImportTemplate(ServiceContext svces, string name, string alias, string text, ITemplate master = null)
|
||||
{
|
||||
var t = new Template(name, alias) { Content = text };
|
||||
if (master != null)
|
||||
t.SetMasterTemplate(master);
|
||||
svces.FileService.SaveTemplate(t);
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
public class TestComponent : IComponent
|
||||
{
|
||||
public void Initialize()
|
||||
{
|
||||
if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true")
|
||||
return;
|
||||
|
||||
RouteTable.Routes.MapRoute(
|
||||
name: "LoadTest",
|
||||
url: "LoadTest/{action}",
|
||||
defaults: new
|
||||
{
|
||||
controller = "LoadTest",
|
||||
action = "Index"
|
||||
},
|
||||
namespaces: new[] { "Umbraco.TestData" }
|
||||
);
|
||||
}
|
||||
|
||||
public void Terminate()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class TestComposer : ComponentComposer<TestComponent>, IUserComposer
|
||||
{
|
||||
public override void Compose(Composition composition)
|
||||
{
|
||||
base.Compose(composition);
|
||||
|
||||
if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true")
|
||||
return;
|
||||
|
||||
composition.Register(typeof(LoadTestController), Lifetime.Request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="LoadTestController.cs" />
|
||||
<Compile Include="SegmentTestController.cs" />
|
||||
<Compile Include="UmbracoTestDataController.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
|
||||
@@ -16,6 +16,8 @@ namespace Umbraco.Tests.Benchmarks
|
||||
// - it's faster to get+invoke the ctor
|
||||
// - emitting the ctor is unless if invoked only 1
|
||||
|
||||
// TODO: Check out https://docs.microsoft.com/en-us/dotnet/api/system.reflection.emit.constructorbuilder?view=netcore-3.1 ?
|
||||
|
||||
//[Config(typeof(Config))]
|
||||
[MemoryDiagnoser]
|
||||
public class CtorInvokeBenchmarks
|
||||
|
||||
@@ -442,7 +442,10 @@ namespace Umbraco.Tests.Models
|
||||
[Test]
|
||||
public void ContentPublishValuesWithMixedPropertyTypeVariations()
|
||||
{
|
||||
var propertyValidationService = new PropertyValidationService(Current.Factory.GetInstance<PropertyEditorCollection>(), Current.Factory.GetInstance<ServiceContext>().DataTypeService);
|
||||
var propertyValidationService = new PropertyValidationService(
|
||||
Current.Factory.GetInstance<PropertyEditorCollection>(),
|
||||
Current.Factory.GetInstance<ServiceContext>().DataTypeService,
|
||||
Current.Factory.GetInstance<ServiceContext>().TextService);
|
||||
const string langFr = "fr-FR";
|
||||
|
||||
// content type varies by Culture
|
||||
@@ -574,7 +577,10 @@ namespace Umbraco.Tests.Models
|
||||
prop.SetValue("a");
|
||||
Assert.AreEqual("a", prop.GetValue());
|
||||
Assert.IsNull(prop.GetValue(published: true));
|
||||
var propertyValidationService = new PropertyValidationService(Current.Factory.GetInstance<PropertyEditorCollection>(), Current.Factory.GetInstance<ServiceContext>().DataTypeService);
|
||||
var propertyValidationService = new PropertyValidationService(
|
||||
Current.Factory.GetInstance<PropertyEditorCollection>(),
|
||||
Current.Factory.GetInstance<ServiceContext>().DataTypeService,
|
||||
Current.Factory.GetInstance<ServiceContext>().TextService);
|
||||
|
||||
Assert.IsTrue(propertyValidationService.IsPropertyValid(prop));
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ namespace Umbraco.Tests.PropertyEditors
|
||||
Assert.AreEqual(0, converted.Layout.Count());
|
||||
|
||||
json = @"{
|
||||
layout: [],
|
||||
layout: {},
|
||||
data: []}";
|
||||
converted = editor.ConvertIntermediateToObject(publishedElement, propertyType, PropertyCacheLevel.None, json, false) as BlockListModel;
|
||||
|
||||
|
||||
@@ -1197,7 +1197,7 @@ namespace Umbraco.Tests.Services
|
||||
Assert.IsFalse(content.HasIdentity);
|
||||
|
||||
// content cannot publish values because they are invalid
|
||||
var propertyValidationService = new PropertyValidationService(Factory.GetInstance<PropertyEditorCollection>(), ServiceContext.DataTypeService);
|
||||
var propertyValidationService = new PropertyValidationService(Factory.GetInstance<PropertyEditorCollection>(), ServiceContext.DataTypeService, ServiceContext.TextService);
|
||||
var isValid = propertyValidationService.IsPropertyDataValid(content, out var invalidProperties, CultureImpact.Invariant);
|
||||
Assert.IsFalse(isValid);
|
||||
Assert.IsNotEmpty(invalidProperties);
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace Umbraco.Tests.Services
|
||||
|
||||
var propEditors = new PropertyEditorCollection(new DataEditorCollection(new[] { dataEditor }));
|
||||
|
||||
validationService = new PropertyValidationService(propEditors, dataTypeService.Object);
|
||||
validationService = new PropertyValidationService(propEditors, dataTypeService.Object, Mock.Of<ILocalizedTextService>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -41,12 +41,12 @@ namespace Umbraco.Tests.TestHelpers.Entities
|
||||
};
|
||||
|
||||
var contentCollection = new PropertyTypeCollection(true);
|
||||
contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "title", Name = "Title", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = -88 });
|
||||
contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "bodyText", Name = "Body Text", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = -87 });
|
||||
contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "title", Name = "Title", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = Constants.DataTypes.Textbox });
|
||||
contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "bodyText", Name = "Body Text", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = Constants.DataTypes.RichtextEditor });
|
||||
|
||||
var metaCollection = new PropertyTypeCollection(true);
|
||||
metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "keywords", Name = "Meta Keywords", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = -88 });
|
||||
metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "description", Name = "Meta Description", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = -89 });
|
||||
metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "keywords", Name = "Meta Keywords", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = Constants.DataTypes.Textbox });
|
||||
metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "description", Name = "Meta Description", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = Constants.DataTypes.Textarea });
|
||||
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(contentCollection) { Name = "Content", SortOrder = 1 });
|
||||
contentType.PropertyGroups.Add(new PropertyGroup(metaCollection) { Name = "Meta", SortOrder = 2 });
|
||||
|
||||
@@ -267,7 +267,7 @@
|
||||
<Compile Include="Web\HttpCookieExtensionsTests.cs" />
|
||||
<Compile Include="Templates\HtmlImageSourceParserTests.cs" />
|
||||
<Compile Include="Web\Mvc\HtmlStringUtilitiesTests.cs" />
|
||||
<Compile Include="Web\ModelStateExtensionsTests.cs" />
|
||||
<Compile Include="Web\Validation\ModelStateExtensionsTests.cs" />
|
||||
<Compile Include="Web\Mvc\RenderIndexActionSelectorAttributeTests.cs" />
|
||||
<Compile Include="Persistence\NPocoTests\PetaPocoCachesTest.cs" />
|
||||
<Compile Include="Persistence\Repositories\AuditRepositoryTest.cs" />
|
||||
@@ -514,6 +514,7 @@
|
||||
<Compile Include="CoreThings\VersionExtensionTests.cs" />
|
||||
<Compile Include="Web\TemplateUtilitiesTests.cs" />
|
||||
<Compile Include="Web\UrlHelperExtensionTests.cs" />
|
||||
<Compile Include="Web\Validation\ContentModelValidatorTests.cs" />
|
||||
<Compile Include="Web\WebExtensionMethodTests.cs" />
|
||||
<Compile Include="CoreThings\XmlExtensionsTests.cs" />
|
||||
<Compile Include="Misc\XmlHelperTests.cs" />
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Core.Logging;
|
||||
using Umbraco.Web;
|
||||
using Umbraco.Web.Editors.Filters;
|
||||
using Umbraco.Tests.TestHelpers.Entities;
|
||||
using Umbraco.Core.Models;
|
||||
using Umbraco.Web.Models.ContentEditing;
|
||||
using Umbraco.Web.Editors.Binders;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Tests.Testing;
|
||||
using Umbraco.Core.Mapping;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
using Umbraco.Core.Composing;
|
||||
using System.Web.Http.ModelBinding;
|
||||
using Umbraco.Web.PropertyEditors;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Tests.TestHelpers;
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Umbraco.Web.PropertyEditors.Validation;
|
||||
|
||||
namespace Umbraco.Tests.Web.Validation
|
||||
{
|
||||
[UmbracoTest(Mapper = true, WithApplication = true, Logger = UmbracoTestOptions.Logger.Console)]
|
||||
[TestFixture]
|
||||
public class ContentModelValidatorTests : UmbracoTestBase
|
||||
{
|
||||
private const int ComplexDataTypeId = 9999;
|
||||
private const string ContentTypeAlias = "textPage";
|
||||
private ContentType _contentType;
|
||||
|
||||
public override void SetUp()
|
||||
{
|
||||
base.SetUp();
|
||||
|
||||
_contentType = MockedContentTypes.CreateTextPageContentType(ContentTypeAlias);
|
||||
// add complex editor
|
||||
_contentType.AddPropertyType(
|
||||
new PropertyType("complexTest", ValueStorageType.Ntext) { Alias = "complex", Name = "Complex", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = ComplexDataTypeId },
|
||||
"Content");
|
||||
|
||||
// make them all validate with a regex rule that will not pass
|
||||
foreach (var prop in _contentType.PropertyTypes)
|
||||
{
|
||||
prop.ValidationRegExp = "^donotmatch$";
|
||||
prop.ValidationRegExpMessage = "Does not match!";
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Compose()
|
||||
{
|
||||
base.Compose();
|
||||
|
||||
var complexEditorConfig = new NestedContentConfiguration
|
||||
{
|
||||
ContentTypes = new[]
|
||||
{
|
||||
new NestedContentConfiguration.ContentType { Alias = "feature" }
|
||||
}
|
||||
};
|
||||
var dataTypeService = new Mock<IDataTypeService>();
|
||||
dataTypeService.Setup(x => x.GetDataType(It.IsAny<int>()))
|
||||
.Returns((int id) => id == ComplexDataTypeId
|
||||
? Mock.Of<IDataType>(x => x.Configuration == complexEditorConfig)
|
||||
: Mock.Of<IDataType>());
|
||||
|
||||
var contentTypeService = new Mock<IContentTypeService>();
|
||||
contentTypeService.Setup(x => x.GetAll(It.IsAny<int[]>()))
|
||||
.Returns(() => new List<IContentType>
|
||||
{
|
||||
_contentType
|
||||
});
|
||||
|
||||
var textService = new Mock<ILocalizedTextService>();
|
||||
textService.Setup(x => x.Localize("validation/invalidPattern", It.IsAny<CultureInfo>(), It.IsAny<IDictionary<string, string>>())).Returns(() => "invalidPattern");
|
||||
textService.Setup(x => x.Localize("validation/invalidNull", It.IsAny<CultureInfo>(), It.IsAny<IDictionary<string, string>>())).Returns("invalidNull");
|
||||
textService.Setup(x => x.Localize("validation/invalidEmpty", It.IsAny<CultureInfo>(), It.IsAny<IDictionary<string, string>>())).Returns("invalidEmpty");
|
||||
|
||||
Composition.RegisterUnique(x => Mock.Of<IDataTypeService>(x => x.GetDataType(It.IsAny<int>()) == Mock.Of<IDataType>()));
|
||||
Composition.RegisterUnique(x => dataTypeService.Object);
|
||||
Composition.RegisterUnique(x => contentTypeService.Object);
|
||||
Composition.RegisterUnique(x => textService.Object);
|
||||
|
||||
Composition.WithCollectionBuilder<DataEditorCollectionBuilder>()
|
||||
.Add<TestEditor>()
|
||||
.Add<ComplexTestEditor>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Serializer()
|
||||
{
|
||||
var nestedLevel2 = new ComplexEditorValidationResult();
|
||||
var id1 = Guid.NewGuid();
|
||||
var addressInfoElementTypeResult = new ComplexEditorElementTypeValidationResult("addressInfo", id1);
|
||||
var cityPropertyTypeResult = new ComplexEditorPropertyTypeValidationResult("city");
|
||||
cityPropertyTypeResult.AddValidationResult(new ValidationResult("City is invalid"));
|
||||
cityPropertyTypeResult.AddValidationResult(new ValidationResult("City cannot be empty"));
|
||||
cityPropertyTypeResult.AddValidationResult(new ValidationResult("City is not in Australia", new[] { "country" }));
|
||||
cityPropertyTypeResult.AddValidationResult(new ValidationResult("Not a capital city", new[] { "capital" }));
|
||||
addressInfoElementTypeResult.ValidationResults.Add(cityPropertyTypeResult);
|
||||
nestedLevel2.ValidationResults.Add(addressInfoElementTypeResult);
|
||||
|
||||
var nestedLevel1 = new ComplexEditorValidationResult();
|
||||
var id2 = Guid.NewGuid();
|
||||
var addressBookElementTypeResult = new ComplexEditorElementTypeValidationResult("addressBook", id2);
|
||||
var addressesPropertyTypeResult = new ComplexEditorPropertyTypeValidationResult("addresses");
|
||||
addressesPropertyTypeResult.AddValidationResult(new ValidationResult("Must have at least 3 addresses", new[] { "counter" }));
|
||||
addressesPropertyTypeResult.AddValidationResult(nestedLevel2); // This is a nested result within the level 1
|
||||
addressBookElementTypeResult.ValidationResults.Add(addressesPropertyTypeResult);
|
||||
var bookNamePropertyTypeResult = new ComplexEditorPropertyTypeValidationResult("bookName");
|
||||
bookNamePropertyTypeResult.AddValidationResult(new ValidationResult("Invalid address book name", new[] { "book" }));
|
||||
addressBookElementTypeResult.ValidationResults.Add(bookNamePropertyTypeResult);
|
||||
nestedLevel1.ValidationResults.Add(addressBookElementTypeResult);
|
||||
|
||||
var id3 = Guid.NewGuid();
|
||||
var addressBookElementTypeResult2 = new ComplexEditorElementTypeValidationResult("addressBook", id3);
|
||||
var addressesPropertyTypeResult2 = new ComplexEditorPropertyTypeValidationResult("addresses");
|
||||
addressesPropertyTypeResult2.AddValidationResult(new ValidationResult("Must have at least 2 addresses", new[] { "counter" }));
|
||||
addressBookElementTypeResult2.ValidationResults.Add(addressesPropertyTypeResult);
|
||||
var bookNamePropertyTypeResult2 = new ComplexEditorPropertyTypeValidationResult("bookName");
|
||||
bookNamePropertyTypeResult2.AddValidationResult(new ValidationResult("Name is too long"));
|
||||
addressBookElementTypeResult2.ValidationResults.Add(bookNamePropertyTypeResult2);
|
||||
nestedLevel1.ValidationResults.Add(addressBookElementTypeResult2);
|
||||
|
||||
// books is the outer most validation result and doesn't have it's own direct ValidationResult errors
|
||||
var outerError = new ComplexEditorValidationResult();
|
||||
var id4 = Guid.NewGuid();
|
||||
var addressBookCollectionElementTypeResult = new ComplexEditorElementTypeValidationResult("addressBookCollection", id4);
|
||||
var booksPropertyTypeResult= new ComplexEditorPropertyTypeValidationResult("books");
|
||||
booksPropertyTypeResult.AddValidationResult(nestedLevel1); // books is the outer most validation result
|
||||
addressBookCollectionElementTypeResult.ValidationResults.Add(booksPropertyTypeResult);
|
||||
outerError.ValidationResults.Add(addressBookCollectionElementTypeResult);
|
||||
|
||||
var serialized = JsonConvert.SerializeObject(outerError, Formatting.Indented, new ValidationResultConverter());
|
||||
Console.WriteLine(serialized);
|
||||
|
||||
var jsonError = JsonConvert.DeserializeObject<JArray>(serialized);
|
||||
|
||||
Assert.IsNotNull(jsonError.SelectToken("$[0]"));
|
||||
Assert.AreEqual(id4.ToString(), jsonError.SelectToken("$[0].$id").Value<string>());
|
||||
Assert.AreEqual("addressBookCollection", jsonError.SelectToken("$[0].$elementTypeAlias").Value<string>());
|
||||
Assert.AreEqual(string.Empty, jsonError.SelectToken("$[0].ModelState['_Properties.books.invariant.null'][0]").Value<string>());
|
||||
|
||||
var error0 = jsonError.SelectToken("$[0].books") as JArray;
|
||||
Assert.IsNotNull(error0);
|
||||
Assert.AreEqual(id2.ToString(), error0.SelectToken("$[0].$id").Value<string>());
|
||||
Assert.AreEqual("addressBook", error0.SelectToken("$[0].$elementTypeAlias").Value<string>());
|
||||
Assert.IsNotNull(error0.SelectToken("$[0].ModelState"));
|
||||
Assert.AreEqual(string.Empty, error0.SelectToken("$[0].ModelState['_Properties.addresses.invariant.null'][0]").Value<string>());
|
||||
var error1 = error0.SelectToken("$[0].ModelState['_Properties.addresses.invariant.null.counter']") as JArray;
|
||||
Assert.IsNotNull(error1);
|
||||
Assert.AreEqual(1, error1.Count);
|
||||
var error2 = error0.SelectToken("$[0].ModelState['_Properties.bookName.invariant.null.book']") as JArray;
|
||||
Assert.IsNotNull(error2);
|
||||
Assert.AreEqual(1, error2.Count);
|
||||
|
||||
Assert.AreEqual(id3.ToString(), error0.SelectToken("$[1].$id").Value<string>());
|
||||
Assert.AreEqual("addressBook", error0.SelectToken("$[1].$elementTypeAlias").Value<string>());
|
||||
Assert.IsNotNull(error0.SelectToken("$[1].ModelState"));
|
||||
Assert.AreEqual(string.Empty, error0.SelectToken("$[1].ModelState['_Properties.addresses.invariant.null'][0]").Value<string>());
|
||||
var error6 = error0.SelectToken("$[1].ModelState['_Properties.addresses.invariant.null.counter']") as JArray;
|
||||
Assert.IsNotNull(error6);
|
||||
Assert.AreEqual(1, error6.Count);
|
||||
var error7 = error0.SelectToken("$[1].ModelState['_Properties.bookName.invariant.null']") as JArray;
|
||||
Assert.IsNotNull(error7);
|
||||
Assert.AreEqual(1, error7.Count);
|
||||
|
||||
Assert.IsNotNull(error0.SelectToken("$[0].addresses"));
|
||||
Assert.AreEqual(id1.ToString(), error0.SelectToken("$[0].addresses[0].$id").Value<string>());
|
||||
Assert.AreEqual("addressInfo", error0.SelectToken("$[0].addresses[0].$elementTypeAlias").Value<string>());
|
||||
Assert.IsNotNull(error0.SelectToken("$[0].addresses[0].ModelState"));
|
||||
var error3 = error0.SelectToken("$[0].addresses[0].ModelState['_Properties.city.invariant.null.country']") as JArray;
|
||||
Assert.IsNotNull(error3);
|
||||
Assert.AreEqual(1, error3.Count);
|
||||
var error4 = error0.SelectToken("$[0].addresses[0].ModelState['_Properties.city.invariant.null.capital']") as JArray;
|
||||
Assert.IsNotNull(error4);
|
||||
Assert.AreEqual(1, error4.Count);
|
||||
var error5 = error0.SelectToken("$[0].addresses[0].ModelState['_Properties.city.invariant.null']") as JArray;
|
||||
Assert.IsNotNull(error5);
|
||||
Assert.AreEqual(2, error5.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validating_ContentItemSave()
|
||||
{
|
||||
var validator = new ContentSaveModelValidator(
|
||||
Factory.GetInstance<ILogger>(),
|
||||
Mock.Of<IUmbracoContextAccessor>(),
|
||||
Factory.GetInstance<ILocalizedTextService>());
|
||||
|
||||
var content = MockedContent.CreateTextpageContent(_contentType, "test", -1);
|
||||
|
||||
var id1 = new Guid("c8df5136-d606-41f0-9134-dea6ae0c2fd9");
|
||||
var id2 = new Guid("f916104a-4082-48b2-a515-5c4bf2230f38");
|
||||
var id3 = new Guid("77E15DE9-1C79-47B2-BC60-4913BC4D4C6A");
|
||||
|
||||
// TODO: Ok now test with a 4th level complex nested editor
|
||||
|
||||
var complexValue = @"[{
|
||||
""key"": """ + id1.ToString() + @""",
|
||||
""name"": ""Hello world"",
|
||||
""ncContentTypeAlias"": """ + ContentTypeAlias + @""",
|
||||
""title"": ""Hello world"",
|
||||
""bodyText"": ""The world is round""
|
||||
}, {
|
||||
""key"": """ + id2.ToString() + @""",
|
||||
""name"": ""Super nested"",
|
||||
""ncContentTypeAlias"": """ + ContentTypeAlias + @""",
|
||||
""title"": ""Hi there!"",
|
||||
""bodyText"": ""Well hello there"",
|
||||
""complex"" : [{
|
||||
""key"": """ + id3.ToString() + @""",
|
||||
""name"": ""I am a sub nested content"",
|
||||
""ncContentTypeAlias"": """ + ContentTypeAlias + @""",
|
||||
""title"": ""Hello up there :)"",
|
||||
""bodyText"": ""Hello way up there on a different level""
|
||||
}]
|
||||
}
|
||||
]";
|
||||
content.SetValue("complex", complexValue);
|
||||
|
||||
// map the persisted properties to a model representing properties to save
|
||||
//var saveProperties = content.Properties.Select(x => Mapper.Map<ContentPropertyBasic>(x)).ToList();
|
||||
var saveProperties = content.Properties.Select(x =>
|
||||
{
|
||||
return new ContentPropertyBasic
|
||||
{
|
||||
Alias = x.Alias,
|
||||
Id = x.Id,
|
||||
Value = x.GetValue()
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var saveVariants = new List<ContentVariantSave>
|
||||
{
|
||||
new ContentVariantSave
|
||||
{
|
||||
Culture = string.Empty,
|
||||
Segment = string.Empty,
|
||||
Name = content.Name,
|
||||
Save = true,
|
||||
Properties = saveProperties
|
||||
}
|
||||
};
|
||||
|
||||
var save = new ContentItemSave
|
||||
{
|
||||
Id = content.Id,
|
||||
Action = ContentSaveAction.Save,
|
||||
ContentTypeAlias = _contentType.Alias,
|
||||
ParentId = -1,
|
||||
PersistedContent = content,
|
||||
TemplateAlias = null,
|
||||
Variants = saveVariants
|
||||
};
|
||||
|
||||
// This will map the ContentItemSave.Variants.PropertyCollectionDto and then map the values in the saved model
|
||||
// back onto the persisted IContent model.
|
||||
ContentItemBinder.BindModel(save, content);
|
||||
|
||||
var modelState = new ModelStateDictionary();
|
||||
var isValid = validator.ValidatePropertiesData(save, saveVariants[0], saveVariants[0].PropertyCollectionDto, modelState);
|
||||
|
||||
// list results for debugging
|
||||
foreach (var state in modelState)
|
||||
{
|
||||
Console.WriteLine(state.Key);
|
||||
foreach (var error in state.Value.Errors)
|
||||
{
|
||||
Console.WriteLine("\t" + error.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// assert
|
||||
|
||||
Assert.IsFalse(isValid);
|
||||
Assert.AreEqual(11, modelState.Keys.Count);
|
||||
const string complexPropertyKey = "_Properties.complex.invariant.null";
|
||||
Assert.IsTrue(modelState.Keys.Contains(complexPropertyKey));
|
||||
foreach (var state in modelState.Where(x => x.Key != complexPropertyKey))
|
||||
{
|
||||
foreach (var error in state.Value.Errors)
|
||||
{
|
||||
Assert.IsFalse(error.ErrorMessage.DetectIsJson()); // non complex is just an error message
|
||||
}
|
||||
}
|
||||
var complexEditorErrors = modelState.Single(x => x.Key == complexPropertyKey).Value.Errors;
|
||||
Assert.AreEqual(1, complexEditorErrors.Count);
|
||||
var nestedError = complexEditorErrors[0];
|
||||
var jsonError = JsonConvert.DeserializeObject<JArray>(nestedError.ErrorMessage);
|
||||
|
||||
var modelStateKeys = new[] { "_Properties.title.invariant.null.innerFieldId", "_Properties.title.invariant.null.value", "_Properties.bodyText.invariant.null.innerFieldId", "_Properties.bodyText.invariant.null.value" };
|
||||
AssertNestedValidation(jsonError, 0, id1, modelStateKeys);
|
||||
AssertNestedValidation(jsonError, 1, id2, modelStateKeys.Concat(new[] { "_Properties.complex.invariant.null.innerFieldId", "_Properties.complex.invariant.null.value" }).ToArray());
|
||||
var nestedJsonError = jsonError.SelectToken("$[1].complex") as JArray;
|
||||
Assert.IsNotNull(nestedJsonError);
|
||||
AssertNestedValidation(nestedJsonError, 0, id3, modelStateKeys);
|
||||
}
|
||||
|
||||
private void AssertNestedValidation(JArray jsonError, int index, Guid id, string[] modelStateKeys)
|
||||
{
|
||||
Assert.IsNotNull(jsonError.SelectToken("$[" + index + "]"));
|
||||
Assert.AreEqual(id.ToString(), jsonError.SelectToken("$[" + index + "].$id").Value<string>());
|
||||
Assert.AreEqual("textPage", jsonError.SelectToken("$[" + index + "].$elementTypeAlias").Value<string>());
|
||||
Assert.IsNotNull(jsonError.SelectToken("$[" + index + "].ModelState"));
|
||||
foreach (var key in modelStateKeys)
|
||||
{
|
||||
var error = jsonError.SelectToken("$[" + index + "].ModelState['" + key + "']") as JArray;
|
||||
Assert.IsNotNull(error);
|
||||
Assert.AreEqual(1, error.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[HideFromTypeFinder]
|
||||
[DataEditor("complexTest", "test", "test")]
|
||||
public class ComplexTestEditor : NestedContentPropertyEditor
|
||||
{
|
||||
public ComplexTestEditor(ILogger logger, Lazy<PropertyEditorCollection> propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) : base(logger, propertyEditors, dataTypeService, contentTypeService)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IDataValueEditor CreateValueEditor()
|
||||
{
|
||||
var editor = base.CreateValueEditor();
|
||||
editor.Validators.Add(new NeverValidateValidator());
|
||||
return editor;
|
||||
}
|
||||
}
|
||||
|
||||
[HideFromTypeFinder]
|
||||
[DataEditor("test", "test", "test")] // This alias aligns with the prop editor alias for all properties created from MockedContentTypes.CreateTextPageContentType
|
||||
public class TestEditor : DataEditor
|
||||
{
|
||||
public TestEditor(ILogger logger)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IDataValueEditor CreateValueEditor() => new TestValueEditor(Attribute);
|
||||
|
||||
private class TestValueEditor : DataValueEditor
|
||||
{
|
||||
public TestValueEditor(DataEditorAttribute attribute)
|
||||
: base(attribute)
|
||||
{
|
||||
Validators.Add(new NeverValidateValidator());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public class NeverValidateValidator : IValueValidator
|
||||
{
|
||||
public IEnumerable<ValidationResult> Validate(object value, string valueType, object dataTypeConfiguration)
|
||||
{
|
||||
yield return new ValidationResult("WRONG!", new[] { "innerFieldId" });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ using NUnit.Framework;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Web;
|
||||
|
||||
namespace Umbraco.Tests.Web
|
||||
namespace Umbraco.Tests.Web.Validation
|
||||
{
|
||||
[TestFixture]
|
||||
public class ModelStateExtensionsTests
|
||||
@@ -69,6 +69,18 @@
|
||||
};
|
||||
}
|
||||
|
||||
if (!String.prototype.trimStartSpecial) {
|
||||
/** trimSpecial extension method for string */
|
||||
// Removes all non printable chars from beginning of a string
|
||||
String.prototype.trimStartSpecial = function () {
|
||||
var index = 0;
|
||||
while (this.charCodeAt(index) <= 46) {
|
||||
index++;
|
||||
}
|
||||
return this.substr(index);
|
||||
};
|
||||
}
|
||||
|
||||
if (!String.prototype.startsWith) {
|
||||
/** startsWith extension method for string */
|
||||
String.prototype.startsWith = function (str) {
|
||||
|
||||
+1
-2
@@ -11,7 +11,7 @@ You can easily add you own tours to the Help-drawer or show and start tours from
|
||||
anywhere in the Umbraco backoffice. To see a real world example of a custom tour implementation, install <a href="https://our.umbraco.com/projects/starter-kits/the-starter-kit/">The Starter Kit</a> in Umbraco 7.8
|
||||
|
||||
<h1><b>Extending the help drawer with custom tours</b></h1>
|
||||
The easiet way to add new tours to Umbraco is through the Help-drawer. All it requires is a my-tour.json file.
|
||||
The easiet way to add new tours to Umbraco is through the Help-drawer. All it requires is a my-tour.json file.
|
||||
Place the file in <i>App_Plugins/{MyPackage}/backoffice/tours/{my-tour}.json</i> and it will automatically be
|
||||
picked up by Umbraco and shown in the Help-drawer.
|
||||
|
||||
@@ -277,7 +277,6 @@ In the following example you see how to run some custom logic before a step goes
|
||||
}
|
||||
|
||||
startStep();
|
||||
// tour completed - final step
|
||||
} else {
|
||||
// tour completed - final step
|
||||
scope.loadingStep = true;
|
||||
|
||||
+20
-14
@@ -4,7 +4,8 @@
|
||||
function ContentEditController($rootScope, $scope, $routeParams, $q, $window,
|
||||
appState, contentResource, entityResource, navigationService, notificationsService,
|
||||
serverValidationManager, contentEditingHelper, localizationService, formHelper, umbRequestHelper,
|
||||
editorState, $http, eventsService, overlayService, $location, localStorageService, treeService) {
|
||||
editorState, $http, eventsService, overlayService, $location, localStorageService, treeService,
|
||||
$exceptionHandler) {
|
||||
|
||||
var evts = [];
|
||||
var infiniteMode = $scope.infiniteModel && $scope.infiniteModel.infiniteMode;
|
||||
@@ -515,6 +516,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleHttpException(err) {
|
||||
if (!err.status) {
|
||||
$exceptionHandler(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Just shows a simple notification that there are client side validation issues to be fixed */
|
||||
function showValidationNotification() {
|
||||
//TODO: We need to make the validation UI much better, there's a lot of inconsistencies in v8 including colors, issues with the property groups and validation errors between variants
|
||||
@@ -575,6 +582,7 @@
|
||||
overlayService.close();
|
||||
}, function (err) {
|
||||
$scope.page.buttonGroupState = 'error';
|
||||
handleHttpException(err);
|
||||
});
|
||||
|
||||
|
||||
@@ -620,8 +628,7 @@
|
||||
model.submitButtonState = "error";
|
||||
//re-map the dialog model since we've re-bound the properties
|
||||
dialog.variants = $scope.content.variants;
|
||||
//don't reject, we've handled the error
|
||||
return $q.when(err);
|
||||
handleHttpException(err);
|
||||
});
|
||||
},
|
||||
close: function () {
|
||||
@@ -642,8 +649,9 @@
|
||||
action: "sendToPublish"
|
||||
}).then(function () {
|
||||
$scope.page.buttonGroupState = "success";
|
||||
}, function () {
|
||||
}, function (err) {
|
||||
$scope.page.buttonGroupState = "error";
|
||||
handleHttpException(err);
|
||||
});;
|
||||
}
|
||||
};
|
||||
@@ -679,8 +687,7 @@
|
||||
model.submitButtonState = "error";
|
||||
//re-map the dialog model since we've re-bound the properties
|
||||
dialog.variants = $scope.content.variants;
|
||||
//don't reject, we've handled the error
|
||||
return $q.when(err);
|
||||
handleHttpException(err);
|
||||
});
|
||||
},
|
||||
close: function () {
|
||||
@@ -703,8 +710,9 @@
|
||||
action: "publish"
|
||||
}).then(function () {
|
||||
$scope.page.buttonGroupState = "success";
|
||||
}, function () {
|
||||
}, function (err) {
|
||||
$scope.page.buttonGroupState = "error";
|
||||
handleHttpException(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -742,8 +750,7 @@
|
||||
model.submitButtonState = "error";
|
||||
//re-map the dialog model since we've re-bound the properties
|
||||
dialog.variants = $scope.content.variants;
|
||||
//don't reject, we've handled the error
|
||||
return $q.when(err);
|
||||
handleHttpException(err);
|
||||
});
|
||||
},
|
||||
close: function (oldModel) {
|
||||
@@ -766,8 +773,9 @@
|
||||
action: "save"
|
||||
}).then(function () {
|
||||
$scope.page.saveButtonState = "success";
|
||||
}, function () {
|
||||
}, function (err) {
|
||||
$scope.page.saveButtonState = "error";
|
||||
handleHttpException(err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -820,8 +828,7 @@
|
||||
model.submitButtonState = "error";
|
||||
//re-map the dialog model since we've re-bound the properties
|
||||
dialog.variants = Utilities.copy($scope.content.variants);
|
||||
//don't reject, we've handled the error
|
||||
return $q.when(err);
|
||||
handleHttpException(err);
|
||||
});
|
||||
|
||||
},
|
||||
@@ -880,8 +887,7 @@
|
||||
model.submitButtonState = "error";
|
||||
//re-map the dialog model since we've re-bound the properties
|
||||
dialog.variants = $scope.content.variants;
|
||||
//don't reject, we've handled the error
|
||||
return $q.when(err);
|
||||
handleHttpException(err);
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
+33
-33
@@ -2,10 +2,10 @@
|
||||
'use strict';
|
||||
|
||||
function EditorContentHeader(serverValidationManager, localizationService, editorState) {
|
||||
function link(scope) {
|
||||
function link(scope) {
|
||||
|
||||
var unsubscribe = [];
|
||||
|
||||
|
||||
if (!scope.serverValidationNameField) {
|
||||
scope.serverValidationNameField = "Name";
|
||||
}
|
||||
@@ -46,55 +46,55 @@
|
||||
scope.vm.variantsWithError = [];
|
||||
scope.vm.defaultVariant = null;
|
||||
scope.vm.errorsOnOtherVariants = false;// indicating wether to show that other variants, than the current, have errors.
|
||||
|
||||
|
||||
function updateVaraintErrors() {
|
||||
scope.content.variants.forEach( function (variant) {
|
||||
scope.content.variants.forEach(function (variant) {
|
||||
variant.hasError = scope.variantHasError(variant);
|
||||
|
||||
|
||||
});
|
||||
checkErrorsOnOtherVariants();
|
||||
}
|
||||
|
||||
function checkErrorsOnOtherVariants() {
|
||||
var check = false;
|
||||
scope.content.variants.forEach( function (variant) {
|
||||
scope.content.variants.forEach(function (variant) {
|
||||
if (variant.active !== true && variant.hasError) {
|
||||
check = true;
|
||||
}
|
||||
});
|
||||
scope.vm.errorsOnOtherVariants = check;
|
||||
}
|
||||
|
||||
|
||||
function onVariantValidation(valid, errors, allErrors, culture, segment) {
|
||||
|
||||
// only want to react to property errors:
|
||||
if(errors.findIndex(error => {return error.propertyAlias !== null;}) === -1) {
|
||||
if (errors.findIndex(error => { return error.propertyAlias !== null; }) === -1) {
|
||||
// we dont have any errors for properties, meaning we will back out.
|
||||
return;
|
||||
}
|
||||
|
||||
// If error coming back is invariant, we will assign the error to the default variant by picking the defaultVariant language.
|
||||
if(culture === "invariant") {
|
||||
if (culture === "invariant" && scope.vm.defaultVariant) {
|
||||
culture = scope.vm.defaultVariant.language.culture;
|
||||
}
|
||||
|
||||
var index = scope.vm.variantsWithError.findIndex((item) => item.culture === culture && item.segment === segment)
|
||||
if(valid === true) {
|
||||
if (valid === true) {
|
||||
if (index !== -1) {
|
||||
scope.vm.variantsWithError.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
if (index === -1) {
|
||||
scope.vm.variantsWithError.push({"culture": culture, "segment": segment});
|
||||
scope.vm.variantsWithError.push({ "culture": culture, "segment": segment });
|
||||
}
|
||||
}
|
||||
scope.$evalAsync(updateVaraintErrors);
|
||||
}
|
||||
|
||||
|
||||
function onInit() {
|
||||
|
||||
|
||||
// find default + check if we have variants.
|
||||
scope.content.variants.forEach( function (variant) {
|
||||
scope.content.variants.forEach(function (variant) {
|
||||
if (variant.language !== null && variant.language.isDefault) {
|
||||
scope.vm.defaultVariant = variant;
|
||||
}
|
||||
@@ -113,41 +113,41 @@
|
||||
|
||||
scope.vm.variantMenu = [];
|
||||
if (scope.vm.hasCulture) {
|
||||
scope.content.variants.forEach( (v) => {
|
||||
scope.content.variants.forEach((v) => {
|
||||
if (v.language !== null && v.segment === null) {
|
||||
var variantMenuEntry = {
|
||||
key: String.CreateGuid(),
|
||||
open: v.language && v.language.culture === scope.editor.culture,
|
||||
variant: v,
|
||||
subVariants: scope.content.variants.filter( (subVariant) => subVariant.language.culture === v.language.culture && subVariant.segment !== null)
|
||||
subVariants: scope.content.variants.filter((subVariant) => subVariant.language.culture === v.language.culture && subVariant.segment !== null)
|
||||
};
|
||||
scope.vm.variantMenu.push(variantMenuEntry);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
scope.content.variants.forEach( (v) => {
|
||||
scope.content.variants.forEach((v) => {
|
||||
scope.vm.variantMenu.push({
|
||||
key: String.CreateGuid(),
|
||||
variant: v
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
scope.editor.variantApps.forEach( (app) => {
|
||||
|
||||
scope.editor.variantApps.forEach((app) => {
|
||||
if (app.alias === "umbContent") {
|
||||
app.anchors = scope.editor.content.tabs;
|
||||
}
|
||||
});
|
||||
|
||||
scope.content.variants.forEach( function (variant) {
|
||||
|
||||
scope.content.variants.forEach(function (variant) {
|
||||
|
||||
// if we are looking for the variant with default language then we also want to check for invariant variant.
|
||||
if (variant.language && variant.language.culture === scope.vm.defaultVariant.language.culture && variant.segment === null) {
|
||||
if (variant.language && scope.vm.defaultVariant && variant.language.culture === scope.vm.defaultVariant.language.culture && variant.segment === null) {
|
||||
unsubscribe.push(serverValidationManager.subscribe(null, "invariant", null, onVariantValidation, null));
|
||||
}
|
||||
unsubscribe.push(serverValidationManager.subscribe(null, variant.language !== null ? variant.language.culture : null, null, onVariantValidation, variant.segment));
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
scope.goBack = function () {
|
||||
@@ -164,15 +164,15 @@
|
||||
}
|
||||
};
|
||||
|
||||
scope.selectNavigationItem = function(item) {
|
||||
if(scope.onSelectNavigationItem) {
|
||||
scope.onSelectNavigationItem({"item": item});
|
||||
scope.selectNavigationItem = function (item) {
|
||||
if (scope.onSelectNavigationItem) {
|
||||
scope.onSelectNavigationItem({ "item": item });
|
||||
}
|
||||
}
|
||||
|
||||
scope.selectAnchorItem = function(item, anchor) {
|
||||
if(scope.onSelectAnchorItem) {
|
||||
scope.onSelectAnchorItem({"item": item, "anchor": anchor});
|
||||
scope.selectAnchorItem = function (item, anchor) {
|
||||
if (scope.onSelectAnchorItem) {
|
||||
scope.onSelectAnchorItem({ "item": item, "anchor": anchor });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,20 +188,20 @@
|
||||
scope.onOpenInSplitView({ "variant": variant });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Check whether a variant has a error, used to display errors in variant switcher.
|
||||
* @param {any} culture
|
||||
*/
|
||||
scope.variantHasError = function(variant) {
|
||||
if(scope.vm.variantsWithError.find((item) => (!variant.language || item.culture === variant.language.culture) && item.segment === variant.segment) !== undefined) {
|
||||
scope.variantHasError = function (variant) {
|
||||
if (scope.vm.variantsWithError.find((item) => (!variant.language || item.culture === variant.language.culture) && item.segment === variant.segment) !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onInit();
|
||||
|
||||
|
||||
scope.$on('$destroy', function () {
|
||||
for (var u in unsubscribe) {
|
||||
unsubscribe[u]();
|
||||
|
||||
+93
-25
@@ -11,13 +11,13 @@
|
||||
var sectionId = '#leftcolumn';
|
||||
var isLeftColumnAbove = false;
|
||||
scope.editors = [];
|
||||
|
||||
|
||||
function addEditor(editor) {
|
||||
editor.inFront = true;
|
||||
editor.moveRight = true;
|
||||
editor.level = 0;
|
||||
editor.styleIndex = 0;
|
||||
|
||||
|
||||
// push the new editor to the dom
|
||||
scope.editors.push(editor);
|
||||
|
||||
@@ -32,20 +32,20 @@
|
||||
$timeout(() => {
|
||||
editor.moveRight = false;
|
||||
})
|
||||
|
||||
|
||||
editor.animating = true;
|
||||
setTimeout(revealEditorContent.bind(this, editor), 400);
|
||||
|
||||
|
||||
updateEditors();
|
||||
|
||||
}
|
||||
|
||||
|
||||
function removeEditor(editor) {
|
||||
editor.moveRight = true;
|
||||
|
||||
|
||||
editor.animating = true;
|
||||
setTimeout(removeEditorFromDOM.bind(this, editor), 400);
|
||||
|
||||
|
||||
updateEditors(-1);
|
||||
|
||||
if(scope.editors.length === 1){
|
||||
@@ -56,17 +56,17 @@
|
||||
isLeftColumnAbove = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function revealEditorContent(editor) {
|
||||
|
||||
|
||||
editor.animating = false;
|
||||
|
||||
|
||||
scope.$digest();
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function removeEditorFromDOM(editor) {
|
||||
|
||||
|
||||
// push the new editor to the dom
|
||||
var index = scope.editors.indexOf(editor);
|
||||
if (index !== -1) {
|
||||
@@ -74,42 +74,42 @@
|
||||
}
|
||||
|
||||
updateEditors();
|
||||
|
||||
|
||||
scope.$digest();
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/** update layer positions. With ability to offset positions, needed for when an item is moving out, then we dont want it to influence positions */
|
||||
function updateEditors(offset) {
|
||||
|
||||
|
||||
offset = offset || 0;// fallback value.
|
||||
|
||||
|
||||
var len = scope.editors.length;
|
||||
var calcLen = len + offset;
|
||||
var ceiling = Math.min(calcLen, allowedNumberOfVisibleEditors);
|
||||
var origin = Math.max(calcLen-1, 0)-ceiling;
|
||||
var origin = Math.max(calcLen - 1, 0) - ceiling;
|
||||
var i = 0;
|
||||
while(i<len) {
|
||||
while (i < len) {
|
||||
var iEditor = scope.editors[i];
|
||||
iEditor.styleIndex = Math.min(i+1, allowedNumberOfVisibleEditors);
|
||||
iEditor.level = Math.max(i-origin, -1);
|
||||
iEditor.styleIndex = Math.min(i + 1, allowedNumberOfVisibleEditors);
|
||||
iEditor.level = Math.max(i - origin, -1);
|
||||
iEditor.inFront = iEditor.level >= ceiling;
|
||||
i++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
evts.push(eventsService.on("appState.editors.open", function (name, args) {
|
||||
addEditor(args.editor);
|
||||
}));
|
||||
|
||||
evts.push(eventsService.on("appState.editors.close", function (name, args) {
|
||||
// remove the closed editor
|
||||
if(args && args.editor) {
|
||||
if (args && args.editor) {
|
||||
removeEditor(args.editor);
|
||||
}
|
||||
// close all editors
|
||||
if(args && !args.editor && args.editors.length === 0) {
|
||||
if (args && !args.editor && args.editors.length === 0) {
|
||||
scope.editors = [];
|
||||
}
|
||||
}));
|
||||
@@ -134,6 +134,74 @@
|
||||
|
||||
}
|
||||
|
||||
// This directive allows for us to run a custom $compile for the view within the repeater which allows
|
||||
// us to maintain a $scope hierarchy with the rendered view based on the $scope that initiated the
|
||||
// infinite editing. The retain the $scope hiearchy a special $parentScope property is passed in to the model.
|
||||
function EditorRepeaterDirective($http, $templateCache, $compile, angularHelper) {
|
||||
function link(scope, el, attr, ctrl) {
|
||||
|
||||
var editor = scope && scope.$parent ? scope.$parent.model : null;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
var unsubscribe = [];
|
||||
|
||||
//if a custom parent scope is defined then we need to manually compile the view
|
||||
if (editor.$parentScope) {
|
||||
var element = el.find(".scoped-view");
|
||||
$http.get(editor.view, { cache: $templateCache })
|
||||
.then(function (response) {
|
||||
var templateScope = editor.$parentScope.$new();
|
||||
|
||||
unsubscribe.push(function () {
|
||||
templateScope.$destroy();
|
||||
});
|
||||
|
||||
// NOTE: the 'model' name here directly affects the naming convention used in infinite editors, this why you access the model
|
||||
// like $scope.model.If this is changed, everything breaks.This is because we are entirely reliant upon ng-include and inheriting $scopes.
|
||||
// by default without a $parentScope used for infinite editing the 'model' propety will be set because the view creates the scopes in
|
||||
// ng-repeat by ng-repeat="model in editors"
|
||||
templateScope.model = editor;
|
||||
|
||||
element.show();
|
||||
|
||||
// if a parentForm is supplied then we can link them but to do that we need to inject a top level form
|
||||
if (editor.$parentForm) {
|
||||
element.html("<ng-form name='infiniteEditorForm'>" + response.data + "</ng-form>");
|
||||
}
|
||||
|
||||
$compile(element)(templateScope);
|
||||
|
||||
// if a parentForm is supplied then we can link them
|
||||
if (editor.$parentForm) {
|
||||
editor.$parentForm.$addControl(templateScope.infiniteEditorForm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scope.$on('$destroy', function () {
|
||||
for (var i = 0; i < unsubscribe.length; i++) {
|
||||
unsubscribe[i]();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var directive = {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
transclude: true,
|
||||
scope: {
|
||||
editors: "="
|
||||
},
|
||||
template: "<div ng-transclude></div>",
|
||||
link: link
|
||||
};
|
||||
|
||||
return directive;
|
||||
}
|
||||
|
||||
angular.module('umbraco.directives').directive('umbEditors', EditorsDirective);
|
||||
angular.module('umbraco.directives').directive('umbEditorRepeater', EditorRepeaterDirective);
|
||||
|
||||
})();
|
||||
|
||||
+2
-2
@@ -280,7 +280,7 @@ Opens an overlay to show a custom YSOD. </br>
|
||||
templateScope.model = scope.model;
|
||||
element.html(response.data);
|
||||
element.show();
|
||||
$compile(element.contents())(templateScope);
|
||||
$compile(element)(templateScope);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -463,7 +463,7 @@ Opens an overlay to show a custom YSOD. </br>
|
||||
|
||||
scope.submitForm = function (model) {
|
||||
if (scope.model.submit) {
|
||||
if (formHelper.submitForm({ scope: scope, skipValidation: scope.model.skipFormValidation })) {
|
||||
if (formHelper.submitForm({ scope: scope, skipValidation: scope.model.skipFormValidation, keepServerValidation: true })) {
|
||||
|
||||
if (scope.model.confirmSubmit && scope.model.confirmSubmit.enable && !scope.directive.enableConfirmButton) {
|
||||
//wrap in a when since we don't know if this is a promise or not
|
||||
|
||||
+67
-38
@@ -3,46 +3,75 @@
|
||||
* @name umbraco.directives.directive:umbProperty
|
||||
* @restrict E
|
||||
**/
|
||||
angular.module("umbraco.directives")
|
||||
.directive('umbProperty', function (userService) {
|
||||
return {
|
||||
scope: {
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
angular
|
||||
.module("umbraco.directives")
|
||||
.component('umbProperty', {
|
||||
templateUrl: 'views/components/property/umb-property.html',
|
||||
controller: UmbPropertyController,
|
||||
controllerAs: 'vm',
|
||||
transclude: true,
|
||||
require: {
|
||||
parentUmbProperty: '?^^umbProperty',
|
||||
parentForm: '?^^form'
|
||||
},
|
||||
bindings: {
|
||||
property: "=",
|
||||
elementKey: "@",
|
||||
// optional, if set this will be used for the property alias validation path (hack required because NC changes the actual property.alias :/)
|
||||
propertyAlias: "@",
|
||||
showInherit: "<",
|
||||
inheritsFrom: "<"
|
||||
},
|
||||
transclude: true,
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: 'views/components/property/umb-property.html',
|
||||
link: function (scope) {
|
||||
|
||||
scope.controlLabelTitle = null;
|
||||
if(Umbraco.Sys.ServerVariables.isDebuggingEnabled) {
|
||||
userService.getCurrentUser().then(function (u) {
|
||||
if(u.allowedSections.indexOf("settings") !== -1 ? true : false) {
|
||||
scope.controlLabelTitle = scope.property.alias;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
//Define a controller for this directive to expose APIs to other directives
|
||||
controller: function ($scope) {
|
||||
|
||||
var self = this;
|
||||
|
||||
//set the API properties/methods
|
||||
|
||||
self.property = $scope.property;
|
||||
self.setPropertyError = function (errorMsg) {
|
||||
$scope.property.propertyErrorMessage = errorMsg;
|
||||
};
|
||||
|
||||
$scope.propertyActions = [];
|
||||
self.setPropertyActions = function(actions) {
|
||||
$scope.propertyActions = actions;
|
||||
};
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
function UmbPropertyController($scope, userService, serverValidationManager, udiService, angularHelper) {
|
||||
|
||||
const vm = this;
|
||||
|
||||
vm.$onInit = onInit;
|
||||
|
||||
vm.setPropertyError = function (errorMsg) {
|
||||
vm.property.propertyErrorMessage = errorMsg;
|
||||
};
|
||||
});
|
||||
|
||||
vm.propertyActions = [];
|
||||
vm.setPropertyActions = function (actions) {
|
||||
vm.propertyActions = actions;
|
||||
};
|
||||
|
||||
// returns the validation path for the property to be used as the validation key for server side validation logic
|
||||
vm.getValidationPath = function () {
|
||||
|
||||
var parentValidationPath = vm.parentUmbProperty ? vm.parentUmbProperty.getValidationPath() : null;
|
||||
var propAlias = vm.propertyAlias ? vm.propertyAlias : vm.property.alias;
|
||||
// the elementKey will be empty when this is not a nested property
|
||||
var valPath = vm.elementKey ? vm.elementKey + "/" + propAlias : propAlias;
|
||||
return serverValidationManager.createPropertyValidationKey(valPath, parentValidationPath);
|
||||
}
|
||||
|
||||
function onInit() {
|
||||
vm.controlLabelTitle = null;
|
||||
if (Umbraco.Sys.ServerVariables.isDebuggingEnabled) {
|
||||
userService.getCurrentUser().then(function (u) {
|
||||
if (u.allowedSections.indexOf("settings") !== -1 ? true : false) {
|
||||
vm.controlLabelTitle = vm.property.alias;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!vm.parentUmbProperty) {
|
||||
// not found, then fallback to searching the scope chain, this may be needed when DOM inheritance isn't maintained but scope
|
||||
// inheritance is (i.e.infinite editing)
|
||||
var found = angularHelper.traverseScopeChain($scope, s => s && s.vm && s.vm.constructor.name === "UmbPropertyController");
|
||||
vm.parentUmbProperty = found ? found.vm : null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
+90
-73
@@ -12,48 +12,88 @@
|
||||
* Another thing this directive does is to ensure that any .control-group that contains form elements that are invalid will
|
||||
* be marked with the 'error' css class. This ensures that labels included in that control group are styled correctly.
|
||||
**/
|
||||
function valFormManager(serverValidationManager, $rootScope, $timeout, $location, overlayService, eventsService, $routeParams, navigationService, editorService, localizationService) {
|
||||
function valFormManager(serverValidationManager, $rootScope, $timeout, $location, overlayService, eventsService, $routeParams, navigationService, editorService, localizationService, angularHelper) {
|
||||
|
||||
var SHOW_VALIDATION_CLASS_NAME = "show-validation";
|
||||
var SAVING_EVENT_NAME = "formSubmitting";
|
||||
var SAVED_EVENT_NAME = "formSubmitted";
|
||||
|
||||
function notify(scope) {
|
||||
scope.$broadcast("valStatusChanged", { form: scope.formCtrl });
|
||||
}
|
||||
|
||||
function ValFormManagerController($scope) {
|
||||
//This exposes an API for direct use with this directive
|
||||
|
||||
// We need this as a way to reference this directive in the scope chain. Since this directive isn't a component and
|
||||
// because it's an attribute instead of an element, we can't use controllerAs or anything like that. Plus since this is
|
||||
// an attribute an isolated scope doesn't work so it's a bit weird. By doing this we are able to lookup the parent valFormManager
|
||||
// in the scope hierarchy even if the DOM hierarchy doesn't match (i.e. in infinite editing)
|
||||
$scope.valFormManager = this;
|
||||
|
||||
var unsubscribe = [];
|
||||
var self = this;
|
||||
|
||||
//This is basically the same as a directive subscribing to an event but maybe a little
|
||||
// nicer since the other directive can use this directive's API instead of a magical event
|
||||
this.onValidationStatusChanged = function (cb) {
|
||||
unsubscribe.push($scope.$on("valStatusChanged", function (evt, args) {
|
||||
cb.apply(self, [evt, args]);
|
||||
}));
|
||||
};
|
||||
|
||||
this.isShowingValidation = () => $scope.showValidation === true;
|
||||
|
||||
this.notify = function () {
|
||||
notify($scope);
|
||||
}
|
||||
|
||||
this.isValid = function () {
|
||||
return !$scope.formCtrl.$invalid;
|
||||
}
|
||||
|
||||
//Ensure to remove the event handlers when this instance is destroyted
|
||||
$scope.$on('$destroy', function () {
|
||||
for (var u in unsubscribe) {
|
||||
unsubscribe[u]();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find's the valFormManager in the scope/DOM hierarchy
|
||||
* @param {any} scope
|
||||
* @param {any} ctrls
|
||||
* @param {any} index
|
||||
*/
|
||||
function getAncestorValFormManager(scope, ctrls, index) {
|
||||
|
||||
// first check the normal directive inheritance which relies on DOM inheritance
|
||||
var found = ctrls[index];
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// not found, then fallback to searching the scope chain, this may be needed when DOM inheritance isn't maintained but scope
|
||||
// inheritance is (i.e.infinite editing)
|
||||
var found = angularHelper.traverseScopeChain(scope, s => s && s.valFormManager && s.valFormManager.constructor.name === "ValFormManagerController");
|
||||
return found ? found.valFormManager : null;
|
||||
}
|
||||
|
||||
return {
|
||||
require: ["form", "^^?valFormManager", "^^?valSubView"],
|
||||
restrict: "A",
|
||||
controller: function($scope) {
|
||||
//This exposes an API for direct use with this directive
|
||||
|
||||
var unsubscribe = [];
|
||||
var self = this;
|
||||
|
||||
//This is basically the same as a directive subscribing to an event but maybe a little
|
||||
// nicer since the other directive can use this directive's API instead of a magical event
|
||||
this.onValidationStatusChanged = function (cb) {
|
||||
unsubscribe.push($scope.$on("valStatusChanged", function(evt, args) {
|
||||
cb.apply(self, [evt, args]);
|
||||
}));
|
||||
};
|
||||
|
||||
this.showValidation = $scope.showValidation === true;
|
||||
|
||||
//Ensure to remove the event handlers when this instance is destroyted
|
||||
$scope.$on('$destroy', function () {
|
||||
for (var u in unsubscribe) {
|
||||
unsubscribe[u]();
|
||||
}
|
||||
});
|
||||
},
|
||||
controller: ValFormManagerController,
|
||||
link: function (scope, element, attr, ctrls) {
|
||||
|
||||
function notifySubView() {
|
||||
if (subView){
|
||||
if (subView) {
|
||||
subView.valStatusChanged({ form: formCtrl, showValidation: scope.showValidation });
|
||||
}
|
||||
}
|
||||
|
||||
var formCtrl = ctrls[0];
|
||||
var parentFormMgr = ctrls.length > 0 ? ctrls[1] : null;
|
||||
var formCtrl = scope.formCtrl = ctrls[0];
|
||||
var parentFormMgr = scope.parentFormMgr = getAncestorValFormManager(scope, ctrls, 1);
|
||||
var subView = ctrls.length > 1 ? ctrls[2] : null;
|
||||
var labels = {};
|
||||
|
||||
@@ -72,45 +112,22 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
});
|
||||
|
||||
//watch the list of validation errors to notify the application of any validation changes
|
||||
scope.$watch(function () {
|
||||
//the validators are in the $error collection: https://docs.angularjs.org/api/ng/type/form.FormController#$error
|
||||
//since each key is the validator name (i.e. 'required') we can't just watch the number of keys, we need to watch
|
||||
//the sum of the items inside of each key
|
||||
scope.$watch(() => angularHelper.countAllFormErrors(formCtrl),
|
||||
function (e) {
|
||||
|
||||
notify(scope);
|
||||
|
||||
notifySubView();
|
||||
|
||||
//find all invalid elements' .control-group's and apply the error class
|
||||
var inError = element.find(".control-group .ng-invalid").closest(".control-group");
|
||||
inError.addClass("error");
|
||||
|
||||
//find all control group's that have no error and ensure the class is removed
|
||||
var noInError = element.find(".control-group .ng-valid").closest(".control-group").not(inError);
|
||||
noInError.removeClass("error");
|
||||
|
||||
//get the lengths of each array for each key in the $error collection
|
||||
var validatorLengths = _.map(formCtrl.$error, function (val, key) {
|
||||
// if there are child ng-forms, include the $error collections in those as well
|
||||
var innerErrorCount = _.reduce(
|
||||
_.map(val, v =>
|
||||
_.reduce(
|
||||
_.map(v.$error, e => e.length),
|
||||
(m, n) => m + n
|
||||
)
|
||||
),
|
||||
(memo, num) => memo + num
|
||||
);
|
||||
return val.length + innerErrorCount;
|
||||
});
|
||||
//sum up all numbers in the resulting array
|
||||
var sum = _.reduce(validatorLengths, function (memo, num) {
|
||||
return memo + num;
|
||||
}, 0);
|
||||
//this is the value we watch to notify of any validation changes on the form
|
||||
return sum;
|
||||
}, function (e) {
|
||||
scope.$broadcast("valStatusChanged", { form: formCtrl });
|
||||
|
||||
notifySubView();
|
||||
|
||||
//find all invalid elements' .control-group's and apply the error class
|
||||
var inError = element.find(".control-group .ng-invalid").closest(".control-group");
|
||||
inError.addClass("error");
|
||||
|
||||
//find all control group's that have no error and ensure the class is removed
|
||||
var noInError = element.find(".control-group .ng-valid").closest(".control-group").not(inError);
|
||||
noInError.removeClass("error");
|
||||
|
||||
});
|
||||
|
||||
//This tracks if the user is currently saving a new item, we use this to determine
|
||||
// if we should display the warning dialog that they are leaving the page - if a new item
|
||||
@@ -119,7 +136,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
var isSavingNewItem = false;
|
||||
|
||||
//we should show validation if there are any msgs in the server validation collection
|
||||
if (serverValidationManager.items.length > 0 || (parentFormMgr && parentFormMgr.showValidation)) {
|
||||
if (serverValidationManager.items.length > 0 || (parentFormMgr && parentFormMgr.isShowingValidation())) {
|
||||
element.addClass(SHOW_VALIDATION_CLASS_NAME);
|
||||
scope.showValidation = true;
|
||||
notifySubView();
|
||||
@@ -128,7 +145,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
var unsubscribe = [];
|
||||
|
||||
//listen for the forms saving event
|
||||
unsubscribe.push(scope.$on(SAVING_EVENT_NAME, function(ev, args) {
|
||||
unsubscribe.push(scope.$on(SAVING_EVENT_NAME, function (ev, args) {
|
||||
element.addClass(SHOW_VALIDATION_CLASS_NAME);
|
||||
scope.showValidation = true;
|
||||
notifySubView();
|
||||
@@ -137,7 +154,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
}));
|
||||
|
||||
//listen for the forms saved event
|
||||
unsubscribe.push(scope.$on(SAVED_EVENT_NAME, function(ev, args) {
|
||||
unsubscribe.push(scope.$on(SAVED_EVENT_NAME, function (ev, args) {
|
||||
//remove validation class
|
||||
element.removeClass(SHOW_VALIDATION_CLASS_NAME);
|
||||
scope.showValidation = false;
|
||||
@@ -151,7 +168,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
|
||||
//This handles the 'unsaved changes' dialog which is triggered when a route is attempting to be changed but
|
||||
// the form has pending changes
|
||||
var locationEvent = $rootScope.$on('$locationChangeStart', function(event, nextLocation, currentLocation) {
|
||||
var locationEvent = $rootScope.$on('$locationChangeStart', function (event, nextLocation, currentLocation) {
|
||||
|
||||
var infiniteEditors = editorService.getEditors();
|
||||
|
||||
@@ -178,10 +195,10 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
"disableEscKey": true,
|
||||
"submitButtonLabel": labels.stayButton,
|
||||
"closeButtonLabel": labels.discardChangesButton,
|
||||
submit: function() {
|
||||
submit: function () {
|
||||
overlayService.close();
|
||||
},
|
||||
close: function() {
|
||||
close: function () {
|
||||
// close all editors
|
||||
editorService.closeAll();
|
||||
// allow redirection
|
||||
@@ -190,7 +207,7 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
var parts = nextPath.split("?");
|
||||
var query = {};
|
||||
if (parts.length > 1) {
|
||||
_.each(parts[1].split("&"), function(q) {
|
||||
_.each(parts[1].split("&"), function (q) {
|
||||
var keyVal = q.split("=");
|
||||
query[keyVal[0]] = keyVal[1];
|
||||
});
|
||||
@@ -215,13 +232,13 @@ function valFormManager(serverValidationManager, $rootScope, $timeout, $location
|
||||
unsubscribe.push(locationEvent);
|
||||
|
||||
//Ensure to remove the event handler when this instance is destroyted
|
||||
scope.$on('$destroy', function() {
|
||||
scope.$on('$destroy', function () {
|
||||
for (var u in unsubscribe) {
|
||||
unsubscribe[u]();
|
||||
}
|
||||
});
|
||||
|
||||
$timeout(function(){
|
||||
$timeout(function () {
|
||||
formCtrl.$setPristine();
|
||||
}, 1000);
|
||||
|
||||
|
||||
+211
-129
@@ -8,24 +8,26 @@
|
||||
* We will listen for server side validation changes
|
||||
* and when an error is detected for this property we'll show the error message.
|
||||
* In order for this directive to work, the valFormManager directive must be placed on the containing form.
|
||||
* We don't set the validity of this validator to false when client side validation fails, only when server side
|
||||
* validation fails however we do respond to the client side validation changes to display error and adjust UI state.
|
||||
**/
|
||||
function valPropertyMsg(serverValidationManager, localizationService) {
|
||||
function valPropertyMsg(serverValidationManager, localizationService, angularHelper) {
|
||||
|
||||
return {
|
||||
require: ['^^form', '^^valFormManager', '^^umbProperty', '?^^umbVariantContent'],
|
||||
require: ['^^form', '^^valFormManager', '^^umbProperty', '?^^umbVariantContent', '?^^valPropertyMsg'],
|
||||
replace: true,
|
||||
restrict: "E",
|
||||
template: "<div ng-show=\"errorMsg != ''\" class='alert alert-error property-error' >{{errorMsg}}</div>",
|
||||
scope: {},
|
||||
link: function (scope, element, attrs, ctrl) {
|
||||
|
||||
|
||||
var unsubscribe = [];
|
||||
var watcher = null;
|
||||
var hasError = false;
|
||||
var hasError = false; // tracks if there is a child error or an explicit error
|
||||
|
||||
//create properties on our custom scope so we can use it in our template
|
||||
scope.errorMsg = "";
|
||||
|
||||
scope.errorMsg = "";
|
||||
|
||||
//the property form controller api
|
||||
var formCtrl = ctrl[0];
|
||||
//the valFormManager controller api
|
||||
@@ -33,21 +35,19 @@ function valPropertyMsg(serverValidationManager, localizationService) {
|
||||
//the property controller api
|
||||
var umbPropCtrl = ctrl[2];
|
||||
//the variants controller api
|
||||
var umbVariantCtrl = ctrl[3];
|
||||
|
||||
var umbVariantCtrl = ctrl[3];
|
||||
|
||||
var currentProperty = umbPropCtrl.property;
|
||||
scope.currentProperty = currentProperty;
|
||||
|
||||
var currentCulture = currentProperty.culture;
|
||||
var currentSegment = currentProperty.segment;
|
||||
|
||||
var currentSegment = currentProperty.segment;
|
||||
|
||||
// validation object won't exist when editor loads outside the content form (ie in settings section when modifying a content type)
|
||||
var isMandatory = currentProperty.validation ? currentProperty.validation.mandatory : undefined;
|
||||
|
||||
var labels = {};
|
||||
localizationService.localize("errors_propertyHasErrors").then(function (data) {
|
||||
labels.propertyHasErrors = data;
|
||||
});
|
||||
var showValidation = false;
|
||||
|
||||
if (umbVariantCtrl) {
|
||||
//if we are inside of an umbVariantContent directive
|
||||
@@ -61,17 +61,16 @@ function valPropertyMsg(serverValidationManager, localizationService) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// if we have reached this part, and there is no culture, then lets fallback to invariant. To get the validation feedback for invariant language.
|
||||
currentCulture = currentCulture || "invariant";
|
||||
|
||||
|
||||
// Gets the error message to display
|
||||
function getErrorMsg() {
|
||||
//this can be null if no property was assigned
|
||||
if (scope.currentProperty) {
|
||||
//first try to get the error msg from the server collection
|
||||
var err = serverValidationManager.getPropertyError(scope.currentProperty.alias, null, "", null);
|
||||
var err = serverValidationManager.getPropertyError(umbPropCtrl.getValidationPath(), null, "", null);
|
||||
//if there's an error message use it
|
||||
if (err && err.errorMsg) {
|
||||
return err.errorMsg;
|
||||
@@ -84,43 +83,109 @@ function valPropertyMsg(serverValidationManager, localizationService) {
|
||||
return labels.propertyHasErrors;
|
||||
}
|
||||
|
||||
// We need to subscribe to any changes to our model (based on user input)
|
||||
// This is required because when we have a server error we actually invalidate
|
||||
// the form which means it cannot be resubmitted.
|
||||
// So once a field is changed that has a server error assigned to it
|
||||
// we need to re-validate it for the server side validator so the user can resubmit
|
||||
// the form. Of course normal client-side validators will continue to execute.
|
||||
// check the current errors in the form (and recursive sub forms), if there is 1 or 2 errors
|
||||
// we can check if those are valPropertyMsg or valServer and can clear our error in those cases.
|
||||
function checkAndClearError() {
|
||||
|
||||
var errCount = angularHelper.countAllFormErrors(formCtrl);
|
||||
|
||||
if (errCount === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (errCount > 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasValServer = Utilities.isArray(formCtrl.$error.valServer);
|
||||
if (errCount === 1 && hasValServer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var hasOwnErr = hasExplicitError();
|
||||
if ((errCount === 1 && hasOwnErr) || (errCount === 2 && hasOwnErr && hasValServer)) {
|
||||
|
||||
var propertyValidationPath = umbPropCtrl.getValidationPath();
|
||||
// check if we can clear it based on child server errors, if we are the only explicit one remaining we can clear ourselves
|
||||
if (isLastServerError(propertyValidationPath)) {
|
||||
serverValidationManager.removePropertyError(propertyValidationPath, currentCulture, "", currentSegment);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// returns true if there is an explicit valPropertyMsg validation error on the form
|
||||
function hasExplicitError() {
|
||||
return Utilities.isArray(formCtrl.$error.valPropertyMsg);
|
||||
}
|
||||
|
||||
// returns true if there is only a single server validation error for this property validation key in it's validation path
|
||||
function isLastServerError(propertyValidationPath) {
|
||||
var nestedErrs = serverValidationManager.getPropertyErrorsByValidationPath(
|
||||
propertyValidationPath,
|
||||
currentCulture,
|
||||
currentSegment,
|
||||
{ matchType: "prefix" });
|
||||
if (nestedErrs.length === 0 || (nestedErrs.length === 1 && nestedErrs[0].propertyAlias === propertyValidationPath)) {
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// a custom $validator function called on when each child ngModelController changes a value.
|
||||
function resetServerValidityValidator(fieldController) {
|
||||
const storedFieldController = fieldController; // pin a reference to this
|
||||
return (modelValue, viewValue) => {
|
||||
// if the ngModelController value has changed, then we can check and clear the error
|
||||
if (storedFieldController.$dirty) {
|
||||
if (checkAndClearError()) {
|
||||
resetError();
|
||||
}
|
||||
}
|
||||
return true; // this validator is always 'valid'
|
||||
};
|
||||
}
|
||||
|
||||
// collect all ng-model controllers recursively within the umbProperty form
|
||||
// until it reaches the next nested umbProperty form
|
||||
function collectAllNgModelControllersRecursively(controls, ngModels) {
|
||||
controls.forEach(ctrl => {
|
||||
if (angularHelper.isForm(ctrl)) {
|
||||
// if it's not another umbProperty form then recurse
|
||||
if (ctrl.$name !== formCtrl.$name) {
|
||||
collectAllNgModelControllersRecursively(ctrl.$getControls(), ngModels);
|
||||
}
|
||||
}
|
||||
else if (ctrl.hasOwnProperty('$modelValue') && Utilities.isObject(ctrl.$validators)) {
|
||||
ngModels.push(ctrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// We start the watch when there's server validation errors detected.
|
||||
// We watch on the current form's properties and on first watch or if they are dynamically changed
|
||||
// we find all ngModel controls recursively on this form (but stop recursing before we get to the next)
|
||||
// umbProperty form). Then for each ngModelController we assign a new $validator. This $validator
|
||||
// will execute whenever the value is changed which allows us to check and reset the server validator
|
||||
// TODO: Is this even needed?
|
||||
function startWatch() {
|
||||
//if there's not already a watch
|
||||
if (!watcher) {
|
||||
watcher = scope.$watch("currentProperty.value",
|
||||
function (newValue, oldValue) {
|
||||
if (angular.equals(newValue, oldValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var errCount = 0;
|
||||
|
||||
for (var e in formCtrl.$error) {
|
||||
if (Utilities.isArray(formCtrl.$error[e])) {
|
||||
errCount++;
|
||||
watcher = scope.$watchCollection(
|
||||
() => formCtrl,
|
||||
function (updatedFormController) {
|
||||
var ngModels = [];
|
||||
collectAllNgModelControllersRecursively(updatedFormController.$getControls(), ngModels);
|
||||
ngModels.forEach(x => {
|
||||
if (!x.$validators.serverValidityResetter) {
|
||||
x.$validators.serverValidityResetter = resetServerValidityValidator(x);
|
||||
}
|
||||
}
|
||||
|
||||
//we are explicitly checking for valServer errors here, since we shouldn't auto clear
|
||||
// based on other errors. We'll also check if there's no other validation errors apart from valPropertyMsg, if valPropertyMsg
|
||||
// is the only one, then we'll clear.
|
||||
|
||||
if (errCount === 0
|
||||
|| (errCount === 1 && Utilities.isArray(formCtrl.$error.valPropertyMsg))
|
||||
|| (formCtrl.$invalid && Utilities.isArray(formCtrl.$error.valServer))) {
|
||||
scope.errorMsg = "";
|
||||
formCtrl.$setValidity('valPropertyMsg', true);
|
||||
} else if (showValidation && scope.errorMsg === "") {
|
||||
formCtrl.$setValidity('valPropertyMsg', false);
|
||||
scope.errorMsg = getErrorMsg();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,22 +197,31 @@ function valPropertyMsg(serverValidationManager, localizationService) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetError() {
|
||||
stopWatch();
|
||||
hasError = false;
|
||||
formCtrl.$setValidity('valPropertyMsg', true);
|
||||
scope.errorMsg = "";
|
||||
|
||||
}
|
||||
|
||||
// This deals with client side validation changes and is executed anytime validators change on the containing
|
||||
// valFormManager. This allows us to know when to display or clear the property error data for non-server side errors.
|
||||
function checkValidationStatus() {
|
||||
if (formCtrl.$invalid) {
|
||||
//first we need to check if the valPropertyMsg validity is invalid
|
||||
if (formCtrl.$error.valPropertyMsg && formCtrl.$error.valPropertyMsg.length > 0) {
|
||||
//since we already have an error we'll just return since this means we've already set the
|
||||
// hasError and errorMsg properties which occurs below in the serverValidationManager.subscribe
|
||||
//hasError and errorMsg properties which occurs below in the serverValidationManager.subscribe
|
||||
return;
|
||||
}
|
||||
//if there are any errors in the current property form that are not valPropertyMsg
|
||||
else if (_.without(_.keys(formCtrl.$error), "valPropertyMsg").length > 0) {
|
||||
|
||||
|
||||
// errors exist, but if the property is NOT mandatory and has no value, the errors should be cleared
|
||||
if (isMandatory !== undefined && isMandatory === false && !currentProperty.value) {
|
||||
hasError = false;
|
||||
showValidation = false;
|
||||
scope.errorMsg = "";
|
||||
|
||||
resetError();
|
||||
|
||||
// if there's no value, the controls can be reset, which clears the error state on formCtrl
|
||||
for (let control of formCtrl.$getControls()) {
|
||||
@@ -164,99 +238,107 @@ function valPropertyMsg(serverValidationManager, localizationService) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
hasError = false;
|
||||
scope.errorMsg = "";
|
||||
resetError();
|
||||
}
|
||||
}
|
||||
else {
|
||||
hasError = false;
|
||||
scope.errorMsg = "";
|
||||
resetError();
|
||||
}
|
||||
}
|
||||
|
||||
//if there's any remaining errors in the server validation service then we should show them.
|
||||
var showValidation = serverValidationManager.items.length > 0;
|
||||
if (!showValidation) {
|
||||
//We can either get the form submitted status by the parent directive valFormManager (if we add a property to it)
|
||||
//or we can just check upwards in the DOM for the css class (easier for now).
|
||||
//The initial hidden state can't always be hidden because when we switch variants in the content editor we cannot
|
||||
//reset the status.
|
||||
showValidation = element.closest(".show-validation").length > 0;
|
||||
}
|
||||
function onInit() {
|
||||
|
||||
localizationService.localize("errors_propertyHasErrors").then(function (data) {
|
||||
|
||||
//listen for form validation changes.
|
||||
//The alternative is to add a watch to formCtrl.$invalid but that would lead to many more watches then
|
||||
// subscribing to this single watch.
|
||||
valFormManager.onValidationStatusChanged(function (evt, args) {
|
||||
checkValidationStatus();
|
||||
});
|
||||
labels.propertyHasErrors = data;
|
||||
|
||||
//listen for the forms saving event
|
||||
unsubscribe.push(scope.$on("formSubmitting", function (ev, args) {
|
||||
showValidation = true;
|
||||
if (hasError && scope.errorMsg === "") {
|
||||
scope.errorMsg = getErrorMsg();
|
||||
startWatch();
|
||||
}
|
||||
else if (!hasError) {
|
||||
scope.errorMsg = "";
|
||||
stopWatch();
|
||||
}
|
||||
}));
|
||||
|
||||
//listen for the forms saved event
|
||||
unsubscribe.push(scope.$on("formSubmitted", function (ev, args) {
|
||||
showValidation = false;
|
||||
scope.errorMsg = "";
|
||||
formCtrl.$setValidity('valPropertyMsg', true);
|
||||
stopWatch();
|
||||
}));
|
||||
|
||||
//listen for server validation changes
|
||||
// NOTE: we pass in "" in order to listen for all validation changes to the content property, not for
|
||||
// validation changes to fields in the property this is because some server side validators may not
|
||||
// return the field name for which the error belongs too, just the property for which it belongs.
|
||||
// It's important to note that we need to subscribe to server validation changes here because we always must
|
||||
// indicate that a content property is invalid at the property level since developers may not actually implement
|
||||
// the correct field validation in their property editors.
|
||||
|
||||
if (scope.currentProperty) { //this can be null if no property was assigned
|
||||
|
||||
function serverValidationManagerCallback(isValid, propertyErrors, allErrors) {
|
||||
hasError = !isValid;
|
||||
if (hasError) {
|
||||
//set the error message to the server message
|
||||
scope.errorMsg = propertyErrors[0].errorMsg;
|
||||
//flag that the current validator is invalid
|
||||
formCtrl.$setValidity('valPropertyMsg', false);
|
||||
startWatch();
|
||||
//if there's any remaining errors in the server validation service then we should show them.
|
||||
showValidation = serverValidationManager.items.length > 0;
|
||||
if (!showValidation) {
|
||||
//We can either get the form submitted status by the parent directive valFormManager (if we add a property to it)
|
||||
//or we can just check upwards in the DOM for the css class (easier for now).
|
||||
//The initial hidden state can't always be hidden because when we switch variants in the content editor we cannot
|
||||
//reset the status.
|
||||
showValidation = element.closest(".show-validation").length > 0;
|
||||
}
|
||||
else {
|
||||
scope.errorMsg = "";
|
||||
//flag that the current validator is valid
|
||||
formCtrl.$setValidity('valPropertyMsg', true);
|
||||
stopWatch();
|
||||
|
||||
//listen for form validation changes.
|
||||
//The alternative is to add a watch to formCtrl.$invalid but that would lead to many more watches then
|
||||
// subscribing to this single watch.
|
||||
valFormManager.onValidationStatusChanged(function (evt, args) {
|
||||
checkValidationStatus();
|
||||
});
|
||||
|
||||
//listen for the forms saving event
|
||||
unsubscribe.push(scope.$on("formSubmitting", function (ev, args) {
|
||||
showValidation = true;
|
||||
if (hasError && scope.errorMsg === "") {
|
||||
scope.errorMsg = getErrorMsg();
|
||||
startWatch();
|
||||
}
|
||||
else if (!hasError) {
|
||||
resetError();
|
||||
}
|
||||
}));
|
||||
|
||||
//listen for the forms saved event
|
||||
unsubscribe.push(scope.$on("formSubmitted", function (ev, args) {
|
||||
showValidation = false;
|
||||
resetError();
|
||||
}));
|
||||
|
||||
if (scope.currentProperty) { //this can be null if no property was assigned
|
||||
|
||||
// listen for server validation changes for property validation path prefix.
|
||||
// We pass in "" in order to listen for all validation changes to the content property, not for
|
||||
// validation changes to fields in the property this is because some server side validators may not
|
||||
// return the field name for which the error belongs too, just the property for which it belongs.
|
||||
// It's important to note that we need to subscribe to server validation changes here because we always must
|
||||
// indicate that a content property is invalid at the property level since developers may not actually implement
|
||||
// the correct field validation in their property editors.
|
||||
|
||||
function serverValidationManagerCallback(isValid, propertyErrors, allErrors) {
|
||||
var hadError = hasError;
|
||||
hasError = !isValid;
|
||||
if (hasError) {
|
||||
//set the error message to the server message
|
||||
scope.errorMsg = propertyErrors.length > 1 ? labels.propertyHasErrors : propertyErrors[0].errorMsg || labels.propertyHasErrors;
|
||||
//flag that the current validator is invalid
|
||||
formCtrl.$setValidity('valPropertyMsg', false);
|
||||
startWatch();
|
||||
|
||||
|
||||
if (propertyErrors.length === 1 && hadError && !formCtrl.$pristine) {
|
||||
var propertyValidationPath = umbPropCtrl.getValidationPath();
|
||||
serverValidationManager.removePropertyError(propertyValidationPath, currentCulture, "", currentSegment);
|
||||
resetError();
|
||||
}
|
||||
}
|
||||
else {
|
||||
resetError();
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe.push(serverValidationManager.subscribe(
|
||||
umbPropCtrl.getValidationPath(),
|
||||
currentCulture,
|
||||
"",
|
||||
serverValidationManagerCallback,
|
||||
currentSegment,
|
||||
{ matchType: "prefix" } // match property validation path prefix
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe.push(serverValidationManager.subscribe(scope.currentProperty.alias,
|
||||
currentCulture,
|
||||
"",
|
||||
serverValidationManagerCallback,
|
||||
currentSegment
|
||||
)
|
||||
);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//when the scope is disposed we need to unsubscribe
|
||||
scope.$on('$destroy', function () {
|
||||
stopWatch();
|
||||
for (var u in unsubscribe) {
|
||||
unsubscribe[u]();
|
||||
}
|
||||
unsubscribe.forEach(u => u());
|
||||
});
|
||||
|
||||
onInit();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ function valServer(serverValidationManager) {
|
||||
link: function (scope, element, attr, ctrls) {
|
||||
|
||||
var modelCtrl = ctrls[0];
|
||||
var umbPropCtrl = ctrls.length > 1 ? ctrls[1] : null;
|
||||
var umbPropCtrl = ctrls[1];
|
||||
if (!umbPropCtrl) {
|
||||
//we cannot proceed, this validator will be disabled
|
||||
return;
|
||||
}
|
||||
|
||||
// optional reference to the varaint-content-controller, needed to avoid validation when the field is invariant on non-default languages.
|
||||
var umbVariantCtrl = ctrls.length > 2 ? ctrls[2] : null;
|
||||
var umbVariantCtrl = ctrls[2];
|
||||
|
||||
var currentProperty = umbPropCtrl.property;
|
||||
var currentCulture = currentProperty.culture;
|
||||
@@ -55,8 +55,11 @@ function valServer(serverValidationManager) {
|
||||
}
|
||||
}
|
||||
|
||||
//Need to watch the value model for it to change, previously we had subscribed to
|
||||
//modelCtrl.$viewChangeListeners but this is not good enough if you have an editor that
|
||||
// Get the property validation path if there is one, this is how wiring up any nested/virtual property validation works
|
||||
var propertyValidationPath = umbPropCtrl ? umbPropCtrl.getValidationPath() : currentProperty.alias;
|
||||
|
||||
// Need to watch the value model for it to change, previously we had subscribed to
|
||||
// modelCtrl.$viewChangeListeners but this is not good enough if you have an editor that
|
||||
// doesn't specifically have a 2 way ng binding. This is required because when we
|
||||
// have a server error we actually invalidate the form which means it cannot be
|
||||
// resubmitted. So once a field is changed that has a server error assigned to it
|
||||
@@ -75,8 +78,10 @@ function valServer(serverValidationManager) {
|
||||
|
||||
if (modelCtrl.$invalid) {
|
||||
modelCtrl.$setValidity('valServer', true);
|
||||
|
||||
//clear the server validation entry
|
||||
serverValidationManager.removePropertyError(currentProperty.alias, currentCulture, fieldName, currentSegment);
|
||||
serverValidationManager.removePropertyError(propertyValidationPath, currentCulture, fieldName, currentSegment);
|
||||
|
||||
stopWatch();
|
||||
}
|
||||
}, true);
|
||||
@@ -105,7 +110,9 @@ function valServer(serverValidationManager) {
|
||||
stopWatch();
|
||||
}
|
||||
}
|
||||
unsubscribe.push(serverValidationManager.subscribe(currentProperty.alias,
|
||||
|
||||
unsubscribe.push(serverValidationManager.subscribe(
|
||||
propertyValidationPath,
|
||||
currentCulture,
|
||||
fieldName,
|
||||
serverValidationManagerCallback,
|
||||
@@ -114,9 +121,7 @@ function valServer(serverValidationManager) {
|
||||
|
||||
scope.$on('$destroy', function () {
|
||||
stopWatch();
|
||||
for (var u in unsubscribe) {
|
||||
unsubscribe[u]();
|
||||
}
|
||||
unsubscribe.forEach(u => u());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
|
||||
function valServerMatch(serverValidationManager) {
|
||||
return {
|
||||
require: ['form', '^^umbProperty', '?^^umbVariantContent'],
|
||||
restrict: "A",
|
||||
scope: {
|
||||
valServerMatch: "="
|
||||
},
|
||||
link: function (scope, element, attr, ctrls) {
|
||||
|
||||
var formCtrl = ctrls[0];
|
||||
var umbPropCtrl = ctrls[1];
|
||||
if (!umbPropCtrl) {
|
||||
//we cannot proceed, this validator will be disabled
|
||||
return;
|
||||
}
|
||||
|
||||
// optional reference to the varaint-content-controller, needed to avoid validation when the field is invariant on non-default languages.
|
||||
var umbVariantCtrl = ctrls[2];
|
||||
|
||||
var currentProperty = umbPropCtrl.property;
|
||||
var currentCulture = currentProperty.culture;
|
||||
var currentSegment = currentProperty.segment;
|
||||
|
||||
if (umbVariantCtrl) {
|
||||
//if we are inside of an umbVariantContent directive
|
||||
|
||||
var currentVariant = umbVariantCtrl.editor.content;
|
||||
|
||||
// Lets check if we have variants and we are on the default language then ...
|
||||
if (umbVariantCtrl.content.variants.length > 1 && (!currentVariant.language || !currentVariant.language.isDefault) && !currentCulture && !currentSegment && !currentProperty.unlockInvariantValue) {
|
||||
//This property is locked cause its a invariant property shown on a non-default language.
|
||||
//Therefor do not validate this field.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if we have reached this part, and there is no culture, then lets fallback to invariant. To get the validation feedback for invariant language.
|
||||
currentCulture = currentCulture || "invariant";
|
||||
|
||||
var unsubscribe = [];
|
||||
|
||||
//subscribe to the server validation changes
|
||||
function serverValidationManagerCallback(isValid, propertyErrors, allErrors) {
|
||||
if (!isValid) {
|
||||
formCtrl.$setValidity('valServerMatch', false);
|
||||
}
|
||||
else {
|
||||
formCtrl.$setValidity('valServerMatch', true);
|
||||
}
|
||||
}
|
||||
|
||||
if (Utilities.isObject(scope.valServerMatch)) {
|
||||
var allowedKeys = ["contains", "prefix", "suffix"];
|
||||
Object.keys(scope.valServerMatch).forEach(k => {
|
||||
if (allowedKeys.indexOf(k) === -1) {
|
||||
throw "valServerMatch dictionary keys must be one of " + allowedKeys.join();
|
||||
}
|
||||
|
||||
unsubscribe.push(serverValidationManager.subscribe(
|
||||
scope.valServerMatch[k],
|
||||
currentCulture,
|
||||
"",
|
||||
serverValidationManagerCallback,
|
||||
currentSegment,
|
||||
{ matchType: k } // specify the match type
|
||||
));
|
||||
|
||||
});
|
||||
}
|
||||
else if (Utilities.isString(scope.valServerMatch)) {
|
||||
unsubscribe.push(serverValidationManager.subscribe(
|
||||
scope.valServerMatch,
|
||||
currentCulture,
|
||||
"",
|
||||
serverValidationManagerCallback,
|
||||
currentSegment));
|
||||
}
|
||||
else {
|
||||
throw "valServerMatch value must be a string or a dictionary";
|
||||
}
|
||||
|
||||
scope.$on('$destroy', function () {
|
||||
unsubscribe.forEach(u => u());
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
angular.module('umbraco.directives.validation').directive("valServerMatch", valServerMatch);
|
||||
@@ -7,8 +7,86 @@
|
||||
* Some angular helper/extension methods
|
||||
*/
|
||||
function angularHelper($q) {
|
||||
|
||||
var requiredFormProps = ["$error", "$name", "$dirty", "$pristine", "$valid", "$submitted", "$pending"];
|
||||
|
||||
function collectAllFormErrorsRecursively(formCtrl, allErrors) {
|
||||
// loop over the error dictionary (see https://docs.angularjs.org/api/ng/type/form.FormController#$error)
|
||||
var keys = Object.keys(formCtrl.$error);
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
keys.forEach(validationKey => {
|
||||
var ctrls = formCtrl.$error[validationKey];
|
||||
ctrls.forEach(ctrl => {
|
||||
if (!ctrl) {
|
||||
// this happens when $setValidity('err', true) is called on a form controller without specifying the 3rd parameter for the control/form
|
||||
// which just means that this is an error on the formCtrl itself
|
||||
allErrors.push(formCtrl); // add the error
|
||||
}
|
||||
else if (isForm(ctrl)) {
|
||||
// sometimes the control in error is the same form so we cannot recurse else we'll cause an infinite loop
|
||||
// and in this case it means the error is assigned directly to the form, not a control
|
||||
if (ctrl === formCtrl) {
|
||||
allErrors.push(ctrl); // add the error
|
||||
return;
|
||||
}
|
||||
// recurse with the sub form
|
||||
collectAllFormErrorsRecursively(ctrl, allErrors);
|
||||
}
|
||||
else {
|
||||
// it's a normal control
|
||||
allErrors.push(ctrl); // add the error
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isForm(obj) {
|
||||
// a method to check that the collection of object prop names contains the property name expected
|
||||
function allPropertiesExist(objectPropNames) {
|
||||
//ensure that every required property name exists on the current object
|
||||
return _.every(requiredFormProps, function (item) {
|
||||
return _.contains(objectPropNames, item);
|
||||
});
|
||||
}
|
||||
|
||||
//get the keys of the property names for the current object
|
||||
var props = _.keys(obj);
|
||||
//if the length isn't correct, try the next prop
|
||||
if (props.length < requiredFormProps.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//ensure that every required property name exists on the current scope property
|
||||
return allPropertiesExist(props);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
countAllFormErrors: function (formCtrl) {
|
||||
var allErrors = [];
|
||||
collectAllFormErrorsRecursively(formCtrl, allErrors);
|
||||
return allErrors.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will traverse up the $scope chain to all ancestors until the predicate matches for the current scope or until it's at the root.
|
||||
* @param {any} scope
|
||||
* @param {any} predicate
|
||||
*/
|
||||
traverseScopeChain: function (scope, predicate) {
|
||||
var s = scope.$parent;
|
||||
while (s) {
|
||||
var result = predicate(s);
|
||||
if (result === true) {
|
||||
return s;
|
||||
}
|
||||
s = s.$parent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Method used to re-run the $parsers for a given ngModel
|
||||
* @param {} scope
|
||||
@@ -83,6 +161,9 @@ function angularHelper($q) {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
isForm: isForm,
|
||||
|
||||
/**
|
||||
* @ngdoc function
|
||||
* @name getCurrentForm
|
||||
@@ -104,31 +185,10 @@ function angularHelper($q) {
|
||||
// is to inject the $element object and use: $element.inheritedData('$formController');
|
||||
|
||||
var form = null;
|
||||
var requiredFormProps = ["$error", "$name", "$dirty", "$pristine", "$valid", "$submitted", "$pending"];
|
||||
|
||||
// a method to check that the collection of object prop names contains the property name expected
|
||||
function propertyExists(objectPropNames) {
|
||||
//ensure that every required property name exists on the current scope property
|
||||
return _.every(requiredFormProps, function (item) {
|
||||
|
||||
return _.contains(objectPropNames, item);
|
||||
});
|
||||
}
|
||||
|
||||
for (var p in scope) {
|
||||
|
||||
if (_.isObject(scope[p]) && p !== "this" && p.substr(0, 1) !== "$") {
|
||||
//get the keys of the property names for the current property
|
||||
var props = _.keys(scope[p]);
|
||||
//if the length isn't correct, try the next prop
|
||||
if (props.length < requiredFormProps.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//ensure that every required property name exists on the current scope property
|
||||
var containProperty = propertyExists(props);
|
||||
|
||||
if (containProperty) {
|
||||
if (this.isForm(scope[p])) {
|
||||
form = scope[p];
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -129,9 +129,9 @@
|
||||
var prop = tab.properties[p];
|
||||
|
||||
// Watch value of property since this is the only value we want to keep synced.
|
||||
// Do notice that it is not performing a deep watch, meaning that we are only watching primatives and changes directly to the object of property-value.
|
||||
// But we like to sync non-primative values as well! Yes, and this does happen, just not through this code, but through the nature of JavaScript.
|
||||
// Non-primative values act as references to the same data and are therefor synced.
|
||||
// Do notice that it is not performing a deep watch, meaning that we are only watching primitive and changes directly to the object of property-value.
|
||||
// But we like to sync non-primitive values as well! Yes, and this does happen, just not through this code, but through the nature of JavaScript.
|
||||
// Non-primitive values act as references to the same data and are therefor synced.
|
||||
blockObject.__watchers.push(isolatedScope.$watch("blockObjects._" + blockObject.key + "." + field + ".variants[0].tabs[" + t + "].properties[" + p + "].value", watcherCreator(blockObject, prop)));
|
||||
|
||||
// We also like to watch our data model to be able to capture changes coming from other places.
|
||||
@@ -257,13 +257,34 @@
|
||||
this.isolatedScope.blockObjects = {};
|
||||
|
||||
this.__watchers.push(this.isolatedScope.$on("$destroy", this.destroy.bind(this)));
|
||||
|
||||
this.__watchers.push(propertyEditorScope.$on("postFormSubmitting", this.sync.bind(this)));
|
||||
|
||||
};
|
||||
|
||||
BlockEditorModelObject.prototype = {
|
||||
|
||||
update: function (propertyModelValue, propertyEditorScope) {
|
||||
// clear watchers
|
||||
this.__watchers.forEach(w => { w(); });
|
||||
delete this.__watchers;
|
||||
|
||||
// clear block objects
|
||||
for (const key in this.isolatedScope.blockObjects) {
|
||||
this.destroyBlockObject(this.isolatedScope.blockObjects[key]);
|
||||
}
|
||||
this.isolatedScope.blockObjects = {};
|
||||
|
||||
// update our values
|
||||
this.value = propertyModelValue;
|
||||
this.value.layout = this.value.layout || {};
|
||||
this.value.data = this.value.data || [];
|
||||
|
||||
// re-create the watchers
|
||||
this.__watchers = [];
|
||||
this.__watchers.push(this.isolatedScope.$on("$destroy", this.destroy.bind(this)));
|
||||
this.__watchers.push(propertyEditorScope.$on("postFormSubmitting", this.sync.bind(this)));
|
||||
},
|
||||
|
||||
/**
|
||||
* @ngdoc method
|
||||
* @name getBlockConfiguration
|
||||
@@ -280,8 +301,8 @@
|
||||
* @ngdoc method
|
||||
* @name load
|
||||
* @methodOf umbraco.services.blockEditorModelObject
|
||||
* @description Load the scaffolding models for the given configuration, these are needed to provide usefull models for each block.
|
||||
* @param {Object} blockObject BlockObject to recive data values from.
|
||||
* @description Load the scaffolding models for the given configuration, these are needed to provide useful models for each block.
|
||||
* @param {Object} blockObject BlockObject to receive data values from.
|
||||
* @returns {Promise} A Promise object which resolves when all scaffold models are loaded.
|
||||
*/
|
||||
load: function() {
|
||||
@@ -296,7 +317,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// removing dublicates.
|
||||
// removing duplicates.
|
||||
scaffoldKeys = scaffoldKeys.filter((value, index, self) => self.indexOf(value) === index);
|
||||
|
||||
scaffoldKeys.forEach((contentTypeKey => {
|
||||
@@ -376,7 +397,7 @@
|
||||
* @name getBlockObject
|
||||
* @methodOf umbraco.services.blockEditorModelObject
|
||||
* @description Retrieve a Block Object for the given layout entry.
|
||||
* The Block Object offers the nesecary data to display and edit a block.
|
||||
* The Block Object offers the necessary data to display and edit a block.
|
||||
* The Block Object setups live syncronization of content and settings models back to the data of your Property Editor model.
|
||||
* The returned object, named ´BlockObject´, contains several usefull models to make editing of this block happen.
|
||||
* The ´BlockObject´ contains the following properties:
|
||||
@@ -449,6 +470,8 @@
|
||||
// make basics from scaffold
|
||||
blockObject.content = Utilities.copy(contentScaffold);
|
||||
blockObject.content.udi = udi;
|
||||
// Change the content.key to the GUID part of the udi, else it's just random which we don't want, it should be consistent
|
||||
blockObject.content.key = udiService.getKey(udi);
|
||||
|
||||
mapToElementModel(blockObject.content, dataModel);
|
||||
|
||||
@@ -482,7 +505,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
blockObject.retriveValuesFrom = function(content, settings) {
|
||||
blockObject.retrieveValuesFrom = function(content, settings) {
|
||||
if (this.content !== null) {
|
||||
mapElementValues(content, this.content);
|
||||
}
|
||||
@@ -492,7 +515,7 @@
|
||||
}
|
||||
|
||||
|
||||
blockObject.sync = function() {
|
||||
blockObject.sync = function () {
|
||||
if (this.content !== null) {
|
||||
mapToPropertyModel(this.content, this.data);
|
||||
}
|
||||
@@ -509,13 +532,14 @@
|
||||
addWatchers(blockObject, this.isolatedScope);
|
||||
addWatchers(blockObject, this.isolatedScope, true);
|
||||
|
||||
blockObject.destroy = function() {
|
||||
blockObject.destroy = function () {
|
||||
// remove property value watchers:
|
||||
this.__watchers.forEach(w => { w(); });
|
||||
delete this.__watchers;
|
||||
|
||||
// help carbage collector:
|
||||
delete this.config;
|
||||
|
||||
delete this.layout;
|
||||
delete this.data;
|
||||
delete this.settingsData;
|
||||
@@ -524,9 +548,12 @@
|
||||
|
||||
// remove model from isolatedScope.
|
||||
delete this.__scope.blockObjects["_" + this.key];
|
||||
// NOTE: It seems like we should call this.__scope.$destroy(); since that is the only way to remove a scope from it's parent,
|
||||
// however that is not the case since __scope is actually this.isolatedScope which gets cleaned up when the outer scope is
|
||||
// destroyed. If we do that here it breaks the scope chain and validation.
|
||||
delete this.__scope;
|
||||
|
||||
// removes this method, making it unposible to destroy again.
|
||||
// removes this method, making it impossible to destroy again.
|
||||
delete this.destroy;
|
||||
|
||||
// lets remove the key to make things blow up if this is still referenced:
|
||||
@@ -639,8 +666,6 @@
|
||||
|
||||
},
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @ngdoc method
|
||||
* @name sync
|
||||
@@ -654,6 +679,7 @@
|
||||
},
|
||||
|
||||
// private
|
||||
// TODO: Then this can just be a method in the outer scope
|
||||
_createDataEntry: function(elementTypeKey) {
|
||||
var content = {
|
||||
contentTypeKey: elementTypeKey,
|
||||
@@ -663,6 +689,7 @@
|
||||
return content.udi;
|
||||
},
|
||||
// private
|
||||
// TODO: Then this can just be a method in the outer scope
|
||||
_getDataByUdi: function(udi) {
|
||||
return this.value.contentData.find(entry => entry.udi === udi) || null;
|
||||
},
|
||||
|
||||
@@ -129,6 +129,21 @@ function clipboardService(notificationsService, eventsService, localStorageServi
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @ngdoc method
|
||||
* @name umbraco.services.clipboardService#registrerPropertyClearingResolver
|
||||
* @methodOf umbraco.services.clipboardService
|
||||
*
|
||||
* @param {string} function A method executed for every property and inner properties copied.
|
||||
*
|
||||
* @description
|
||||
* Executed for all properties including inner properties when performing a copy action.
|
||||
*/
|
||||
service.registrerClearPropertyResolver = function(resolver) {
|
||||
clearPropertyResolvers.push(resolver);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @ngdoc method
|
||||
* @name umbraco.services.clipboardService#copy
|
||||
|
||||
@@ -32,6 +32,26 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, editorSt
|
||||
return true;
|
||||
}
|
||||
|
||||
function showNotificationsForModelsState(ms) {
|
||||
for (const [key, value] of Object.entries(ms)) {
|
||||
|
||||
var errorMsg = value[0];
|
||||
// if the error message is json it's a complex editor validation response that we need to parse
|
||||
if ((Utilities.isString(errorMsg) && errorMsg.startsWith("[")) || Utilities.isArray(errorMsg)) {
|
||||
// flatten the json structure, create validation paths for each property and add each as a property error
|
||||
var idsToErrors = serverValidationManager.parseComplexEditorError(errorMsg, "");
|
||||
idsToErrors.forEach(x => {
|
||||
if (x.modelState) {
|
||||
showNotificationsForModelsState(x.modelState);
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (value[0]) {
|
||||
notificationsService.error("Validation", value[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
//TODO: We need to move some of this to formHelper for saving, too many editors use this method for saving when this entire
|
||||
@@ -614,10 +634,9 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, editorSt
|
||||
formHelper.handleServerValidation(args.err.data.ModelState);
|
||||
|
||||
//add model state errors to notifications
|
||||
// TODO: Need to ignore complex messages
|
||||
if (args.showNotifications) {
|
||||
for (var e in args.err.data.ModelState) {
|
||||
notificationsService.error("Validation", args.err.data.ModelState[e][0]);
|
||||
}
|
||||
showNotificationsForModelsState(args.err.data.ModelState);
|
||||
}
|
||||
|
||||
if (!this.redirectToCreatedContent(args.err.data.id, args.softRedirect) || args.softRedirect) {
|
||||
|
||||
@@ -44,7 +44,7 @@ function formHelper(angularHelper, serverValidationManager, notificationsService
|
||||
//the first thing any form must do is broadcast the formSubmitting event
|
||||
args.scope.$broadcast("formSubmitting", { scope: args.scope, action: args.action });
|
||||
|
||||
// Some property editors need to performe an action after all property editors have reacted to the formSubmitting.
|
||||
// Some property editors need to perform an action after all property editors have reacted to the formSubmitting.
|
||||
args.scope.$broadcast("postFormSubmitting", { scope: args.scope, action: args.action });
|
||||
|
||||
//then check if the form is valid
|
||||
@@ -54,8 +54,13 @@ function formHelper(angularHelper, serverValidationManager, notificationsService
|
||||
}
|
||||
}
|
||||
|
||||
//reset the server validations
|
||||
serverValidationManager.reset();
|
||||
//reset the server validations if required (default is true), otherwise notify existing ones of changes
|
||||
if (!args.keepServerValidation) {
|
||||
serverValidationManager.reset();
|
||||
}
|
||||
else {
|
||||
serverValidationManager.notify();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -147,73 +152,7 @@ function formHelper(angularHelper, serverValidationManager, notificationsService
|
||||
* @param {object} err The error object returned from the http promise
|
||||
*/
|
||||
handleServerValidation: function (modelState) {
|
||||
for (var e in modelState) {
|
||||
|
||||
//This is where things get interesting....
|
||||
// We need to support validation for all editor types such as both the content and content type editors.
|
||||
// The Content editor ModelState is quite specific with the way that Properties are validated especially considering
|
||||
// that each property is a User Developer property editor.
|
||||
// The way that Content Type Editor ModelState is created is simply based on the ASP.Net validation data-annotations
|
||||
// system.
|
||||
// So, to do this there's some special ModelState syntax we need to know about.
|
||||
// For Content Properties, which are user defined, we know that they will exist with a prefixed
|
||||
// ModelState of "_Properties.", so if we detect this, then we know it's for a content Property.
|
||||
|
||||
//the alias in model state can be in dot notation which indicates
|
||||
// * the first part is the content property alias
|
||||
// * the second part is the field to which the valiation msg is associated with
|
||||
//There will always be at least 4 parts for content properties since all model errors for properties are prefixed with "_Properties"
|
||||
//If it is not prefixed with "_Properties" that means the error is for a field of the object directly.
|
||||
|
||||
// Example: "_Properties.headerImage.en-US.mySegment.myField"
|
||||
// * it's for a property since it has a _Properties prefix
|
||||
// * it's for the headerImage property type
|
||||
// * it's for the en-US culture
|
||||
// * it's for the mySegment segment
|
||||
// * it's for the myField html field (optional)
|
||||
|
||||
var parts = e.split(".");
|
||||
|
||||
//Check if this is for content properties - specific to content/media/member editors because those are special
|
||||
// user defined properties with custom controls.
|
||||
if (parts.length > 1 && parts[0] === "_Properties") {
|
||||
|
||||
var propertyAlias = parts[1];
|
||||
|
||||
var culture = null;
|
||||
if (parts.length > 2) {
|
||||
culture = parts[2];
|
||||
//special check in case the string is formatted this way
|
||||
if (culture === "null") {
|
||||
culture = null;
|
||||
}
|
||||
}
|
||||
|
||||
var segment = null;
|
||||
if (parts.length > 3) {
|
||||
segment = parts[3];
|
||||
//special check in case the string is formatted this way
|
||||
if (segment === "null") {
|
||||
segment = null;
|
||||
}
|
||||
}
|
||||
|
||||
var htmlFieldReference = "";
|
||||
if (parts.length > 4) {
|
||||
htmlFieldReference = parts[4] || "";
|
||||
}
|
||||
|
||||
// add a generic error for the property
|
||||
serverValidationManager.addPropertyError(propertyAlias, culture, htmlFieldReference, modelState[e][0], segment);
|
||||
|
||||
} else {
|
||||
|
||||
//Everthing else is just a 'Field'... the field name could contain any level of 'parts' though, for example:
|
||||
// Groups[0].Properties[2].Alias
|
||||
serverValidationManager.addFieldError(e, modelState[e][0]);
|
||||
}
|
||||
|
||||
}
|
||||
serverValidationManager.addErrorsForModelState(modelState);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,13 +16,32 @@
|
||||
* @function
|
||||
*
|
||||
* @description
|
||||
* Generates a Udi string.
|
||||
* Generates a Udi string with a new ID
|
||||
*
|
||||
* @param {string} entityType The entityType as a string.
|
||||
* @returns {string} The generated UDI
|
||||
*/
|
||||
create: function(entityType) {
|
||||
return "umb://" + entityType + "/" + (String.CreateGuid().replace(/-/g, ""));
|
||||
return this.build(entityType, String.CreateGuid());
|
||||
},
|
||||
|
||||
build: function (entityType, guid) {
|
||||
return "umb://" + entityType + "/" + (guid.replace(/-/g, ""));
|
||||
},
|
||||
|
||||
getKey: function (udi) {
|
||||
if (!Utilities.isString(udi)) {
|
||||
throw "udi is not a string";
|
||||
}
|
||||
if (!udi.startsWith("umb://")) {
|
||||
throw "udi does not start with umb://";
|
||||
}
|
||||
var withoutScheme = udi.substr("umb://".length);
|
||||
var withoutHost = withoutScheme.substr(withoutScheme.indexOf("/") + 1).trim();
|
||||
if (withoutHost.length !== 32) {
|
||||
throw "udi is not 32 chars";
|
||||
}
|
||||
return `${withoutHost.substr(0, 8)}-${withoutHost.substr(8, 4)}-${withoutHost.substr(12, 4)}-${withoutHost.substr(16, 4)}-${withoutHost.substr(20)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,11 +165,9 @@ function umbRequestHelper($http, $q, notificationsService, eventsService, formHe
|
||||
return; //sometimes oddly this happens, nothing we can do
|
||||
}
|
||||
|
||||
if (!response.status && response.message && response.stack) {
|
||||
//this is a JS/angular error that we should deal with
|
||||
return $q.reject({
|
||||
errorMsg: response.message
|
||||
});
|
||||
if (!response.status) {
|
||||
//this is a JS/angular error
|
||||
return $q.reject(response);
|
||||
}
|
||||
|
||||
//invoke the callback
|
||||
@@ -280,7 +278,11 @@ function umbRequestHelper($http, $q, notificationsService, eventsService, formHe
|
||||
//the data returned is the up-to-date data so the UI will refresh
|
||||
return $q.resolve(response.data);
|
||||
}, function (response) {
|
||||
//failure callback
|
||||
|
||||
if (!response.status) {
|
||||
//this is a JS/angular error
|
||||
return $q.reject(response);
|
||||
}
|
||||
|
||||
//when there's a 500 (unhandled) error show a YSOD overlay if debugging is enabled.
|
||||
if (response.status >= 500 && response.status < 600) {
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
position: relative;
|
||||
text-align: left;
|
||||
background: @white;
|
||||
border: 1px solid @gray-9;
|
||||
border-radius: @baseBorderRadius;
|
||||
transition: border-color 120ms;
|
||||
margin-bottom: 4px;
|
||||
margin-top: 4px;
|
||||
|
||||
&.--error {
|
||||
border-color: @formErrorBorder !important;
|
||||
}
|
||||
}
|
||||
|
||||
.umb-nested-content__item.ui-sortable-placeholder {
|
||||
@@ -54,7 +63,7 @@
|
||||
}
|
||||
|
||||
.umb-nested-content__header-bar {
|
||||
border-bottom: 1px solid @gray-9;
|
||||
|
||||
cursor: pointer;
|
||||
background-color: @white;
|
||||
|
||||
@@ -207,9 +216,9 @@
|
||||
|
||||
.umb-nested-content__content {
|
||||
border-top: 1px solid transparent;
|
||||
border-bottom: 1px solid @gray-9;
|
||||
border-left: 1px solid @gray-9;
|
||||
border-right: 1px solid @gray-9;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-left: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ h6.-black {
|
||||
}
|
||||
}
|
||||
|
||||
.umb-property:last-of-type .umb-control-group {
|
||||
umb-property:last-of-type .umb-control-group {
|
||||
&::after {
|
||||
margin-top: 0px;
|
||||
height: 0;
|
||||
|
||||
+28
-17
@@ -4,18 +4,16 @@ angular.module("umbraco")
|
||||
var vm = this;
|
||||
|
||||
vm.model = $scope.model;
|
||||
|
||||
vm.model = $scope.model;
|
||||
vm.tabs = [];
|
||||
localizationService.localizeMany([
|
||||
$scope.model.liveEditing ? "prompt_discardChanges" : "general_close",
|
||||
$scope.model.liveEditing ? "buttons_confirmActionConfirm" : "buttons_submitChanges"
|
||||
vm.model.liveEditing ? "prompt_discardChanges" : "general_close",
|
||||
vm.model.liveEditing ? "buttons_confirmActionConfirm" : "buttons_submitChanges"
|
||||
]).then(function (data) {
|
||||
vm.closeLabel = data[0];
|
||||
vm.submitLabel = data[1];
|
||||
});
|
||||
|
||||
|
||||
vm.tabs = [];
|
||||
|
||||
if ($scope.model.content && $scope.model.content.variants) {
|
||||
|
||||
var apps = $scope.model.content.apps;
|
||||
@@ -24,11 +22,11 @@ angular.module("umbraco")
|
||||
|
||||
// replace view of content app.
|
||||
var contentApp = apps.find(entry => entry.alias === "umbContent");
|
||||
if(contentApp) {
|
||||
if (contentApp) {
|
||||
contentApp.view = "views/common/infiniteeditors/blockeditor/blockeditor.content.html";
|
||||
if($scope.model.hideContent) {
|
||||
if(vm.model.hideContent) {
|
||||
apps.splice(apps.indexOf(contentApp), 1);
|
||||
} else if ($scope.model.openSettings !== true) {
|
||||
} else if (vm.model.openSettings !== true) {
|
||||
contentApp.active = true;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +37,7 @@ angular.module("umbraco")
|
||||
|
||||
}
|
||||
|
||||
if ($scope.model.settings && $scope.model.settings.variants) {
|
||||
if (vm.model.settings && vm.model.settings.variants) {
|
||||
localizationService.localize("blockEditor_tabBlockSettings").then(
|
||||
function (settingsName) {
|
||||
var settingsTab = {
|
||||
@@ -49,7 +47,7 @@ angular.module("umbraco")
|
||||
"view": "views/common/infiniteeditors/blockeditor/blockeditor.settings.html"
|
||||
};
|
||||
vm.tabs.push(settingsTab);
|
||||
if ($scope.model.openSettings) {
|
||||
if (vm.model.openSettings) {
|
||||
settingsTab.active = true;
|
||||
}
|
||||
}
|
||||
@@ -57,17 +55,30 @@ angular.module("umbraco")
|
||||
}
|
||||
|
||||
vm.submitAndClose = function () {
|
||||
if ($scope.model && $scope.model.submit) {
|
||||
if (formHelper.submitForm({ scope: $scope })) {
|
||||
$scope.model.submit($scope.model);
|
||||
if (vm.model && vm.model.submit) {
|
||||
// always keep server validations since this will be a nested editor and server validations are global
|
||||
if (formHelper.submitForm({ scope: $scope, formCtrl: vm.blockForm, keepServerValidation: true })) {
|
||||
vm.model.submit(vm.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.close = function() {
|
||||
if ($scope.model && $scope.model.close) {
|
||||
vm.close = function () {
|
||||
if (vm.model && vm.model.close) {
|
||||
// TODO: At this stage there could very well have been server errors that have been cleared
|
||||
// but if we 'close' we are basically cancelling the value changes which means we'd want to cancel
|
||||
// all of the server errors just cleared. It would be possible to do that but also quite annoying.
|
||||
// The rudimentary way would be to:
|
||||
// * Track all cleared server errors here by subscribing to the prefix validation of controls contained here
|
||||
// * If this is closed, re-add all of those server validation errors
|
||||
// A more robust way to do this would be to:
|
||||
// * Add functionality to the serverValidationManager whereby we can remove validation errors and it will
|
||||
// maintain a copy of the original errors
|
||||
// * 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
|
||||
|
||||
// TODO: check if content/settings has changed and ask user if they are sure.
|
||||
$scope.model.close($scope.model);
|
||||
vm.model.close(vm.model);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
<div class="umb-block-editor" ng-controller="Umbraco.Editors.BlockEditorController as vm">
|
||||
|
||||
<ng-form name="blockForm" val-form-manager>
|
||||
<ng-form name="vm.blockForm" val-form-manager>
|
||||
|
||||
<umb-editor-view umb-tabs ng-if="!page.loading">
|
||||
|
||||
<umb-editor-header
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<div class="umb-editors">
|
||||
|
||||
<div class="umb-editor"
|
||||
ng-repeat="model in editors"
|
||||
ng-class="{'umb-editor--small': model.size === 'small',
|
||||
'umb-editor--medium': model.size === 'medium',
|
||||
'umb-editor--animating': model.animating,
|
||||
'umb-editor--notInFront': model.inFront !== true,
|
||||
'umb-editor--infiniteMode': model.infiniteMode,
|
||||
'umb-editor--moveRight': model.moveRight,
|
||||
'umb-editor--n0': model.styleIndex === 0,
|
||||
'umb-editor--n1': model.styleIndex === 1,
|
||||
'umb-editor--n2': model.styleIndex === 2,
|
||||
'umb-editor--n3': model.styleIndex === 3,
|
||||
'umb-editor--outOfRange': model.level === -1,
|
||||
'umb-editor--level0': model.level === 0,
|
||||
'umb-editor--level1': model.level === 1,
|
||||
'umb-editor--level2': model.level === 2,
|
||||
'umb-editor--level3': model.level === 3}"
|
||||
>
|
||||
<umb-editor-repeater class="umb-editor"
|
||||
ng-repeat="model in editors"
|
||||
ng-class="{'umb-editor--small': model.size === 'small',
|
||||
'umb-editor--medium': model.size === 'medium',
|
||||
'umb-editor--animating': model.animating,
|
||||
'umb-editor--notInFront': model.inFront !== true,
|
||||
'umb-editor--infiniteMode': model.infiniteMode,
|
||||
'umb-editor--moveRight': model.moveRight,
|
||||
'umb-editor--n0': model.styleIndex === 0,
|
||||
'umb-editor--n1': model.styleIndex === 1,
|
||||
'umb-editor--n2': model.styleIndex === 2,
|
||||
'umb-editor--n3': model.styleIndex === 3,
|
||||
'umb-editor--outOfRange': model.level === -1,
|
||||
'umb-editor--level0': model.level === 0,
|
||||
'umb-editor--level1': model.level === 1,
|
||||
'umb-editor--level2': model.level === 2,
|
||||
'umb-editor--level3': model.level === 3}">
|
||||
|
||||
<div ng-if="!model.view && !model.animating" ng-transclude></div>
|
||||
<div ng-if="model.view && !model.animating" ng-include="model.view"></div>
|
||||
|
||||
<div ng-if="model.view && !model.animating && !model.$parentScope" ng-include="model.view"></div>
|
||||
<div ng-show="model.$parentScope" class="scoped-view"></div>
|
||||
<div class="umb-editor__overlay"></div>
|
||||
</div>
|
||||
|
||||
</umb-editor-repeater>
|
||||
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -1,8 +1,8 @@
|
||||
<div class="form-horizontal">
|
||||
<ng-form name="elementTypeContentForm">
|
||||
<div class="umb-group-panel"
|
||||
data-element="group-{{group.alias}}"
|
||||
ng-repeat="group in vm.model.variants[0].tabs track by group.label">
|
||||
data-element="group-{{group.alias}}"
|
||||
ng-repeat="group in vm.model.variants[0].tabs track by group.label">
|
||||
|
||||
<div class="umb-group-panel__header">
|
||||
<div id="group-{{group.id}}">{{ group.label }}</div>
|
||||
@@ -13,6 +13,7 @@
|
||||
data-element="property-{{property.alias}}"
|
||||
ng-repeat="property in group.properties track by property.alias"
|
||||
property="property"
|
||||
element-key="{{vm.model.key}}"
|
||||
show-inherit="vm.model.variants.length > 1 && !property.culture && !activeVariant.language.isDefault"
|
||||
inherits-from="defaultVariant.language.name">
|
||||
|
||||
|
||||
+2
@@ -1,6 +1,8 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// TODO: Add docs - this component is used to render a content item based on an Element Type as a nested editor
|
||||
|
||||
angular
|
||||
.module('umbraco.directives')
|
||||
.component('umbElementEditorContent', {
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
<div class="umb-property">
|
||||
<ng-form name="propertyForm">
|
||||
<div class="control-group umb-control-group" ng-class="{hidelabel:property.hideLabel, 'umb-control-group__listview': property.alias === '_umb_containerView'}">
|
||||
<div class="control-group umb-control-group"
|
||||
ng-class="{hidelabel:vm.property.hideLabel, 'umb-control-group__listview': vm.property.alias === '_umb_containerView'}">
|
||||
|
||||
<val-property-msg></val-property-msg>
|
||||
|
||||
<div class="umb-el-wrap">
|
||||
|
||||
<div class="control-header" ng-hide="property.hideLabel === true">
|
||||
<small ng-if="showInherit" class="db" style="padding-top: 0; margin-bottom: 5px;">
|
||||
<localize key="contentTypeEditor_inheritedFrom"></localize> {{inheritsFrom}}
|
||||
<div class="control-header" ng-hide="vm.property.hideLabel === true">
|
||||
<small ng-if="vm.showInherit" class="db" style="padding-top: 0; margin-bottom: 5px;">
|
||||
<localize key="contentTypeEditor_inheritedFrom"></localize> {{vm.inheritsFrom}}
|
||||
</small>
|
||||
|
||||
<label class="control-label" for="{{property.alias}}" ng-attr-title="{{controlLabelTitle}}">
|
||||
<label class="control-label" for="{{vm.property.alias}}" ng-attr-title="{{vm.controlLabelTitle}}">
|
||||
|
||||
{{property.label}}
|
||||
{{vm.property.label}}
|
||||
|
||||
<span ng-if="property.validation.mandatory">
|
||||
<span ng-if="vm.property.validation.mandatory">
|
||||
<strong class="umb-control-required">*</strong>
|
||||
</span>
|
||||
|
||||
</label>
|
||||
|
||||
<umb-property-actions actions="propertyActions"></umb-property-actions>
|
||||
<umb-property-actions actions="vm.propertyActions"></umb-property-actions>
|
||||
|
||||
<small class="control-description" ng-if="property.description" ng-bind-html="property.description | preserveNewLineInHtml"></small>
|
||||
<small class="control-description" ng-if="vm.property.description" ng-bind-html="vm.property.description | preserveNewLineInHtml"></small>
|
||||
</div>
|
||||
|
||||
<div class="controls" ng-transclude>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<umb-block-list-property-editor property-form="propertyForm" model="model"/>
|
||||
<umb-block-list-property-editor model="model"/>
|
||||
|
||||
+6
-2
@@ -1,5 +1,9 @@
|
||||
<div class="blockelement-inlineblock-editor" ng-controller="Umbraco.PropertyEditors.BlockEditor.InlineBlockEditor as vm">
|
||||
<button type="button" class="btn-reset umb-outline blockelement__draggable-element" ng-click="vm.openBlock(block)" ng-focus="block.focus">
|
||||
<div class="blockelement-inlineblock-editor"
|
||||
ng-controller="Umbraco.PropertyEditors.BlockEditor.InlineBlockEditor as vm"
|
||||
ng-class="{ '--error': parentForm.$invalid && valFormManager.isShowingValidation() }">
|
||||
<button type="button" class="btn-reset umb-outline blockelement__draggable-element"
|
||||
ng-click="vm.openBlock(block)"
|
||||
ng-focus="block.focus">
|
||||
<span class="caret"></span>
|
||||
<i class="icon {{block.content.icon}}"></i>
|
||||
<span>{{block.label}}</span>
|
||||
|
||||
+9
-6
@@ -1,5 +1,4 @@
|
||||
.blockelement-inlineblock-editor {
|
||||
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
margin-top: 4px;
|
||||
@@ -14,25 +13,24 @@
|
||||
> button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
|
||||
cursor: pointer;
|
||||
color: @ui-action-discreet-type;
|
||||
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
user-select: none;
|
||||
|
||||
|
||||
.caret {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 80ms ease-out;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 22px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@@ -47,12 +45,17 @@
|
||||
.umb-block-list__block.--active & {
|
||||
border-color: @gray-8;
|
||||
box-shadow: 0 0 2px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
> button {
|
||||
> .caret {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.--error {
|
||||
border-color: @formErrorBorder !important;
|
||||
}
|
||||
}
|
||||
.blockelement-inlineblock-editor__inner {
|
||||
border-top: 1px solid @gray-8;
|
||||
@@ -63,7 +66,7 @@
|
||||
box-shadow: none;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 0;
|
||||
> .umb-group-panel__content > .umb-property {
|
||||
> .umb-group-panel__content .umb-property {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
<button type="button" class="btn-reset umb-outline blockelement-labelblock-editor blockelement__draggable-element" ng-click="api.editBlock(block, block.hideContentInOverlay)" ng-focus="block.focus" ng-class="{'--active':block.active}">
|
||||
<button type="button" class="btn-reset umb-outline blockelement-labelblock-editor blockelement__draggable-element"
|
||||
ng-click="api.editBlock(block, block.hideContentInOverlay, index, parentForm)"
|
||||
ng-focus="block.focus"
|
||||
ng-class="{ '--active': block.active, '--error': parentForm.$invalid && valFormManager.isShowingValidation() }"
|
||||
val-server-property-class="">
|
||||
<i class="icon {{block.content.icon}}"></i>
|
||||
<span>{{block.label}}</span>
|
||||
</button>
|
||||
|
||||
+6
-6
@@ -1,5 +1,4 @@
|
||||
.blockelement-labelblock-editor {
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
@@ -8,24 +7,21 @@
|
||||
min-height: 48px;
|
||||
border: 1px solid @gray-9;
|
||||
border-radius: @baseBorderRadius;
|
||||
|
||||
cursor: pointer;
|
||||
color: @ui-action-discreet-type;
|
||||
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
user-select: none;
|
||||
|
||||
transition: border-color 120ms;
|
||||
|
||||
|
||||
i {
|
||||
font-size: 22px;
|
||||
margin-right: 5px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@@ -41,4 +37,8 @@
|
||||
border-color: @ui-active;
|
||||
background-color: @ui-active;
|
||||
}
|
||||
|
||||
&.--error {
|
||||
border-color: @formErrorBorder !important;
|
||||
}
|
||||
}
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @ngdoc directive
|
||||
* @name umbraco.directives.directive:umbBlockListBlock
|
||||
* @description
|
||||
* The component for a style-inheriting block of the block list property editor.
|
||||
*/
|
||||
|
||||
angular
|
||||
.module("umbraco")
|
||||
.component("umbBlockListBlock", {
|
||||
template: '<div ng-include="model.view"></div>',
|
||||
controller: BlockListBlockController,
|
||||
controllerAs: "model",
|
||||
bindings: {
|
||||
view: "@",
|
||||
block: "=",
|
||||
api: "<",
|
||||
index: "<"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function BlockListBlockController($scope) {
|
||||
var model = this;
|
||||
model.$onInit = function() {
|
||||
$scope.block = model.block;
|
||||
$scope.api = model.api;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
})();
|
||||
+4
-41
@@ -18,47 +18,10 @@
|
||||
<div class="__plus" ng-style="{'left':inlineCreateButtonCtrl.plusPosX}">+</div>
|
||||
</button>
|
||||
|
||||
<div class="umb-block-list__block" ng-class="{'--active':layout.$block.active}">
|
||||
|
||||
<umb-block-list-scoped-block
|
||||
ng-if="layout.$block.config.stylesheet"
|
||||
class="umb-block-list__block--content blockelement__draggable-element"
|
||||
view="{{layout.$block.view}}"
|
||||
stylesheet="/{{::layout.$block.config.stylesheet}}"
|
||||
api="vm.blockEditorApi"
|
||||
block="layout.$block"
|
||||
index="$index">
|
||||
</umb-block-list-scoped-block>
|
||||
<umb-block-list-block
|
||||
ng-if="!layout.$block.config.stylesheet"
|
||||
class="umb-block-list__block--content"
|
||||
view="{{layout.$block.view}}"
|
||||
api="vm.blockEditorApi"
|
||||
block="layout.$block"
|
||||
index="$index">
|
||||
</umb-block-list-block>
|
||||
|
||||
<div class="umb-block-list__block--actions">
|
||||
<button type="button" class="btn-reset umb-outline action --settings" localize="title" title="actions_editSettings" ng-click="vm.blockEditorApi.openSettingsForBlock(layout.$block);" ng-if="layout.$block.showSettings === true">
|
||||
<i class="icon icon-settings" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_settings">Settings</localize>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="btn-reset umb-outline action --copy" localize="title" title="actions_copy" ng-click="vm.blockEditorApi.copyBlock(layout.$block);" ng-if="layout.$block.showCopy === true">
|
||||
<i class="icon icon-documents" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_copy">Copy</localize>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="btn-reset umb-outline action --delete" localize="title" title="actions_delete" ng-click="vm.blockEditorApi.requestDeleteBlock(layout.$block);">
|
||||
<i class="icon icon-trash" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_delete">Delete</localize>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<umb-block-list-row block-editor-api="vm.blockEditorApi"
|
||||
layout="layout"
|
||||
index="$index">
|
||||
</umb-block-list-row>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<ng-form name="vm.blockRowForm" val-server-match="{ 'contains' : vm.layout.$block.content.key }">
|
||||
<div class="umb-block-list__block" ng-class="{'--active':vm.layout.$block.active}">
|
||||
|
||||
<umb-block-list-block stylesheet="{{::vm.layout.$block.config.stylesheet}}"
|
||||
class="umb-block-list__block--content"
|
||||
view="{{vm.layout.$block.view}}"
|
||||
api="vm.blockEditorApi"
|
||||
block="vm.layout.$block"
|
||||
index="vm.index"
|
||||
parent-form="vm.blockRowForm">
|
||||
</umb-block-list-block>
|
||||
|
||||
<div class="umb-block-list__block--actions">
|
||||
<button type="button" class="btn-reset umb-outline action --settings" localize="title" title="actions_editSettings"
|
||||
ng-click="vm.blockEditorApi.openSettingsForBlock(vm.layout.$block, vm.index);"
|
||||
ng-if="vm.layout.$block.showSettings === true">
|
||||
<i class="icon icon-settings" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_settings">Settings</localize>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="btn-reset umb-outline action --copy" localize="title" title="actions_copy" ng-click="vm.blockEditorApi.copyBlock(vm.layout.$block);" ng-if="vm.layout.$block.showCopy === true">
|
||||
<i class="icon icon-documents" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_copy">Copy</localize>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="btn-reset umb-outline action --delete" localize="title" title="actions_delete" ng-click="vm.blockEditorApi.requestDeleteBlock(vm.layout.$block);">
|
||||
<i class="icon icon-trash" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_delete">Delete</localize>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-form>
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @ngdoc directive
|
||||
* @name umbraco.directives.directive:umbBlockListScopedBlock
|
||||
* @description
|
||||
* The component for a style-scoped block of the block list property editor.
|
||||
* Uses a ShadowDom to make a scoped element.
|
||||
* This way the backoffice styling does not collide with the block style.
|
||||
*/
|
||||
|
||||
angular
|
||||
.module("umbraco")
|
||||
.component("umbBlockListScopedBlock", {
|
||||
controller: BlockListScopedBlockController,
|
||||
controllerAs: "model",
|
||||
bindings: {
|
||||
stylesheet: "@",
|
||||
view: "@",
|
||||
block: "=",
|
||||
api: "<",
|
||||
index: "<"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function BlockListScopedBlockController($compile, $element, $scope) {
|
||||
var model = this;
|
||||
model.$onInit = function() {
|
||||
$scope.block = model.block;
|
||||
$scope.api = model.api;
|
||||
var shadowRoot = $element[0].attachShadow({mode:'open'});
|
||||
shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import "${model.stylesheet}"
|
||||
</style>
|
||||
<div ng-include="'${model.view}'"></div>
|
||||
`;
|
||||
$compile(shadowRoot)($scope);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
})();
|
||||
+69
-26
@@ -17,10 +17,10 @@
|
||||
controller: BlockListController,
|
||||
controllerAs: "vm",
|
||||
bindings: {
|
||||
model: "=",
|
||||
propertyForm: "="
|
||||
model: "="
|
||||
},
|
||||
require: {
|
||||
propertyForm: "^form",
|
||||
umbProperty: "?^umbProperty",
|
||||
umbVariantContent: '?^^umbVariantContent',
|
||||
umbVariantContentEditors: '?^^umbVariantContentEditors',
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
function BlockListController($scope, editorService, clipboardService, localizationService, overlayService, blockEditorService) {
|
||||
function BlockListController($scope, editorService, clipboardService, localizationService, overlayService, blockEditorService, udiService, serverValidationManager, angularHelper) {
|
||||
|
||||
var unsubscribe = [];
|
||||
var modelObject;
|
||||
@@ -53,8 +53,8 @@
|
||||
}
|
||||
vm.supportCopy = clipboardService.isSupported();
|
||||
|
||||
vm.layout = [];// The layout object specific to this Block Editor, will be a direct reference from Property Model.
|
||||
vm.availableBlockTypes = [];// Available block entries of this property editor.
|
||||
vm.layout = []; // The layout object specific to this Block Editor, will be a direct reference from Property Model.
|
||||
vm.availableBlockTypes = []; // Available block entries of this property editor.
|
||||
|
||||
var labels = {};
|
||||
vm.labels = labels;
|
||||
@@ -63,11 +63,20 @@
|
||||
labels.content_createEmpty = data[1];
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
vm.$onInit = function() {
|
||||
if (!vm.umbVariantContent) {
|
||||
// not found, then fallback to searching the scope chain, this may be needed when DOM inheritance isn't maintained but scope
|
||||
// inheritance is (i.e.infinite editing)
|
||||
var found = angularHelper.traverseScopeChain($scope, s => s && s.vm && s.vm.constructor.name === "umbVariantContentController");
|
||||
vm.umbVariantContent = found ? found.vm : null;
|
||||
if (!vm.umbVariantContent) {
|
||||
throw "Could not find umbVariantContent in the $scope chain";
|
||||
}
|
||||
}
|
||||
|
||||
// set the onValueChanged callback, this will tell us if the block list model changed on the server
|
||||
// once the data is submitted. If so we need to re-initialize
|
||||
vm.model.onValueChanged = onServerValueChanged;
|
||||
|
||||
inlineEditing = vm.model.config.useInlineEditingAsDefault;
|
||||
liveEditing = vm.model.config.useLiveEditing;
|
||||
@@ -121,8 +130,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Called when we save the value, the server may return an updated data and our value is re-synced
|
||||
// we need to deal with that here so that our model values are all in sync so we basically re-initialize.
|
||||
function onServerValueChanged(newVal, oldVal) {
|
||||
modelObject.update(newVal, $scope);
|
||||
onLoaded();
|
||||
}
|
||||
|
||||
function setDirty() {
|
||||
if (vm.propertyForm) {
|
||||
@@ -137,7 +150,7 @@
|
||||
|
||||
// Append the blockObjects to our layout.
|
||||
vm.layout.forEach(entry => {
|
||||
// $block must have the data property to be a valid BlockObject, if not its concidered as a destroyed blockObject.
|
||||
// $block must have the data property to be a valid BlockObject, if not its considered as a destroyed blockObject.
|
||||
if (entry.$block === undefined || entry.$block === null || entry.$block.data === undefined) {
|
||||
var block = getBlockObject(entry);
|
||||
|
||||
@@ -172,11 +185,25 @@
|
||||
|
||||
if (block === null) return null;
|
||||
|
||||
// ensure that the containing content variant language/culture is transfered along
|
||||
// to the scaffolded content object representing this block. This is required for validation
|
||||
// along with ensuring that the umb-property inheritance is constently maintained.
|
||||
if (vm.umbVariantContent.editor.content.language) {
|
||||
block.content.language = vm.umbVariantContent.editor.content.language;
|
||||
// currently we only ever deal with invariant content for blocks so there's only one
|
||||
block.content.variants[0].tabs.forEach(tab => {
|
||||
tab.properties.forEach(prop => {
|
||||
prop.culture = vm.umbVariantContent.editor.content.language.culture;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Why is there a '/' prefixed? that means this will never work with virtual directories
|
||||
block.view = (block.config.view ? "/" + block.config.view : getDefaultViewForBlock(block));
|
||||
|
||||
block.hideContentInOverlay = block.config.forceHideContentEditorInOverlay === true || inlineEditing === true;
|
||||
block.showSettings = block.config.settingsElementTypeKey != null;
|
||||
block.showCopy = vm.supportCopy && block.config.contentTypeKey != null;// if we have content, otherwise it dosnt make sense to copy.
|
||||
block.showCopy = vm.supportCopy && block.config.contentTypeKey != null;// if we have content, otherwise it doesn't make sense to copy.
|
||||
|
||||
return block;
|
||||
}
|
||||
@@ -211,15 +238,22 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function deleteBlock(block) {
|
||||
|
||||
var layoutIndex = vm.layout.findIndex(entry => entry.udi === block.content.udi);
|
||||
if(layoutIndex === -1) {
|
||||
if (layoutIndex === -1) {
|
||||
throw new Error("Could not find layout entry of block with udi: "+block.content.udi)
|
||||
}
|
||||
vm.layout.splice(layoutIndex, 1);
|
||||
|
||||
setDirty();
|
||||
|
||||
var removed = vm.layout.splice(layoutIndex, 1);
|
||||
removed.forEach(x => {
|
||||
// remove any server validation errors associated
|
||||
var guid = udiService.getKey(x.udi);
|
||||
serverValidationManager.removePropertyError(guid, vm.umbProperty.property.culture, vm.umbProperty.property.segment, "", { matchType: "contains" });
|
||||
});
|
||||
|
||||
modelObject.removeDataAndDestroyModel(block);
|
||||
|
||||
}
|
||||
@@ -234,7 +268,12 @@
|
||||
blockObject.active = true;
|
||||
}
|
||||
|
||||
function editBlock(blockObject, openSettings) {
|
||||
function editBlock(blockObject, openSettings, blockIndex, parentForm) {
|
||||
|
||||
// this must be set
|
||||
if (blockIndex === undefined) {
|
||||
throw "blockIndex was not specified on call to editBlock";
|
||||
}
|
||||
|
||||
var wasNotActiveBefore = blockObject.active !== true;
|
||||
|
||||
@@ -259,17 +298,20 @@
|
||||
}
|
||||
|
||||
var blockEditorModel = {
|
||||
$parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing
|
||||
$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,
|
||||
liveEditing: liveEditing,
|
||||
title: blockObject.label,
|
||||
index: blockIndex,
|
||||
view: "views/common/infiniteeditors/blockeditor/blockeditor.html",
|
||||
size: blockObject.config.editorSize || "medium",
|
||||
submit: function(blockEditorModel) {
|
||||
|
||||
if (liveEditing === false) {
|
||||
// transfer values when submitting in none-liveediting mode.
|
||||
blockObject.retriveValuesFrom(blockEditorModel.content, blockEditorModel.settings);
|
||||
blockObject.retrieveValuesFrom(blockEditorModel.content, blockEditorModel.settings);
|
||||
}
|
||||
|
||||
blockObject.active = false;
|
||||
@@ -279,7 +321,7 @@
|
||||
|
||||
if (liveEditing === true) {
|
||||
// revert values when closing in liveediting mode.
|
||||
blockObject.retriveValuesFrom(blockContentClone, blockSettingsClone);
|
||||
blockObject.retrieveValuesFrom(blockContentClone, blockSettingsClone);
|
||||
}
|
||||
|
||||
if (wasNotActiveBefore === true) {
|
||||
@@ -314,6 +356,8 @@
|
||||
|
||||
var amountOfAvailableTypes = vm.availableBlockTypes.length;
|
||||
var blockPickerModel = {
|
||||
$parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing
|
||||
$parentForm: vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form)
|
||||
availableItems: vm.availableBlockTypes,
|
||||
title: vm.labels.grid_addElement,
|
||||
orderBy: "$index",
|
||||
@@ -347,7 +391,7 @@
|
||||
if (inlineEditing === true) {
|
||||
activateBlock(vm.layout[createIndex].$block);
|
||||
} else if (inlineEditing === false && vm.layout[createIndex].$block.hideContentInOverlay !== true) {
|
||||
editBlock(vm.layout[createIndex].$block);
|
||||
editBlock(vm.layout[createIndex].$block, false, createIndex, blockPickerModel.$parentForm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,19 +534,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function openSettingsForBlock(block) {
|
||||
editBlock(block, true);
|
||||
// TODO: We'll need to pass in a parentForm here too
|
||||
function openSettingsForBlock(block, blockIndex) {
|
||||
editBlock(block, true, blockIndex);
|
||||
}
|
||||
|
||||
|
||||
|
||||
vm.blockEditorApi = {
|
||||
activateBlock: activateBlock,
|
||||
editBlock: editBlock,
|
||||
copyBlock: copyBlock,
|
||||
requestDeleteBlock: requestDeleteBlock,
|
||||
deleteBlock: deleteBlock,
|
||||
openSettingsForBlock: openSettingsForBlock
|
||||
openSettingsForBlock: openSettingsForBlock
|
||||
}
|
||||
|
||||
vm.sortableOptions = {
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @ngdoc directive
|
||||
* @name umbraco.directives.directive:umbBlockListBlock
|
||||
* @description
|
||||
* The component to render the view for a block.
|
||||
* If a stylesheet is used then this uses a ShadowDom to make a scoped element.
|
||||
* This way the backoffice styling does not collide with the block style.
|
||||
*/
|
||||
|
||||
angular
|
||||
.module("umbraco")
|
||||
.component("umbBlockListBlock", {
|
||||
controller: BlockListBlockController,
|
||||
controllerAs: "model",
|
||||
bindings: {
|
||||
stylesheet: "@",
|
||||
view: "@",
|
||||
block: "=",
|
||||
api: "<",
|
||||
index: "<",
|
||||
parentForm: "<"
|
||||
},
|
||||
require: {
|
||||
valFormManager: "^^valFormManager"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function BlockListBlockController($scope, $compile, $element) {
|
||||
var model = this;
|
||||
|
||||
model.$onInit = function () {
|
||||
// This is ugly and is only necessary because we are not using components and instead
|
||||
// relying on ng-include. It is definitely possible to compile the contents
|
||||
// of the view into the DOM using $templateCache and $http instead of using
|
||||
// ng - include which means that the controllerAs flows directly to the view.
|
||||
// This would mean that any custom components would need to be updated instead of relying on $scope.
|
||||
// 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.
|
||||
|
||||
$scope.block = model.block;
|
||||
$scope.api = model.api;
|
||||
$scope.index = model.index;
|
||||
$scope.parentForm = model.parentForm;
|
||||
$scope.valFormManager = model.valFormManager;
|
||||
|
||||
if (model.stylesheet) {
|
||||
// TODO: Not sure why this needs a prefixed /? this means it will never work in a virtual directory
|
||||
model.stylesheet = "/" + model.stylesheet;
|
||||
var shadowRoot = $element[0].attachShadow({ mode: 'open' });
|
||||
shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import "${model.stylesheet}"
|
||||
</style>
|
||||
<div ng-include="'${model.view}'"></div>
|
||||
`;
|
||||
$compile(shadowRoot)($scope);
|
||||
}
|
||||
else {
|
||||
$element.append($compile('<div ng-include="model.view"></div>')($scope));
|
||||
}
|
||||
};
|
||||
|
||||
// We need to watch for changes on primitive types and upate the $scope values.
|
||||
model.$onChanges = function (changes) {
|
||||
if (changes.index) {
|
||||
$scope.index = changes.index.currentValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
})();
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @ngdoc directive
|
||||
* @name umbraco.directives.directive:umbBlockListRow
|
||||
* @description
|
||||
* renders each row for the block list editor
|
||||
*/
|
||||
|
||||
angular
|
||||
.module("umbraco")
|
||||
.component("umbBlockListRow", {
|
||||
templateUrl: 'views/propertyeditors/blocklist/umb-block-list-row.html',
|
||||
controller: BlockListRowController,
|
||||
controllerAs: "vm",
|
||||
bindings: {
|
||||
blockEditorApi: "<",
|
||||
layout: "<",
|
||||
index: "<"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function BlockListRowController($scope) {
|
||||
|
||||
var vm = this;
|
||||
|
||||
vm.$onInit = function () {
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
})();
|
||||
+17
-4
@@ -65,7 +65,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
function NestedContentController($scope, $interpolate, $filter, $timeout, contentResource, localizationService, iconHelper, clipboardService, eventsService, overlayService) {
|
||||
function NestedContentController($scope, $interpolate, $filter, serverValidationManager, contentResource, localizationService, iconHelper, clipboardService, eventsService, overlayService) {
|
||||
|
||||
var vm = this;
|
||||
var model = $scope.$parent.$parent.model;
|
||||
@@ -298,8 +298,15 @@
|
||||
}
|
||||
|
||||
function deleteNode(idx) {
|
||||
vm.nodes.splice(idx, 1);
|
||||
var removed = vm.nodes.splice(idx, 1);
|
||||
|
||||
setDirty();
|
||||
|
||||
removed.forEach(x => {
|
||||
// remove any server validation errors associated
|
||||
serverValidationManager.removePropertyError(x.key, vm.umbProperty.property.culture, vm.umbProperty.property.segment, "", { matchType: "contains" });
|
||||
});
|
||||
|
||||
updateModel();
|
||||
validate();
|
||||
};
|
||||
@@ -588,8 +595,14 @@
|
||||
for (var p = 0; p < tab.properties.length; p++) {
|
||||
var prop = tab.properties[p];
|
||||
|
||||
// store the original alias before we change below, see notes
|
||||
prop.propertyAlias = prop.alias;
|
||||
|
||||
// NOTE: This is super ugly, the reason it is like this is because it controls the label/html id in the umb-property component at a higher level.
|
||||
// not pretty :/ but we can't change this now since it would require a bunch of plumbing to be able to change the id's higher up.
|
||||
prop.alias = model.alias + "___" + prop.alias;
|
||||
|
||||
// TODO: Do we need to deal with this separately?
|
||||
// Force validation to occur server side as this is the
|
||||
// only way we can have consistency between mandatory and
|
||||
// regex validation messages. Not ideal, but it works.
|
||||
@@ -664,8 +677,8 @@
|
||||
];
|
||||
|
||||
this.$onInit = function () {
|
||||
if (this.umbProperty) {
|
||||
this.umbProperty.setPropertyActions(propertyActions);
|
||||
if (vm.umbProperty) {
|
||||
vm.umbProperty.setPropertyActions(propertyActions);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+13
-7
@@ -1,13 +1,19 @@
|
||||
<div class="umb-pane">
|
||||
<div ng-repeat="property in tab.properties" class="umb-nested-content-property-container">
|
||||
<div ng-repeat="property in tab.properties" class="umb-nested-content-property-container">
|
||||
|
||||
<umb-property property="property" ng-class="{'umb-nested-content--not-supported': property.notSupported, 'umb-nested-content--mandatory': property.ncMandatory}" data-element="property-{{property.alias}}">
|
||||
<umb-property-editor model="property"></umb-property-editor>
|
||||
</umb-property>
|
||||
<umb-property property="property"
|
||||
property-alias="{{property.propertyAlias}}"
|
||||
element-key="{{model.key}}"
|
||||
ng-class="{'umb-nested-content--not-supported': property.notSupported, 'umb-nested-content--mandatory': property.ncMandatory}"
|
||||
data-element="property-{{property.alias}}">
|
||||
|
||||
<div ng-if="property.notSupported" class="umb-nested-content-overlay"></div>
|
||||
<umb-property-editor model="property"></umb-property-editor>
|
||||
|
||||
<p ng-if="property.notSupported">{{property.notSupportedMessage}}</p>
|
||||
</umb-property>
|
||||
|
||||
</div>
|
||||
<div ng-if="property.notSupported" class="umb-nested-content-overlay"></div>
|
||||
|
||||
<p ng-if="property.notSupported">{{property.notSupportedMessage}}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-19
@@ -6,30 +6,41 @@
|
||||
|
||||
<div class="umb-nested-content__items" ng-hide="vm.nodes.length === 0" ui-sortable="vm.sortableOptions" ng-model="vm.nodes">
|
||||
|
||||
<div class="umb-nested-content__item" ng-repeat="node in vm.nodes" ng-class="{ 'umb-nested-content__item--active' : vm.currentNode.key === node.key, 'umb-nested-content__item--single' : vm.singleMode }">
|
||||
<div ng-repeat="node in vm.nodes">
|
||||
|
||||
<div class="umb-nested-content__header-bar" ng-click="vm.editNode($index)" ng-hide="vm.singleMode" umb-auto-focus="{{vm.currentNode.key === node.key ? 'true' : 'false'}}">
|
||||
<ng-form name="ncRowForm" val-server-match="{ 'contains' : node.key }">
|
||||
|
||||
<div class="umb-nested-content__heading"><i ng-if="vm.showIcons" class="icon" ng-class="vm.getIcon($index)"></i><span class="umb-nested-content__item-name" ng-class="{'--has-icon': vm.showIcons}" ng-bind="vm.getName($index)"></span></div>
|
||||
<div class="umb-nested-content__item"
|
||||
ng-class="{ 'umb-nested-content__item--active' : vm.currentNode.key === node.key, 'umb-nested-content__item--single' : vm.singleMode, '--error': ncRowForm.$invalid }">
|
||||
|
||||
<div class="umb-nested-content__icons">
|
||||
<button type="button" class="umb-nested-content__icon umb-nested-content__icon--copy" title="{{vm.labels.copy_icon_title}}" ng-click="vm.clickCopy($event, node);" ng-if="vm.showCopy">
|
||||
<i class="icon icon-documents" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{vm.labels.copy_icon_title}}</span>
|
||||
</button>
|
||||
<button type="button" class="umb-nested-content__icon umb-nested-content__icon--delete" localize="title" title="general_delete" ng-class="{ 'umb-nested-content__icon--disabled': !vm.canDeleteNode($index) }" ng-click="vm.requestDeleteNode($index); $event.stopPropagation();">
|
||||
<i class="icon icon-trash" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_delete">Delete</localize>
|
||||
</span>
|
||||
</button>
|
||||
<div class="umb-nested-content__header-bar"
|
||||
ng-click="vm.editNode($index)"
|
||||
ng-hide="vm.singleMode"
|
||||
umb-auto-focus="{{vm.currentNode.key === node.key ? 'true' : 'false'}}">
|
||||
|
||||
<div class="umb-nested-content__heading"><i ng-if="vm.showIcons" class="icon" ng-class="vm.getIcon($index)"></i><span class="umb-nested-content__item-name" ng-class="{'--has-icon': vm.showIcons}" ng-bind="vm.getName($index)"></span></div>
|
||||
|
||||
<div class="umb-nested-content__icons">
|
||||
<button type="button" class="umb-nested-content__icon umb-nested-content__icon--copy" title="{{vm.labels.copy_icon_title}}" ng-click="vm.clickCopy($event, node);" ng-if="vm.showCopy">
|
||||
<i class="icon icon-documents" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{vm.labels.copy_icon_title}}</span>
|
||||
</button>
|
||||
<button type="button" class="umb-nested-content__icon umb-nested-content__icon--delete" localize="title" title="general_delete" ng-class="{ 'umb-nested-content__icon--disabled': !vm.canDeleteNode($index) }" ng-click="vm.requestDeleteNode($index); $event.stopPropagation();">
|
||||
<i class="icon icon-trash" aria-hidden="true"></i>
|
||||
<span class="sr-only">
|
||||
<localize key="general_delete">Delete</localize>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="umb-nested-content__content" ng-if="vm.currentNode.key === node.key && !vm.sorting">
|
||||
<umb-nested-content-editor ng-model="node" tab-alias="ncTabAlias" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="umb-nested-content__content" ng-if="vm.currentNode.key === node.key && !vm.sorting">
|
||||
<umb-nested-content-editor ng-model="node" tab-alias="ncTabAlias" />
|
||||
</div>
|
||||
</ng-form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
});
|
||||
|
||||
|
||||
it('getBlockObject syncs primative values', function (done) {
|
||||
it('getBlockObject syncs primitive values', function (done) {
|
||||
|
||||
var propertyModel = angular.copy(propertyModelMock);
|
||||
|
||||
|
||||
+329
-15
@@ -1,10 +1,13 @@
|
||||
describe('serverValidationManager tests', function () {
|
||||
var serverValidationManager;
|
||||
var $rootScope, serverValidationManager, $timeout;
|
||||
|
||||
beforeEach(module('umbraco.services'));
|
||||
|
||||
beforeEach(inject(function ($injector) {
|
||||
serverValidationManager = $injector.get('serverValidationManager');
|
||||
$rootScope = $injector.get('$rootScope');
|
||||
$timeout = $injector.get('$timeout');
|
||||
serverValidationManager = $injector.get('serverValidationManager');
|
||||
serverValidationManager.clear();
|
||||
}));
|
||||
|
||||
describe('managing field validation errors', function () {
|
||||
@@ -314,6 +317,158 @@
|
||||
|
||||
});
|
||||
|
||||
describe('managing complex editor validation errors', function () {
|
||||
|
||||
// this root element doesn't have it's own attached errors, instead it has model state just
|
||||
// showing that it has errors within it's nested properties. that ModelState is automatically
|
||||
// added on the server side.
|
||||
var nonRootLevelComplexValidationMsg = `[
|
||||
{
|
||||
"$elementTypeAlias": "addressBook",
|
||||
"$id": "34E3A26C-103D-4A05-AB9D-7E14032309C3",
|
||||
"addresses":
|
||||
[
|
||||
{
|
||||
"$elementTypeAlias": "addressInfo",
|
||||
"$id": "FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1",
|
||||
"ModelState":
|
||||
{
|
||||
"_Properties.city.invariant.null.country": [
|
||||
"City is not in Australia"
|
||||
],
|
||||
"_Properties.city.invariant.null.capital": [
|
||||
"Not a capital city"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$elementTypeAlias": "addressInfo",
|
||||
"$id": "7170A4DD-2441-4B1B-A8D3-437D75C4CBC9",
|
||||
"ModelState":
|
||||
{
|
||||
"_Properties.city.invariant.null.country": [
|
||||
"City is not in Australia"
|
||||
],
|
||||
"_Properties.city.invariant.null.capital": [
|
||||
"Not a capital city"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ModelState":
|
||||
{
|
||||
"_Properties.addresses.invariant.null": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
]`;
|
||||
|
||||
it('create dictionary of id to ModelState', function () {
|
||||
|
||||
//arrange
|
||||
var complexValidationMsg = `[
|
||||
{
|
||||
"$elementTypeAlias": "addressBook",
|
||||
"$id": "34E3A26C-103D-4A05-AB9D-7E14032309C3",
|
||||
"addresses":
|
||||
[
|
||||
{
|
||||
"$elementTypeAlias": "addressInfo",
|
||||
"$id": "FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1",
|
||||
"ModelState":
|
||||
{
|
||||
"_Properties.city.invariant.null.country": [
|
||||
"City is not in Australia"
|
||||
],
|
||||
"_Properties.city.invariant.null.capital": [
|
||||
"Not a capital city"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$elementTypeAlias": "addressInfo",
|
||||
"$id": "7170A4DD-2441-4B1B-A8D3-437D75C4CBC9",
|
||||
"ModelState":
|
||||
{
|
||||
"_Properties.city.invariant.null.country": [
|
||||
"City is not in Australia"
|
||||
],
|
||||
"_Properties.city.invariant.null.capital": [
|
||||
"Not a capital city"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ModelState":
|
||||
{
|
||||
"_Properties.addresses.invariant.null.counter": [
|
||||
"Must have at least 3 addresses"
|
||||
],
|
||||
"_Properties.bookName.invariant.null.book": [
|
||||
"Invalid address book name"
|
||||
]
|
||||
}
|
||||
}
|
||||
]`;
|
||||
|
||||
//act
|
||||
var ms = serverValidationManager.parseComplexEditorError(complexValidationMsg, "myBlockEditor");
|
||||
|
||||
//assert
|
||||
expect(ms.length).toEqual(3);
|
||||
expect(ms[0].validationPath).toEqual("myBlockEditor/34E3A26C-103D-4A05-AB9D-7E14032309C3");
|
||||
expect(ms[1].validationPath).toEqual("myBlockEditor/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1");
|
||||
expect(ms[2].validationPath).toEqual("myBlockEditor/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/7170A4DD-2441-4B1B-A8D3-437D75C4CBC9");
|
||||
|
||||
});
|
||||
|
||||
it('create dictionary of id to ModelState with inherited errors', function () {
|
||||
|
||||
//act
|
||||
var ms = serverValidationManager.parseComplexEditorError(nonRootLevelComplexValidationMsg, "myBlockEditor");
|
||||
|
||||
//assert
|
||||
expect(ms.length).toEqual(3);
|
||||
expect(ms[0].validationPath).toEqual("myBlockEditor/34E3A26C-103D-4A05-AB9D-7E14032309C3");
|
||||
var item0ModelState = ms[0].modelState;
|
||||
expect(Object.keys(item0ModelState).length).toEqual(1);
|
||||
expect(item0ModelState["_Properties.addresses.invariant.null"].length).toEqual(1);
|
||||
expect(item0ModelState["_Properties.addresses.invariant.null"][0]).toEqual("");
|
||||
expect(ms[1].validationPath).toEqual("myBlockEditor/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1");
|
||||
expect(ms[2].validationPath).toEqual("myBlockEditor/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/7170A4DD-2441-4B1B-A8D3-437D75C4CBC9");
|
||||
|
||||
});
|
||||
|
||||
it('add errors for ModelState with inherited errors', function () {
|
||||
|
||||
//act
|
||||
let modelState = {
|
||||
"_Properties.blockFeatures.invariant.null": [
|
||||
nonRootLevelComplexValidationMsg
|
||||
]
|
||||
};
|
||||
serverValidationManager.addErrorsForModelState(modelState);
|
||||
|
||||
//assert
|
||||
var propertyErrors = [
|
||||
"blockFeatures",
|
||||
"blockFeatures/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses",
|
||||
"blockFeatures/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city",
|
||||
"blockFeatures/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/7170A4DD-2441-4B1B-A8D3-437D75C4CBC9/city"
|
||||
]
|
||||
// These will all exist
|
||||
propertyErrors.forEach(x => expect(serverValidationManager.getPropertyError(x)).toBeDefined());
|
||||
|
||||
// These field errors also exist
|
||||
expect(serverValidationManager.getPropertyError(propertyErrors[2], null, "country")).toBeDefined();
|
||||
expect(serverValidationManager.getPropertyError(propertyErrors[2], null, "capital")).toBeDefined();
|
||||
expect(serverValidationManager.getPropertyError(propertyErrors[3], null, "country")).toBeDefined();
|
||||
expect(serverValidationManager.getPropertyError(propertyErrors[3], null, "capital")).toBeDefined();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('validation error subscriptions', function() {
|
||||
|
||||
it('can subscribe to a field error', function() {
|
||||
@@ -327,7 +482,9 @@
|
||||
allErrors: allErrors
|
||||
};
|
||||
}, null);
|
||||
|
||||
|
||||
|
||||
//act
|
||||
serverValidationManager.addFieldError("Name", "Required");
|
||||
serverValidationManager.addPropertyError("myProperty", null, "value1", "Some value 1", null);
|
||||
@@ -348,7 +505,9 @@
|
||||
var cb2 = function () {
|
||||
};
|
||||
serverValidationManager.subscribe(null, null, "Name", cb1, null);
|
||||
|
||||
serverValidationManager.subscribe(null, null, "Title", cb2, null);
|
||||
|
||||
|
||||
//act
|
||||
serverValidationManager.addFieldError("Name", "Required");
|
||||
@@ -374,6 +533,7 @@
|
||||
var args1;
|
||||
var args2;
|
||||
var numCalled = 0;
|
||||
var numCalledWithErrors = 0;
|
||||
|
||||
//arrange
|
||||
serverValidationManager.subscribe("myProperty", null, "value1", function (isValid, propertyErrors, allErrors) {
|
||||
@@ -384,15 +544,19 @@
|
||||
};
|
||||
}, null);
|
||||
|
||||
|
||||
serverValidationManager.subscribe("myProperty", null, "", function (isValid, propertyErrors, allErrors) {
|
||||
numCalled++;
|
||||
if (propertyErrors.length > 0) {
|
||||
numCalledWithErrors++;
|
||||
}
|
||||
args2 = {
|
||||
isValid: isValid,
|
||||
propertyErrors: propertyErrors,
|
||||
allErrors: allErrors
|
||||
};
|
||||
}, null);
|
||||
|
||||
|
||||
//act
|
||||
serverValidationManager.addPropertyError("myProperty", null, "value1", "Some value 1", null);
|
||||
serverValidationManager.addPropertyError("myProperty", null, "value2", "Some value 2", null);
|
||||
@@ -415,28 +579,36 @@
|
||||
expect(args2.propertyErrors[0].errorMsg).toEqual("Some value 1");
|
||||
expect(args2.propertyErrors[1].errorMsg).toEqual("Some value 2");
|
||||
expect(args2.allErrors.length).toEqual(2);
|
||||
//Even though only 2 errors are added, the callback is called 3 times because any call to addPropertyError will invoke the callback
|
||||
// if the property has errors existing.
|
||||
expect(numCalled).toEqual(3);
|
||||
//3 errors are added but a call to subscribe also calls the callback
|
||||
expect(numCalled).toEqual(4);
|
||||
expect(numCalledWithErrors).toEqual(3);
|
||||
});
|
||||
|
||||
it('can subscribe to a culture error for both a property and its sub field', function () {
|
||||
var args1;
|
||||
var args2;
|
||||
var numCalled = 0;
|
||||
var numCalledWithErrors = 0;
|
||||
|
||||
//arrange
|
||||
serverValidationManager.subscribe(null, "en-US", null, function (isValid, propertyErrors, allErrors) {
|
||||
numCalled++;
|
||||
if (propertyErrors.length > 0) {
|
||||
numCalledWithErrors++;
|
||||
}
|
||||
args1 = {
|
||||
isValid: isValid,
|
||||
propertyErrors: propertyErrors,
|
||||
allErrors: allErrors
|
||||
};
|
||||
}, null);
|
||||
|
||||
|
||||
serverValidationManager.subscribe(null, "es-ES", null, function (isValid, propertyErrors, allErrors) {
|
||||
numCalled++;
|
||||
if (propertyErrors.length > 0) {
|
||||
numCalledWithErrors++;
|
||||
}
|
||||
args2 = {
|
||||
isValid: isValid,
|
||||
propertyErrors: propertyErrors,
|
||||
@@ -445,20 +617,162 @@
|
||||
}, null);
|
||||
|
||||
//act
|
||||
serverValidationManager.addPropertyError("myProperty", null, "value1", "Some value 1", null);
|
||||
serverValidationManager.addPropertyError("myProperty", "en-US", "value1", "Some value 1", null);
|
||||
serverValidationManager.addPropertyError("myProperty", "en-US", "value2", "Some value 2", null);
|
||||
serverValidationManager.addPropertyError("myProperty", "fr-FR", "", "Some value 3", null);
|
||||
serverValidationManager.addPropertyError("myProperty", null, "value1", "Some value 1", null); // doesn't match
|
||||
serverValidationManager.addPropertyError("myProperty", "en-US", "value1", "Some value 1", null); // matches callback 1
|
||||
serverValidationManager.addPropertyError("myProperty", "en-US", "value2", "Some value 2", null); // matches callback 1
|
||||
serverValidationManager.addPropertyError("myProperty", "fr-FR", "", "Some value 3", null); // doesn't match - but all callbacks still execute
|
||||
|
||||
//assert
|
||||
expect(args1).not.toBeUndefined();
|
||||
expect(args1.isValid).toBe(false);
|
||||
expect(args2.isValid).toBe(true); // no errors registered for this callback
|
||||
|
||||
expect(args2).toBeUndefined();
|
||||
|
||||
expect(numCalled).toEqual(2);
|
||||
expect(numCalled).toEqual(10); // both subscriptions will be called once per addPropertyError and also called on subscribe
|
||||
expect(numCalledWithErrors).toEqual(3); // the first subscription is called 3 times with errors because the 4th time we call addPropertyError all callbacks still execute
|
||||
});
|
||||
|
||||
|
||||
it('can subscribe to a property validation path prefix', function () {
|
||||
var callbackA = [];
|
||||
var callbackB = [];
|
||||
|
||||
//arrange
|
||||
serverValidationManager.subscribe("myProperty", null, null, function (isValid, propertyErrors, allErrors) {
|
||||
if (propertyErrors.length > 0) {
|
||||
callbackA.push(propertyErrors);
|
||||
}
|
||||
}, null, { matchType: "prefix" });
|
||||
|
||||
serverValidationManager.subscribe("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses", null, null, function (isValid, propertyErrors, allErrors) {
|
||||
if (propertyErrors.length > 0) {
|
||||
callbackB.push(propertyErrors);
|
||||
}
|
||||
}, null, { matchType: "prefix" });
|
||||
|
||||
//act
|
||||
// will match A:
|
||||
serverValidationManager.addPropertyError("myProperty", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("myProperty", null, "value1", "value error", null);
|
||||
// will match A + B
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", null, "value1", "value error", null);
|
||||
// won't match:
|
||||
serverValidationManager.addPropertyError("myProperty", "en-US", null, "property error", null);
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", "en-US", null, "property error", null);
|
||||
serverValidationManager.addPropertyError("otherProperty", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("otherProperty", null, "value1", "value error", null);
|
||||
|
||||
//assert
|
||||
|
||||
// both will be called each time addPropertyError is called
|
||||
expect(callbackA.length).toEqual(8);
|
||||
expect(callbackB.length).toEqual(6); // B - will only be called 6 times with errors because the first 2 calls to addPropertyError haven't added errors for B yet
|
||||
expect(callbackA[callbackA.length - 1].length).toEqual(4); // 4 errors for A
|
||||
expect(callbackB[callbackB.length - 1].length).toEqual(2); // 2 errors for B
|
||||
|
||||
// clear the data and notify
|
||||
callbackA = [];
|
||||
callbackB = [];
|
||||
|
||||
serverValidationManager.notify();
|
||||
$timeout.flush();
|
||||
|
||||
expect(callbackA.length).toEqual(1);
|
||||
expect(callbackB.length).toEqual(1);
|
||||
expect(callbackA[0].length).toEqual(4); // 4 errors for A
|
||||
expect(callbackB[0].length).toEqual(2); // 2 errors for B
|
||||
|
||||
});
|
||||
|
||||
it('can subscribe to a property validation path suffix', function () {
|
||||
var callbackA = [];
|
||||
var callbackB = [];
|
||||
|
||||
//arrange
|
||||
serverValidationManager.subscribe("myProperty", null, null, function (isValid, propertyErrors, allErrors) {
|
||||
if (propertyErrors.length > 0) {
|
||||
callbackA.push(propertyErrors);
|
||||
}
|
||||
}, null, { matchType: "suffix" });
|
||||
|
||||
serverValidationManager.subscribe("city", null, null, function (isValid, propertyErrors, allErrors) {
|
||||
if (propertyErrors.length > 0) {
|
||||
callbackB.push(propertyErrors);
|
||||
}
|
||||
}, null, { matchType: "suffix" });
|
||||
|
||||
//act
|
||||
// will match A:
|
||||
serverValidationManager.addPropertyError("myProperty", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("myProperty", null, "value1", "value error", null);
|
||||
// will match B
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", null, "value1", "value error", null);
|
||||
// won't match:
|
||||
serverValidationManager.addPropertyError("myProperty", "en-US", null, "property error", null);
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", "en-US", null, "property error", null);
|
||||
serverValidationManager.addPropertyError("otherProperty", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("otherProperty", null, "value1", "value error", null);
|
||||
|
||||
//assert
|
||||
|
||||
// both will be called each time addPropertyError is called
|
||||
expect(callbackA.length).toEqual(8);
|
||||
expect(callbackB.length).toEqual(6); // B - will only be called 6 times with errors because the first 2 calls to addPropertyError haven't added errors for B yet
|
||||
expect(callbackA[callbackA.length - 1].length).toEqual(2); // 2 errors for A
|
||||
expect(callbackB[callbackB.length - 1].length).toEqual(2); // 2 errors for B
|
||||
|
||||
// clear the data and notify
|
||||
callbackA = [];
|
||||
callbackB = [];
|
||||
|
||||
serverValidationManager.notify();
|
||||
$timeout.flush();
|
||||
|
||||
expect(callbackA.length).toEqual(1);
|
||||
expect(callbackB.length).toEqual(1);
|
||||
expect(callbackA[0].length).toEqual(2); // 2 errors for A
|
||||
expect(callbackB[0].length).toEqual(2); // 2 errors for B
|
||||
|
||||
});
|
||||
|
||||
it('can subscribe to a property validation path contains', function () {
|
||||
var callbackA = [];
|
||||
|
||||
//arrange
|
||||
serverValidationManager.subscribe("addresses", null, null, function (isValid, propertyErrors, allErrors) {
|
||||
if (propertyErrors.length > 0) {
|
||||
callbackA.push(propertyErrors);
|
||||
}
|
||||
}, null, { matchType: "contains" });
|
||||
|
||||
//act
|
||||
// will match A:
|
||||
serverValidationManager.addPropertyError("addresses", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("addresses", null, "value1", "value error", null);
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("myProperty/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", null, "value1", "value error", null);
|
||||
// won't match:
|
||||
serverValidationManager.addPropertyError("addresses", "en-US", null, "property error", null);
|
||||
serverValidationManager.addPropertyError("addresses/34E3A26C-103D-4A05-AB9D-7E14032309C3/addresses/FBEAEE8F-4BC9-43EE-8B81-FCA8978850F1/city", "en-US", null, "property error", null);
|
||||
serverValidationManager.addPropertyError("otherProperty", null, null, "property error", null);
|
||||
serverValidationManager.addPropertyError("otherProperty", null, "value1", "value error", null);
|
||||
|
||||
//assert
|
||||
|
||||
// both will be called each time addPropertyError is called
|
||||
expect(callbackA.length).toEqual(8);
|
||||
expect(callbackA[callbackA.length - 1].length).toEqual(4); // 4 errors for A
|
||||
|
||||
// clear the data and notify
|
||||
callbackA = [];
|
||||
|
||||
serverValidationManager.notify();
|
||||
$timeout.flush();
|
||||
|
||||
expect(callbackA.length).toEqual(1);
|
||||
expect(callbackA[0].length).toEqual(4); // 4 errors for A
|
||||
|
||||
});
|
||||
|
||||
// TODO: Finish testing the rest!
|
||||
|
||||
});
|
||||
|
||||
@@ -126,6 +126,10 @@
|
||||
<Project>{52ac0ba8-a60e-4e36-897b-e8b97a54ed1c}</Project>
|
||||
<Name>Umbraco.ModelsBuilder.Embedded</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\Umbraco.TestData\Umbraco.TestData.csproj">
|
||||
<Project>{fb5676ed-7a69-492c-b802-e7b24144c0fc}</Project>
|
||||
<Name>Umbraco.TestData</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\Umbraco.Web\Umbraco.Web.csproj">
|
||||
<Project>{651e1350-91b6-44b7-bd60-7207006d7003}</Project>
|
||||
<Name>Umbraco.Web</Name>
|
||||
@@ -349,6 +353,9 @@
|
||||
<DevelopmentServerPort>8700</DevelopmentServerPort>
|
||||
<DevelopmentServerVPath>/</DevelopmentServerVPath>
|
||||
<IISUrl>http://localhost:8700</IISUrl>
|
||||
<DevelopmentServerPort>8640</DevelopmentServerPort>
|
||||
<DevelopmentServerVPath>/</DevelopmentServerVPath>
|
||||
<IISUrl>http://localhost:8640</IISUrl>
|
||||
<NTLMAuthentication>False</NTLMAuthentication>
|
||||
<UseCustomServer>False</UseCustomServer>
|
||||
<CustomServerUrl>
|
||||
@@ -430,4 +437,4 @@
|
||||
<Message Text="ConfigFile: $(OriginalFileName) -> $(OutputFileName)" Importance="high" Condition="Exists('$(ModifiedFileName)')" />
|
||||
<Copy SourceFiles="$(ModifiedFileName)" DestinationFiles="$(OutputFileName)" OverwriteReadOnlyFiles="true" SkipUnchangedFiles="false" Condition="Exists('$(ModifiedFileName)')" />
|
||||
</Target>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -218,7 +218,7 @@ namespace Umbraco.Web.Compose
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Error<InstructionProcessTask>("Failed (will repeat).", e);
|
||||
_logger.Error<InstructionProcessTask>(e, "Failed (will repeat).");
|
||||
}
|
||||
return true; // repeat
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace Umbraco.Web.Editors.Binders
|
||||
{
|
||||
}
|
||||
|
||||
public BlueprintItemBinder(ILogger logger, ServiceContext services, IUmbracoContextAccessor umbracoContextAccessor)
|
||||
: base(logger, services, umbracoContextAccessor)
|
||||
public BlueprintItemBinder(ServiceContext services)
|
||||
: base(services)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,13 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// </summary>
|
||||
internal class ContentItemBinder : IModelBinder
|
||||
{
|
||||
private readonly ContentModelBinderHelper _modelBinderHelper;
|
||||
|
||||
public ContentItemBinder() : this(Current.Logger, Current.Services, Current.UmbracoContextAccessor)
|
||||
public ContentItemBinder() : this(Current.Services)
|
||||
{
|
||||
}
|
||||
|
||||
public ContentItemBinder(ILogger logger, ServiceContext services, IUmbracoContextAccessor umbracoContextAccessor)
|
||||
public ContentItemBinder(ServiceContext services)
|
||||
{
|
||||
Services = services;
|
||||
_modelBinderHelper = new ContentModelBinderHelper();
|
||||
}
|
||||
|
||||
protected ServiceContext Services { get; }
|
||||
@@ -39,10 +36,20 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// <returns></returns>
|
||||
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
|
||||
{
|
||||
var model = _modelBinderHelper.BindModelFromMultipartRequest<ContentItemSave>(actionContext, bindingContext);
|
||||
var model = ContentModelBinderHelper.BindModelFromMultipartRequest<ContentItemSave>(actionContext, bindingContext);
|
||||
if (model == null) return false;
|
||||
|
||||
model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model);
|
||||
BindModel(model, ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static void BindModel(ContentItemSave model, IContent persistedContent)
|
||||
{
|
||||
if (model is null) throw new ArgumentNullException(nameof(model));
|
||||
if (persistedContent is null) throw new ArgumentNullException(nameof(persistedContent));
|
||||
|
||||
model.PersistedContent = persistedContent;
|
||||
|
||||
//create the dto from the persisted model
|
||||
if (model.PersistedContent != null)
|
||||
@@ -60,11 +67,9 @@ namespace Umbraco.Web.Editors.Binders
|
||||
});
|
||||
|
||||
//now map all of the saved values to the dto
|
||||
_modelBinderHelper.MapPropertyValuesFromSaved(variant, variant.PropertyCollectionDto);
|
||||
ContentModelBinderHelper.MapPropertyValuesFromSaved(variant, variant.PropertyCollectionDto);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected virtual IContent GetExisting(ContentItemSave model)
|
||||
|
||||
@@ -15,9 +15,9 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// <summary>
|
||||
/// Helper methods to bind media/member models
|
||||
/// </summary>
|
||||
internal class ContentModelBinderHelper
|
||||
internal static class ContentModelBinderHelper
|
||||
{
|
||||
public TModelSave BindModelFromMultipartRequest<TModelSave>(HttpActionContext actionContext, ModelBindingContext bindingContext)
|
||||
public static TModelSave BindModelFromMultipartRequest<TModelSave>(HttpActionContext actionContext, ModelBindingContext bindingContext)
|
||||
where TModelSave : IHaveUploadedFiles
|
||||
{
|
||||
var result = actionContext.ReadAsMultipart(SystemDirectories.TempFileUploads);
|
||||
@@ -86,7 +86,7 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// </summary>
|
||||
/// <param name="saveModel"></param>
|
||||
/// <param name="dto"></param>
|
||||
public void MapPropertyValuesFromSaved(IContentProperties<ContentPropertyBasic> saveModel, ContentPropertyCollectionDto dto)
|
||||
public static void MapPropertyValuesFromSaved(IContentProperties<ContentPropertyBasic> saveModel, ContentPropertyCollectionDto dto)
|
||||
{
|
||||
//NOTE: Don't convert this to linq, this is much quicker
|
||||
foreach (var p in saveModel.Properties)
|
||||
|
||||
@@ -13,7 +13,6 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// </summary>
|
||||
internal class MediaItemBinder : IModelBinder
|
||||
{
|
||||
private readonly ContentModelBinderHelper _modelBinderHelper;
|
||||
private readonly ServiceContext _services;
|
||||
|
||||
public MediaItemBinder() : this(Current.Services)
|
||||
@@ -23,7 +22,6 @@ namespace Umbraco.Web.Editors.Binders
|
||||
public MediaItemBinder(ServiceContext services)
|
||||
{
|
||||
_services = services;
|
||||
_modelBinderHelper = new ContentModelBinderHelper();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -34,7 +32,7 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// <returns></returns>
|
||||
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
|
||||
{
|
||||
var model = _modelBinderHelper.BindModelFromMultipartRequest<MediaItemSave>(actionContext, bindingContext);
|
||||
var model = ContentModelBinderHelper.BindModelFromMultipartRequest<MediaItemSave>(actionContext, bindingContext);
|
||||
if (model == null) return false;
|
||||
|
||||
model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model);
|
||||
@@ -44,7 +42,7 @@ namespace Umbraco.Web.Editors.Binders
|
||||
{
|
||||
model.PropertyCollectionDto = Current.Mapper.Map<IMedia, ContentPropertyCollectionDto>(model.PersistedContent);
|
||||
//now map all of the saved values to the dto
|
||||
_modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto);
|
||||
ContentModelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto);
|
||||
}
|
||||
|
||||
model.Name = model.Name.Trim();
|
||||
|
||||
@@ -20,7 +20,6 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// </summary>
|
||||
internal class MemberBinder : IModelBinder
|
||||
{
|
||||
private readonly ContentModelBinderHelper _modelBinderHelper;
|
||||
private readonly ServiceContext _services;
|
||||
|
||||
public MemberBinder() : this(Current.Services)
|
||||
@@ -30,7 +29,6 @@ namespace Umbraco.Web.Editors.Binders
|
||||
public MemberBinder(ServiceContext services)
|
||||
{
|
||||
_services = services;
|
||||
_modelBinderHelper = new ContentModelBinderHelper();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -41,7 +39,7 @@ namespace Umbraco.Web.Editors.Binders
|
||||
/// <returns></returns>
|
||||
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
|
||||
{
|
||||
var model = _modelBinderHelper.BindModelFromMultipartRequest<MemberSave>(actionContext, bindingContext);
|
||||
var model = ContentModelBinderHelper.BindModelFromMultipartRequest<MemberSave>(actionContext, bindingContext);
|
||||
if (model == null) return false;
|
||||
|
||||
model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model);
|
||||
@@ -51,7 +49,7 @@ namespace Umbraco.Web.Editors.Binders
|
||||
{
|
||||
model.PropertyCollectionDto = Current.Mapper.Map<IMember, ContentPropertyCollectionDto>(model.PersistedContent);
|
||||
//now map all of the saved values to the dto
|
||||
_modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto);
|
||||
ContentModelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto);
|
||||
}
|
||||
|
||||
model.Name = model.Name.Trim();
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Web.Http.Controllers;
|
||||
using System.Web.Http.ModelBinding;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Logging;
|
||||
using Umbraco.Core.Models;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Web.Models.ContentEditing;
|
||||
using Umbraco.Web.PropertyEditors.Validation;
|
||||
|
||||
namespace Umbraco.Web.Editors.Filters
|
||||
{
|
||||
@@ -125,18 +129,6 @@ namespace Umbraco.Web.Editors.Filters
|
||||
{
|
||||
var properties = modelWithProperties.Properties.ToDictionary(x => x.Alias, x => x);
|
||||
|
||||
// Retrieve default messages used for required and regex validatation. We'll replace these
|
||||
// if set with custom ones if they've been provided for a given property.
|
||||
var requiredDefaultMessages = new[]
|
||||
{
|
||||
_textService.Localize("validation", "invalidNull"),
|
||||
_textService.Localize("validation", "invalidEmpty")
|
||||
};
|
||||
var formatDefaultMessages = new[]
|
||||
{
|
||||
_textService.Localize("validation", "invalidPattern"),
|
||||
};
|
||||
|
||||
foreach (var p in dto.Properties)
|
||||
{
|
||||
var editor = p.PropertyEditor;
|
||||
@@ -156,7 +148,7 @@ namespace Umbraco.Web.Editors.Filters
|
||||
|
||||
var postedValue = postedProp.Value;
|
||||
|
||||
ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState, requiredDefaultMessages, formatDefaultMessages);
|
||||
ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState);
|
||||
|
||||
}
|
||||
|
||||
@@ -180,26 +172,29 @@ namespace Umbraco.Web.Editors.Filters
|
||||
IDataEditor editor,
|
||||
ContentPropertyDto property,
|
||||
object postedValue,
|
||||
ModelStateDictionary modelState,
|
||||
string[] requiredDefaultMessages,
|
||||
string[] formatDefaultMessages)
|
||||
ModelStateDictionary modelState)
|
||||
{
|
||||
var valueEditor = editor.GetValueEditor(property.DataType.Configuration);
|
||||
foreach (var r in valueEditor.Validate(postedValue, property.IsRequired, property.ValidationRegExp))
|
||||
if (property is null) throw new ArgumentNullException(nameof(property));
|
||||
if (property.DataType is null) throw new InvalidOperationException($"{nameof(property)}.{nameof(property.DataType)} cannot be null");
|
||||
|
||||
foreach (var validationResult in PropertyValidationService.ValidatePropertyValue(
|
||||
_textService, editor, property.DataType, postedValue, property.IsRequired,
|
||||
property.ValidationRegExp, property.IsRequiredMessage, property.ValidationRegExpMessage))
|
||||
{
|
||||
// If we've got custom error messages, we'll replace the default ones that will have been applied in the call to Validate().
|
||||
if (property.IsRequired && !string.IsNullOrWhiteSpace(property.IsRequiredMessage) && requiredDefaultMessages.Contains(r.ErrorMessage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
r.ErrorMessage = property.IsRequiredMessage;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(property.ValidationRegExp) && !string.IsNullOrWhiteSpace(property.ValidationRegExpMessage) && formatDefaultMessages.Contains(r.ErrorMessage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
r.ErrorMessage = property.ValidationRegExpMessage;
|
||||
}
|
||||
|
||||
modelState.AddPropertyError(r, property.Alias, property.Culture, property.Segment);
|
||||
AddPropertyError(model, modelWithProperties, editor, property, validationResult, modelState);
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void AddPropertyError(
|
||||
TModelSave model,
|
||||
TModelWithProperties modelWithProperties,
|
||||
IDataEditor editor,
|
||||
ContentPropertyDto property,
|
||||
ValidationResult validationResult,
|
||||
ModelStateDictionary modelState)
|
||||
{
|
||||
modelState.AddPropertyError(validationResult, property.Alias, property.Culture, property.Segment);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Umbraco.Core.Logging;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Web.Http.ModelBinding;
|
||||
using Umbraco.Core.Logging;
|
||||
using Umbraco.Core.Models;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Web.Models.ContentEditing;
|
||||
|
||||
@@ -13,5 +16,6 @@ namespace Umbraco.Web.Editors.Filters
|
||||
public ContentSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService) : base(logger, umbracoContextAccessor, textService)
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
@@ -6,6 +7,7 @@ using System.Web.Mvc;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Models;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Web.PropertyEditors.Validation;
|
||||
|
||||
namespace Umbraco.Web
|
||||
{
|
||||
@@ -40,9 +42,9 @@ namespace Umbraco.Web
|
||||
{
|
||||
return state.Where(v => v.Key.StartsWith(prefix + ".")).All(v => !v.Value.Errors.Any());
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Adds the error to model state correctly for a property so we can use it on the client side.
|
||||
/// Adds an <see cref="ContentPropertyValidationResult"/> error to model state for a property so we can use it on the client side.
|
||||
/// </summary>
|
||||
/// <param name="modelState"></param>
|
||||
/// <param name="result"></param>
|
||||
@@ -51,9 +53,24 @@ namespace Umbraco.Web
|
||||
internal static void AddPropertyError(this System.Web.Http.ModelBinding.ModelStateDictionary modelState,
|
||||
ValidationResult result, string propertyAlias, string culture = "", string segment = "")
|
||||
{
|
||||
if (culture == null)
|
||||
culture = "";
|
||||
modelState.AddValidationError(result, "_Properties", propertyAlias,
|
||||
modelState.AddPropertyValidationError(new ContentPropertyValidationResult(result, culture, segment), propertyAlias, culture, segment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the <see cref="ValidationResult"/> to the model state with the appropriate keys for property errors
|
||||
/// </summary>
|
||||
/// <param name="modelState"></param>
|
||||
/// <param name="result"></param>
|
||||
/// <param name="propertyAlias"></param>
|
||||
/// <param name="culture"></param>
|
||||
/// <param name="segment"></param>
|
||||
internal static void AddPropertyValidationError(this System.Web.Http.ModelBinding.ModelStateDictionary modelState,
|
||||
ValidationResult result, string propertyAlias, string culture = "", string segment = "")
|
||||
{
|
||||
modelState.AddValidationError(
|
||||
result,
|
||||
"_Properties",
|
||||
propertyAlias,
|
||||
//if the culture is null, we'll add the term 'invariant' as part of the key
|
||||
culture.IsNullOrWhiteSpace() ? "invariant" : culture,
|
||||
// if the segment is null, we'll add the term 'null' as part of the key
|
||||
@@ -166,12 +183,12 @@ namespace Umbraco.Web
|
||||
var delimitedParts = string.Join(".", parts);
|
||||
foreach (var memberName in result.MemberNames)
|
||||
{
|
||||
modelState.TryAddModelError($"{delimitedParts}.{memberName}", result.ErrorMessage);
|
||||
modelState.TryAddModelError($"{delimitedParts}.{memberName}", result.ToString());
|
||||
withNames = true;
|
||||
}
|
||||
if (!withNames)
|
||||
{
|
||||
modelState.TryAddModelError($"{delimitedParts}", result.ErrorMessage);
|
||||
modelState.TryAddModelError($"{delimitedParts}", result.ToString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -236,5 +253,6 @@ namespace Umbraco.Web
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Umbraco.Web.Models.ContentEditing
|
||||
[DataContract(Name = "content", Namespace = "")]
|
||||
public class ContentItemSave : IContentSave<IContent>
|
||||
{
|
||||
protected ContentItemSave()
|
||||
public ContentItemSave()
|
||||
{
|
||||
UploadedFiles = new List<ContentPropertyFile>();
|
||||
Variants = new List<ContentVariantSave>();
|
||||
|
||||
@@ -44,6 +44,9 @@ namespace Umbraco.Web.Models.Mapping
|
||||
property.PropertyType.PropertyEditorAlias);
|
||||
|
||||
editor = _propertyEditors[Constants.PropertyEditors.Aliases.Label];
|
||||
|
||||
if (editor == null)
|
||||
throw new InvalidOperationException($"Could not resolve the property editor {Constants.PropertyEditors.Aliases.Label}");
|
||||
}
|
||||
|
||||
dest.Id = property.Id;
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Logging;
|
||||
using Umbraco.Core.Models;
|
||||
@@ -12,9 +10,11 @@ using Umbraco.Core.Models.Blocks;
|
||||
using Umbraco.Core.Models.Editors;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
using Umbraco.Core.Services;
|
||||
using static Umbraco.Core.Models.Blocks.BlockEditorData;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Abstract class for block editor based editors
|
||||
/// </summary>
|
||||
@@ -22,15 +22,15 @@ namespace Umbraco.Web.PropertyEditors
|
||||
{
|
||||
public const string ContentTypeKeyPropertyKey = "contentTypeKey";
|
||||
public const string UdiPropertyKey = "udi";
|
||||
private readonly IBlockEditorDataHelper _dataHelper;
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly Lazy<PropertyEditorCollection> _propertyEditors;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly IContentTypeService _contentTypeService;
|
||||
|
||||
public BlockEditorPropertyEditor(ILogger logger, Lazy<PropertyEditorCollection> propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, IBlockEditorDataHelper dataHelper)
|
||||
public BlockEditorPropertyEditor(ILogger logger, Lazy<PropertyEditorCollection> propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService localizedTextService)
|
||||
: base(logger)
|
||||
{
|
||||
_dataHelper = dataHelper;
|
||||
_localizedTextService = localizedTextService;
|
||||
_propertyEditors = propertyEditors;
|
||||
_dataTypeService = dataTypeService;
|
||||
_contentTypeService = contentTypeService;
|
||||
@@ -41,23 +41,21 @@ namespace Umbraco.Web.PropertyEditors
|
||||
|
||||
#region Value Editor
|
||||
|
||||
protected override IDataValueEditor CreateValueEditor() => new BlockEditorPropertyValueEditor(Attribute, _dataHelper, PropertyEditors, _dataTypeService, _contentTypeService);
|
||||
protected override IDataValueEditor CreateValueEditor() => new BlockEditorPropertyValueEditor(Attribute, PropertyEditors, _dataTypeService, _contentTypeService, _localizedTextService);
|
||||
|
||||
internal class BlockEditorPropertyValueEditor : DataValueEditor, IDataValueReference
|
||||
{
|
||||
private readonly IBlockEditorDataHelper _dataHelper;
|
||||
private readonly PropertyEditorCollection _propertyEditors;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly IDataTypeService _dataTypeService; // TODO: Not used yet but we'll need it to fill in the FromEditor/ToEditor
|
||||
private readonly BlockEditorValues _blockEditorValues;
|
||||
|
||||
public BlockEditorPropertyValueEditor(DataEditorAttribute attribute, IBlockEditorDataHelper dataHelper, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService)
|
||||
public BlockEditorPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService textService)
|
||||
: base(attribute)
|
||||
{
|
||||
_dataHelper = dataHelper;
|
||||
_propertyEditors = propertyEditors;
|
||||
_dataTypeService = dataTypeService;
|
||||
_blockEditorValues = new BlockEditorValues(dataHelper, contentTypeService);
|
||||
Validators.Add(new BlockEditorValidator(propertyEditors, dataTypeService, _blockEditorValues));
|
||||
_blockEditorValues = new BlockEditorValues(new BlockListEditorDataConverter(), contentTypeService);
|
||||
Validators.Add(new BlockEditorValidator(_blockEditorValues, propertyEditors, dataTypeService, textService));
|
||||
}
|
||||
|
||||
public IEnumerable<UmbracoEntityReference> GetReferences(object value)
|
||||
@@ -66,210 +64,146 @@ namespace Umbraco.Web.PropertyEditors
|
||||
|
||||
var result = new List<UmbracoEntityReference>();
|
||||
|
||||
foreach (var row in _blockEditorValues.GetPropertyValues(rawJson, out _))
|
||||
foreach (var row in _blockEditorValues.GetPropertyValues(rawJson))
|
||||
{
|
||||
if (row.PropType == null) continue;
|
||||
foreach (var prop in row.PropertyValues)
|
||||
{
|
||||
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
||||
|
||||
var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias];
|
||||
var valueEditor = propEditor?.GetValueEditor();
|
||||
if (!(valueEditor is IDataValueReference reference)) continue;
|
||||
|
||||
var valueEditor = propEditor?.GetValueEditor();
|
||||
if (!(valueEditor is IDataValueReference reference)) continue;
|
||||
var val = prop.Value.Value?.ToString();
|
||||
|
||||
var val = row.JsonRowValue[row.PropKey]?.ToString();
|
||||
var refs = reference.GetReferences(val);
|
||||
|
||||
var refs = reference.GetReferences(val);
|
||||
|
||||
result.AddRange(refs);
|
||||
result.AddRange(refs);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal class BlockEditorValidator : IValueValidator
|
||||
internal class BlockEditorValidator : ComplexEditorValidator
|
||||
{
|
||||
private readonly PropertyEditorCollection _propertyEditors;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly BlockEditorValues _blockEditorValues;
|
||||
|
||||
public BlockEditorValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, BlockEditorValues blockEditorValues)
|
||||
public BlockEditorValidator(BlockEditorValues blockEditorValues, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService) : base(propertyEditors, dataTypeService, textService)
|
||||
{
|
||||
_propertyEditors = propertyEditors;
|
||||
_dataTypeService = dataTypeService;
|
||||
_blockEditorValues = blockEditorValues;
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(object rawValue, string valueType, object dataTypeConfiguration)
|
||||
protected override IEnumerable<ElementTypeValidationModel> GetElementTypeValidation(object value)
|
||||
{
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
foreach (var row in _blockEditorValues.GetPropertyValues(rawValue, out _))
|
||||
foreach (var row in _blockEditorValues.GetPropertyValues(value))
|
||||
{
|
||||
if (row.PropType == null) continue;
|
||||
|
||||
var config = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration;
|
||||
var propertyEditor = _propertyEditors[row.PropType.PropertyEditorAlias];
|
||||
if (propertyEditor == null) continue;
|
||||
|
||||
foreach (var validator in propertyEditor.GetValueEditor().Validators)
|
||||
var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Id);
|
||||
foreach (var prop in row.PropertyValues)
|
||||
{
|
||||
foreach (var result in validator.Validate(row.JsonRowValue[row.PropKey], propertyEditor.GetValueEditor().ValueType, config))
|
||||
{
|
||||
result.ErrorMessage = "Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' " + result.ErrorMessage;
|
||||
validationResults.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Check mandatory
|
||||
if (row.PropType.Mandatory)
|
||||
{
|
||||
if (row.JsonRowValue[row.PropKey] == null)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage)
|
||||
? $"'{row.PropType.Name}' cannot be null"
|
||||
: row.PropType.MandatoryMessage;
|
||||
validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey }));
|
||||
}
|
||||
else if (row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace() || (row.JsonRowValue[row.PropKey].Type == JTokenType.Array && !row.JsonRowValue[row.PropKey].HasValues))
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage)
|
||||
? $"'{row.PropType.Name}' cannot be empty"
|
||||
: row.PropType.MandatoryMessage;
|
||||
validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey }));
|
||||
}
|
||||
}
|
||||
|
||||
// Check regex
|
||||
if (!row.PropType.ValidationRegExp.IsNullOrWhiteSpace()
|
||||
&& row.JsonRowValue[row.PropKey] != null && !row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace())
|
||||
{
|
||||
var regex = new Regex(row.PropType.ValidationRegExp);
|
||||
if (!regex.IsMatch(row.JsonRowValue[row.PropKey].ToString()))
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(row.PropType.ValidationRegExpMessage)
|
||||
? $"'{row.PropType.Name}' is invalid, it does not match the correct pattern"
|
||||
: row.PropType.ValidationRegExpMessage;
|
||||
validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey }));
|
||||
}
|
||||
elementValidation.AddPropertyTypeValidation(
|
||||
new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value));
|
||||
}
|
||||
yield return elementValidation;
|
||||
}
|
||||
|
||||
return validationResults;
|
||||
}
|
||||
}
|
||||
|
||||
internal class BlockEditorValues
|
||||
{
|
||||
private readonly IBlockEditorDataHelper _dataHelper;
|
||||
private readonly Lazy<Dictionary<Guid, IContentType>> _contentTypes;
|
||||
private readonly BlockEditorDataConverter _dataConverter;
|
||||
|
||||
public BlockEditorValues(IBlockEditorDataHelper dataHelper, IContentTypeService contentTypeService)
|
||||
public BlockEditorValues(BlockEditorDataConverter dataConverter, IContentTypeService contentTypeService)
|
||||
{
|
||||
_dataHelper = dataHelper;
|
||||
_contentTypes = new Lazy<Dictionary<Guid, IContentType>>(() => contentTypeService.GetAll().ToDictionary(c => c.Key));
|
||||
_dataConverter = dataConverter;
|
||||
}
|
||||
|
||||
private IContentType GetElementType(JObject item)
|
||||
private IContentType GetElementType(BlockItemData item)
|
||||
{
|
||||
Guid contentTypeKey = item[ContentTypeKeyPropertyKey]?.ToObject<Guid>() ?? Guid.Empty;
|
||||
_contentTypes.Value.TryGetValue(contentTypeKey, out var contentType);
|
||||
_contentTypes.Value.TryGetValue(item.ContentTypeKey, out var contentType);
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public IEnumerable<RowValue> GetPropertyValues(object propertyValue, out List<JObject> deserialized)
|
||||
public IReadOnlyList<BlockValue> GetPropertyValues(object propertyValue)
|
||||
{
|
||||
var rowValues = new List<RowValue>();
|
||||
|
||||
deserialized = null;
|
||||
|
||||
if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString()))
|
||||
return Enumerable.Empty<RowValue>();
|
||||
return new List<BlockValue>();
|
||||
|
||||
var data = JsonConvert.DeserializeObject<BlockEditorData>(propertyValue.ToString());
|
||||
if (data?.Layout == null || data.Data == null || data.Data.Count == 0)
|
||||
return Enumerable.Empty<RowValue>();
|
||||
var converted = _dataConverter.Convert(propertyValue.ToString());
|
||||
|
||||
var blockRefs = _dataHelper.GetBlockReferences(data.Layout);
|
||||
if (blockRefs == null)
|
||||
return Enumerable.Empty<RowValue>();
|
||||
if (converted.Blocks.Count == 0)
|
||||
return new List<BlockValue>();
|
||||
|
||||
var dataMap = new Dictionary<Udi, JObject>(data.Data.Count);
|
||||
data.Data.ForEach(d =>
|
||||
var contentTypePropertyTypes = new Dictionary<string, Dictionary<string, PropertyType>>();
|
||||
var result = new List<BlockValue>();
|
||||
|
||||
foreach(var block in converted.Blocks)
|
||||
{
|
||||
var udiObj = d?[UdiPropertyKey];
|
||||
if (Udi.TryParse(udiObj == null || udiObj.Type != JTokenType.String ? null : udiObj.ToString(), out var udi))
|
||||
dataMap[udi] = d;
|
||||
});
|
||||
|
||||
deserialized = blockRefs.Select(r => dataMap.TryGetValue(r.Udi, out var block) ? block : null).Where(r => r != null).ToList();
|
||||
if (deserialized == null || deserialized.Count == 0)
|
||||
return Enumerable.Empty<RowValue>();
|
||||
|
||||
var index = 0;
|
||||
|
||||
foreach (var o in deserialized)
|
||||
{
|
||||
var propValues = o;
|
||||
|
||||
var contentType = GetElementType(propValues);
|
||||
var contentType = GetElementType(block);
|
||||
if (contentType == null)
|
||||
continue;
|
||||
|
||||
var propertyTypes = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x);
|
||||
var propAliases = propValues.Properties().Select(x => x.Name);
|
||||
foreach (var propAlias in propAliases)
|
||||
// get the prop types for this content type but keep a dictionary of found ones so we don't have to keep re-looking and re-creating
|
||||
// objects on each iteration.
|
||||
if (!contentTypePropertyTypes.TryGetValue(contentType.Alias, out var propertyTypes))
|
||||
propertyTypes = contentTypePropertyTypes[contentType.Alias] = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x);
|
||||
|
||||
var propValues = new Dictionary<string, BlockPropertyValue>();
|
||||
|
||||
// find any keys that are not real property types and remove them
|
||||
foreach (var prop in block.RawPropertyValues.ToList())
|
||||
{
|
||||
propertyTypes.TryGetValue(propAlias, out var propType);
|
||||
rowValues.Add(new RowValue(propAlias, propType, propValues, index));
|
||||
// doesn't exist so remove it
|
||||
if (!propertyTypes.TryGetValue(prop.Key, out var propType))
|
||||
{
|
||||
block.RawPropertyValues.Remove(prop.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
// set the value to include the resolved property type
|
||||
propValues[prop.Key] = new BlockPropertyValue
|
||||
{
|
||||
PropertyType = propType,
|
||||
Value = prop.Value
|
||||
};
|
||||
}
|
||||
}
|
||||
index++;
|
||||
|
||||
result.Add(new BlockValue
|
||||
{
|
||||
ContentTypeAlias = contentType.Alias,
|
||||
PropertyValues = propValues,
|
||||
Id = ((GuidUdi)block.Udi).Guid
|
||||
});
|
||||
}
|
||||
|
||||
return rowValues;
|
||||
return result;
|
||||
}
|
||||
|
||||
internal class RowValue
|
||||
/// <summary>
|
||||
/// Used during deserialization to populate the property value/property type of a nested content row property
|
||||
/// </summary>
|
||||
internal class BlockPropertyValue
|
||||
{
|
||||
public RowValue(string propKey, PropertyType propType, JObject propValues, int index)
|
||||
{
|
||||
PropKey = propKey ?? throw new ArgumentNullException(nameof(propKey));
|
||||
PropType = propType;
|
||||
JsonRowValue = propValues ?? throw new ArgumentNullException(nameof(propValues));
|
||||
RowIndex = index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current property key being iterated for the row value
|
||||
/// </summary>
|
||||
public string PropKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="PropertyType"/> of the value (if any), this may be null
|
||||
/// </summary>
|
||||
public PropertyType PropType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The json values for the current row
|
||||
/// </summary>
|
||||
public JObject JsonRowValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The Nested Content row index
|
||||
/// </summary>
|
||||
public int RowIndex { get; }
|
||||
public object Value { get; set; }
|
||||
public PropertyType PropertyType { get; set; }
|
||||
}
|
||||
|
||||
private class BlockEditorData
|
||||
/// <summary>
|
||||
/// Used during deserialization to populate the content type alias and property values of a block
|
||||
/// </summary>
|
||||
internal class BlockValue
|
||||
{
|
||||
[JsonProperty("layout")]
|
||||
public JObject Layout { get; set; }
|
||||
|
||||
[JsonProperty("data")]
|
||||
public List<JObject> Data { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string ContentTypeAlias { get; set; }
|
||||
public IDictionary<string, BlockPropertyValue> PropertyValues { get; set; } = new Dictionary<string, BlockPropertyValue>();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static bool IsSystemPropertyKey(string propertyKey) => ContentTypeKeyPropertyKey == propertyKey || UdiPropertyKey == propertyKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ namespace Umbraco.Web.PropertyEditors
|
||||
Icon = "icon-thumbnail-list")]
|
||||
public class BlockListPropertyEditor : BlockEditorPropertyEditor
|
||||
{
|
||||
public BlockListPropertyEditor(ILogger logger, Lazy<PropertyEditorCollection> propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService)
|
||||
: base(logger, propertyEditors, dataTypeService, contentTypeService, new DataHelper())
|
||||
public BlockListPropertyEditor(ILogger logger, Lazy<PropertyEditorCollection> propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService localizedTextService)
|
||||
: base(logger, propertyEditors, dataTypeService, contentTypeService, localizedTextService)
|
||||
{ }
|
||||
|
||||
#region Pre Value Editor
|
||||
@@ -31,36 +31,5 @@ namespace Umbraco.Web.PropertyEditors
|
||||
protected override IConfigurationEditor CreateConfigurationEditor() => new BlockListConfigurationEditor();
|
||||
|
||||
#endregion
|
||||
|
||||
#region IBlockEditorDataHelper
|
||||
|
||||
private class DataHelper : IBlockEditorDataHelper
|
||||
{
|
||||
public IEnumerable<IBlockReference> GetBlockReferences(JObject layout)
|
||||
{
|
||||
if (!(layout?[Constants.PropertyEditors.Aliases.BlockList] is JArray blLayouts))
|
||||
yield break;
|
||||
|
||||
foreach (var blLayout in blLayouts)
|
||||
{
|
||||
if (!(blLayout is JObject blockRef) || !(blockRef[UdiPropertyKey] is JValue udiRef) || udiRef.Type != JTokenType.String || !Udi.TryParse(udiRef.ToString(), out var udi)) continue;
|
||||
yield return new SimpleRef(udi);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEditorSpecificPropertyKey(string propertyKey) => false;
|
||||
|
||||
private class SimpleRef : IBlockReference
|
||||
{
|
||||
public SimpleRef(Udi udi)
|
||||
{
|
||||
Udi = udi;
|
||||
}
|
||||
|
||||
public Udi Udi { get; }
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Models;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Web.PropertyEditors.Validation;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors
|
||||
{
|
||||
/// <summary>
|
||||
/// Used to validate complex editors that contain nested editors
|
||||
/// </summary>
|
||||
public abstract class ComplexEditorValidator : IValueValidator
|
||||
{
|
||||
private readonly PropertyValidationService _propertyValidationService;
|
||||
|
||||
public ComplexEditorValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService)
|
||||
{
|
||||
_propertyValidationService = new PropertyValidationService(propertyEditors, dataTypeService, textService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a single <see cref="ComplexEditorValidationResult"/> for all sub nested validation results in the complex editor
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <param name="valueType"></param>
|
||||
/// <param name="dataTypeConfiguration"></param>
|
||||
/// <returns></returns>
|
||||
public IEnumerable<ValidationResult> Validate(object value, string valueType, object dataTypeConfiguration)
|
||||
{
|
||||
var elementTypeValues = GetElementTypeValidation(value).ToList();
|
||||
var rowResults = GetNestedValidationResults(elementTypeValues).ToList();
|
||||
|
||||
if (rowResults.Count > 0)
|
||||
{
|
||||
var result = new ComplexEditorValidationResult();
|
||||
foreach(var rowResult in rowResults)
|
||||
{
|
||||
result.ValidationResults.Add(rowResult);
|
||||
}
|
||||
return result.Yield();
|
||||
}
|
||||
|
||||
return Enumerable.Empty<ValidationResult>();
|
||||
}
|
||||
|
||||
|
||||
protected abstract IEnumerable<ElementTypeValidationModel> GetElementTypeValidation(object value);
|
||||
|
||||
/// <summary>
|
||||
/// Return a nested validation result per row (Element Type)
|
||||
/// </summary>
|
||||
/// <param name="rawValue"></param>
|
||||
/// <returns></returns>
|
||||
protected IEnumerable<ComplexEditorElementTypeValidationResult> GetNestedValidationResults(IEnumerable<ElementTypeValidationModel> elements)
|
||||
{
|
||||
foreach (var row in elements)
|
||||
{
|
||||
var elementTypeValidationResult = new ComplexEditorElementTypeValidationResult(row.ElementTypeAlias, row.Id);
|
||||
|
||||
foreach (var prop in row.PropertyTypeValidation)
|
||||
{
|
||||
var propValidationResult = new ComplexEditorPropertyTypeValidationResult(prop.PropertyType.Alias);
|
||||
|
||||
foreach (var validationResult in _propertyValidationService.ValidatePropertyValue(prop.PropertyType, prop.PostedValue))
|
||||
{
|
||||
// add the result to the property results
|
||||
propValidationResult.AddValidationResult(validationResult);
|
||||
}
|
||||
|
||||
// add the property results to the element type results
|
||||
if (propValidationResult.ValidationResults.Count > 0)
|
||||
elementTypeValidationResult.ValidationResults.Add(propValidationResult);
|
||||
}
|
||||
|
||||
if (elementTypeValidationResult.ValidationResults.Count > 0)
|
||||
{
|
||||
yield return elementTypeValidationResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class PropertyTypeValidationModel
|
||||
{
|
||||
public PropertyTypeValidationModel(PropertyType propertyType, object postedValue)
|
||||
{
|
||||
PostedValue = postedValue;
|
||||
PropertyType = propertyType ?? throw new ArgumentNullException(nameof(propertyType));
|
||||
}
|
||||
|
||||
public object PostedValue { get; }
|
||||
public PropertyType PropertyType { get; }
|
||||
}
|
||||
|
||||
public class ElementTypeValidationModel
|
||||
{
|
||||
public ElementTypeValidationModel(string elementTypeAlias, Guid id)
|
||||
{
|
||||
ElementTypeAlias = elementTypeAlias;
|
||||
Id = id;
|
||||
}
|
||||
|
||||
private List<PropertyTypeValidationModel> _list = new List<PropertyTypeValidationModel>();
|
||||
|
||||
public IEnumerable<PropertyTypeValidationModel> PropertyTypeValidation => _list;
|
||||
|
||||
public string ElementTypeAlias { get; }
|
||||
public Guid Id { get; }
|
||||
|
||||
public void AddPropertyTypeValidation(PropertyTypeValidationModel propValidation) => _list.Add(propValidation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Umbraco.Core;
|
||||
@@ -13,9 +14,11 @@ using Umbraco.Core.Models;
|
||||
using Umbraco.Core.Models.Editors;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Web.PropertyEditors.Validation;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Represents a nested content property editor.
|
||||
/// </summary>
|
||||
@@ -31,14 +34,20 @@ namespace Umbraco.Web.PropertyEditors
|
||||
private readonly Lazy<PropertyEditorCollection> _propertyEditors;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly IContentTypeService _contentTypeService;
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
internal const string ContentTypeAliasPropertyKey = "ncContentTypeAlias";
|
||||
|
||||
[Obsolete("Use the constructor specifying all parameters instead")]
|
||||
public NestedContentPropertyEditor(ILogger logger, Lazy<PropertyEditorCollection> propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService)
|
||||
: this(logger, propertyEditors, dataTypeService, contentTypeService, Current.Services.TextService) { }
|
||||
|
||||
public NestedContentPropertyEditor(ILogger logger, Lazy<PropertyEditorCollection> propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService localizedTextService)
|
||||
: base (logger)
|
||||
{
|
||||
_propertyEditors = propertyEditors;
|
||||
_dataTypeService = dataTypeService;
|
||||
_contentTypeService = contentTypeService;
|
||||
_localizedTextService = localizedTextService;
|
||||
}
|
||||
|
||||
// has to be lazy else circular dep in ctor
|
||||
@@ -52,7 +61,7 @@ namespace Umbraco.Web.PropertyEditors
|
||||
|
||||
#region Value Editor
|
||||
|
||||
protected override IDataValueEditor CreateValueEditor() => new NestedContentPropertyValueEditor(Attribute, PropertyEditors, _dataTypeService, _contentTypeService);
|
||||
protected override IDataValueEditor CreateValueEditor() => new NestedContentPropertyValueEditor(Attribute, PropertyEditors, _dataTypeService, _contentTypeService, _localizedTextService);
|
||||
|
||||
internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference
|
||||
{
|
||||
@@ -60,13 +69,13 @@ namespace Umbraco.Web.PropertyEditors
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly NestedContentValues _nestedContentValues;
|
||||
|
||||
public NestedContentPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService)
|
||||
public NestedContentPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService textService)
|
||||
: base(attribute)
|
||||
{
|
||||
_propertyEditors = propertyEditors;
|
||||
_dataTypeService = dataTypeService;
|
||||
_nestedContentValues = new NestedContentValues(contentTypeService);
|
||||
Validators.Add(new NestedContentValidator(propertyEditors, dataTypeService, _nestedContentValues));
|
||||
Validators.Add(new NestedContentValidator(_nestedContentValues, propertyEditors, dataTypeService, textService));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -89,41 +98,37 @@ namespace Umbraco.Web.PropertyEditors
|
||||
|
||||
public override string ConvertDbToString(PropertyType propertyType, object propertyValue, IDataTypeService dataTypeService)
|
||||
{
|
||||
var vals = _nestedContentValues.GetPropertyValues(propertyValue, out var deserialized).ToList();
|
||||
var rows = _nestedContentValues.GetPropertyValues(propertyValue);
|
||||
|
||||
if (vals.Count == 0)
|
||||
if (rows.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
foreach (var row in vals)
|
||||
foreach (var row in rows.ToList())
|
||||
{
|
||||
if (row.PropType == null)
|
||||
{
|
||||
// type not found, and property is not system: just delete the value
|
||||
if (IsSystemPropertyKey(row.PropKey) == false)
|
||||
row.JsonRowValue[row.PropKey] = null;
|
||||
}
|
||||
else
|
||||
foreach(var prop in row.PropertyValues.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
// convert the value, and store the converted value
|
||||
var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias];
|
||||
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
||||
if (propEditor == null) continue;
|
||||
|
||||
var tempConfig = dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration;
|
||||
var tempConfig = dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId).Configuration;
|
||||
var valEditor = propEditor.GetValueEditor(tempConfig);
|
||||
var convValue = valEditor.ConvertDbToString(row.PropType, row.JsonRowValue[row.PropKey]?.ToString(), dataTypeService);
|
||||
row.JsonRowValue[row.PropKey] = convValue;
|
||||
var convValue = valEditor.ConvertDbToString(prop.Value.PropertyType, prop.Value.Value, dataTypeService);
|
||||
|
||||
// update the raw value since this is what will get serialized out
|
||||
row.RawPropertyValues[prop.Key] = convValue;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// deal with weird situations by ignoring them (no comment)
|
||||
row.JsonRowValue[row.PropKey] = null;
|
||||
row.RawPropertyValues.Remove(prop.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(deserialized).ToXmlString<string>();
|
||||
return JsonConvert.SerializeObject(rows).ToXmlString<string>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -134,98 +139,92 @@ namespace Umbraco.Web.PropertyEditors
|
||||
|
||||
// note: there is NO variant support here
|
||||
|
||||
// TODO: What does this do?
|
||||
public override object ToEditor(Property property, IDataTypeService dataTypeService, string culture = null, string segment = null)
|
||||
{
|
||||
var val = property.GetValue(culture, segment);
|
||||
|
||||
var vals = _nestedContentValues.GetPropertyValues(val, out var deserialized).ToList();
|
||||
var rows = _nestedContentValues.GetPropertyValues(val);
|
||||
|
||||
if (vals.Count == 0)
|
||||
if (rows.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
foreach (var row in vals)
|
||||
foreach (var row in rows.ToList())
|
||||
{
|
||||
if (row.PropType == null)
|
||||
{
|
||||
// type not found, and property is not system: just delete the value
|
||||
if (IsSystemPropertyKey(row.PropKey) == false)
|
||||
row.JsonRowValue[row.PropKey] = null;
|
||||
}
|
||||
else
|
||||
foreach(var prop in row.PropertyValues.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
// create a temp property with the value
|
||||
// - force it to be culture invariant as NC can't handle culture variant element properties
|
||||
row.PropType.Variations = ContentVariation.Nothing;
|
||||
var tempProp = new Property(row.PropType);
|
||||
tempProp.SetValue(row.JsonRowValue[row.PropKey] == null ? null : row.JsonRowValue[row.PropKey].ToString());
|
||||
prop.Value.PropertyType.Variations = ContentVariation.Nothing;
|
||||
var tempProp = new Property(prop.Value.PropertyType);
|
||||
|
||||
tempProp.SetValue(prop.Value.Value);
|
||||
|
||||
// convert that temp property, and store the converted value
|
||||
var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias];
|
||||
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
||||
if (propEditor == null)
|
||||
{
|
||||
row.JsonRowValue[row.PropKey] = tempProp.GetValue()?.ToString();
|
||||
// update the raw value since this is what will get serialized out
|
||||
row.RawPropertyValues[prop.Key] = tempProp.GetValue()?.ToString();
|
||||
continue;
|
||||
}
|
||||
|
||||
var tempConfig = dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration;
|
||||
var tempConfig = dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId).Configuration;
|
||||
var valEditor = propEditor.GetValueEditor(tempConfig);
|
||||
var convValue = valEditor.ToEditor(tempProp, dataTypeService);
|
||||
row.JsonRowValue[row.PropKey] = convValue == null ? null : JToken.FromObject(convValue);
|
||||
|
||||
// update the raw value since this is what will get serialized out
|
||||
row.RawPropertyValues[prop.Key] = convValue == null ? null : JToken.FromObject(convValue);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// deal with weird situations by ignoring them (no comment)
|
||||
row.JsonRowValue[row.PropKey] = null;
|
||||
row.RawPropertyValues.Remove(prop.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return json
|
||||
return deserialized;
|
||||
return rows;
|
||||
}
|
||||
|
||||
// TODO: What does this do?
|
||||
public override object FromEditor(ContentPropertyData editorValue, object currentValue)
|
||||
{
|
||||
if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString()))
|
||||
return null;
|
||||
|
||||
var vals = _nestedContentValues.GetPropertyValues(editorValue.Value, out var deserialized).ToList();
|
||||
var rows = _nestedContentValues.GetPropertyValues(editorValue.Value);
|
||||
|
||||
if (vals.Count == 0)
|
||||
if (rows.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
foreach (var row in vals)
|
||||
foreach (var row in rows.ToList())
|
||||
{
|
||||
if (row.PropType == null)
|
||||
{
|
||||
// type not found, and property is not system: just delete the value
|
||||
if (IsSystemPropertyKey(row.PropKey) == false)
|
||||
row.JsonRowValue[row.PropKey] = null;
|
||||
}
|
||||
else
|
||||
foreach(var prop in row.PropertyValues.ToList())
|
||||
{
|
||||
// Fetch the property types prevalue
|
||||
var propConfiguration = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration;
|
||||
var propConfiguration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId).Configuration;
|
||||
|
||||
// Lookup the property editor
|
||||
var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias];
|
||||
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
||||
if (propEditor == null) continue;
|
||||
|
||||
// Create a fake content property data object
|
||||
var contentPropData = new ContentPropertyData(row.JsonRowValue[row.PropKey], propConfiguration);
|
||||
var contentPropData = new ContentPropertyData(prop.Value.Value, propConfiguration);
|
||||
|
||||
// Get the property editor to do it's conversion
|
||||
var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, row.JsonRowValue[row.PropKey]);
|
||||
var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, prop.Value.Value);
|
||||
|
||||
// Store the value back
|
||||
row.JsonRowValue[row.PropKey] = (newValue == null) ? null : JToken.FromObject(newValue);
|
||||
// update the raw value since this is what will get serialized out
|
||||
row.RawPropertyValues[prop.Key] = (newValue == null) ? null : JToken.FromObject(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
// return json
|
||||
return JsonConvert.SerializeObject(deserialized);
|
||||
return JsonConvert.SerializeObject(rows);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -235,98 +234,58 @@ namespace Umbraco.Web.PropertyEditors
|
||||
|
||||
var result = new List<UmbracoEntityReference>();
|
||||
|
||||
foreach (var row in _nestedContentValues.GetPropertyValues(rawJson, out _))
|
||||
foreach (var row in _nestedContentValues.GetPropertyValues(rawJson))
|
||||
{
|
||||
if (row.PropType == null) continue;
|
||||
foreach(var prop in row.PropertyValues)
|
||||
{
|
||||
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
||||
|
||||
var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias];
|
||||
var valueEditor = propEditor?.GetValueEditor();
|
||||
if (!(valueEditor is IDataValueReference reference)) continue;
|
||||
|
||||
var valueEditor = propEditor?.GetValueEditor();
|
||||
if (!(valueEditor is IDataValueReference reference)) continue;
|
||||
var val = prop.Value.Value?.ToString();
|
||||
|
||||
var val = row.JsonRowValue[row.PropKey]?.ToString();
|
||||
var refs = reference.GetReferences(val);
|
||||
|
||||
var refs = reference.GetReferences(val);
|
||||
|
||||
result.AddRange(refs);
|
||||
result.AddRange(refs);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal class NestedContentValidator : IValueValidator
|
||||
/// <summary>
|
||||
/// Validator for nested content to ensure that all nesting of editors is validated
|
||||
/// </summary>
|
||||
internal class NestedContentValidator : ComplexEditorValidator
|
||||
{
|
||||
private readonly PropertyEditorCollection _propertyEditors;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly NestedContentValues _nestedContentValues;
|
||||
|
||||
public NestedContentValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, NestedContentValues nestedContentValues)
|
||||
public NestedContentValidator(NestedContentValues nestedContentValues, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService)
|
||||
: base(propertyEditors, dataTypeService, textService)
|
||||
{
|
||||
_propertyEditors = propertyEditors;
|
||||
_dataTypeService = dataTypeService;
|
||||
_nestedContentValues = nestedContentValues;
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(object rawValue, string valueType, object dataTypeConfiguration)
|
||||
protected override IEnumerable<ElementTypeValidationModel> GetElementTypeValidation(object value)
|
||||
{
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
foreach(var row in _nestedContentValues.GetPropertyValues(rawValue, out _))
|
||||
foreach (var row in _nestedContentValues.GetPropertyValues(value))
|
||||
{
|
||||
if (row.PropType == null) continue;
|
||||
|
||||
var config = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration;
|
||||
var propertyEditor = _propertyEditors[row.PropType.PropertyEditorAlias];
|
||||
if (propertyEditor == null) continue;
|
||||
|
||||
foreach (var validator in propertyEditor.GetValueEditor().Validators)
|
||||
var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Id);
|
||||
foreach (var prop in row.PropertyValues)
|
||||
{
|
||||
foreach (var result in validator.Validate(row.JsonRowValue[row.PropKey], propertyEditor.GetValueEditor().ValueType, config))
|
||||
{
|
||||
result.ErrorMessage = "Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' " + result.ErrorMessage;
|
||||
validationResults.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Check mandatory
|
||||
if (row.PropType.Mandatory)
|
||||
{
|
||||
if (row.JsonRowValue[row.PropKey] == null)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage)
|
||||
? $"'{row.PropType.Name}' cannot be null"
|
||||
: row.PropType.MandatoryMessage;
|
||||
validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey }));
|
||||
}
|
||||
else if (row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace() || (row.JsonRowValue[row.PropKey].Type == JTokenType.Array && !row.JsonRowValue[row.PropKey].HasValues))
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage)
|
||||
? $"'{row.PropType.Name}' cannot be empty"
|
||||
: row.PropType.MandatoryMessage;
|
||||
validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey }));
|
||||
}
|
||||
}
|
||||
|
||||
// Check regex
|
||||
if (!row.PropType.ValidationRegExp.IsNullOrWhiteSpace()
|
||||
&& row.JsonRowValue[row.PropKey] != null && !row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace())
|
||||
{
|
||||
var regex = new Regex(row.PropType.ValidationRegExp);
|
||||
if (!regex.IsMatch(row.JsonRowValue[row.PropKey].ToString()))
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(row.PropType.ValidationRegExpMessage)
|
||||
? $"'{row.PropType.Name}' is invalid, it does not match the correct pattern"
|
||||
: row.PropType.ValidationRegExpMessage;
|
||||
validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey }));
|
||||
}
|
||||
elementValidation.AddPropertyTypeValidation(
|
||||
new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value));
|
||||
}
|
||||
yield return elementValidation;
|
||||
}
|
||||
|
||||
return validationResults;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to deserialize the nested content serialized value
|
||||
/// </summary>
|
||||
internal class NestedContentValues
|
||||
{
|
||||
private readonly Lazy<Dictionary<string, IContentType>> _contentTypes;
|
||||
@@ -336,84 +295,110 @@ namespace Umbraco.Web.PropertyEditors
|
||||
_contentTypes = new Lazy<Dictionary<string, IContentType>>(() => contentTypeService.GetAll().ToDictionary(c => c.Alias));
|
||||
}
|
||||
|
||||
private IContentType GetElementType(JObject item)
|
||||
private IContentType GetElementType(NestedContentRowValue item)
|
||||
{
|
||||
var contentTypeAlias = item[ContentTypeAliasPropertyKey]?.ToObject<string>() ?? string.Empty;
|
||||
_contentTypes.Value.TryGetValue(contentTypeAlias, out var contentType);
|
||||
_contentTypes.Value.TryGetValue(item.ContentTypeAlias, out var contentType);
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public IEnumerable<RowValue> GetPropertyValues(object propertyValue, out List<JObject> deserialized)
|
||||
/// <summary>
|
||||
/// Deserialize the raw json property value
|
||||
/// </summary>
|
||||
/// <param name="propertyValue"></param>
|
||||
/// <returns></returns>
|
||||
public IReadOnlyList<NestedContentRowValue> GetPropertyValues(object propertyValue)
|
||||
{
|
||||
var rowValues = new List<RowValue>();
|
||||
|
||||
deserialized = null;
|
||||
|
||||
if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString()))
|
||||
return Enumerable.Empty<RowValue>();
|
||||
return new List<NestedContentRowValue>();
|
||||
|
||||
deserialized = JsonConvert.DeserializeObject<List<JObject>>(propertyValue.ToString());
|
||||
var rowValues = JsonConvert.DeserializeObject<List<NestedContentRowValue>>(propertyValue.ToString());
|
||||
|
||||
// There was a note here about checking if the result had zero items and if so it would return null, so we'll continue to do that
|
||||
// The original note was: "Issue #38 - Keep recursive property lookups working"
|
||||
// Which is from the original NC tracker: https://github.com/umco/umbraco-nested-content/issues/38
|
||||
// This check should be used everywhere when iterating NC prop values, instead of just the one previous place so that
|
||||
// empty values don't get persisted when there is nothing, it should actually be null.
|
||||
if (deserialized == null || deserialized.Count == 0)
|
||||
return Enumerable.Empty<RowValue>();
|
||||
if (rowValues == null || rowValues.Count == 0)
|
||||
return new List<NestedContentRowValue>();
|
||||
|
||||
var index = 0;
|
||||
var contentTypePropertyTypes = new Dictionary<string, Dictionary<string, PropertyType>>();
|
||||
|
||||
foreach (var o in deserialized)
|
||||
foreach (var row in rowValues)
|
||||
{
|
||||
var propValues = o;
|
||||
|
||||
var contentType = GetElementType(propValues);
|
||||
var contentType = GetElementType(row);
|
||||
if (contentType == null)
|
||||
continue;
|
||||
|
||||
var propertyTypes = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x);
|
||||
var propAliases = propValues.Properties().Select(x => x.Name);
|
||||
foreach (var propAlias in propAliases)
|
||||
// get the prop types for this content type but keep a dictionary of found ones so we don't have to keep re-looking and re-creating
|
||||
// objects on each iteration.
|
||||
if (!contentTypePropertyTypes.TryGetValue(contentType.Alias, out var propertyTypes))
|
||||
propertyTypes = contentTypePropertyTypes[contentType.Alias] = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x);
|
||||
|
||||
// find any keys that are not real property types and remove them
|
||||
foreach(var prop in row.RawPropertyValues.ToList())
|
||||
{
|
||||
propertyTypes.TryGetValue(propAlias, out var propType);
|
||||
rowValues.Add(new RowValue(propAlias, propType, propValues, index));
|
||||
if (IsSystemPropertyKey(prop.Key)) continue;
|
||||
|
||||
// doesn't exist so remove it
|
||||
if (!propertyTypes.TryGetValue(prop.Key, out var propType))
|
||||
{
|
||||
row.RawPropertyValues.Remove(prop.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
// set the value to include the resolved property type
|
||||
row.PropertyValues[prop.Key] = new NestedContentPropertyValue
|
||||
{
|
||||
PropertyType = propType,
|
||||
Value = prop.Value
|
||||
};
|
||||
}
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return rowValues;
|
||||
}
|
||||
|
||||
internal class RowValue
|
||||
/// <summary>
|
||||
/// Used during deserialization to populate the property value/property type of a nested content row property
|
||||
/// </summary>
|
||||
internal class NestedContentPropertyValue
|
||||
{
|
||||
public RowValue(string propKey, PropertyType propType, JObject propValues, int index)
|
||||
{
|
||||
PropKey = propKey ?? throw new ArgumentNullException(nameof(propKey));
|
||||
PropType = propType;
|
||||
JsonRowValue = propValues ?? throw new ArgumentNullException(nameof(propValues));
|
||||
RowIndex = index;
|
||||
}
|
||||
public object Value { get; set; }
|
||||
public PropertyType PropertyType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to deserialize a nested content row
|
||||
/// </summary>
|
||||
internal class NestedContentRowValue
|
||||
{
|
||||
[JsonProperty("key")]
|
||||
public Guid Id{ get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("ncContentTypeAlias")]
|
||||
public string ContentTypeAlias { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The current property key being iterated for the row value
|
||||
/// The remaining properties will be serialized to a dictionary
|
||||
/// </summary>
|
||||
public string PropKey { get; }
|
||||
/// <remarks>
|
||||
/// The JsonExtensionDataAttribute is used to put the non-typed properties into a bucket
|
||||
/// http://www.newtonsoft.com/json/help/html/DeserializeExtensionData.htm
|
||||
/// NestedContent serializes to string, int, whatever eg
|
||||
/// "stringValue":"Some String","numericValue":125,"otherNumeric":null
|
||||
/// </remarks>
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, object> RawPropertyValues { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="PropertyType"/> of the value (if any), this may be null
|
||||
/// Used during deserialization to convert the raw property data into data with a property type context
|
||||
/// </summary>
|
||||
public PropertyType PropType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The json values for the current row
|
||||
/// </summary>
|
||||
public JObject JsonRowValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The Nested Content row index
|
||||
/// </summary>
|
||||
public int RowIndex { get; }
|
||||
[JsonIgnore]
|
||||
public IDictionary<string, NestedContentPropertyValue> PropertyValues { get; set; } = new Dictionary<string, NestedContentPropertyValue>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors.Validation
|
||||
{
|
||||
/// <summary>
|
||||
/// A collection of <see cref="ComplexEditorPropertyTypeValidationResult"/> for an element type within complex editor represented by an Element Type
|
||||
/// </summary>
|
||||
public class ComplexEditorElementTypeValidationResult : ValidationResult
|
||||
{
|
||||
public ComplexEditorElementTypeValidationResult(string elementTypeAlias, Guid blockId)
|
||||
: base(string.Empty)
|
||||
{
|
||||
ElementTypeAlias = elementTypeAlias;
|
||||
BlockId = blockId;
|
||||
}
|
||||
|
||||
public IList<ComplexEditorPropertyTypeValidationResult> ValidationResults { get; } = new List<ComplexEditorPropertyTypeValidationResult>();
|
||||
|
||||
/// <summary>
|
||||
/// The element type alias of the validation result
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is useful for debugging purposes but it's not actively used in the angular app
|
||||
/// </remarks>
|
||||
public string ElementTypeAlias { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The Block ID of the validation result
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the GUID id of the content item based on the element type
|
||||
/// </remarks>
|
||||
public Guid BlockId { get; }
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors.Validation
|
||||
{
|
||||
/// <summary>
|
||||
/// A collection of <see cref="ValidationResult"/> for a property type within a complex editor represented by an Element Type
|
||||
/// </summary>
|
||||
public class ComplexEditorPropertyTypeValidationResult : ValidationResult
|
||||
{
|
||||
public ComplexEditorPropertyTypeValidationResult(string propertyTypeAlias)
|
||||
: base(string.Empty)
|
||||
{
|
||||
PropertyTypeAlias = propertyTypeAlias;
|
||||
}
|
||||
|
||||
private readonly List<ValidationResult> _validationResults = new List<ValidationResult>();
|
||||
|
||||
public void AddValidationResult(ValidationResult validationResult)
|
||||
{
|
||||
if (validationResult is ComplexEditorValidationResult && _validationResults.Any(x => x is ComplexEditorValidationResult))
|
||||
throw new InvalidOperationException($"Cannot add more than one {typeof(ComplexEditorValidationResult)}");
|
||||
|
||||
_validationResults.Add(validationResult);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ValidationResult> ValidationResults => _validationResults;
|
||||
public string PropertyTypeAlias { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors.Validation
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// A collection of <see cref="ComplexEditorElementTypeValidationResult"/> for a complex editor represented by an Element Type
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For example, each <see cref="ComplexEditorValidationResult"/> represents validation results for a row in Nested Content
|
||||
/// </remarks>
|
||||
public class ComplexEditorValidationResult : ValidationResult
|
||||
{
|
||||
public ComplexEditorValidationResult()
|
||||
: base(string.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
public IList<ComplexEditorElementTypeValidationResult> ValidationResults { get; } = new List<ComplexEditorElementTypeValidationResult>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors.Validation
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom <see cref="ValidationResult"/> for content properties
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This clones the original result and then ensures the nested result if it's the correct type
|
||||
/// </remarks>
|
||||
public class ContentPropertyValidationResult : ValidationResult
|
||||
{
|
||||
private readonly string _culture;
|
||||
private readonly string _segment;
|
||||
|
||||
public ContentPropertyValidationResult(ValidationResult nested, string culture, string segment)
|
||||
: base(nested.ErrorMessage, nested.MemberNames)
|
||||
{
|
||||
ComplexEditorResults = nested as ComplexEditorValidationResult;
|
||||
_culture = culture;
|
||||
_segment = segment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nested validation results for the content property
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There can be nested results for complex editors that contain other editors
|
||||
/// </remarks>
|
||||
public ComplexEditorValidationResult ComplexEditorResults { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Return the <see cref="ValidationResult.ErrorMessage"/> if <see cref="ComplexEditorResults"/> is null, else the serialized
|
||||
/// complex validation results
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public override string ToString()
|
||||
{
|
||||
if (ComplexEditorResults == null)
|
||||
return base.ToString();
|
||||
|
||||
var json = JsonConvert.SerializeObject(this, new ValidationResultConverter(_culture, _segment));
|
||||
return json;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Web.Http.ModelBinding;
|
||||
using Umbraco.Core;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors.Validation
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom json converter for <see cref="ValidationResult"/> and <see cref="ContentPropertyValidationResult"/>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This converter is specifically used to convert validation results for content in order to be able to have nested
|
||||
/// validation results for complex editors.
|
||||
/// </remarks>
|
||||
internal class ValidationResultConverter : JsonConverter
|
||||
{
|
||||
private readonly string _culture;
|
||||
private readonly string _segment;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="culture">The culture of the containing property which will be transfered to all child model state</param>
|
||||
/// <param name="segment">The segment of the containing property which will be transfered to all child model state</param>
|
||||
public ValidationResultConverter(string culture = "", string segment = "")
|
||||
{
|
||||
_culture = culture;
|
||||
_segment = segment;
|
||||
}
|
||||
|
||||
public override bool CanConvert(Type objectType) => typeof(ValidationResult).IsAssignableFrom(objectType);
|
||||
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
var camelCaseSerializer = new JsonSerializer
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
DefaultValueHandling = DefaultValueHandling.Ignore
|
||||
};
|
||||
foreach (var c in serializer.Converters)
|
||||
camelCaseSerializer.Converters.Add(c);
|
||||
|
||||
var validationResult = (ValidationResult)value;
|
||||
|
||||
if (validationResult is ComplexEditorValidationResult nestedResult && nestedResult.ValidationResults.Count > 0)
|
||||
{
|
||||
var ja = new JArray();
|
||||
foreach(var nested in nestedResult.ValidationResults)
|
||||
{
|
||||
// recurse to write out the ComplexEditorElementTypeValidationResult
|
||||
var block = JObject.FromObject(nested, camelCaseSerializer);
|
||||
ja.Add(block);
|
||||
}
|
||||
if (nestedResult.ValidationResults.Count > 0)
|
||||
{
|
||||
ja.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
else if (validationResult is ComplexEditorElementTypeValidationResult elementTypeValidationResult && elementTypeValidationResult.ValidationResults.Count > 0)
|
||||
{
|
||||
var joElementType = new JObject
|
||||
{
|
||||
{ "$id", elementTypeValidationResult.BlockId },
|
||||
|
||||
// We don't use this anywhere, though it's nice for debugging
|
||||
{ "$elementTypeAlias", elementTypeValidationResult.ElementTypeAlias }
|
||||
};
|
||||
|
||||
var modelState = new ModelStateDictionary();
|
||||
|
||||
// loop over property validations
|
||||
foreach (var propTypeResult in elementTypeValidationResult.ValidationResults)
|
||||
{
|
||||
// group the results by their type and iterate the groups
|
||||
foreach (var result in propTypeResult.ValidationResults.GroupBy(x => x.GetType()))
|
||||
{
|
||||
// if the group's type isn't ComplexEditorValidationResult then it will in 99% of cases be
|
||||
// just ValidationResult for whcih we want to create the sub "ModelState" data. If it's not a normal
|
||||
// ValidationResult it will still just be converted to normal ModelState.
|
||||
|
||||
if (result.Key == typeof(ComplexEditorValidationResult))
|
||||
{
|
||||
// if it's ComplexEditorValidationResult then there can only be one which is validated so just get the single
|
||||
if (result.Any())
|
||||
{
|
||||
var complexResult = result.Single();
|
||||
// recurse to get the validation result object
|
||||
var obj = JToken.FromObject(complexResult, camelCaseSerializer);
|
||||
joElementType.Add(propTypeResult.PropertyTypeAlias, obj);
|
||||
|
||||
// For any nested property error we add the model state as empty state for that nested property
|
||||
// NOTE: Instead of the empty validation message we could put in the translated
|
||||
// "errors/propertyHasErrors" message, however I think that leaves for less flexibility since it could/should be
|
||||
// up to the front-end validator to show whatever message it wants (if any) for an error indicating a nested property error.
|
||||
// Will leave blank.
|
||||
modelState.AddPropertyValidationError(new ValidationResult(string.Empty), propTypeResult.PropertyTypeAlias, _culture, _segment);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var v in result)
|
||||
{
|
||||
modelState.AddPropertyValidationError(v, propTypeResult.PropertyTypeAlias, _culture, _segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modelState.Count > 0)
|
||||
{
|
||||
joElementType.Add("ModelState", JObject.FromObject(modelState.ToErrorDictionary()));
|
||||
}
|
||||
|
||||
joElementType.WriteTo(writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
if (validationResult is ContentPropertyValidationResult propertyValidationResult
|
||||
&& propertyValidationResult.ComplexEditorResults?.ValidationResults.Count > 0)
|
||||
{
|
||||
// recurse to write out the NestedValidationResults
|
||||
var obj = JToken.FromObject(propertyValidationResult.ComplexEditorResults, camelCaseSerializer);
|
||||
obj.WriteTo(writer);
|
||||
}
|
||||
|
||||
var jo = new JObject();
|
||||
if (!validationResult.ErrorMessage.IsNullOrWhiteSpace())
|
||||
{
|
||||
var errObj = JToken.FromObject(validationResult.ErrorMessage, camelCaseSerializer);
|
||||
jo.Add("errorMessage", errObj);
|
||||
}
|
||||
if (validationResult.MemberNames.Any())
|
||||
{
|
||||
var memberNamesObj = JToken.FromObject(validationResult.MemberNames, camelCaseSerializer);
|
||||
jo.Add("memberNames", memberNamesObj);
|
||||
}
|
||||
if (jo.HasValues)
|
||||
jo.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Models.Blocks;
|
||||
using Umbraco.Core.Models.PublishedContent;
|
||||
using Umbraco.Core.PropertyEditors;
|
||||
using Umbraco.Web.PublishedCache;
|
||||
|
||||
namespace Umbraco.Web.PropertyEditors.ValueConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts json block objects into <see cref="IPublishedElement"/>
|
||||
/// </summary>
|
||||
public sealed class BlockEditorConverter
|
||||
{
|
||||
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
|
||||
@@ -20,36 +24,25 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters
|
||||
}
|
||||
|
||||
public IPublishedElement ConvertToElement(
|
||||
JObject sourceObject, string contentTypeKeyPropertyKey,
|
||||
BlockEditorData.BlockItemData data,
|
||||
PropertyCacheLevel referenceCacheLevel, bool preview)
|
||||
{
|
||||
var elementTypeKey = sourceObject[contentTypeKeyPropertyKey]?.ToObject<Guid>();
|
||||
if (!elementTypeKey.HasValue)
|
||||
return null;
|
||||
|
||||
// hack! we need to cast, we have n ochoice beacuse we cannot make breaking changes.
|
||||
// hack! we need to cast, we have no choice beacuse we cannot make breaking changes.
|
||||
var publishedContentCache = _publishedSnapshotAccessor.PublishedSnapshot.Content as IPublishedContentCache2;
|
||||
if (publishedContentCache == null)
|
||||
throw new InvalidOperationException("The published content cache is not " + typeof(IPublishedContentCache2));
|
||||
|
||||
// only convert element types - content types will cause an exception when PublishedModelFactory creates the model
|
||||
var publishedContentType = publishedContentCache.GetContentType(elementTypeKey.Value);
|
||||
var publishedContentType = publishedContentCache.GetContentType(data.ContentTypeKey);
|
||||
if (publishedContentType == null || publishedContentType.IsElement == false)
|
||||
return null;
|
||||
|
||||
var propertyValues = sourceObject.ToObject<Dictionary<string, object>>();
|
||||
var propertyValues = data.RawPropertyValues;
|
||||
|
||||
if (!propertyValues.TryGetValue("key", out var keyo) || !Guid.TryParse(keyo.ToString(), out var key))
|
||||
{
|
||||
if (propertyValues.TryGetValue("udi", out var udio) && udio is string udis && GuidUdi.TryParse(udis, out var udi))
|
||||
{
|
||||
key = udi.Guid;
|
||||
}
|
||||
else
|
||||
{
|
||||
key = Guid.Empty;
|
||||
}
|
||||
}
|
||||
// Get the udi from the deserialized object. If this is empty we can fallback to checking the 'key' if there is one
|
||||
var key = (data.Udi is GuidUdi gudi) ? gudi.Guid : Guid.Empty;
|
||||
if (propertyValues.TryGetValue("key", out var keyo))
|
||||
Guid.TryParse(keyo.ToString(), out key);
|
||||
|
||||
IPublishedElement element = new PublishedElement(publishedContentType, key, propertyValues, preview, referenceCacheLevel, _publishedSnapshotAccessor);
|
||||
element = _publishedModelFactory.CreateModel(element);
|
||||
|
||||
+10
-18
@@ -61,22 +61,16 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters
|
||||
var value = (string)inter;
|
||||
if (string.IsNullOrWhiteSpace(value)) return model;
|
||||
|
||||
var objects = JsonConvert.DeserializeObject<JObject>(value);
|
||||
if (objects.Count == 0) return model;
|
||||
var converter = new BlockListEditorDataConverter();
|
||||
var converted = converter.Convert(value);
|
||||
if (converted.Blocks.Count == 0) return model;
|
||||
|
||||
var jsonLayout = objects["layout"] as JObject;
|
||||
if (jsonLayout == null) return model;
|
||||
|
||||
var jsonData = objects["data"] as JArray;
|
||||
if (jsonData == null) return model;
|
||||
|
||||
var blockListLayouts = jsonLayout[Constants.PropertyEditors.Aliases.BlockList] as JArray;
|
||||
if (blockListLayouts == null) return model;
|
||||
var blockListLayout = converted.Layout.ToObject<IEnumerable<BlockListLayoutItem>>();
|
||||
|
||||
// parse the data elements
|
||||
foreach (var data in jsonData.Cast<JObject>())
|
||||
foreach (var data in converted.Blocks)
|
||||
{
|
||||
var element = _blockConverter.ConvertToElement(data, BlockEditorPropertyEditor.ContentTypeKeyPropertyKey, referenceCacheLevel, preview);
|
||||
var element = _blockConverter.ConvertToElement(data, referenceCacheLevel, preview);
|
||||
if (element == null) continue;
|
||||
elements[element.Key] = element;
|
||||
}
|
||||
@@ -84,14 +78,12 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters
|
||||
// if there's no elements just return since if there's no data it doesn't matter what is stored in layout
|
||||
if (elements.Count == 0) return model;
|
||||
|
||||
foreach (var blockListLayout in blockListLayouts)
|
||||
foreach (var layoutItem in blockListLayout)
|
||||
{
|
||||
var settingsJson = blockListLayout["settings"] as JObject;
|
||||
// the result of this can be null, that's ok
|
||||
var element = settingsJson != null ? _blockConverter.ConvertToElement(settingsJson, BlockEditorPropertyEditor.ContentTypeKeyPropertyKey, referenceCacheLevel, preview) : null;
|
||||
var element = layoutItem.Settings != null ? _blockConverter.ConvertToElement(layoutItem.Settings, referenceCacheLevel, preview) : null;
|
||||
|
||||
if (!Udi.TryParse(blockListLayout.Value<string>("udi"), out var udi) || !(udi is GuidUdi guidUdi))
|
||||
continue;
|
||||
var guidUdi = (GuidUdi)layoutItem.Udi;
|
||||
|
||||
// get the data reference
|
||||
if (!elements.TryGetValue(guidUdi.Guid, out var data))
|
||||
@@ -107,7 +99,7 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters
|
||||
if (element != null && string.IsNullOrWhiteSpace(blockConfig.SettingsElementTypeKey))
|
||||
element = null;
|
||||
|
||||
var layoutRef = new BlockListLayoutReference(udi, data, element);
|
||||
var layoutRef = new BlockListLayoutReference(layoutItem.Udi, data, element);
|
||||
layout.Add(layoutRef);
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource
|
||||
if (dto.EditData == null)
|
||||
{
|
||||
if (Debugger.IsAttached)
|
||||
throw new Exception("Missing cmsContentNu edited content for node " + dto.Id + ", consider rebuilding.");
|
||||
throw new InvalidOperationException("Missing cmsContentNu edited content for node " + dto.Id + ", consider rebuilding.");
|
||||
Current.Logger.Warn<DatabaseDataSource>("Missing cmsContentNu edited content for node {NodeId}, consider rebuilding.", dto.Id);
|
||||
}
|
||||
else
|
||||
@@ -235,7 +235,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource
|
||||
if (dto.PubData == null)
|
||||
{
|
||||
if (Debugger.IsAttached)
|
||||
throw new Exception("Missing cmsContentNu published content for node " + dto.Id + ", consider rebuilding.");
|
||||
throw new InvalidOperationException("Missing cmsContentNu published content for node " + dto.Id + ", consider rebuilding.");
|
||||
Current.Logger.Warn<DatabaseDataSource>("Missing cmsContentNu published content for node {NodeId}, consider rebuilding.", dto.Id);
|
||||
}
|
||||
else
|
||||
@@ -274,7 +274,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource
|
||||
private static ContentNodeKit CreateMediaNodeKit(ContentSourceDto dto)
|
||||
{
|
||||
if (dto.EditData == null)
|
||||
throw new Exception("No data for media " + dto.Id);
|
||||
throw new InvalidOperationException("No data for media " + dto.Id);
|
||||
|
||||
var nested = DeserializeNestedData(dto.EditData);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Umbraco.Core;
|
||||
using Umbraco.Core.Composing;
|
||||
using Umbraco.Core.Configuration;
|
||||
using Umbraco.Core.Logging;
|
||||
using Umbraco.Core.Scoping;
|
||||
using Umbraco.Core.Sync;
|
||||
using Umbraco.Web.HealthCheck;
|
||||
|
||||
@@ -15,16 +16,17 @@ namespace Umbraco.Web.Scheduling
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly HealthCheckCollection _healthChecks;
|
||||
private readonly HealthCheckNotificationMethodCollection _notifications;
|
||||
private readonly IScopeProvider _scopeProvider;
|
||||
private readonly IProfilingLogger _logger;
|
||||
|
||||
public HealthCheckNotifier(IBackgroundTaskRunner<RecurringTaskBase> runner, int delayMilliseconds, int periodMilliseconds,
|
||||
HealthCheckCollection healthChecks, HealthCheckNotificationMethodCollection notifications,
|
||||
IRuntimeState runtimeState,
|
||||
IProfilingLogger logger)
|
||||
IScopeProvider scopeProvider, IRuntimeState runtimeState, IProfilingLogger logger)
|
||||
: base(runner, delayMilliseconds, periodMilliseconds)
|
||||
{
|
||||
_healthChecks = healthChecks;
|
||||
_notifications = notifications;
|
||||
_scopeProvider = scopeProvider;
|
||||
_runtimeState = runtimeState;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -51,6 +53,10 @@ namespace Umbraco.Web.Scheduling
|
||||
return false; // do NOT repeat, going down
|
||||
}
|
||||
|
||||
// Ensure we use an explicit scope since we are running on a background thread and plugin health
|
||||
// checks can be making service/database calls so we want to ensure the CallContext/Ambient scope
|
||||
// isn't used since that can be problematic.
|
||||
using (var scope = _scopeProvider.CreateScope())
|
||||
using (_logger.DebugDuration<HealthCheckNotifier>("Health checks executing", "Health checks complete"))
|
||||
{
|
||||
var healthCheckConfig = Current.Configs.HealthChecks();
|
||||
|
||||
@@ -70,8 +70,7 @@ namespace Umbraco.Web.Scheduling
|
||||
return false; // do NOT repeat, going down
|
||||
}
|
||||
|
||||
// running on a background task, and Log.CleanLogs uses the old SqlHelper,
|
||||
// better wrap in a scope and ensure it's all cleaned up and nothing leaks
|
||||
// Ensure we use an explicit scope since we are running on a background thread.
|
||||
using (var scope = _scopeProvider.CreateScope())
|
||||
using (_logger.DebugDuration<LogScrubber>("Log scrubbing executing", "Log scrubbing complete"))
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Logging;
|
||||
using Umbraco.Core.Scoping;
|
||||
using Umbraco.Core.Services;
|
||||
using Umbraco.Core.Sync;
|
||||
|
||||
@@ -55,30 +56,24 @@ namespace Umbraco.Web.Scheduling
|
||||
|
||||
try
|
||||
{
|
||||
// ensure we run with an UmbracoContext, because this may run in a background task,
|
||||
// yet developers may be using the 'current' UmbracoContext in the event handlers
|
||||
//
|
||||
// TODO: or maybe not, CacheRefresherComponent already ensures a context when handling events
|
||||
// - UmbracoContext 'current' needs to be refactored and cleaned up
|
||||
// - batched messenger should not depend on a current HttpContext
|
||||
// but then what should be its "scope"? could we attach it to scopes?
|
||||
// - and we should definitively *not* have to flush it here (should be auto)
|
||||
//
|
||||
using (var contextReference = _umbracoContextFactory.EnsureUmbracoContext())
|
||||
// We don't need an explicit scope here because PerformScheduledPublish creates it's own scope
|
||||
// so it's safe as it will create it's own ambient scope.
|
||||
// Ensure we run with an UmbracoContext, because this will run in a background task,
|
||||
// and developers may be using the UmbracoContext in the event handlers.
|
||||
|
||||
using var contextReference = _umbracoContextFactory.EnsureUmbracoContext();
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
// run
|
||||
var result = _contentService.PerformScheduledPublish(DateTime.Now);
|
||||
foreach (var grouped in result.GroupBy(x => x.Result))
|
||||
_logger.Info<ScheduledPublishing>("Scheduled publishing result: '{StatusCount}' items with status {Status}", grouped.Count(), grouped.Key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// if running on a temp context, we have to flush the messenger
|
||||
if (contextReference.IsRoot && Composing.Current.ServerMessenger is BatchedDatabaseServerMessenger m)
|
||||
m.FlushBatch();
|
||||
}
|
||||
// run
|
||||
var result = _contentService.PerformScheduledPublish(DateTime.Now);
|
||||
foreach (var grouped in result.GroupBy(x => x.Result))
|
||||
_logger.Info<ScheduledPublishing>("Scheduled publishing result: '{StatusCount}' items with status {Status}", grouped.Count(), grouped.Key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// if running on a temp context, we have to flush the messenger
|
||||
if (contextReference.IsRoot && Composing.Current.ServerMessenger is BatchedDatabaseServerMessenger m)
|
||||
m.FlushBatch();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -155,7 +155,7 @@ namespace Umbraco.Web.Scheduling
|
||||
}
|
||||
|
||||
var periodInMilliseconds = healthCheckConfig.NotificationSettings.PeriodInHours * 60 * 60 * 1000;
|
||||
var task = new HealthCheckNotifier(_healthCheckRunner, delayInMilliseconds, periodInMilliseconds, healthChecks, notifications, _runtime, logger);
|
||||
var task = new HealthCheckNotifier(_healthCheckRunner, delayInMilliseconds, periodInMilliseconds, healthChecks, notifications, _scopeProvider, _runtime, logger);
|
||||
_healthCheckRunner.TryAdd(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -249,8 +249,14 @@
|
||||
<Compile Include="PropertyEditors\BlockListConfigurationEditor.cs" />
|
||||
<Compile Include="PropertyEditors\BlockListPropertyEditor.cs" />
|
||||
<Compile Include="Compose\NestedContentPropertyComposer.cs" />
|
||||
<Compile Include="PropertyEditors\ComplexEditorValidator.cs" />
|
||||
<Compile Include="PropertyEditors\ParameterEditors\MultipleMediaPickerParameterEditor.cs" />
|
||||
<Compile Include="PropertyEditors\RichTextEditorPastedImages.cs" />
|
||||
<Compile Include="PropertyEditors\Validation\ComplexEditorElementTypeValidationResult.cs" />
|
||||
<Compile Include="PropertyEditors\Validation\ComplexEditorPropertyTypeValidationResult.cs" />
|
||||
<Compile Include="PropertyEditors\Validation\ComplexEditorValidationResult.cs" />
|
||||
<Compile Include="PropertyEditors\Validation\ContentPropertyValidationResult.cs" />
|
||||
<Compile Include="PropertyEditors\Validation\ValidationResultConverter.cs" />
|
||||
<Compile Include="PropertyEditors\ValueConverters\BlockEditorConverter.cs" />
|
||||
<Compile Include="PropertyEditors\ValueConverters\BlockListPropertyValueConverter.cs" />
|
||||
<Compile Include="PublishedCache\NuCache\PublishedSnapshotServiceOptions.cs" />
|
||||
|
||||
Reference in New Issue
Block a user