Browse Source

New validation logic for references and assets.

pull/596/head
Sebastian 5 years ago
parent
commit
7adcb6f7ca
  1. 1
      backend/i18n/frontend_en.json
  2. 1
      backend/i18n/frontend_it.json
  3. 1
      backend/i18n/frontend_nl.json
  4. 4
      backend/i18n/source/backend_en.json
  5. 4
      backend/i18n/source/backend_it.json
  6. 4
      backend/i18n/source/backend_nl.json
  7. 1
      backend/i18n/source/frontend_en.json
  8. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs
  9. 28
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/DefaultFieldValueValidatorsFactory.cs
  10. 156
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs
  11. 53
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs
  12. 86
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidatorBase.cs
  13. 70
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs
  14. 3
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs
  15. 8
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs
  16. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs
  17. 3
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs
  18. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  19. 28
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs
  20. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  21. 18
      backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs
  22. 8
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs
  23. 12
      backend/src/Squidex.Shared/Texts.it.resx
  24. 12
      backend/src/Squidex.Shared/Texts.nl.resx
  25. 12
      backend/src/Squidex.Shared/Texts.resx
  26. 5
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs
  27. 88
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs
  28. 99
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs
  29. 20
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs
  30. 76
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs
  31. 175
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs
  32. 3
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs
  33. 2
      frontend/app/features/content/shared/list/content-list-cell.directive.ts
  34. 6
      frontend/app/features/content/shared/references/content-selector-item.component.html
  35. 7
      frontend/app/features/content/shared/references/content-selector.component.html
  36. 4
      frontend/app/features/content/shared/references/reference-item.component.html
  37. 14
      frontend/app/features/schemas/pages/schema/fields/types/references-validation.component.html
  38. 3
      frontend/app/features/schemas/pages/schema/fields/types/references-validation.component.ts
  39. 1
      frontend/app/shared/services/schemas.types.ts

1
backend/i18n/frontend_en.json

@ -731,6 +731,7 @@
"schemas.fieldTypes.references.countMax": "Max Items", "schemas.fieldTypes.references.countMax": "Max Items",
"schemas.fieldTypes.references.countMin": "Min Items", "schemas.fieldTypes.references.countMin": "Min Items",
"schemas.fieldTypes.references.description": "Links to other content items.", "schemas.fieldTypes.references.description": "Links to other content items.",
"schemas.fieldTypes.references.mustBePublished": "References must be published",
"schemas.fieldTypes.references.resolveHint": "Show the name of the referenced item in content list when MaxItems is set to 1.", "schemas.fieldTypes.references.resolveHint": "Show the name of the referenced item in content list when MaxItems is set to 1.",
"schemas.fieldTypes.string.characters": "Characters", "schemas.fieldTypes.string.characters": "Characters",
"schemas.fieldTypes.string.charactersMax": "Max Characters", "schemas.fieldTypes.string.charactersMax": "Max Characters",

1
backend/i18n/frontend_it.json

@ -731,6 +731,7 @@
"schemas.fieldTypes.references.countMax": "Numero Max Elementi", "schemas.fieldTypes.references.countMax": "Numero Max Elementi",
"schemas.fieldTypes.references.countMin": "Numero Min Elementi", "schemas.fieldTypes.references.countMin": "Numero Min Elementi",
"schemas.fieldTypes.references.description": "Link ad altri elementi del contenuto.", "schemas.fieldTypes.references.description": "Link ad altri elementi del contenuto.",
"schemas.fieldTypes.references.mustBePublished": "References must be published",
"schemas.fieldTypes.references.resolveHint": "Mostra il nome dell'elemento collegato (reference) nella lista dei contenuti quando il numero massimo di elementi è impostato a 1.", "schemas.fieldTypes.references.resolveHint": "Mostra il nome dell'elemento collegato (reference) nella lista dei contenuti quando il numero massimo di elementi è impostato a 1.",
"schemas.fieldTypes.string.characters": "Caratteri", "schemas.fieldTypes.string.characters": "Caratteri",
"schemas.fieldTypes.string.charactersMax": "Max numero di Caratteri", "schemas.fieldTypes.string.charactersMax": "Max numero di Caratteri",

1
backend/i18n/frontend_nl.json

@ -731,6 +731,7 @@
"schemas.fieldTypes.references.countMax": "Max. aantal items", "schemas.fieldTypes.references.countMax": "Max. aantal items",
"schemas.fieldTypes.references.countMin": "Min. items", "schemas.fieldTypes.references.countMin": "Min. items",
"schemas.fieldTypes.references.description": "Links naar andere inhoudsitems.", "schemas.fieldTypes.references.description": "Links naar andere inhoudsitems.",
"schemas.fieldTypes.references.mustBePublished": "References must be published",
"schemas.fieldTypes.references.resolveHint": "Toon de naam van het item waarnaar wordt verwezen in de inhoudslijst wanneer MaxItems is ingesteld op 1.", "schemas.fieldTypes.references.resolveHint": "Toon de naam van het item waarnaar wordt verwezen in de inhoudslijst wanneer MaxItems is ingesteld op 1.",
"schemas.fieldTypes.string.characters": "Karakters", "schemas.fieldTypes.string.characters": "Karakters",
"schemas.fieldTypes.string.charactersMax": "Max. karakters", "schemas.fieldTypes.string.charactersMax": "Max. karakters",

4
backend/i18n/source/backend_en.json

@ -106,8 +106,6 @@
"common.properties": "Properties", "common.properties": "Properties",
"common.property": "Property", "common.property": "Property",
"common.readonlyMode": "Application is in readonly mode at the moment.", "common.readonlyMode": "Application is in readonly mode at the moment.",
"common.referenceNotFound": "Contains invalid reference '{id}'.",
"common.referenceToInvalidSchema": "Contains reference '{id}' to invalid schema.",
"common.remove": "Remove", "common.remove": "Remove",
"common.resultTooLarge": "Result set is too large to be retrieved. Use $take parameter to reduce the number of items.", "common.resultTooLarge": "Result set is too large to be retrieved. Use $take parameter to reduce the number of items.",
"common.role": "Role", "common.role": "Role",
@ -172,6 +170,8 @@
"contents.validation.normalCharactersBetween": "Must have between {min} and {max} text character(s).", "contents.validation.normalCharactersBetween": "Must have between {min} and {max} text character(s).",
"contents.validation.notAllowed": "Not an allowed value.", "contents.validation.notAllowed": "Not an allowed value.",
"contents.validation.pattern": "Must follow the pattern.", "contents.validation.pattern": "Must follow the pattern.",
"contents.validation.referenceNotFound": "Reference '{id}' not found.",
"contents.validation.referenceToInvalidSchema": "Reference '{id}' has invalid schema.",
"contents.validation.regexTooSlow": "Regex is too slow.", "contents.validation.regexTooSlow": "Regex is too slow.",
"contents.validation.required": "Field is required.", "contents.validation.required": "Field is required.",
"contents.validation.unique": "Another content with the same value exists.", "contents.validation.unique": "Another content with the same value exists.",

4
backend/i18n/source/backend_it.json

@ -105,8 +105,6 @@
"common.properties": "Proprietà", "common.properties": "Proprietà",
"common.property": "Proprietà", "common.property": "Proprietà",
"common.readonlyMode": "Al momento l'applicazione è in sola lettura.", "common.readonlyMode": "Al momento l'applicazione è in sola lettura.",
"common.referenceNotFound": "Contiene un collegamento '{id}' non valido.",
"common.referenceToInvalidSchema": "Contiene dei collegamenti '{id}' ad uno schema errato.",
"common.remove": "Rimuovi", "common.remove": "Rimuovi",
"common.resultTooLarge": "Il numero di risultati è troppo grande per essere recuperato. Utilizza il parametro $take per ridurre il numero di elementi.", "common.resultTooLarge": "Il numero di risultati è troppo grande per essere recuperato. Utilizza il parametro $take per ridurre il numero di elementi.",
"common.role": "Ruolo", "common.role": "Ruolo",
@ -169,6 +167,8 @@
"contents.validation.normalCharactersBetween": "Deve essere un testo tra {min} e {max} carattere(i).", "contents.validation.normalCharactersBetween": "Deve essere un testo tra {min} e {max} carattere(i).",
"contents.validation.notAllowed": "Non è un valore consentito.", "contents.validation.notAllowed": "Non è un valore consentito.",
"contents.validation.pattern": "Deve seguire il pattern.", "contents.validation.pattern": "Deve seguire il pattern.",
"contents.validation.referenceNotFound": "Contiene un collegamento '{id}' non valido.",
"contents.validation.referenceToInvalidSchema": "Contiene dei collegamenti '{id}' ad uno schema errato.",
"contents.validation.regexTooSlow": "La Regex è troppo lenta.", "contents.validation.regexTooSlow": "La Regex è troppo lenta.",
"contents.validation.required": "Il campo è obbligatorio.", "contents.validation.required": "Il campo è obbligatorio.",
"contents.validation.unique": "Esiste un altro contenuto con lo stesso valore.", "contents.validation.unique": "Esiste un altro contenuto con lo stesso valore.",

