diff --git a/src/Squidex.Core/Schemas/AssetsField.cs b/src/Squidex.Core/Schemas/AssetsField.cs index 98c955d63..5b40ce970 100644 --- a/src/Squidex.Core/Schemas/AssetsField.cs +++ b/src/Squidex.Core/Schemas/AssetsField.cs @@ -32,7 +32,7 @@ namespace Squidex.Core.Schemas protected override IEnumerable CreateValidators() { - yield return new AssetsValidator(Properties.IsRequired); + yield return new AssetsValidator(Properties.IsRequired, Properties.MinItems, Properties.MaxItems); } public IEnumerable GetReferencedIds(JToken value) diff --git a/src/Squidex.Core/Schemas/AssetsFieldProperties.cs b/src/Squidex.Core/Schemas/AssetsFieldProperties.cs index 05704e0bb..ff60f0b0b 100644 --- a/src/Squidex.Core/Schemas/AssetsFieldProperties.cs +++ b/src/Squidex.Core/Schemas/AssetsFieldProperties.cs @@ -15,6 +15,31 @@ namespace Squidex.Core.Schemas [TypeName("AssetsField")] public sealed class AssetsFieldProperties : FieldProperties { + private int? minItems; + private int? maxItems; + + public int? MinItems + { + get { return minItems; } + set + { + ThrowIfFrozen(); + + minItems = value; + } + } + + public int? MaxItems + { + get { return maxItems; } + set + { + ThrowIfFrozen(); + + maxItems = value; + } + } + public override JToken GetDefaultValue() { return new JArray(); @@ -22,7 +47,10 @@ namespace Squidex.Core.Schemas protected override IEnumerable ValidateCore() { - yield break; + if (MaxItems.HasValue && MinItems.HasValue && MinItems.Value >= MaxItems.Value) + { + yield return new ValidationError("Max items must be greater than min items", nameof(MinItems), nameof(MaxItems)); + } } } } diff --git a/src/Squidex.Core/Schemas/ReferencesField.cs b/src/Squidex.Core/Schemas/ReferencesField.cs index bb3d06cb5..d68eebc05 100644 --- a/src/Squidex.Core/Schemas/ReferencesField.cs +++ b/src/Squidex.Core/Schemas/ReferencesField.cs @@ -34,7 +34,7 @@ namespace Squidex.Core.Schemas { if (Properties.SchemaId != Guid.Empty) { - yield return new ReferencesValidator(Properties.IsRequired, Properties.SchemaId); + yield return new ReferencesValidator(Properties.IsRequired, Properties.SchemaId, Properties.MinItems, Properties.MaxItems); } } diff --git a/src/Squidex.Core/Schemas/ReferencesFieldProperties.cs b/src/Squidex.Core/Schemas/ReferencesFieldProperties.cs index e1b3a5f95..020c1bee3 100644 --- a/src/Squidex.Core/Schemas/ReferencesFieldProperties.cs +++ b/src/Squidex.Core/Schemas/ReferencesFieldProperties.cs @@ -16,8 +16,32 @@ namespace Squidex.Core.Schemas [TypeName("References")] public sealed class ReferencesFieldProperties : FieldProperties { + private int? minItems; + private int? maxItems; private Guid schemaId; + public int? MinItems + { + get { return minItems; } + set + { + ThrowIfFrozen(); + + minItems = value; + } + } + + public int? MaxItems + { + get { return maxItems; } + set + { + ThrowIfFrozen(); + + maxItems = value; + } + } + public Guid SchemaId { get { return schemaId; } @@ -36,7 +60,10 @@ namespace Squidex.Core.Schemas protected override IEnumerable ValidateCore() { - yield break; + if (MaxItems.HasValue && MinItems.HasValue && MinItems.Value >= MaxItems.Value) + { + yield return new ValidationError("Max items must be greater than min items", nameof(MinItems), nameof(MaxItems)); + } } } } diff --git a/src/Squidex.Core/Schemas/Validators/AssetsValidator.cs b/src/Squidex.Core/Schemas/Validators/AssetsValidator.cs index 11198aca5..53aec4947 100644 --- a/src/Squidex.Core/Schemas/Validators/AssetsValidator.cs +++ b/src/Squidex.Core/Schemas/Validators/AssetsValidator.cs @@ -14,10 +14,14 @@ namespace Squidex.Core.Schemas.Validators public sealed class AssetsValidator : IValidator { private readonly bool isRequired; + private readonly int? minItems; + private readonly int? maxItems; - public AssetsValidator(bool isRequired) + public AssetsValidator(bool isRequired, int? minItems = null, int? maxItems = null) { this.isRequired = isRequired; + this.minItems = minItems; + this.maxItems = maxItems; } public async Task ValidateAsync(object value, ValidationContext context, Action addError) @@ -34,6 +38,16 @@ namespace Squidex.Core.Schemas.Validators return; } + if (minItems.HasValue && assets.AssetIds.Count < minItems.Value) + { + addError($" must have at least {minItems} asset(s)"); + } + + if (maxItems.HasValue && assets.AssetIds.Count > maxItems.Value) + { + addError($" must have not more than {maxItems} asset(s)"); + } + var invalidIds = await context.GetInvalidAssetIdsAsync(assets.AssetIds); foreach (var invalidId in invalidIds) diff --git a/src/Squidex.Core/Schemas/Validators/ReferencesValidator.cs b/src/Squidex.Core/Schemas/Validators/ReferencesValidator.cs index cef850ea1..09f863494 100644 --- a/src/Squidex.Core/Schemas/Validators/ReferencesValidator.cs +++ b/src/Squidex.Core/Schemas/Validators/ReferencesValidator.cs @@ -15,11 +15,15 @@ namespace Squidex.Core.Schemas.Validators { private readonly bool isRequired; private readonly Guid schemaId; + private readonly int? minItems; + private readonly int? maxItems; - public ReferencesValidator(bool isRequired, Guid schemaId) + public ReferencesValidator(bool isRequired, Guid schemaId, int? minItems = null, int? maxItems = null) { this.isRequired = isRequired; this.schemaId = schemaId; + this.minItems = minItems; + this.maxItems = maxItems; } public async Task ValidateAsync(object value, ValidationContext context, Action addError) @@ -36,6 +40,16 @@ namespace Squidex.Core.Schemas.Validators return; } + if (minItems.HasValue && references.ContentIds.Count < minItems.Value) + { + addError($" must have at least {minItems} reference(s)"); + } + + if (maxItems.HasValue && references.ContentIds.Count > maxItems.Value) + { + addError($" must have not more than {maxItems} reference(s)"); + } + var invalidIds = await context.GetInvalidContentIdsAsync(references.ContentIds, schemaId); foreach (var invalidId in invalidIds) diff --git a/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs index d8ca104b7..974c14c98 100644 --- a/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs @@ -80,7 +80,7 @@ namespace Squidex.Read.MongoDb.Contents.Visitors if (ids != null && ids.Count > 0) { - Filter.In(x => x.Id, ids); + filters.Add(Filter.In(x => x.Id, ids)); } var filter = FilterBuilder.Build(query, schema); diff --git a/src/Squidex/Controllers/Api/Schemas/Models/AssetsFieldPropertiesDto.cs b/src/Squidex/Controllers/Api/Schemas/Models/AssetsFieldPropertiesDto.cs index 9b333e7f9..ce9d63d68 100644 --- a/src/Squidex/Controllers/Api/Schemas/Models/AssetsFieldPropertiesDto.cs +++ b/src/Squidex/Controllers/Api/Schemas/Models/AssetsFieldPropertiesDto.cs @@ -15,6 +15,16 @@ namespace Squidex.Controllers.Api.Schemas.Models [JsonSchema("Assets")] public sealed class AssetsFieldPropertiesDto : FieldPropertiesDto { + /// + /// The minimum allowed items for the field value. + /// + public int? MinItems { get; set; } + + /// + /// The maximum allowed items for the field value. + /// + public int? MaxItems { get; set; } + public override FieldProperties ToProperties() { return SimpleMapper.Map(this, new AssetsFieldProperties()); diff --git a/src/Squidex/Controllers/Api/Schemas/Models/ReferencesFieldPropertiesDto.cs b/src/Squidex/Controllers/Api/Schemas/Models/ReferencesFieldPropertiesDto.cs index 590b1255e..8a06c5407 100644 --- a/src/Squidex/Controllers/Api/Schemas/Models/ReferencesFieldPropertiesDto.cs +++ b/src/Squidex/Controllers/Api/Schemas/Models/ReferencesFieldPropertiesDto.cs @@ -16,6 +16,16 @@ namespace Squidex.Controllers.Api.Schemas.Models [JsonSchema("References")] public sealed class ReferencesFieldPropertiesDto : FieldPropertiesDto { + /// + /// The minimum allowed items for the field value. + /// + public int? MinItems { get; set; } + + /// + /// The maximum allowed items for the field value. + /// + public int? MaxItems { get; set; } + /// /// The id of the referenced schema. /// diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index d3812df07..344bbe6a7 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -6,7 +6,7 @@ */ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { AbstractControl, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable, Subject, Subscription } from 'rxjs'; @@ -28,10 +28,7 @@ import { ModalView, MessageBus, NotificationService, - NumberFieldPropertiesDto, SchemaDetailsDto, - StringFieldPropertiesDto, - ValidatorsEx, Version } from 'shared'; @@ -192,34 +189,14 @@ export class ContentPageComponent extends AppComponentBase implements CanCompone const controls: { [key: string]: AbstractControl } = {}; for (const field of schema.fields) { - const validators: ValidatorFn[] = []; - - if (field.properties.isRequired) { - validators.push(Validators.required); - } - if (field.properties instanceof NumberFieldPropertiesDto) { - validators.push(ValidatorsEx.between(field.properties.minValue, field.properties.maxValue)); - } - if (field.properties instanceof StringFieldPropertiesDto) { - if (field.properties.minLength) { - validators.push(Validators.minLength(field.properties.minLength)); - } - if (field.properties.maxLength) { - validators.push(Validators.maxLength(field.properties.maxLength)); - } - if (field.properties.pattern) { - validators.push(ValidatorsEx.pattern(field.properties.pattern, field.properties.patternMessage)); - } - } - const group = new FormGroup({}); if (field.partitioning === 'language') { for (let language of this.languages) { - group.addControl(language.iso2Code, new FormControl(undefined, validators)); + group.addControl(language.iso2Code, new FormControl(undefined, field.createValidators())); } } else { - group.addControl('iv', new FormControl(undefined, validators)); + group.addControl('iv', new FormControl(undefined, field.createValidators())); } controls[field.name] = group; diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-page.component.html index d793d3134..8a339144b 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.html @@ -25,7 +25,13 @@ -

Contents

+

+ Contents +

+ +

+ References +

@@ -39,7 +45,7 @@ - + @@ -62,10 +68,9 @@ - - + [schemaFields]="contentFields" + [schema]="schema" + isReadOnly="true"> diff --git a/src/Squidex/app/features/content/shared/content-item.component.html b/src/Squidex/app/features/content/shared/content-item.component.html index 749689e98..2bb20075d 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.html +++ b/src/Squidex/app/features/content/shared/content-item.component.html @@ -29,7 +29,7 @@ - + diff --git a/src/Squidex/app/features/content/shared/content-item.component.ts b/src/Squidex/app/features/content/shared/content-item.component.ts index 28cc13843..722fc648e 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.ts +++ b/src/Squidex/app/features/content/shared/content-item.component.ts @@ -12,7 +12,6 @@ import { AppLanguageDto, AppsStoreService, ContentDto, - DateTime, fadeAnimation, FieldDto, ModalView, @@ -41,10 +40,10 @@ export class ContentItemComponent extends AppComponentBase implements OnInit, On public deleting = new EventEmitter(); @Input() - public fields: FieldDto[]; + public language: AppLanguageDto; @Input() - public language: AppLanguageDto; + public schemaFields: FieldDto[]; @Input() public schema: SchemaDto; @@ -52,6 +51,9 @@ export class ContentItemComponent extends AppComponentBase implements OnInit, On @Input() public isReadOnly = false; + @Input() + public isReference = false; + @Input('sqxContent') public content: ContentDto; @@ -72,57 +74,25 @@ export class ContentItemComponent extends AppComponentBase implements OnInit, On private updateValues() { this.values = []; - for (let field of this.fields) { - this.values.push(this.getValue(field)); + if (this.schemaFields) { + for (let field of this.schemaFields) { + this.values.push(this.getValue(field)); + } } } private getValue(field: FieldDto): any { const contentField = this.content.data[field.name]; - if (!contentField) { - return ''; - } - - const properties = field.properties; - - let value: any; - - if (field.partitioning === 'language') { - value = contentField[this.language.iso2Code]; - } else { - value = contentField['iv']; - } - - if (value) { - if (properties.fieldType === 'Json') { - value = ''; - } else if (properties.fieldType === 'Geolocation') { - value = `${value.longitude}, ${value.latitude}`; - } else if (properties.fieldType === 'Boolean') { - value = value ? '✔' : '-'; - }else if (properties.fieldType === 'Assets') { - try { - value = `${value.length} Asset(s)`; - } catch (ex) { - value = '0 Asset(s)'; - } - } else if (properties.fieldType === 'DateTime') { - try { - const parsed = DateTime.parseISO_UTC(value); - - if (properties['editor'] === 'Date') { - value = parsed.toStringFormat('YYYY-MM-DD'); - } else { - value = parsed.toStringFormat('YYYY-MM-DD hh:mm:ss'); - } - } catch (ex) { - value = value; - } + if (contentField) { + if (field.partitioning === 'language') { + return field.formatValue(contentField[this.language.iso2Code]); + } else { + return field.formatValue(contentField['iv']); } + } else { + return ''; } - - return value; } } diff --git a/src/Squidex/app/features/content/shared/references-editor.component.html b/src/Squidex/app/features/content/shared/references-editor.component.html index c4d0b27ce..e4df4e916 100644 --- a/src/Squidex/app/features/content/shared/references-editor.component.html +++ b/src/Squidex/app/features/content/shared/references-editor.component.html @@ -19,11 +19,13 @@ - + (deleting)="onContentRemoving(content)" + isReadOnly="true" + isReference="true"> diff --git a/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.html b/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.html index 54887deda..35be4c232 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.html @@ -6,4 +6,17 @@ + +
+ + +
+ + + +
+
+ +
+
\ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.scss b/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.scss index f9405a205..2edff0687 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.scss +++ b/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.scss @@ -1,6 +1,16 @@ @import '_vars'; @import '_mixins'; +.minitems { + &-col { + position: relative; + } + + &-label { + @include absolute(0, -.2rem, auto, auto); + } +} + .form-check-input { margin: 0; } diff --git a/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.ts b/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.ts index 07d8f2fed..c0cdb7cce 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/types/assets-validation.component.ts @@ -5,8 +5,8 @@ * Copyright (c) Sebastian Stehle. All rights reserved */ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; import { AssetsFieldPropertiesDto } from 'shared'; @@ -16,10 +16,18 @@ import { AssetsFieldPropertiesDto } from 'shared'; templateUrl: 'assets-validation.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) -export class AssetsValidationComponent { +export class AssetsValidationComponent implements OnInit { @Input() public editForm: FormGroup; @Input() public properties: AssetsFieldPropertiesDto; + + public ngOnInit() { + this.editForm.setControl('maxItems', + new FormControl(this.properties.maxItems)); + + this.editForm.setControl('minItems', + new FormControl(this.properties.minItems)); + } } \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.html b/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.html index c4fd37c92..66ed2a9d4 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.html @@ -8,6 +8,7 @@ +
@@ -15,5 +16,18 @@
+ +
+ + +
+ + + +
+
+ +
+
diff --git a/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.scss b/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.scss index f9405a205..2edff0687 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.scss +++ b/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.scss @@ -1,6 +1,16 @@ @import '_vars'; @import '_mixins'; +.minitems { + &-col { + position: relative; + } + + &-label { + @include absolute(0, -.2rem, auto, auto); + } +} + .form-check-input { margin: 0; } diff --git a/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.ts b/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.ts index 4da4ba98e..b5b8be562 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/types/references-validation.component.ts @@ -27,6 +27,12 @@ export class ReferencesValidationComponent implements OnInit { public schemas: SchemaDto[]; public ngOnInit() { + this.editForm.setControl('maxItems', + new FormControl(this.properties.maxItems)); + + this.editForm.setControl('minItems', + new FormControl(this.properties.minItems)); + this.editForm.setControl('schemaId', new FormControl(this.properties.schemaId, [ Validators.required diff --git a/src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.scss b/src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.scss index b9d07d654..644d162d7 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.scss +++ b/src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.scss @@ -7,7 +7,7 @@ } &-label { - @include absolute(0, -2rem, auto, auto); + @include absolute(0, -.2rem, auto, auto); } } diff --git a/src/Squidex/app/framework/angular/control-errors.component.ts b/src/Squidex/app/framework/angular/control-errors.component.ts index 3533d8c81..a32cbca88 100644 --- a/src/Squidex/app/framework/angular/control-errors.component.ts +++ b/src/Squidex/app/framework/angular/control-errors.component.ts @@ -16,8 +16,8 @@ const DEFAULT_ERRORS: { [key: string]: string } = { patternmessage: '{message}', minvalue: '{field} must be larger than {minValue}.', maxvalue: '{field} must be smaller than {maxValue}.', - minlength: '{field} must have more than {requiredLength} characters.', - maxlength: '{field} cannot have more than {requiredLength} characters.', + minlength: '{field} must have a length of more than {requiredLength}.', + maxlength: '{field} must have a length of less than {requiredLength}.', match: '{message}', validdatetime: '{field} is not a valid date time', validnumber: '{field} is not a valid number.', diff --git a/src/Squidex/app/shared/services/schemas.fields.spec.ts b/src/Squidex/app/shared/services/schemas.fields.spec.ts new file mode 100644 index 000000000..92bf4a9d4 --- /dev/null +++ b/src/Squidex/app/shared/services/schemas.fields.spec.ts @@ -0,0 +1,175 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { + AssetsFieldPropertiesDto, + BooleanFieldPropertiesDto, + DateTimeFieldPropertiesDto, + FieldDto, + FieldPropertiesDto, + GeolocationFieldPropertiesDto, + JsonFieldPropertiesDto, + NumberFieldPropertiesDto, + ReferencesFieldPropertiesDto, + StringFieldPropertiesDto +} from './../'; + +describe('AssetsField', () => { + const field = createField(new AssetsFieldPropertiesDto(null, null, null, true, false, 1, 1)); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(3); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to asset count', () => { + expect(field.formatValue([1, 2, 3])).toBe('3 Asset(s)'); + }); + + it('should return zero formatting if other type', () => { + expect(field.formatValue(1)).toBe('0 Assets'); + }); +}); + +describe('BooleanField', () => { + const field = createField(new BooleanFieldPropertiesDto(null, null, null, true, false, 'Checkbox')); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(1); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to checkmark if true', () => { + expect(field.formatValue(true)).toBe('✔'); + }); + + it('should format to minus if false', () => { + expect(field.formatValue(false)).toBe('-'); + }); +}); + +describe('DateTimeField', () => { + const field = createField(new DateTimeFieldPropertiesDto(null, null, null, true, false, 'Date')); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(1); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to input if parsing failed', () => { + expect(field.formatValue(true)).toBe(true); + }); + + it('should format to date', () => { + const dateField = createField(new DateTimeFieldPropertiesDto(null, null, null, true, false, 'Date')); + + expect(dateField.formatValue('2017-12-12T16:00:00Z')).toBe('2017-12-12'); + }); + + it('should format to date', () => { + const dateTimeField = createField(new DateTimeFieldPropertiesDto(null, null, null, true, false, 'DateTime')); + + expect(dateTimeField.formatValue('2017-12-12T16:00:00Z').substr(0, 10)).toBe('2017-12-12'); + }); +}); + +describe('GeolocationField', () => { + const field = createField(new GeolocationFieldPropertiesDto(null, null, null, true, false, 'Default')); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(1); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to latitude and longitude', () => { + expect(field.formatValue({ latitude: 42, longitude: 3.14 })).toBe('3.14, 42'); + }); +}); + +describe('JsonField', () => { + const field = createField(new JsonFieldPropertiesDto(null, null, null, true, false)); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(1); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to constant', () => { + expect(field.formatValue({})).toBe(''); + }); +}); + +describe('NumberField', () => { + const field = createField(new NumberFieldPropertiesDto(null, null, null, true, false, 'Input', undefined, 3, 1, [1, 2, 3])); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(4); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to number', () => { + expect(field.formatValue(42)).toBe(42); + }); +}); + +describe('ReferencesField', () => { + const field = createField(new ReferencesFieldPropertiesDto(null, null, null, true, false, 1, 1)); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(3); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to asset count', () => { + expect(field.formatValue([1, 2, 3])).toBe('3 Reference(s)'); + }); + + it('should return zero formatting if other type', () => { + expect(field.formatValue(1)).toBe('0 References'); + }); +}); + +describe('NumberField', () => { + const field = createField(new StringFieldPropertiesDto(null, null, null, true, false, 'Input', undefined, 'pattern', undefined, 3, 1, ['1', '2'])); + + it('should create validators', () => { + expect(field.createValidators().length).toBe(5); + }); + + it('should format to empty string if null', () => { + expect(field.formatValue(null)).toBe(''); + }); + + it('should format to string', () => { + expect(field.formatValue('hello')).toBe('hello'); + }); +}); + +function createField(properties: FieldPropertiesDto) { + return new FieldDto(1, 'field1', false, false, 'languages', properties); +} \ No newline at end of file diff --git a/src/Squidex/app/shared/services/schemas.service.ts b/src/Squidex/app/shared/services/schemas.service.ts index 68db677d4..7e9cc1ee0 100644 --- a/src/Squidex/app/shared/services/schemas.service.ts +++ b/src/Squidex/app/shared/services/schemas.service.ts @@ -6,6 +6,7 @@ */ import { Injectable } from '@angular/core'; +import { ValidatorFn, Validators } from '@angular/forms'; import { Observable } from 'rxjs'; import 'framework/angular/http-extensions'; @@ -14,6 +15,7 @@ import { ApiUrlConfig, DateTime, EntityCreatedDto, + ValidatorsEx, Version } from 'framework'; @@ -110,6 +112,14 @@ export class FieldDto { public readonly properties: FieldPropertiesDto ) { } + + public formatValue(value: any): string { + return this.properties.formatValue(value); + } + + public createValidators(): ValidatorFn[] { + return this.properties.createValidators(); + } } export abstract class FieldPropertiesDto { @@ -122,6 +132,10 @@ export abstract class FieldPropertiesDto { public readonly isListField: boolean ) { } + + public abstract formatValue(value: any): string; + + public abstract createValidators(): ValidatorFn[]; } export class StringFieldPropertiesDto extends FieldPropertiesDto { @@ -138,6 +152,40 @@ export class StringFieldPropertiesDto extends FieldPropertiesDto { ) { super('String', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (!value) { + return ''; + } + + return value; + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + if (this.minLength) { + validators.push(Validators.minLength(this.minLength)); + } + + if (this.maxLength) { + validators.push(Validators.maxLength(this.maxLength)); + } + + if (this.pattern && this.pattern.length > 0) { + validators.push(ValidatorsEx.pattern(this.pattern, this.patternMessage)); + } + + if (this.allowedValues && this.allowedValues.length > 0) { + validators.push(ValidatorsEx.validValues(this.allowedValues)); + } + + return validators; + } } export class NumberFieldPropertiesDto extends FieldPropertiesDto { @@ -152,6 +200,36 @@ export class NumberFieldPropertiesDto extends FieldPropertiesDto { ) { super('Number', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (!value) { + return ''; + } + + return value; + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + if (this.minValue) { + validators.push(Validators.min(this.minValue)); + } + + if (this.maxValue) { + validators.push(Validators.max(this.maxValue)); + } + + if (this.allowedValues && this.allowedValues.length > 0) { + validators.push(ValidatorsEx.validValues(this.allowedValues)); + } + + return validators; + } } export class DateTimeFieldPropertiesDto extends FieldPropertiesDto { @@ -166,6 +244,34 @@ export class DateTimeFieldPropertiesDto extends FieldPropertiesDto { ) { super('DateTime', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (!value) { + return ''; + } + + try { + const parsed = DateTime.parseISO_UTC(value); + + if (this.editor === 'Date') { + return parsed.toStringFormat('YYYY-MM-DD'); + } else { + return parsed.toStringFormat('YYYY-MM-DD HH:mm:ss'); + } + } catch (ex) { + return value; + } + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + return validators; + } } export class BooleanFieldPropertiesDto extends FieldPropertiesDto { @@ -177,6 +283,24 @@ export class BooleanFieldPropertiesDto extends FieldPropertiesDto { ) { super('Boolean', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (value === null || value === undefined) { + return ''; + } + + return value ? '✔' : '-'; + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + return validators; + } } export class GeolocationFieldPropertiesDto extends FieldPropertiesDto { @@ -187,25 +311,107 @@ export class GeolocationFieldPropertiesDto extends FieldPropertiesDto { ) { super('Geolocation', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (!value) { + return ''; + } + + return `${value.longitude}, ${value.latitude}`; + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + return validators; + } } export class ReferencesFieldPropertiesDto extends FieldPropertiesDto { constructor(label: string | null, hints: string | null, placeholder: string | null, isRequired: boolean, isListField: boolean, + public readonly minItems?: number, + public readonly maxItems?: number, public readonly schemaId?: string ) { super('References', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (!value) { + return ''; + } + + if (value.length) { + return `${value.length} Reference(s)`; + } else { + return '0 References'; + } + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + if (this.minItems) { + validators.push(Validators.minLength(this.minItems)); + } + + if (this.maxItems) { + validators.push(Validators.maxLength(this.maxItems)); + } + + return validators; + } } export class AssetsFieldPropertiesDto extends FieldPropertiesDto { constructor(label: string | null, hints: string | null, placeholder: string | null, isRequired: boolean, - isListField: boolean + isListField: boolean, + public readonly minItems?: number, + public readonly maxItems?: number ) { super('Assets', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (!value) { + return ''; + } + + if (value.length) { + return `${value.length} Asset(s)`; + } else { + return '0 Assets'; + } + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + if (this.minItems) { + validators.push(Validators.minLength(this.minItems)); + } + + if (this.maxItems) { + validators.push(Validators.maxLength(this.maxItems)); + } + + return validators; + } } export class JsonFieldPropertiesDto extends FieldPropertiesDto { @@ -215,6 +421,24 @@ export class JsonFieldPropertiesDto extends FieldPropertiesDto { ) { super('Json', label, hints, placeholder, isRequired, isListField); } + + public formatValue(value: any): string { + if (!value) { + return ''; + } + + return ''; + } + + public createValidators(): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + if (this.isRequired) { + validators.push(Validators.required); + } + + return validators; + } } export class SchemaPropertiesDto { diff --git a/tests/Squidex.Core.Tests/Schemas/AssetsFieldPropertiesTests.cs b/tests/Squidex.Core.Tests/Schemas/AssetsFieldPropertiesTests.cs index f7de7b4a9..e4c369793 100644 --- a/tests/Squidex.Core.Tests/Schemas/AssetsFieldPropertiesTests.cs +++ b/tests/Squidex.Core.Tests/Schemas/AssetsFieldPropertiesTests.cs @@ -7,14 +7,33 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; +using FluentAssertions; +using Squidex.Infrastructure; using Xunit; namespace Squidex.Core.Schemas { public class AssetsFieldPropertiesTests { + private readonly List errors = new List(); + + [Fact] + public void Should_add_error_if_min_greater_than_max() + { + var sut = new AssetsFieldProperties { MinItems = 10, MaxItems = 5 }; + + sut.Validate(errors); + + errors.ShouldBeEquivalentTo( + new List + { + new ValidationError("Max items must be greater than min items", "MinItems", "MaxItems") + }); + } + [Fact] public void Should_set_or_freeze_sut() { diff --git a/tests/Squidex.Core.Tests/Schemas/AssetsFieldTests.cs b/tests/Squidex.Core.Tests/Schemas/AssetsFieldTests.cs index a13bcde77..bbf615ec4 100644 --- a/tests/Squidex.Core.Tests/Schemas/AssetsFieldTests.cs +++ b/tests/Squidex.Core.Tests/Schemas/AssetsFieldTests.cs @@ -91,6 +91,28 @@ namespace Squidex.Core.Schemas new[] { " is not a valid value" }); } + [Fact] + public async Task Should_add_errors_if_value_has_not_enough_items() + { + var sut = new AssetsField(1, "my-asset", Partitioning.Invariant, new AssetsFieldProperties { MinItems = 3 }); + + await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors); + + errors.ShouldBeEquivalentTo( + new[] { " must have at least 3 asset(s)" }); + } + + [Fact] + public async Task Should_add_errors_if_value_has_too_much_items() + { + var sut = new AssetsField(1, "my-asset", Partitioning.Invariant, new AssetsFieldProperties { MaxItems = 1 }); + + await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors); + + errors.ShouldBeEquivalentTo( + new[] { " must have not more than 1 asset(s)" }); + } + [Fact] public async Task Should_add_errors_if_asset_are_not_valid() { diff --git a/tests/Squidex.Core.Tests/Schemas/ReferencesFieldPropertiesTests.cs b/tests/Squidex.Core.Tests/Schemas/ReferencesFieldPropertiesTests.cs index acecb972d..7767224f6 100644 --- a/tests/Squidex.Core.Tests/Schemas/ReferencesFieldPropertiesTests.cs +++ b/tests/Squidex.Core.Tests/Schemas/ReferencesFieldPropertiesTests.cs @@ -7,14 +7,33 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; +using FluentAssertions; +using Squidex.Infrastructure; using Xunit; namespace Squidex.Core.Schemas { public class ReferencesFieldPropertiesTests { + private readonly List errors = new List(); + + [Fact] + public void Should_add_error_if_min_greater_than_max() + { + var sut = new ReferencesFieldProperties { MinItems = 10, MaxItems = 5 }; + + sut.Validate(errors); + + errors.ShouldBeEquivalentTo( + new List + { + new ValidationError("Max items must be greater than min items", "MinItems", "MaxItems") + }); + } + [Fact] public void Should_set_or_freeze_sut() { diff --git a/tests/Squidex.Core.Tests/Schemas/ReferencesFieldTests.cs b/tests/Squidex.Core.Tests/Schemas/ReferencesFieldTests.cs index 91381d066..1db9942a5 100644 --- a/tests/Squidex.Core.Tests/Schemas/ReferencesFieldTests.cs +++ b/tests/Squidex.Core.Tests/Schemas/ReferencesFieldTests.cs @@ -92,6 +92,28 @@ namespace Squidex.Core.Schemas new[] { " is not a valid value" }); } + [Fact] + public async Task Should_add_errors_if_value_has_not_enough_items() + { + var sut = new ReferencesField(1, "my-refs", Partitioning.Invariant, new ReferencesFieldProperties { SchemaId = schemaId, MinItems = 3 }); + + await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors); + + errors.ShouldBeEquivalentTo( + new[] { " must have at least 3 reference(s)" }); + } + + [Fact] + public async Task Should_add_errors_if_value_has_too_much_items() + { + var sut = new ReferencesField(1, "my-refs", Partitioning.Invariant, new ReferencesFieldProperties { SchemaId = schemaId, MaxItems = 1 }); + + await sut.ValidateAsync(CreateValue(Guid.NewGuid(), Guid.NewGuid()), errors); + + errors.ShouldBeEquivalentTo( + new[] { " must have not more than 1 reference(s)" }); + } + [Fact] public async Task Should_add_errors_if_reference_are_not_valid() { diff --git a/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs b/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs index 09bddaba6..d4fd9d1eb 100644 --- a/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs +++ b/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs @@ -332,7 +332,9 @@ namespace Squidex.Core.Schemas .AddOrUpdateField(new DateTimeField(8, "my-date", Partitioning.Invariant, new DateTimeFieldProperties { Editor = DateTimeFieldEditor.Date })) .AddOrUpdateField(new GeolocationField(9, "my-geolocation", Partitioning.Invariant, - new GeolocationFieldProperties())); + new GeolocationFieldProperties())) + .AddOrUpdateField(new ReferencesField(10, "my-references", Partitioning.Invariant, + new ReferencesFieldProperties())); return schema; }