4
backend/i18n/source/backend_nl.json

@ -106,8 +106,6 @@
"common.properties": "Eigenschappen", "common.properties": "Eigenschappen",
"common.property": "Eigenschap", "common.property": "Eigenschap",
"common.readonlyMode": "Applicatie is momenteel in de alleen-lezen modus.", "common.readonlyMode": "Applicatie is momenteel in de alleen-lezen modus.",
"common.referenceNotFound": "Bevat ongeldige referentie '{id}'.",
"common.referenceToInvalidSchema": "Bevat verwijzing '{id}' naar ongeldig schema.",
"common.remove": "Verwijderen", "common.remove": "Verwijderen",
"common.resultTooLarge": "Resultaatset is te groot om opgehaald te worden. Gebruik $ take parameter om het aantal items te verminderen.", "common.resultTooLarge": "Resultaatset is te groot om opgehaald te worden. Gebruik $ take parameter om het aantal items te verminderen.",
"common.role": "Rol", "common.role": "Rol",
@ -172,6 +170,8 @@
"contents.validation.normalCharactersBetween": "Moet tussen {min} en {max} tekstteken (s) bevatten.", "contents.validation.normalCharactersBetween": "Moet tussen {min} en {max} tekstteken (s) bevatten.",
"contents.validation.notAllowed": "Geen toegestane waarde.", "contents.validation.notAllowed": "Geen toegestane waarde.",
"contents.validation.pattern": "Moet het patroon volgen.", "contents.validation.pattern": "Moet het patroon volgen.",
"contents.validation.referenceNotFound": "Bevat ongeldige referentie '{id}'.",
"contents.validation.referenceToInvalidSchema": "Bevat verwijzing '{id}' naar ongeldig schema.",
"contents.validation.regexTooSlow": "Regex is te traag.", "contents.validation.regexTooSlow": "Regex is te traag.",
"contents.validation.required": "Veld is verplicht.", "contents.validation.required": "Veld is verplicht.",
"contents.validation.unique": "Er bestaat een andere inhoud met dezelfde waarde.", "contents.validation.unique": "Er bestaat een andere inhoud met dezelfde waarde.",

1
backend/i18n/source/frontend_en.json

@ -731,6 +731,7 @@
"schemas.fieldTypes.references.countMax": "Max Items", "schemas.fieldTypes.references.countMax": "Max Items",
"schemas.fieldTypes.references.countMin": "Min Items", "schemas.fieldTypes.references.countMin": "Min Items",
"schemas.fieldTypes.references.description": "Links to other content items.", "schemas.fieldTypes.references.description": "Links to other content items.",
"schemas.fieldTypes.references.mustBePublished": "References must be published",
"schemas.fieldTypes.references.resolveHint": "Show the name of the referenced item in content list when MaxItems is set to 1.", "schemas.fieldTypes.references.resolveHint": "Show the name of the referenced item in content list when MaxItems is set to 1.",
"schemas.fieldTypes.string.characters": "Characters", "schemas.fieldTypes.string.characters": "Characters",
"schemas.fieldTypes.string.charactersMax": "Max Characters", "schemas.fieldTypes.string.charactersMax": "Max Characters",

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs

@ -23,6 +23,8 @@ namespace Squidex.Domain.Apps.Core.Schemas
public bool AllowDuplicates { get; set; } public bool AllowDuplicates { get; set; }
public bool MustBePublished { get; set; }
public string[]? DefaultValue { get; set; } public string[]? DefaultValue { get; set; }
public ReferencesFieldEditor Editor { get; set; } public ReferencesFieldEditor Editor { get; set; }

28
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/DefaultFieldValueValidatorsFactory.cs

@ -59,19 +59,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
public IEnumerable<IValidator> Visit(IField<AssetsFieldProperties> field) public IEnumerable<IValidator> Visit(IField<AssetsFieldProperties> field)
{ {
var properties = field.Properties; yield break;
var isRequired = IsRequired(properties);
if (isRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue)
{
yield return new CollectionValidator(isRequired, properties.MinItems, properties.MaxItems);
}
if (!properties.AllowDuplicates)
{
yield return new UniqueValuesValidator<string>();
}
} }
public IEnumerable<IValidator> Visit(IField<BooleanFieldProperties> field) public IEnumerable<IValidator> Visit(IField<BooleanFieldProperties> field)
@ -151,19 +139,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
public IEnumerable<IValidator> Visit(IField<ReferencesFieldProperties> field) public IEnumerable<IValidator> Visit(IField<ReferencesFieldProperties> field)
{ {
var properties = field.Properties; yield break;
var isRequired = IsRequired(properties);
if (isRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue)
{
yield return new CollectionValidator(isRequired, properties.MinItems, properties.MaxItems);
}
if (!properties.AllowDuplicates)
{
yield return new UniqueValuesValidator<string>();
}
} }
public IEnumerable<IValidator> Visit(IField<StringFieldProperties> field) public IEnumerable<IValidator> Visit(IField<StringFieldProperties> field)

156
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
@ -21,20 +22,34 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
public sealed class AssetsValidator : IValidator public sealed class AssetsValidator : IValidator
{ {
private readonly AssetsFieldProperties properties; private readonly AssetsFieldProperties properties;
private readonly CollectionValidator? collectionValidator;
private readonly UniqueValuesValidator<DomainId>? uniqueValidator;
private readonly CheckAssets checkAssets; private readonly CheckAssets checkAssets;
public AssetsValidator(AssetsFieldProperties properties, CheckAssets checkAssets) public AssetsValidator(bool isRequired, AssetsFieldProperties properties, CheckAssets checkAssets)
{ {
Guard.NotNull(properties, nameof(properties)); Guard.NotNull(properties, nameof(properties));
Guard.NotNull(checkAssets, nameof(checkAssets)); Guard.NotNull(checkAssets, nameof(checkAssets));
this.properties = properties; this.properties = properties;
if (isRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue)
{
collectionValidator = new CollectionValidator(isRequired, properties.MinItems, properties.MaxItems);
}
if (!properties.AllowDuplicates)
{
uniqueValidator = new UniqueValuesValidator<DomainId>();
}
this.checkAssets = checkAssets; this.checkAssets = checkAssets;
} }
public async Task ValidateAsync(object? value, ValidationContext context, AddError addError) public async Task ValidateAsync(object? value, ValidationContext context, AddError addError)
{ {
var foundIds = new List<DomainId>();
if (value is ICollection<DomainId> assetIds && assetIds.Count > 0) if (value is ICollection<DomainId> assetIds && assetIds.Count > 0)
{ {
var assets = await checkAssets(assetIds); var assets = await checkAssets(assetIds);
@ -50,80 +65,111 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
if (asset == null) if (asset == null)
{ {
addError(path, T.Get("contents.validation.assetNotFound", new { id = assetId })); if (context.Action == ValidationAction.Upsert)
{
addError(path, T.Get("contents.validation.assetNotFound", new { id = assetId }));
}
continue; continue;
} }
if (properties.MinSize.HasValue && asset.FileSize < properties.MinSize) foundIds.Add(asset.AssetId);
{
var min = properties.MinSize.Value.ToReadableSize();
addError(path, T.Get("contents.validation.minimumSize", new { size = asset.FileSize.ToReadableSize(), min })); ValidateCommon(asset, path, addError);
}
if (properties.MaxSize.HasValue && asset.FileSize > properties.MaxSize) if (asset.Type != AssetType.Image)
{ {
var max = properties.MaxSize.Value.ToReadableSize(); ValidateNonImage(path, addError);
addError(path, T.Get("contents.validation.maximumSize", new { size = asset.FileSize.ToReadableSize(), max }));
} }
else
if (properties.AllowedExtensions != null &&
properties.AllowedExtensions.Count > 0 &&
!properties.AllowedExtensions.Any(x => asset.FileName.EndsWith("." + x, StringComparison.OrdinalIgnoreCase)))
{ {
addError(path, T.Get("contents.validation.extension")); ValidateImage(asset, path, addError);
} }
}
}
if (asset.Type != AssetType.Image) if (collectionValidator != null)
{ {
if (properties.MustBeImage) await collectionValidator.ValidateAsync(foundIds, context, addError);
{ }
addError(path, T.Get("contents.validation.image"));
}
continue; if (uniqueValidator != null)
} {
await uniqueValidator.ValidateAsync(foundIds, context, addError);
}
}
var pixelWidth = asset.Metadata.GetPixelWidth(); private void ValidateCommon(IAssetInfo asset, ImmutableQueue<string> path, AddError addError)
var pixelHeight = asset.Metadata.GetPixelHeight(); {
if (properties.MinSize.HasValue && asset.FileSize < properties.MinSize)
{
var min = properties.MinSize.Value.ToReadableSize();
if (pixelWidth.HasValue && pixelHeight.HasValue) addError(path, T.Get("contents.validation.minimumSize", new { size = asset.FileSize.ToReadableSize(), min }));
{ }
var w = pixelWidth.Value;
var h = pixelHeight.Value;
var actualRatio = (double)w / h; if (properties.MaxSize.HasValue && asset.FileSize > properties.MaxSize)
{
var max = properties.MaxSize.Value.ToReadableSize();
if (properties.MinWidth.HasValue && w < properties.MinWidth) addError(path, T.Get("contents.validation.maximumSize", new { size = asset.FileSize.ToReadableSize(), max }));
{ }
addError(path, T.Get("contents.validation.minimumWidth", new { width = w, min = properties.MinWidth }));
}
if (properties.MaxWidth.HasValue && w > properties.MaxWidth) if (properties.AllowedExtensions != null &&
{ properties.AllowedExtensions.Count > 0 &&
addError(path, T.Get("contents.validation.maximumWidth", new { width = w, max = properties.MaxWidth })); !properties.AllowedExtensions.Any(x => asset.FileName.EndsWith("." + x, StringComparison.OrdinalIgnoreCase)))
} {
addError(path, T.Get("contents.validation.extension"));
}
}
if (properties.MinHeight.HasValue && h < properties.MinHeight) private void ValidateNonImage(ImmutableQueue<string> path, AddError addError)
{ {
addError(path, T.Get("contents.validation.minimumHeight", new { height = h, min = properties.MinHeight })); if (properties.MustBeImage)
} {
addError(path, T.Get("contents.validation.image"));
}
}
if (properties.MaxHeight.HasValue && h > properties.MaxHeight) private void ValidateImage(IAssetInfo asset, ImmutableQueue<string> path, AddError addError)
{ {
addError(path, T.Get("contents.validation.maximumHeight", new { height = h, max = properties.MaxHeight })); var pixelWidth = asset.Metadata.GetPixelWidth();
} var pixelHeight = asset.Metadata.GetPixelHeight();
if (properties.AspectHeight.HasValue && properties.AspectWidth.HasValue) if (pixelWidth.HasValue && pixelHeight.HasValue)
{ {
var expectedRatio = (double)properties.AspectWidth.Value / properties.AspectHeight.Value; var w = pixelWidth.Value;
var h = pixelHeight.Value;
if (Math.Abs(expectedRatio - actualRatio) > double.Epsilon) var actualRatio = (double)w / h;
{
addError(path, T.Get("contents.validation.aspectRatio", new { width = properties.AspectWidth, height = properties.AspectHeight })); if (properties.MinWidth.HasValue && w < properties.MinWidth)
} {
} addError(path, T.Get("contents.validation.minimumWidth", new { width = w, min = properties.MinWidth }));
}
if (properties.MaxWidth.HasValue && w > properties.MaxWidth)
{
addError(path, T.Get("contents.validation.maximumWidth", new { width = w, max = properties.MaxWidth }));
}
if (properties.MinHeight.HasValue && h < properties.MinHeight)
{
addError(path, T.Get("contents.validation.minimumHeight", new { height = h, min = properties.MinHeight }));
}
if (properties.MaxHeight.HasValue && h > properties.MaxHeight)
{
addError(path, T.Get("contents.validation.maximumHeight", new { height = h, max = properties.MaxHeight }));
}
if (properties.AspectHeight.HasValue && properties.AspectWidth.HasValue)
{
var expectedRatio = (double)properties.AspectWidth.Value / properties.AspectHeight.Value;
if (Math.Abs(expectedRatio - actualRatio) > double.Epsilon)
{
addError(path, T.Get("contents.validation.aspectRatio", new { width = properties.AspectWidth, height = properties.AspectHeight }));
} }
} }
} }

53
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs

@ -5,21 +5,66 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{ {
public sealed class CollectionValidator : CollectionValidatorBase, IValidator public sealed class CollectionValidator : IValidator
{ {
private readonly bool isRequired;
private readonly int? minItems;
private readonly int? maxItems;
public CollectionValidator(bool isRequired, int? minItems = null, int? maxItems = null) public CollectionValidator(bool isRequired, int? minItems = null, int? maxItems = null)
: base(isRequired, minItems, maxItems)
{ {
if (minItems.HasValue && maxItems.HasValue && minItems > maxItems)
{
throw new ArgumentException("Min length must be greater than max length.", nameof(minItems));
}
this.isRequired = isRequired;
this.minItems = minItems;
this.maxItems = maxItems;
} }
public Task ValidateAsync(object? value, ValidationContext context, AddError addError) public Task ValidateAsync(object? value, ValidationContext context, AddError addError)
{ {
ValidateRequired(value, context, addError); if (!(value is ICollection items) || items.Count == 0)
ValidateSize(value, context, addError); {
if (isRequired && !context.IsOptional)
{
addError(context.Path, T.Get("contents.validation.required"));
}
return Task.CompletedTask;
}
if (minItems.HasValue && maxItems.HasValue)
{
if (minItems == maxItems && minItems != items.Count)
{
addError(context.Path, T.Get("contents.validation.itemCount", new { count = minItems }));
}
else if (items.Count < minItems || items.Count > maxItems)
{
addError(context.Path, T.Get("contents.validation.itemCountBetween", new { min = minItems, max = maxItems }));
}
}
else
{
if (minItems.HasValue && items.Count < minItems)
{
addError(context.Path, T.Get("contents.validation.minItems", new { min = minItems }));
}
if (maxItems.HasValue && items.Count > maxItems)
{
addError(context.Path, T.Get("contents.validation.maxItems", new { max = maxItems }));
}
}
return Task.CompletedTask; return Task.CompletedTask;
} }

86
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidatorBase.cs

@ -1,86 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{
public abstract class CollectionValidatorBase
{
private readonly bool isRequired;
private readonly int? minItems;
private readonly int? maxItems;
protected CollectionValidatorBase(bool isRequired, int? minItems = null, int? maxItems = null)
{
if (minItems.HasValue && maxItems.HasValue && minItems > maxItems)
{
throw new ArgumentException("Min length must be greater than max length.", nameof(minItems));
}
this.isRequired = isRequired;
this.minItems = minItems;
this.maxItems = maxItems;
}
protected void ValidateRequired(object? value, ValidationContext context, AddError addError)
{
var size = 0;
if (value is ICollection items)
{
size = items.Count;
}
if (size == 0 && isRequired && !context.IsOptional)
{
addError(context.Path, T.Get("contents.validation.required"));
}
}
protected void ValidateSize(object? value, ValidationContext context, AddError addError)
{
var size = 0;
if (value is ICollection items)
{
size = items.Count;
}
if (size == 0)
{
return;
}
if (minItems.HasValue && maxItems.HasValue)
{
if (minItems == maxItems && minItems != size)
{
addError(context.Path, T.Get("contents.validation.itemCount", new { count = minItems }));
}
else if (size < minItems || size > maxItems)
{
addError(context.Path, T.Get("contents.validation.itemCountBetween", new { min = minItems, max = maxItems }));
}
}
else
{
if (minItems.HasValue && size < minItems)
{
addError(context.Path, T.Get("contents.validation.minItems", new { min = minItems }));
}
if (maxItems.HasValue && size > maxItems)
{
addError(context.Path, T.Get("contents.validation.maxItems", new { max = maxItems }));
}
}
}
}
}

70
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs

@ -8,47 +8,99 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{ {
public delegate Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> CheckContentsByIds(HashSet<DomainId> ids); public delegate Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> CheckContentsByIds(HashSet<DomainId> ids);
public sealed class ReferencesValidator : IValidator public sealed class ReferencesValidator : IValidator
{ {
private readonly IEnumerable<DomainId>? schemaIds; private readonly ReferencesFieldProperties properties;
private readonly CollectionValidator? collectionValidator;
private readonly UniqueValuesValidator<DomainId>? uniqueValidator;
private readonly CheckContentsByIds checkReferences; private readonly CheckContentsByIds checkReferences;
public ReferencesValidator(IEnumerable<DomainId>? schemaIds, CheckContentsByIds checkReferences) public ReferencesValidator(bool isRequired, ReferencesFieldProperties properties, CheckContentsByIds checkReferences)
{ {
Guard.NotNull(properties, nameof(properties));
Guard.NotNull(checkReferences, nameof(checkReferences)); Guard.NotNull(checkReferences, nameof(checkReferences));
this.schemaIds = schemaIds; this.properties = properties;
if (isRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue)
{
collectionValidator = new CollectionValidator(isRequired, properties.MinItems, properties.MaxItems);
}
if (!properties.AllowDuplicates)
{
uniqueValidator = new UniqueValuesValidator<DomainId>();
}
this.checkReferences = checkReferences; this.checkReferences = checkReferences;
} }
public async Task ValidateAsync(object? value, ValidationContext context, AddError addError) public async Task ValidateAsync(object? value, ValidationContext context, AddError addError)
{ {
var foundIds = new List<DomainId>();
if (value is ICollection<DomainId> contentIds && contentIds.Count > 0) if (value is ICollection<DomainId> contentIds && contentIds.Count > 0)
{ {
var foundIds = await checkReferences(contentIds.ToHashSet()); var references = await checkReferences(contentIds.ToHashSet());
var index = 0;
foreach (var id in contentIds) foreach (var id in contentIds)
{ {
var (schemaId, _) = foundIds.FirstOrDefault(x => x.Id == id); index++;
var path = context.Path.Enqueue($"[{index}]");
var (schemaId, _, status) = references.FirstOrDefault(x => x.Id == id);
if (schemaId == DomainId.Empty) if (schemaId == DomainId.Empty)
{ {
addError(context.Path, T.Get("common.referenceNotFound", new { id })); if (context.Action == ValidationAction.Upsert)
{
addError(path, T.Get("contents.validation.referenceNotFound", new { id }));
}
continue;
} }
else if (schemaIds?.Any() == true && !schemaIds.Contains(schemaId))
var isValid = true;
if (properties.SchemaIds?.Any() == true && !properties.SchemaIds.Contains(schemaId))
{ {
addError(context.Path, T.Get("common.referenceToInvalidSchema", new { id })); if (context.Action == ValidationAction.Upsert)
{
addError(path, T.Get("contents.validation.referenceToInvalidSchema", new { id }));
}
isValid = false;
}
isValid &= (!properties.MustBePublished || status == Status.Published);
if (isValid)
{
foundIds.Add(id);
} }
} }
} }
if (collectionValidator != null)
{
await collectionValidator.ValidateAsync(foundIds, context, addError);
}
if (uniqueValidator != null)
{
await uniqueValidator.ValidateAsync(foundIds, context, addError);
}
} }
} }
} }

3
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs

@ -8,13 +8,14 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{ {
public delegate Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> CheckUniqueness(FilterNode<ClrValue> filter); public delegate Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> CheckUniqueness(FilterNode<ClrValue> filter);
public sealed class UniqueValidator : IValidator public sealed class UniqueValidator : IValidator
{ {

8
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs

@ -14,11 +14,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField); private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
private static readonly Lazy<string> SchemaIdField = new Lazy<string>(GetSchemaIdField); private static readonly Lazy<string> SchemaIdField = new Lazy<string>(GetSchemaIdField);
private static readonly Lazy<string> StatusField = new Lazy<string>(GetStatusField);
public static string Id => IdField.Value; public static string Id => IdField.Value;
public static string SchemaId => SchemaIdField.Value; public static string SchemaId => SchemaIdField.Value;
public static string Status => StatusField.Value;
private static string GetIdField() private static string GetIdField()
{ {
return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.Id)).ElementName; return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.Id)).ElementName;
@ -28,5 +31,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.IndexedSchemaId)).ElementName; return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.IndexedSchemaId)).ElementName;
} }
private static string GetStatusField()
{
return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.Status)).ElementName;
}
} }
} }

5
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs

@ -12,6 +12,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Contents.Text;
@ -112,7 +113,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids) public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids)
{ {
using (Profiler.TraceMethod<MongoContentRepository>()) using (Profiler.TraceMethod<MongoContentRepository>())
{ {
@ -120,7 +121,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode) public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode)
{ {
using (Profiler.TraceMethod<MongoContentRepository>()) using (Profiler.TraceMethod<MongoContentRepository>())
{ {

3
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs

@ -11,6 +11,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Contents.Text;
@ -105,7 +106,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids) public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids)
{ {
using (Profiler.TraceMethod<MongoContentRepository>()) using (Profiler.TraceMethod<MongoContentRepository>())
{ {

5
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -11,6 +11,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.Repositories;
@ -102,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
public Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope) public Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope)
{ {
if (scope == SearchScope.All) if (scope == SearchScope.All)
{ {
@ -124,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
return collectionAll.QueryScheduledWithoutDataAsync(now, callback); return collectionAll.QueryScheduledWithoutDataAsync(now, callback);
} }
public Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode) public Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode)
{ {
return collectionAll.QueryIdsAsync(appId, schemaId, filterNode); return collectionAll.QueryIdsAsync(appId, schemaId, filterNode);
} }

28
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs

@ -10,6 +10,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.MongoDb.Queries; using Squidex.Infrastructure.MongoDb.Queries;
@ -19,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{ {
internal sealed class QueryIdsAsync : OperationBase internal sealed class QueryIdsAsync : OperationBase
{ {
private static readonly List<(DomainId SchemaId, DomainId Id)> EmptyIds = new List<(DomainId SchemaId, DomainId Id)>(); private static readonly List<(DomainId SchemaId, DomainId Id, Status Status)> EmptyIds = new List<(DomainId SchemaId, DomainId Id, Status Status)>();
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
public QueryIdsAsync(IAppProvider appProvider) public QueryIdsAsync(IAppProvider appProvider)
@ -38,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return Collection.Indexes.CreateOneAsync(index, cancellationToken: ct); return Collection.Indexes.CreateOneAsync(index, cancellationToken: ct);
} }
public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> DoAsync(DomainId appId, HashSet<DomainId> ids) public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> DoAsync(DomainId appId, HashSet<DomainId> ids)
{ {
var documentIds = ids.Select(x => DomainId.Combine(appId, x)); var documentIds = ids.Select(x => DomainId.Combine(appId, x));
@ -47,14 +48,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
Filter.In(x => x.DocumentId, documentIds), Filter.In(x => x.DocumentId, documentIds),
Filter.Ne(x => x.IsDeleted, true)); Filter.Ne(x => x.IsDeleted, true));
var contentEntities = return await SearchAsync(filter);
await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId)
.ToListAsync();
return contentEntities.Select(x => (DomainId.Create(x[Fields.SchemaId].AsString), DomainId.Create(x[Fields.Id].AsString))).ToList();
} }
public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> DoAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode) public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> DoAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode)
{ {
var schema = await appProvider.GetSchemaAsync(appId, schemaId, false); var schema = await appProvider.GetSchemaAsync(appId, schemaId, false);
@ -65,14 +62,23 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
var filter = BuildFilter(filterNode.AdjustToModel(schema.SchemaDef), appId, schemaId); var filter = BuildFilter(filterNode.AdjustToModel(schema.SchemaDef), appId, schemaId);
return await SearchAsync(filter);
}
private async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> SearchAsync(FilterDefinition<MongoContentEntity> filter)
{
var contentEntities = var contentEntities =
await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId) await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId, x => x.Status)
.ToListAsync(); .ToListAsync();
return contentEntities.Select(x => (DomainId.Create(x[Fields.SchemaId].AsString), DomainId.Create(x[Fields.Id].AsString))).ToList(); return contentEntities.Select(x => (
DomainId.Create(x[Fields.SchemaId].AsString),
DomainId.Create(x[Fields.Id].AsString),
new Status(x[Fields.Status].AsString)
)).ToList();
} }
public static FilterDefinition<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filterNode, DomainId appId, DomainId schemaId) private static FilterDefinition<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filterNode, DomainId appId, DomainId schemaId)
{ {
var filters = new List<FilterDefinition<MongoContentEntity>> var filters = new List<FilterDefinition<MongoContentEntity>>
{ {

5
backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -24,9 +25,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope); Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope);
Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode); Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode);
Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope); Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope);
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope); Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope);

18
backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs

@ -36,6 +36,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Validation
yield break; yield break;
} }
var isRequired = IsRequired(context, field.RawProperties);
if (field is IField<AssetsFieldProperties> assetsField) if (field is IField<AssetsFieldProperties> assetsField)
{ {
var checkAssets = new CheckAssets(async ids => var checkAssets = new CheckAssets(async ids =>
@ -43,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Validation
return await assetRepository.QueryAsync(context.AppId.Id, new HashSet<DomainId>(ids)); return await assetRepository.QueryAsync(context.AppId.Id, new HashSet<DomainId>(ids));
}); });
yield return new AssetsValidator(assetsField.Properties, checkAssets); yield return new AssetsValidator(isRequired, assetsField.Properties, checkAssets);
} }
if (field is IField<ReferencesFieldProperties> referencesField) if (field is IField<ReferencesFieldProperties> referencesField)
@ -53,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Validation
return await contentRepository.QueryIdsAsync(context.AppId.Id, ids, SearchScope.All); return await contentRepository.QueryIdsAsync(context.AppId.Id, ids, SearchScope.All);
}); });
yield return new ReferencesValidator(referencesField.Properties.SchemaIds, checkReferences); yield return new ReferencesValidator(isRequired, referencesField.Properties, checkReferences);
} }
if (field is IField<NumberFieldProperties> numberField && numberField.Properties.IsUnique) if (field is IField<NumberFieldProperties> numberField && numberField.Properties.IsUnique)
@ -76,5 +78,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Validation
yield return new UniqueValidator(checkUniqueness); yield return new UniqueValidator(checkUniqueness);
} }
} }
private static bool IsRequired(ValidatorContext context, FieldProperties properties)
{
var isRequired = properties.IsRequired;
if (context.Action == ValidationAction.Publish)
{
isRequired = isRequired || properties.IsRequiredOnPublish;
}
return isRequired;
}
} }
} }

8
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs

@ -75,6 +75,14 @@ namespace Squidex.Infrastructure.MongoDb
return find.Project<BsonDocument>(Builders<T>.Projection.Include(include1).Include(include2)); return find.Project<BsonDocument>(Builders<T>.Projection.Include(include1).Include(include2));
} }
public static IFindFluent<T, BsonDocument> Only<T>(this IFindFluent<T, T> find,
Expression<Func<T, object>> include1,
Expression<Func<T, object>> include2,
Expression<Func<T, object>> include3)
{
return find.Project<BsonDocument>(Builders<T>.Projection.Include(include1).Include(include2).Include(include3));
}
public static IFindFluent<T, T> Not<T>(this IFindFluent<T, T> find, public static IFindFluent<T, T> Not<T>(this IFindFluent<T, T> find,
Expression<Func<T, object>> exclude) Expression<Func<T, object>> exclude)
{ {

12
backend/src/Squidex.Shared/Texts.it.resx

@ -403,12 +403,6 @@
<data name="common.readonlyMode" xml:space="preserve"> <data name="common.readonlyMode" xml:space="preserve">
<value>Al momento l'applicazione è in sola lettura.</value> <value>Al momento l'applicazione è in sola lettura.</value>
</data> </data>
<data name="common.referenceNotFound" xml:space="preserve">
<value>Contiene un collegamento '{id}' non valido.</value>
</data>
<data name="common.referenceToInvalidSchema" xml:space="preserve">
<value>Contiene dei collegamenti '{id}' ad uno schema errato.</value>
</data>
<data name="common.remove" xml:space="preserve"> <data name="common.remove" xml:space="preserve">
<value>Rimuovi</value> <value>Rimuovi</value>
</data> </data>
@ -601,6 +595,12 @@
<data name="contents.validation.pattern" xml:space="preserve"> <data name="contents.validation.pattern" xml:space="preserve">
<value>Deve seguire il pattern.</value> <value>Deve seguire il pattern.</value>
</data> </data>
<data name="contents.validation.referenceNotFound" xml:space="preserve">
<value>Contiene un collegamento '{id}' non valido.</value>
</data>
<data name="contents.validation.referenceToInvalidSchema" xml:space="preserve">
<value>Contiene dei collegamenti '{id}' ad uno schema errato.</value>
</data>
<data name="contents.validation.regexTooSlow" xml:space="preserve"> <data name="contents.validation.regexTooSlow" xml:space="preserve">
<value>La Regex è troppo lenta.</value> <value>La Regex è troppo lenta.</value>
</data> </data>

12
backend/src/Squidex.Shared/Texts.nl.resx

@ -403,12 +403,6 @@
<data name="common.readonlyMode" xml:space="preserve"> <data name="common.readonlyMode" xml:space="preserve">
<value>Applicatie is momenteel in de alleen-lezen modus.</value> <value>Applicatie is momenteel in de alleen-lezen modus.</value>
</data> </data>
<data name="common.referenceNotFound" xml:space="preserve">
<value>Bevat ongeldige referentie '{id}'.</value>
</data>
<data name="common.referenceToInvalidSchema" xml:space="preserve">
<value>Bevat verwijzing '{id}' naar ongeldig schema.</value>
</data>
<data name="common.remove" xml:space="preserve"> <data name="common.remove" xml:space="preserve">
<value>Verwijderen</value> <value>Verwijderen</value>
</data> </data>
@ -601,6 +595,12 @@
<data name="contents.validation.pattern" xml:space="preserve"> <data name="contents.validation.pattern" xml:space="preserve">
<value>Moet het patroon volgen.</value> <value>Moet het patroon volgen.</value>
</data> </data>
<data name="contents.validation.referenceNotFound" xml:space="preserve">
<value>Bevat ongeldige referentie '{id}'.</value>
</data>
<data name="contents.validation.referenceToInvalidSchema" xml:space="preserve">
<value>Bevat verwijzing '{id}' naar ongeldig schema.</value>
</data>
<data name="contents.validation.regexTooSlow" xml:space="preserve"> <data name="contents.validation.regexTooSlow" xml:space="preserve">
<value>Regex is te traag.</value> <value>Regex is te traag.</value>
</data> </data>

12
backend/src/Squidex.Shared/Texts.resx

@ -403,12 +403,6 @@
<data name="common.readonlyMode" xml:space="preserve"> <data name="common.readonlyMode" xml:space="preserve">
<value>Application is in readonly mode at the moment.</value> <value>Application is in readonly mode at the moment.</value>
</data> </data>
<data name="common.referenceNotFound" xml:space="preserve">
<value>Contains invalid reference '{id}'.</value>
</data>
<data name="common.referenceToInvalidSchema" xml:space="preserve">
<value>Contains reference '{id}' to invalid schema.</value>
</data>
<data name="common.remove" xml:space="preserve"> <data name="common.remove" xml:space="preserve">
<value>Remove</value> <value>Remove</value>
</data> </data>
@ -601,6 +595,12 @@
<data name="contents.validation.pattern" xml:space="preserve"> <data name="contents.validation.pattern" xml:space="preserve">
<value>Must follow the pattern.</value> <value>Must follow the pattern.</value>
</data> </data>
<data name="contents.validation.referenceNotFound" xml:space="preserve">
<value>Reference '{id}' not found.</value>
</data>
<data name="contents.validation.referenceToInvalidSchema" xml:space="preserve">
<value>Reference '{id}' has invalid schema.</value>
</data>
<data name="contents.validation.regexTooSlow" xml:space="preserve"> <data name="contents.validation.regexTooSlow" xml:space="preserve">
<value>Regex is too slow.</value> <value>Regex is too slow.</value>
</data> </data>

5
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs

@ -39,6 +39,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields
/// </summary> /// </summary>
public bool ResolveReference { get; set; } public bool ResolveReference { get; set; }
/// <summary>
/// True when all references must be published.
/// </summary>
public bool MustBePublished { get; set; }
/// <summary> /// <summary>
/// The editor that is used to manage this field. /// The editor that is used to manage this field.
/// </summary> /// </summary>

88
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs

@ -5,14 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Infrastructure.Json.Objects;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Core.Operations.ValidateContent namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
@ -34,93 +30,11 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
{ {
var sut = Field(new AssetsFieldProperties()); var sut = Field(new AssetsFieldProperties());
await sut.ValidateAsync(CreateValue(null), errors); await sut.ValidateAsync(null, errors);
Assert.Empty(errors); Assert.Empty(errors);
} }
[Fact]
public async Task Should_not_add_error_if_number_of_assets_is_equal_to_min_and_max_items()
{
var sut = Field(new AssetsFieldProperties { MinItems = 2, MaxItems = 2 });
await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_duplicate_values_are_ignored()
{
var sut = Field(new AssetsFieldProperties { AllowDuplicates = true });
await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_add_error_if_assets_are_required_and_null()
{
var sut = Field(new AssetsFieldProperties { IsRequired = true });
await sut.ValidateAsync(CreateValue(null), errors);
errors.Should().BeEquivalentTo(
new[] { "Field is required." });
}
[Fact]
public async Task Should_add_error_if_assets_are_required_and_empty()
{
var sut = Field(new AssetsFieldProperties { IsRequired = true });
await sut.ValidateAsync(CreateValue(), errors);
errors.Should().BeEquivalentTo(
new[] { "Field is required." });
}
[Fact]
public async Task Should_add_error_if_value_has_not_enough_items()
{
var sut = Field(new AssetsFieldProperties { MinItems = 3 });
await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors);
errors.Should().BeEquivalentTo(
new[] { "Must have at least 3 item(s)." });
}
[Fact]
public async Task Should_add_error_if_value_has_too_much_items()
{
var sut = Field(new AssetsFieldProperties { MaxItems = 1 });
await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not have more than 1 item(s)." });
}
[Fact]
public async Task Should_add_error_if_values_contains_duplicate()
{
var sut = Field(new AssetsFieldProperties());
var id = Guid.NewGuid();
await sut.ValidateAsync(CreateValue(id, id), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not contain duplicate values." });
}
private static IJsonValue CreateValue(params Guid[]? ids)
{
return ids == null ? JsonValue.Null : JsonValue.Array(ids.Select(x => (object)x.ToString()).ToArray());
}
private static RootField<AssetsFieldProperties> Field(AssetsFieldProperties properties) private static RootField<AssetsFieldProperties> Field(AssetsFieldProperties properties)
{ {
return Fields.Assets(1, "my-assets", Partitioning.Invariant, properties); return Fields.Assets(1, "my-assets", Partitioning.Invariant, properties);

99
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs

@ -6,13 +6,9 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Core.Operations.ValidateContent namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
@ -20,9 +16,6 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
public class ReferencesFieldTests : IClassFixture<TranslationsFixture> public class ReferencesFieldTests : IClassFixture<TranslationsFixture>
{ {
private readonly List<string> errors = new List<string>(); private readonly List<string> errors = new List<string>();
private readonly DomainId schemaId = DomainId.NewGuid();
private readonly DomainId ref1 = DomainId.NewGuid();
private readonly DomainId ref2 = DomainId.NewGuid();
[Fact] [Fact]
public void Should_instantiate_field() public void Should_instantiate_field()
@ -32,106 +25,16 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
Assert.Equal("my-refs", sut.Name); Assert.Equal("my-refs", sut.Name);
} }
[Fact]
public async Task Should_not_add_error_if_references_are_valid()
{
var sut = Field(new ReferencesFieldProperties());
await sut.ValidateAsync(CreateValue(ref1), errors);
Assert.Empty(errors);
}
[Fact] [Fact]
public async Task Should_not_add_error_if_references_are_null_and_valid() public async Task Should_not_add_error_if_references_are_null_and_valid()
{ {
var sut = Field(new ReferencesFieldProperties()); var sut = Field(new ReferencesFieldProperties());
await sut.ValidateAsync(CreateValue(null), errors); await sut.ValidateAsync(null, errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_number_of_references_is_equal_to_min_and_max_items()
{
var sut = Field(new ReferencesFieldProperties { MinItems = 2, MaxItems = 2 });
await sut.ValidateAsync(CreateValue(ref1, ref2), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_duplicate_values_are_allowed()
{
var sut = Field(new ReferencesFieldProperties { MinItems = 2, MaxItems = 2, AllowDuplicates = true });
await sut.ValidateAsync(CreateValue(ref1, ref1), errors);
Assert.Empty(errors); Assert.Empty(errors);
} }
[Fact]
public async Task Should_add_error_if_references_are_required_and_null()
{
var sut = Field(new ReferencesFieldProperties { SchemaId = schemaId, IsRequired = true });
await sut.ValidateAsync(CreateValue(null), errors);
errors.Should().BeEquivalentTo(
new[] { "Field is required." });
}
[Fact]
public async Task Should_add_error_if_references_are_required_and_empty()
{
var sut = Field(new ReferencesFieldProperties { SchemaId = schemaId, IsRequired = true });
await sut.ValidateAsync(CreateValue(), errors);
errors.Should().BeEquivalentTo(
new[] { "Field is required." });
}
[Fact]
public async Task Should_add_error_if_value_has_not_enough_items()
{
var sut = Field(new ReferencesFieldProperties { SchemaId = schemaId, MinItems = 3 });
await sut.ValidateAsync(CreateValue(ref1, ref2), errors);
errors.Should().BeEquivalentTo(
new[] { "Must have at least 3 item(s)." });
}
[Fact]
public async Task Should_add_error_if_value_has_too_much_items()
{
var sut = Field(new ReferencesFieldProperties { SchemaId = schemaId, MaxItems = 1 });
await sut.ValidateAsync(CreateValue(ref1, ref2), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not have more than 1 item(s)." });
}
[Fact]
public async Task Should_add_error_if_reference_contains_duplicate_values()
{
var sut = Field(new ReferencesFieldProperties { SchemaId = schemaId });
await sut.ValidateAsync(CreateValue(ref1, ref1), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not contain duplicate values." });
}
private static IJsonValue CreateValue(params DomainId[]? ids)
{
return ids == null ? JsonValue.Null : JsonValue.Array(ids.Select(x => (object)x.ToString()).ToArray());
}
private static RootField<ReferencesFieldProperties> Field(ReferencesFieldProperties properties) private static RootField<ReferencesFieldProperties> Field(ReferencesFieldProperties properties)
{ {
return Fields.References(1, "my-refs", Partitioning.Invariant, properties); return Fields.References(1, "my-refs", Partitioning.Invariant, properties);

20
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ValidationTestExtensions.cs

@ -31,9 +31,10 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
public static Task ValidateAsync(this IValidator validator, object? value, IList<string> errors, public static Task ValidateAsync(this IValidator validator, object? value, IList<string> errors,
Schema? schema = null, Schema? schema = null,
ValidationMode mode = ValidationMode.Default, ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null) ValidationUpdater? updater = null,
ValidationAction action = ValidationAction.Upsert)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater, action);
return validator.ValidateAsync(value, context, CreateFormatter(errors)); return validator.ValidateAsync(value, context, CreateFormatter(errors));
} }
@ -42,9 +43,10 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
Schema? schema = null, Schema? schema = null,
ValidationMode mode = ValidationMode.Default, ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null, ValidationUpdater? updater = null,
IValidatorsFactory? factory = null) IValidatorsFactory? factory = null,
ValidationAction action = ValidationAction.Upsert)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater, action);
var validators = Factories(factory).SelectMany(x => x.CreateValueValidators(context, field, null!)).ToArray(); var validators = Factories(factory).SelectMany(x => x.CreateValueValidators(context, field, null!)).ToArray();
@ -56,9 +58,10 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
Schema? schema = null, Schema? schema = null,
ValidationMode mode = ValidationMode.Default, ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null, ValidationUpdater? updater = null,
IValidatorsFactory? factory = null) IValidatorsFactory? factory = null,
ValidationAction action = ValidationAction.Upsert)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater, action);
var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log); var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log);
@ -74,9 +77,10 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
Schema? schema = null, Schema? schema = null,
ValidationMode mode = ValidationMode.Default, ValidationMode mode = ValidationMode.Default,
ValidationUpdater? updater = null, ValidationUpdater? updater = null,
IValidatorsFactory? factory = null) IValidatorsFactory? factory = null,
ValidationAction action = ValidationAction.Upsert)
{ {
var context = CreateContext(schema, mode, updater); var context = CreateContext(schema, mode, updater, action);
var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log); var validator = new ContentValidator(partitionResolver, context, Factories(factory), Log);

76
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs

@ -89,6 +89,47 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators
Assert.Empty(errors); Assert.Empty(errors);
} }
[Fact]
public async Task Should_not_add_error_if_assets_are_null_but_not_required()
{
var sut = Validator(new AssetsFieldProperties());
await sut.ValidateAsync(null, errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_assets_are_empty_but_not_required()
{
var sut = Validator(new AssetsFieldProperties());
await sut.ValidateAsync(CreateValue(), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_duplicates_are_allowed()
{
var sut = Validator(new AssetsFieldProperties { AllowDuplicates = true });
await sut.ValidateAsync(CreateValue(image1.AssetId, image1.AssetId), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_add_error_if_references_are_required()
{
var sut = Validator(new AssetsFieldProperties { IsRequired = true });
await sut.ValidateAsync(CreateValue(), errors);
errors.Should().BeEquivalentTo(
new[] { "Field is required." });
}
[Fact] [Fact]
public async Task Should_add_error_if_asset_are_not_valid() public async Task Should_add_error_if_asset_are_not_valid()
{ {
@ -190,6 +231,39 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators
new[] { "[2]: Must have aspect ratio 1:1." }); new[] { "[2]: Must have aspect ratio 1:1." });
} }
[Fact]
public async Task Should_add_error_if_value_has_not_enough_items()
{
var sut = Validator(new AssetsFieldProperties { MinItems = 2 });
await sut.ValidateAsync(CreateValue(image1.AssetId), errors);
errors.Should().BeEquivalentTo(
new[] { "Must have at least 2 item(s)." });
}
[Fact]
public async Task Should_add_error_if_value_has_too_much_items()
{
var sut = Validator(new AssetsFieldProperties { MaxItems = 1 });
await sut.ValidateAsync(CreateValue(image1.AssetId, image2.AssetId), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not have more than 1 item(s)." });
}
[Fact]
public async Task Should_add_error_if_reference_contains_duplicate_values()
{
var sut = Validator(new AssetsFieldProperties());
await sut.ValidateAsync(CreateValue(image1.AssetId, image1.AssetId), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not contain duplicate values." });
}
[Fact] [Fact]
public async Task Should_add_error_if_image_has_invalid_extension() public async Task Should_add_error_if_image_has_invalid_extension()
{ {
@ -212,7 +286,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators
private IValidator Validator(AssetsFieldProperties properties) private IValidator Validator(AssetsFieldProperties properties)
{ {
return new AssetsValidator(properties, ids => return new AssetsValidator(properties.IsRequired, properties, ids =>
{ {
return Task.FromResult<IReadOnlyList<IAssetInfo>>(new List<IAssetInfo> { document, image1, image2 }); return Task.FromResult<IReadOnlyList<IAssetInfo>>(new List<IAssetInfo> { document, image1, image2 });
}); });

175
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs

@ -9,7 +9,10 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Core.ValidateContent.Validators; using Squidex.Domain.Apps.Core.ValidateContent.Validators;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Xunit; using Xunit;
@ -19,40 +22,185 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators
public class ReferencesValidatorTests : IClassFixture<TranslationsFixture> public class ReferencesValidatorTests : IClassFixture<TranslationsFixture>
{ {
private readonly List<string> errors = new List<string>(); private readonly List<string> errors = new List<string>();
private readonly DomainId schemaId = DomainId.NewGuid(); private readonly DomainId schemaId1 = DomainId.NewGuid();
private readonly DomainId schemaId2 = DomainId.NewGuid();
private readonly DomainId ref1 = DomainId.NewGuid(); private readonly DomainId ref1 = DomainId.NewGuid();
private readonly DomainId ref2 = DomainId.NewGuid(); private readonly DomainId ref2 = DomainId.NewGuid();
[Fact] [Fact]
public async Task Should_add_error_if_references_are_not_valid() public async Task Should_not_add_error_if_reference_invalid_but_publishing()
{ {
var sut = new ReferencesValidator(Enumerable.Repeat(schemaId, 1), FoundReferences()); var properties = new ReferencesFieldProperties { SchemaId = schemaId1 };
await sut.ValidateAsync(CreateValue(ref1), errors); var sut = Validator(properties, FoundReferences((schemaId2, ref2, Status.Published)));
errors.Should().BeEquivalentTo( await sut.ValidateAsync(CreateValue(ref2), errors, action: ValidationAction.Publish);
new[] { $"Contains invalid reference '{ref1}'." });
Assert.Empty(errors);
} }
[Fact] [Fact]
public async Task Should_not_add_error_if_schemas_not_defined() public async Task Should_not_add_error_if_schemas_not_defined()
{ {
var sut = new ReferencesValidator(null, FoundReferences((DomainId.NewGuid(), ref2))); var properties = new ReferencesFieldProperties();
var sut = Validator(properties, FoundReferences((schemaId2, ref2, Status.Published)));
await sut.ValidateAsync(CreateValue(ref2), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_schema_is_valid()
{
var properties = new ReferencesFieldProperties { SchemaId = schemaId1 };
var sut = Validator(properties, FoundReferences((schemaId1, ref2, Status.Published)));
await sut.ValidateAsync(CreateValue(ref2), errors); await sut.ValidateAsync(CreateValue(ref2), errors);
Assert.Empty(errors); Assert.Empty(errors);
} }
[Fact]
public async Task Should_not_add_error_if_references_are_null_but_not_required()
{
var properties = new ReferencesFieldProperties();
var sut = Validator(properties, FoundReferences());
await sut.ValidateAsync(null, errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_references_are_empty_but_not_required()
{
var properties = new ReferencesFieldProperties();
var sut = Validator(properties, FoundReferences());
await sut.ValidateAsync(CreateValue(), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_duplicates_are_allowed()
{
var properties = new ReferencesFieldProperties { AllowDuplicates = true };
var sut = Validator(properties, FoundReferences((schemaId1, ref1, Status.Published)));
await sut.ValidateAsync(CreateValue(ref1, ref1), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_add_error_if_references_are_required()
{
var properties = new ReferencesFieldProperties { IsRequired = true };
var sut = Validator(properties, FoundReferences());
await sut.ValidateAsync(CreateValue(), errors);
errors.Should().BeEquivalentTo(
new[] { "Field is required." });
}
[Fact]
public async Task Should_add_error_if_references_are_published_required()
{
var properties = new ReferencesFieldProperties { MustBePublished = true, IsRequired = true };
var sut = Validator(properties, FoundReferences((schemaId1, ref1, Status.Published)));
await sut.ValidateAsync(CreateValue(), errors);
errors.Should().BeEquivalentTo(
new[] { "Field is required." });
}
[Fact]
public async Task Should_add_error_if_references_are_not_valid()
{
var properties = new ReferencesFieldProperties();
var sut = Validator(properties, FoundReferences());
await sut.ValidateAsync(CreateValue(ref1), errors);
errors.Should().BeEquivalentTo(
new[] { $"[1]: Reference '{ref1}' not found." });
}
[Fact] [Fact]
public async Task Should_add_error_if_reference_schema_is_not_valid() public async Task Should_add_error_if_reference_schema_is_not_valid()
{ {
var sut = new ReferencesValidator(Enumerable.Repeat(schemaId, 1), FoundReferences((DomainId.NewGuid(), ref2))); var properties = new ReferencesFieldProperties { SchemaId = schemaId1 };
var sut = Validator(properties, FoundReferences((schemaId2, ref2, Status.Draft)));
await sut.ValidateAsync(CreateValue(ref2), errors); await sut.ValidateAsync(CreateValue(ref2), errors);
errors.Should().BeEquivalentTo( errors.Should().BeEquivalentTo(
new[] { $"Contains reference '{ref2}' to invalid schema." }); new[] { $"[1]: Reference '{ref2}' has invalid schema." });
}
[Fact]
public async Task Should_add_error_if_value_has_not_enough_items()
{
var properties = new ReferencesFieldProperties { MinItems = 2 };
var sut = Validator(properties, FoundReferences((schemaId2, ref2, Status.Draft)));
await sut.ValidateAsync(CreateValue(ref2), errors);
errors.Should().BeEquivalentTo(
new[] { "Must have at least 2 item(s)." });
}
[Fact]
public async Task Should_add_error_if_value_has_not_enough_published_items()
{
var properties = new ReferencesFieldProperties { MinItems = 2, MustBePublished = true };
var sut = Validator(properties, FoundReferences((schemaId1, ref1, Status.Published), (schemaId1, ref2, Status.Draft)));
await sut.ValidateAsync(CreateValue(ref1, ref2), errors);
errors.Should().BeEquivalentTo(
new[] { "Must have at least 2 item(s)." });
}
[Fact]
public async Task Should_add_error_if_value_has_too_much_items()
{
var properties = new ReferencesFieldProperties { MaxItems = 1 };
var sut = Validator(properties, FoundReferences((schemaId1, ref1, Status.Published), (schemaId1, ref2, Status.Draft)));
await sut.ValidateAsync(CreateValue(ref1, ref2), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not have more than 1 item(s)." });
}
[Fact]
public async Task Should_add_error_if_reference_contains_duplicate_values()
{
var properties = new ReferencesFieldProperties();
var sut = Validator(properties, FoundReferences((schemaId1, ref1, Status.Published)));
await sut.ValidateAsync(CreateValue(ref1, ref1), errors);
errors.Should().BeEquivalentTo(
new[] { "Must not contain duplicate values." });
} }
private static List<DomainId> CreateValue(params DomainId[] ids) private static List<DomainId> CreateValue(params DomainId[] ids)
@ -60,9 +208,14 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators
return ids.ToList(); return ids.ToList();
} }
private static CheckContentsByIds FoundReferences(params (DomainId SchemaId, DomainId Id)[] references) private static CheckContentsByIds FoundReferences(params (DomainId SchemaId, DomainId Id, Status Status)[] references)
{
return x => Task.FromResult<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>>(references.ToList());
}
private IValidator Validator(ReferencesFieldProperties properties, CheckContentsByIds found)
{ {
return x => Task.FromResult<IReadOnlyList<(DomainId SchemaId, DomainId Id)>>(references.ToList()); return new ReferencesValidator(properties.IsRequired, properties, found);
} }
} }
} }

3
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Core.ValidateContent.Validators; using Squidex.Domain.Apps.Core.ValidateContent.Validators;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -91,7 +92,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators
{ {
filter?.Invoke(filterNode.ToString()); filter?.Invoke(filterNode.ToString());
return Task.FromResult<IReadOnlyList<(DomainId, DomainId)>>(new List<(DomainId, DomainId)> { (schemaId, id) }); return Task.FromResult<IReadOnlyList<(DomainId, DomainId, Status)>>(new List<(DomainId, DomainId, Status)> { (schemaId, id, Status.Published) });
}; };
} }
} }

2
frontend/app/features/content/shared/list/content-list-cell.directive.ts

@ -42,7 +42,7 @@ export function getCellWidth(field: TableField) {
case MetaFields.statusNext: case MetaFields.statusNext:
return 240; return 240;
case MetaFields.statusColor: case MetaFields.statusColor:
return 80; return 50;
case MetaFields.version: case MetaFields.version:
return 80; return 80;
} }

6
frontend/app/features/content/shared/references/content-selector-item.component.html

@ -8,11 +8,15 @@
</td> </td>
<td sqxContentListCell="meta.lastModifiedBy.avatar"> <td sqxContentListCell="meta.lastModifiedBy.avatar">
<sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content" [language]="language"></sqx-content-list-field> <sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content"></sqx-content-list-field>
</td> </td>
<td *ngFor="let field of schema.defaultReferenceFields" [sqxContentListCell]="field"> <td *ngFor="let field of schema.defaultReferenceFields" [sqxContentListCell]="field">
<sqx-content-list-field [field]="field" [content]="content" [language]="language"></sqx-content-list-field> <sqx-content-list-field [field]="field" [content]="content" [language]="language"></sqx-content-list-field>
</td> </td>
<td sqxContentListCell="meta.status.color">
<sqx-content-list-field field="meta.status.color" [content]="content"></sqx-content-list-field>
</td>
</tr> </tr>
<tr class="spacer"></tr> <tr class="spacer"></tr>

7
frontend/app/features/content/shared/references/content-selector.component.html

@ -44,7 +44,9 @@
<thead> <thead>
<tr> <tr>
<th class="cell-select"> <th class="cell-select">
<input type="checkbox" class="custom-control custom-checkbox" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)"> <input type="checkbox" class="custom-control custom-checkbox"
[ngModel]="selectedAll"
(ngModelChange)="selectAll($event)">
</th> </th>
<th sqxContentListCell="meta.lastModifiedBy.avatar"> <th sqxContentListCell="meta.lastModifiedBy.avatar">
<sqx-content-list-header field="meta.lastModifiedBy.avatar"></sqx-content-list-header> <sqx-content-list-header field="meta.lastModifiedBy.avatar"></sqx-content-list-header>
@ -57,6 +59,9 @@
[language]="language"> [language]="language">
</sqx-content-list-header> </sqx-content-list-header>
</th> </th>
<th sqxContentListCell="meta.status.color">
<sqx-content-list-header field="meta.status.color"></sqx-content-list-header>
</th>
</tr> </tr>
</thead> </thead>
</table> </table>

4
frontend/app/features/content/shared/references/reference-item.component.html

@ -11,6 +11,10 @@
<sqx-content-value [value]="value"></sqx-content-value> <sqx-content-value [value]="value"></sqx-content-value>
</td> </td>
<td sqxContentListCell="meta.status.color">
<sqx-content-list-field field="meta.status.color" [content]="content"></sqx-content-list-field>
</td>
<td class="cell-label" *ngIf="!isCompact"> <td class="cell-label" *ngIf="!isCompact">
<span class="badge badge-pill truncate-inline badge-primary">{{content.schemaDisplayName}}</span> <span class="badge badge-pill truncate-inline badge-primary">{{content.schemaDisplayName}}</span>
</td> </td>

14
frontend/app/features/schemas/pages/schema/fields/types/references-validation.component.html

@ -3,7 +3,8 @@
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaId">{{ 'common.schemas' | sqxTranslate }}</label> <label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaId">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col-6"> <div class="col-6">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds" [converter]="schemasSource.converter | async" [suggestions]="(schemasSource.converter | async)?.suggestions"> <sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"
[converter]="schemasSource.converter | async" [suggestions]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
</div> </div>
@ -32,6 +33,17 @@
</div> </div>
</div> </div>
<div class="form-group row mb-3">
<div class="col-9 offset-3">
<div class="custom-control custom-checkbox">
<input class="custom-control-input" type="checkbox" id="{{field.fieldId}}_fieldMustBePublished" formControlName="mustBePublished">
<label class="custom-control-label" for="{{field.fieldId}}_fieldMustBePublished">
{{ 'schemas.fieldTypes.references.mustBePublished' | sqxTranslate }}
</label>
</div>
</div>
</div>
<div class="form-group row"> <div class="form-group row">
<label class="col-3 col-form-label">{{ 'schemas.field.defaultValue' | sqxTranslate }}</label> <label class="col-3 col-form-label">{{ 'schemas.field.defaultValue' | sqxTranslate }}</label>

3
frontend/app/features/schemas/pages/schema/fields/types/references-validation.component.ts

@ -44,5 +44,8 @@ export class ReferencesValidationComponent implements OnInit {
this.fieldForm.setControl('defaultValue', this.fieldForm.setControl('defaultValue',
new FormControl(this.properties.defaultValue)); new FormControl(this.properties.defaultValue));
this.fieldForm.setControl('mustBePublished',
new FormControl(this.properties.mustBePublished));
} }
} }

1
frontend/app/shared/services/schemas.types.ts

@ -330,6 +330,7 @@ export class ReferencesFieldPropertiesDto extends FieldPropertiesDto {
public readonly editor: ReferencesFieldEditor = 'List'; public readonly editor: ReferencesFieldEditor = 'List';
public readonly maxItems?: number; public readonly maxItems?: number;
public readonly minItems?: number; public readonly minItems?: number;
public readonly mustBePublished?: boolean;
public readonly resolveReference?: boolean; public readonly resolveReference?: boolean;
public readonly schemaIds?: ReadonlyArray<string>; public readonly schemaIds?: ReadonlyArray<string>;

Loading…
Cancel
Save