diff --git a/src/Squidex.Core/Contents/ContentData.cs b/src/Squidex.Core/Contents/ContentData.cs index 3fdd3db2a..4e655316d 100644 --- a/src/Squidex.Core/Contents/ContentData.cs +++ b/src/Squidex.Core/Contents/ContentData.cs @@ -7,7 +7,10 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; +using Newtonsoft.Json.Linq; using Squidex.Infrastructure; namespace Squidex.Core.Contents @@ -16,6 +19,8 @@ namespace Squidex.Core.Contents { private readonly ImmutableDictionary fields; + public static readonly ContentData Empty = new ContentData(ImmutableDictionary.Empty.WithComparers (StringComparer.OrdinalIgnoreCase)); + public ImmutableDictionary Fields { get { return fields; } @@ -28,16 +33,21 @@ namespace Squidex.Core.Contents this.fields = fields; } - public static ContentData Empty() - { - return new ContentData(ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase)); - } - public ContentData AddField(string fieldName, ContentFieldData data) { Guard.ValidPropertyName(fieldName, nameof(fieldName)); return new ContentData(Fields.Add(fieldName, data)); } + + public static ContentData Create(Dictionary> raw) + { + return new ContentData(raw.ToImmutableDictionary(x => x.Key, x => new ContentFieldData(x.Value.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)), StringComparer.OrdinalIgnoreCase)); + } + + public Dictionary> ToRaw() + { + return fields.ToDictionary(x => x.Key, x => x.Value.ValueByLanguage.ToDictionary(y => y.Key, y => y.Value)); + } } } diff --git a/src/Squidex.Core/Contents/ContentFieldData.cs b/src/Squidex.Core/Contents/ContentFieldData.cs index b4270069f..935d3260a 100644 --- a/src/Squidex.Core/Contents/ContentFieldData.cs +++ b/src/Squidex.Core/Contents/ContentFieldData.cs @@ -17,6 +17,8 @@ namespace Squidex.Core.Contents { private readonly ImmutableDictionary valueByLanguage; + public static readonly ContentFieldData Empty = new ContentFieldData(ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase)); + public ImmutableDictionary ValueByLanguage { get { return valueByLanguage; } @@ -29,11 +31,6 @@ namespace Squidex.Core.Contents this.valueByLanguage = valueByLanguage; } - public static ContentFieldData New() - { - return new ContentFieldData(ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase)); - } - public ContentFieldData AddValue(JToken value) { return new ContentFieldData(valueByLanguage.Add("iv", value)); diff --git a/src/Squidex.Core/Schemas/Schema.cs b/src/Squidex.Core/Schemas/Schema.cs index a8173ed6e..09cb2cb57 100644 --- a/src/Squidex.Core/Schemas/Schema.cs +++ b/src/Squidex.Core/Schemas/Schema.cs @@ -201,7 +201,7 @@ namespace Squidex.Core.Schemas { var fieldErrors = new List(); - var fieldData = data.Fields.GetOrDefault(field.Name) ?? ContentFieldData.New(); + var fieldData = data.Fields.GetOrDefault(field.Name) ?? ContentFieldData.Empty; if (field.RawProperties.IsLocalizable) { diff --git a/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs b/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs index 3e92dff3f..795695484 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs @@ -48,6 +48,7 @@ namespace Squidex.Infrastructure.CQRS.Commands { Guard.NotNull(creator, nameof(creator)); Guard.NotNull(command, nameof(command)); + Guard.NotEmpty(command.AggregateId, nameof(command.AggregateId)); var aggregate = domainObjectFactory.CreateNew(command.AggregateId); @@ -60,6 +61,7 @@ namespace Squidex.Infrastructure.CQRS.Commands { Guard.NotNull(updater, nameof(updater)); Guard.NotNull(command, nameof(command)); + Guard.NotEmpty(command.AggregateId, nameof(command.AggregateId)); var aggregate = await domainObjectRepository.GetByIdAsync(command.AggregateId); diff --git a/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs b/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs index 5381b5b3c..e75660e1d 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs @@ -31,6 +31,11 @@ namespace Squidex.Infrastructure.CQRS.Commands logger.LogError(InfrastructureErrors.CommandFailed, exception, "Handling {0} command failed", context.Command); } + if (!context.IsHandled) + { + logger.LogCritical(InfrastructureErrors.CommandUnknown, exception, "Unknown command {0}", context.Command); + } + return Task.FromResult(false); } } diff --git a/src/Squidex.Infrastructure/CQRS/Events/EnrichWithActorProcessor.cs b/src/Squidex.Infrastructure/CQRS/Events/EnrichWithActorProcessor.cs index 62000129e..b3e8c8539 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/EnrichWithActorProcessor.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/EnrichWithActorProcessor.cs @@ -16,11 +16,11 @@ namespace Squidex.Infrastructure.CQRS.Events { public Task ProcessEventAsync(Envelope @event, IAggregate aggregate, ICommand command) { - var userCommand = command as IActorCommand; + var actorCommand = command as IActorCommand; - if (userCommand != null) + if (actorCommand != null) { - @event.SetActor(userCommand.Actor); + @event.SetActor(actorCommand.Actor); } return TaskHelper.Done; diff --git a/src/Squidex.Infrastructure/InfrastructureErrors.cs b/src/Squidex.Infrastructure/InfrastructureErrors.cs index 296be6f06..33d0aa582 100644 --- a/src/Squidex.Infrastructure/InfrastructureErrors.cs +++ b/src/Squidex.Infrastructure/InfrastructureErrors.cs @@ -12,6 +12,8 @@ namespace Squidex.Infrastructure { public class InfrastructureErrors { + public static readonly EventId CommandUnknown = new EventId(20000, "CommandUnknown"); + public static readonly EventId CommandFailed = new EventId(20001, "CommandFailed"); public static readonly EventId EventHandlingFailed = new EventId(10001, "EventHandlingFailed"); diff --git a/src/Squidex.Read/Contents/IContentEntity.cs b/src/Squidex.Read/Contents/IContentEntity.cs index 6b85654dc..7d1bce4cf 100644 --- a/src/Squidex.Read/Contents/IContentEntity.cs +++ b/src/Squidex.Read/Contents/IContentEntity.cs @@ -6,12 +6,14 @@ // All rights reserved. // ========================================================================== -using Newtonsoft.Json.Linq; +using Squidex.Core.Contents; namespace Squidex.Read.Contents { public interface IContentEntity : IEntity { - JToken Data { get; } + bool IsPublished { get; } + + ContentData Data { get; } } } diff --git a/src/Squidex.Read/Contents/Repositories/IContentRepoitory.cs b/src/Squidex.Read/Contents/Repositories/IContentRepository.cs similarity index 57% rename from src/Squidex.Read/Contents/Repositories/IContentRepoitory.cs rename to src/Squidex.Read/Contents/Repositories/IContentRepository.cs index ff66ad955..10116bb09 100644 --- a/src/Squidex.Read/Contents/Repositories/IContentRepoitory.cs +++ b/src/Squidex.Read/Contents/Repositories/IContentRepository.cs @@ -1,5 +1,5 @@ // ========================================================================== -// IContentRepoitory.cs +// IContentRepository.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group @@ -12,10 +12,12 @@ using System.Threading.Tasks; namespace Squidex.Read.Contents.Repositories { - public interface IContentRepoitory + public interface IContentRepository { - Task> QueryAsync(); + Task> QueryAsync(Guid schemaId, bool nonPublished, int? take, int? skip, string query); - Task FindContentAsync(Guid id); + Task CountAsync(Guid schemaId, bool nonPublished, string query); + + Task FindContentAsync(Guid schemaId, Guid id); } } diff --git a/src/Squidex.Store.MongoDb/Contents/MongoContentEntity.cs b/src/Squidex.Store.MongoDb/Contents/MongoContentEntity.cs new file mode 100644 index 000000000..05d1c299d --- /dev/null +++ b/src/Squidex.Store.MongoDb/Contents/MongoContentEntity.cs @@ -0,0 +1,99 @@ +// ========================================================================== +// MongoContentEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Text; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Core.Contents; +using Squidex.Infrastructure.MongoDb; +using Squidex.Read.Contents; +// ReSharper disable InvertIf + +namespace Squidex.Store.MongoDb.Contents +{ + public sealed class MongoContentEntity : MongoEntity, IContentEntity + { + private BsonDocument data; + private ContentData contentData; + + [BsonRequired] + [BsonElement] + public bool IsDeleted { get; set; } + + [BsonRequired] + [BsonElement] + public bool IsPublished { get; set; } + + [BsonRequired] + [BsonElement] + public string Text { get; set; } + + [BsonRequired] + [BsonElement] + public BsonDocument Data + { + get { return data; } + set + { + data = value; + + contentData = null; + } + } + + ContentData IContentEntity.Data + { + get + { + if (contentData == null) + { + if (data != null) + { + contentData = JsonConvert.DeserializeObject(data.ToJson()); + } + } + + return contentData; + } + } + + public void SetData(ContentData newContentData) + { + data = null; + + if (newContentData != null) + { + data = BsonDocument.Parse(JsonConvert.SerializeObject(newContentData)); + } + + Text = ExtractText(newContentData); + } + + private static string ExtractText(ContentData data) + { + if (data == null) + { + return string.Empty; + } + var stringBuilder = new StringBuilder(); + + foreach (var text in data.Fields.Values.SelectMany(x => x.ValueByLanguage.Values).Where(x => x != null).OfType()) + { + if (text.Type == JTokenType.String) + { + stringBuilder.Append(text); + } + } + + return stringBuilder.ToString(); + } + } +} diff --git a/src/Squidex.Store.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Store.MongoDb/Contents/MongoContentRepository.cs new file mode 100644 index 000000000..9afee4029 --- /dev/null +++ b/src/Squidex.Store.MongoDb/Contents/MongoContentRepository.cs @@ -0,0 +1,214 @@ +// ========================================================================== +// MongoContentRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Events; +using Squidex.Events.Contents; +using Squidex.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.CQRS.Replay; +using Squidex.Infrastructure.Dispatching; +using Squidex.Infrastructure.Reflection; +using Squidex.Read.Contents; +using Squidex.Read.Contents.Repositories; +using Squidex.Store.MongoDb.Utils; + +namespace Squidex.Store.MongoDb.Contents +{ + public class MongoContentRepository : IContentRepository, ICatchEventConsumer, IReplayableStore + { + private const string Prefix = "Projections_Content_"; + private readonly IMongoDatabase database; + + protected ProjectionDefinitionBuilder Projection + { + get + { + return Builders.Projection; + } + } + + protected SortDefinitionBuilder Sort + { + get + { + return Builders.Sort; + } + } + + protected UpdateDefinitionBuilder Update + { + get + { + return Builders.Update; + } + } + + protected FilterDefinitionBuilder Filter + { + get + { + return Builders.Filter; + } + } + + protected IndexKeysDefinitionBuilder IndexKeys + { + get + { + return Builders.IndexKeys; + } + } + + public MongoContentRepository(IMongoDatabase database) + { + Guard.NotNull(database, nameof(database)); + + this.database = database; + } + + public async Task ClearAsync() + { + using (var collections = await database.ListCollectionsAsync()) + { + while (await collections.MoveNextAsync()) + { + foreach (var collection in collections.Current) + { + var name = collection["name"].ToString(); + + if (name.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) + { + await database.DropCollectionAsync(name); + } + } + } + } + } + + public async Task> QueryAsync(Guid schemaId, bool nonPublished, int? take, int? skip, string query) + { + var cursor = BuildQuery(schemaId, nonPublished, query); + + if (take.HasValue) + { + cursor.Limit(take.Value); + } + + if (skip.HasValue) + { + cursor.Skip(skip.Value); + } + + cursor.SortByDescending(x => x.LastModified); + + var entities = + await cursor.ToListAsync(); + + return entities.OfType().ToList(); + } + + public Task CountAsync(Guid schemaId, bool nonPublished, string query) + { + var cursor = BuildQuery(schemaId, nonPublished, query); + + return cursor.CountAsync(); + } + + private IFindFluent BuildQuery(Guid schemaId, bool nonPublished, string query) + { + var filters = new List> + { + Filter.Eq(x => x.IsDeleted, false) + }; + + if (!string.IsNullOrWhiteSpace(query)) + { + filters.Add(Filter.Text(query, "en")); + } + + if (!nonPublished) + { + filters.Add(Filter.Eq(x => x.IsPublished, false)); + } + + var collection = GetCollection(schemaId); + + var cursor = collection.Find(Filter.And(filters)); + + return cursor; + } + + public async Task FindContentAsync(Guid schemaId, Guid id) + { + var collection = GetCollection(schemaId); + + var entity = + await collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + + return entity; + } + + protected Task On(ContentCreated @event, EnvelopeHeaders headers) + { + var collection = GetCollection(headers.SchemaId()); + + return collection.CreateAsync(headers, x => + { + SimpleMapper.Map(@event, x); + + x.SetData(@event.Data); + }); + } + + protected Task On(ContentUpdated @event, EnvelopeHeaders headers) + { + var collection = GetCollection(headers.SchemaId()); + + return collection.UpdateAsync(headers, x => + { + x.SetData(@event.Data); + }); + } + + protected Task On(ContentDeleted @event, EnvelopeHeaders headers) + { + var collection = GetCollection(headers.SchemaId()); + + return collection.UpdateAsync(headers, x => + { + x.IsDeleted = true; + }); + } + + protected Task On(SchemaCreated @event, EnvelopeHeaders headers) + { + var collection = GetCollection(headers.AggregateId()); + + return collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.IsPublished).Text(x => x.Text)); + } + + public Task On(Envelope @event) + { + return this.DispatchActionAsync(@event.Payload, @event.Headers); + } + + private IMongoCollection GetCollection(Guid schemaId) + { + var name = $"{Prefix}{schemaId}"; + + return database.GetCollection(name); + } + } +} diff --git a/src/Squidex.Store.MongoDb/MongoDbModule.cs b/src/Squidex.Store.MongoDb/MongoDbModule.cs index 4cbaf2561..c7a2d3d83 100644 --- a/src/Squidex.Store.MongoDb/MongoDbModule.cs +++ b/src/Squidex.Store.MongoDb/MongoDbModule.cs @@ -16,10 +16,12 @@ using Squidex.Infrastructure.CQRS.Events; using Squidex.Infrastructure.CQRS.Replay; using Squidex.Infrastructure.MongoDb; using Squidex.Read.Apps.Repositories; +using Squidex.Read.Contents.Repositories; using Squidex.Read.History.Repositories; using Squidex.Read.Schemas.Repositories; using Squidex.Read.Users.Repositories; using Squidex.Store.MongoDb.Apps; +using Squidex.Store.MongoDb.Contents; using Squidex.Store.MongoDb.History; using Squidex.Store.MongoDb.Infrastructure; using Squidex.Store.MongoDb.Schemas; @@ -70,6 +72,12 @@ namespace Squidex.Store.MongoDb .As() .InstancePerLifetimeScope(); + builder.RegisterType() + .As() + .As() + .As() + .SingleInstance(); + builder.RegisterType() .As() .As() diff --git a/src/Squidex.Write/EnrichWithSchemaIdProcessor.cs b/src/Squidex.Write/EnrichWithSchemaIdProcessor.cs new file mode 100644 index 000000000..434073e58 --- /dev/null +++ b/src/Squidex.Write/EnrichWithSchemaIdProcessor.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// EnrichWithSchemaIdProcessor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Events; +using Squidex.Infrastructure.CQRS; +using Squidex.Infrastructure.CQRS.Commands; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.Tasks; +using Squidex.Write.Schemas; + +namespace Squidex.Write +{ + public sealed class EnrichWithSchemaIdProcessor : IEventProcessor + { + public Task ProcessEventAsync(Envelope @event, IAggregate aggregate, ICommand command) + { + var schemaDomainObject = aggregate as SchemaDomainObject; + + if (schemaDomainObject != null) + { + @event.SetSchemaId(aggregate.Id); + } + else + { + var schemaCommand = command as ISchemaCommand; + + if (schemaCommand != null) + { + @event.SetSchemaId(schemaCommand.SchemaId); + } + } + + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex/Config/Domain/WriteModule.cs b/src/Squidex/Config/Domain/WriteModule.cs index ea4b25d00..c04f595f4 100644 --- a/src/Squidex/Config/Domain/WriteModule.cs +++ b/src/Squidex/Config/Domain/WriteModule.cs @@ -11,6 +11,7 @@ using Squidex.Infrastructure.CQRS.Events; using Squidex.Pipeline.CommandHandlers; using Squidex.Write; using Squidex.Write.Apps; +using Squidex.Write.Contents; using Squidex.Write.Schemas; namespace Squidex.Config.Domain @@ -39,6 +40,10 @@ namespace Squidex.Config.Domain .As() .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() .As() .SingleInstance(); @@ -55,6 +60,18 @@ namespace Squidex.Config.Domain .AsSelf() .InstancePerDependency(); + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .AsSelf() + .InstancePerDependency(); + + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() .AsSelf() .InstancePerDependency(); diff --git a/src/Squidex/Controllers/ContentApi/ContentsController.cs b/src/Squidex/Controllers/ContentApi/ContentsController.cs new file mode 100644 index 000000000..5e7ab5815 --- /dev/null +++ b/src/Squidex/Controllers/ContentApi/ContentsController.cs @@ -0,0 +1,140 @@ +// ========================================================================== +// ContentsController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using Squidex.Controllers.Api; +using Squidex.Controllers.ContentApi.Models; +using Squidex.Core.Contents; +using Squidex.Infrastructure.CQRS.Commands; +using Squidex.Infrastructure.Reflection; +using Squidex.Pipeline; +using Squidex.Read.Contents.Repositories; +using Squidex.Read.Schemas.Services; +using Squidex.Write.Contents.Commands; + +namespace Squidex.Controllers.ContentApi +{ + [Authorize(Roles = "app-editor,app-owner,app-developer")] + [ApiExceptionFilter] + [ServiceFilter(typeof(AppFilterAttribute))] + public class ContentsController : ControllerBase + { + private readonly ISchemaProvider schemaProvider; + private readonly IContentRepository contentRepository; + + public ContentsController(ICommandBus commandBus, ISchemaProvider schemaProvider, IContentRepository contentRepository) + : base(commandBus) + { + this.schemaProvider = schemaProvider; + this.contentRepository = contentRepository; + } + + [HttpGet] + [Route("content/{app}/{name}")] + public async Task GetContents(string name, [FromQuery] string query = null, [FromQuery] int? take = null, [FromQuery] int? skip = null, [FromQuery] bool nonPublished = false) + { + var schemaEntity = await schemaProvider.FindSchemaByNameAsync(AppId, name); + + if (schemaEntity == null) + { + return NotFound(); + } + + var taskForContents = contentRepository.QueryAsync(schemaEntity.Id, nonPublished, take, skip, query); + var taskForCount = contentRepository.CountAsync(schemaEntity.Id, nonPublished, query); + + await Task.WhenAll(taskForContents, taskForCount); + + var model = new ContentsDto + { + Total = taskForCount.Result, + Items = taskForContents.Result.Select(x => + { + var itemModel = SimpleMapper.Map(x, new ContentDto()); + + if (x.Data != null) + { + itemModel.Data = x.Data.ToRaw(); + } + + return itemModel; + }).ToArray() + }; + + return Ok(model); + } + + [HttpGet] + [Route("content/{app}/{name}/{id}")] + public async Task GetContent(string name, Guid id) + { + var schemaEntity = await schemaProvider.FindSchemaByNameAsync(AppId, name); + + if (schemaEntity == null) + { + return NotFound(); + } + + var content = await contentRepository.FindContentAsync(schemaEntity.Id, id); + + if (content == null) + { + return NotFound(); + } + + var model = SimpleMapper.Map(content, new ContentDto()); + + if (content.Data != null) + { + model.Data = content.Data.ToRaw(); + } + + return Ok(model); + } + + [HttpPost] + [Route("content/{app}/{name}/")] + public async Task PostContent([FromBody] Dictionary> request) + { + var command = new CreateContent { Data = ContentData.Create(request), AggregateId = Guid.NewGuid() }; + + var context = await CommandBus.PublishAsync(command); + var result = context.Result(); + + return CreatedAtAction(nameof(GetContent), new { id = result }, new EntityCreatedDto { Id = result.ToString() }); + } + + [HttpPut] + [Route("content/{app}/{name}/{id}")] + public async Task PutContent(Guid id, [FromBody] Dictionary> request) + { + var command = new UpdateContent { AggregateId = id, Data = ContentData.Create(request) }; + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + + [HttpDelete] + [Route("content/{app}/{name}/{id}")] + public async Task PutContent(Guid id) + { + var command = new DeleteContent { AggregateId = id }; + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + } +} diff --git a/src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs b/src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs index 8f6daadf3..ace4ae714 100644 --- a/src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs +++ b/src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs @@ -335,7 +335,7 @@ When you change the field to be localizable the value will become the value for ["createdBy"] = CreateProperty($"The user that has created the {schemaName} content element.", null), ["lastModified"] = CreateProperty($"The date and time when the {schemaName} content element has been modified last.", "date-time"), ["lastModifiedBy"] = CreateProperty($"The user that has updated the {schemaName} content element.", null), - ["isPublished"] = new JsonProperty + ["nonPublished"] = new JsonProperty { Description = $"Indicates if the {schemaName} content element is publihed.", IsRequired = true, Type = JsonObjectType.Boolean } diff --git a/src/Squidex/Controllers/ContentApi/Models/ContentEntryDto.cs b/src/Squidex/Controllers/ContentApi/Models/ContentDto.cs similarity index 88% rename from src/Squidex/Controllers/ContentApi/Models/ContentEntryDto.cs rename to src/Squidex/Controllers/ContentApi/Models/ContentDto.cs index 04da2267d..3e26e52a3 100644 --- a/src/Squidex/Controllers/ContentApi/Models/ContentEntryDto.cs +++ b/src/Squidex/Controllers/ContentApi/Models/ContentDto.cs @@ -1,5 +1,5 @@ // ========================================================================== -// ContentEntryDto.cs +// ContentDto.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group @@ -7,13 +7,14 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using Squidex.Core.Contents; +using Newtonsoft.Json.Linq; using Squidex.Infrastructure; namespace Squidex.Controllers.ContentApi.Models { - public sealed class ContentEntryDto + public sealed class ContentDto { /// /// The if of the content element. @@ -36,7 +37,7 @@ namespace Squidex.Controllers.ContentApi.Models /// The data of the content item. /// [Required] - public ContentData Data { get; set; } + public Dictionary> Data { get; set; } /// /// The date and time when the content element has been created. diff --git a/src/Squidex/Controllers/ContentApi/Models/ContentsDto.cs b/src/Squidex/Controllers/ContentApi/Models/ContentsDto.cs new file mode 100644 index 000000000..fa8a42661 --- /dev/null +++ b/src/Squidex/Controllers/ContentApi/Models/ContentsDto.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// ContentsDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Controllers.ContentApi.Models +{ + public class ContentsDto + { + /// + /// The total number of content items. + /// + public long Total { get; set; } + + /// + /// The content items. + /// + public ContentDto[] Items { get; set; } + } +} diff --git a/src/Squidex/Controllers/ControllerBase.cs b/src/Squidex/Controllers/ControllerBase.cs index 195b57150..d29906722 100644 --- a/src/Squidex/Controllers/ControllerBase.cs +++ b/src/Squidex/Controllers/ControllerBase.cs @@ -22,6 +22,11 @@ namespace Squidex.Controllers CommandBus = commandBus; } + protected ControllerBase() + { + throw new NotImplementedException(); + } + public Guid AppId { get diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index b3deb97ae..79f6e07e3 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -21,56 +21,57 @@
+
+
+ - - -
-
-
-
-
- -
-
- -
-
- +
+
+
+
+ +
+
+ +
+
+ +
-
-
-
-
- -
-
- -
-
- -
-
- +
+
+
+ +
+
+ +
+
+ +
+
+ +
-
-
-
-
-
- +
+
+
+
+ +
+
+
-
-
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 43aed15ba..ea3bcaf82 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 @@ -12,6 +12,7 @@ import { ActivatedRoute } from '@angular/router'; import { AppComponentBase, AppsStoreService, + ContentsService, NotificationService, NumberFieldPropertiesDto, SchemaDetailsDto, @@ -31,9 +32,12 @@ export class ContentPageComponent extends AppComponentBase { public contentFormSubmitted = false; public contentForm: FormGroup; + public languages = ['iv']; + public isNewMode = false; constructor(apps: AppsStoreService, notifications: NotificationService, users: UsersProviderService, + private readonly contentsService: ContentsService, private readonly route: ActivatedRoute ) { super(apps, notifications, users); @@ -53,6 +57,25 @@ export class ContentPageComponent extends AppComponentBase { public saveContent() { this.contentFormSubmitted = true; + + if (this.contentForm.valid) { + this.contentForm.disable(); + + const data = this.contentForm.value; + + this.appName() + .switchMap(app => this.contentsService.postContent(app, this.schema.name, data)) + .subscribe(() => { + this.reset(); + }, error => { + this.contentForm.enable(); + }); + } + } + + public reset() { + this.contentForm.reset(); + this.contentFormSubmitted = false; } private setupForm(schema: SchemaDetailsDto) { @@ -79,7 +102,11 @@ export class ContentPageComponent extends AppComponentBase { } } - controls[field.name] = new FormControl(undefined, validators); + const group = new FormGroup({ + 'iv': new FormControl(undefined, validators) + }); + + controls[field.name] = group; } this.contentForm = new FormGroup(controls); diff --git a/src/Squidex/app/shared/declarations.ts b/src/Squidex/app/shared/declarations.ts index 296cee640..a3dbf558c 100644 --- a/src/Squidex/app/shared/declarations.ts +++ b/src/Squidex/app/shared/declarations.ts @@ -21,6 +21,7 @@ export * from './services/app-languages.service'; export * from './services/apps-store.service'; export * from './services/apps.service'; export * from './services/auth.service'; +export * from './services/contents.service'; export * from './services/history.service'; export * from './services/languages.service'; export * from './services/schemas.service'; diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index 5f4b5cf26..7a452d6cb 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -18,6 +18,7 @@ import { AppsService, AppMustExistGuard, AuthService, + ContentsService, DashboardLinkDirective, HistoryComponent, HistoryService, @@ -58,6 +59,7 @@ export class SqxSharedModule { AppsService, AppMustExistGuard, AuthService, + ContentsService, HistoryService, LanguageService, MustBeAuthenticatedGuard, diff --git a/src/Squidex/app/shared/services/contents.service.ts b/src/Squidex/app/shared/services/contents.service.ts new file mode 100644 index 000000000..35e9097c5 --- /dev/null +++ b/src/Squidex/app/shared/services/contents.service.ts @@ -0,0 +1,106 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import 'framework/angular/http-extensions'; + +import { + ApiUrlConfig, + DateTime, + EntityCreatedDto +} from 'framework'; + +import { AuthService } from './auth.service'; + +export class ContentDto { + constructor( + public readonly id: string, + public readonly isPublished: boolean, + public readonly createdBy: string, + public readonly lastModifiedBy: string, + public readonly created: DateTime, + public readonly lastModified: DateTime, + public readonly data: any + ) { + } +} + +@Injectable() +export class ContentsService { + constructor( + private readonly authService: AuthService, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getContents(appName: string, schemaName: string, take: number, skip: number, query: string): Observable { + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/?query=${query}&take=${take}&skip=${skip}&query=${query}&nonPublished=true`); + + return this.authService.authGet(url) + .map(response => response.json()) + .map(response => { + const items: any[] = response; + + return items.map(item => { + return new ContentDto( + item.id, + item.isPublished, + item.createdBy, + item.lastModifiedBy, + DateTime.parseISO(item.created), + DateTime.parseISO(item.lastModified), + item.data); + }); + }) + .catchError('Failed to load contents. Please reload.'); + } + + public getContent(appName: string, schemaName: string, id: string): Observable { + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/`); + + return this.authService.authGet(url) + .map(response => response.json()) + .map(response => { + return new ContentDto( + response.id, + response.isPublished, + response.createdBy, + response.lastModifiedBy, + DateTime.parseISO(response.created), + DateTime.parseISO(response.lastModified), + response.data); + }) + .catchError('Failed to load content. Please reload.'); + } + + public postContent(appName: string, schemaName: string, dto: any): Observable { + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/`); + + return this.authService.authPost(url, dto) + .map(response => response.json()) + .map(response => { + return new EntityCreatedDto(response.id); + }) + .catchError('Failed to create content. Please reload.'); + } + + public putContent(appName: string, schemaName: string, id: string, dto: any): Observable { + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/`); + + return this.authService.authPut(url, dto) + .catchError('Failed to update Content. Please reload.'); + } + + public deleteContent(appName: string, schemaName: string, id: string, dto: any): Observable { + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/`); + + return this.authService.authDelete(url, dto) + .catchError('Failed to delete Content. Please reload.'); + } +} \ No newline at end of file diff --git a/src/Squidex/npm-debug.log.3790864127 b/src/Squidex/npm-debug.log.3790864127 new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Squidex.Core.Tests/Contents/ContentDataTests.cs b/tests/Squidex.Core.Tests/Contents/ContentDataTests.cs new file mode 100644 index 000000000..e2176d05f --- /dev/null +++ b/tests/Squidex.Core.Tests/Contents/ContentDataTests.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// ContentDataTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Squidex.Core.Contents +{ + public class ContentDataTests + { + [Fact] + public void Should_convert_from_dictionary() + { + var input = + new Dictionary> + { + ["field1"] = new Dictionary + { + ["en"] = "en_string", + ["de"] = "de_string" + }, + ["field2"] = new Dictionary + { + ["en"] = 1, + ["de"] = 2 + } + }; + + var actual = ContentData.Create(input); + + var expected = + ContentData.Empty + .AddField("field1", + ContentFieldData.Empty + .AddValue("en", "en_string") + .AddValue("de", "de_string")) + .AddField("field2", + ContentFieldData.Empty + .AddValue("en", 1) + .AddValue("de", 2)); + + var output = actual.ToRaw(); + + actual.ShouldBeEquivalentTo(expected); + output.ShouldBeEquivalentTo(input); + } + } +} diff --git a/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs b/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs index 367377c40..d0de3bb63 100644 --- a/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs +++ b/tests/Squidex.Core.Tests/Schemas/SchemaTests.cs @@ -256,7 +256,7 @@ namespace Squidex.Core.Schemas .AddOrUpdateField(new BooleanField(3, "admin", new BooleanFieldProperties())) .AddOrUpdateField(new NumberField(4, "age", - new NumberFieldProperties())); + new NumberFieldProperties { MinValue = 1, MaxValue = 10 })); var languages = new HashSet(new[] { Language.GetLanguage("de"), Language.GetLanguage("en") }); diff --git a/tests/Squidex.Core.Tests/Schemas/SchemaValidationTests.cs b/tests/Squidex.Core.Tests/Schemas/SchemaValidationTests.cs index e5d1ac1a8..45ab58b22 100644 --- a/tests/Squidex.Core.Tests/Schemas/SchemaValidationTests.cs +++ b/tests/Squidex.Core.Tests/Schemas/SchemaValidationTests.cs @@ -25,9 +25,9 @@ namespace Squidex.Core.Schemas public async Task Should_add_error_if_validating_data_with_unknown_field() { var data = - ContentData.Empty() + ContentData.Empty .AddField("unknown", - ContentFieldData.New()); + ContentFieldData.Empty); await sut.ValidateAsync(data, errors, languages); @@ -44,9 +44,9 @@ namespace Squidex.Core.Schemas sut = sut.AddOrUpdateField(new NumberField(1, "my-field", new NumberFieldProperties { MaxValue = 100 })); var data = - ContentData.Empty() + ContentData.Empty .AddField("my-field", - ContentFieldData.New() + ContentFieldData.Empty .AddValue(1000)); await sut.ValidateAsync(data, errors, languages); @@ -64,9 +64,9 @@ namespace Squidex.Core.Schemas sut = sut.AddOrUpdateField(new NumberField(1, "my-field", new NumberFieldProperties())); var data = - ContentData.Empty() + ContentData.Empty .AddField("my-field", - ContentFieldData.New() + ContentFieldData.Empty .AddValue("es", 1) .AddValue("it", 1)); @@ -85,7 +85,7 @@ namespace Squidex.Core.Schemas sut = sut.AddOrUpdateField(new NumberField(1, "my-field", new NumberFieldProperties { IsRequired = true, IsLocalizable = true })); var data = - ContentData.Empty(); + ContentData.Empty; await sut.ValidateAsync(data, errors, languages); @@ -103,7 +103,7 @@ namespace Squidex.Core.Schemas sut = sut.AddOrUpdateField(new NumberField(1, "my-field", new NumberFieldProperties { IsRequired = true })); var data = - ContentData.Empty(); + ContentData.Empty; await sut.ValidateAsync(data, errors, languages); @@ -120,9 +120,9 @@ namespace Squidex.Core.Schemas sut = sut.AddOrUpdateField(new NumberField(1, "my-field", new NumberFieldProperties { IsLocalizable = true })); var data = - ContentData.Empty() + ContentData.Empty .AddField("my-field", - ContentFieldData.New() + ContentFieldData.Empty .AddValue("de", 1) .AddValue("xx", 1)); @@ -141,9 +141,9 @@ namespace Squidex.Core.Schemas sut = sut.AddOrUpdateField(new NumberField(1, "my-field", new NumberFieldProperties { IsLocalizable = true })); var data = - ContentData.Empty() + ContentData.Empty .AddField("my-field", - ContentFieldData.New() + ContentFieldData.Empty .AddValue("es", 1) .AddValue("it", 1)); diff --git a/tests/Squidex.Infrastructure.Tests/CQRS/Commands/LogExceptionHandlerTests.cs b/tests/Squidex.Infrastructure.Tests/CQRS/Commands/LogExceptionHandlerTests.cs index 704fb880d..411ccbdc7 100644 --- a/tests/Squidex.Infrastructure.Tests/CQRS/Commands/LogExceptionHandlerTests.cs +++ b/tests/Squidex.Infrastructure.Tests/CQRS/Commands/LogExceptionHandlerTests.cs @@ -48,10 +48,12 @@ namespace Squidex.Infrastructure.CQRS.Commands } [Fact] - public async Task Should_do_nothing_if_context_has_no_exception() + public async Task Should_do_nothing_if_command_is_succeeded() { var context = new CommandContext(new MyCommand()); + context.Succeed(); + var isHandled = await sut.HandleAsync(context); Assert.False(isHandled); @@ -59,7 +61,7 @@ namespace Squidex.Infrastructure.CQRS.Commands } [Fact] - public async Task Should_log_if_context_has_exception2() + public async Task Should_log_if_command_failed() { var context = new CommandContext(new MyCommand()); @@ -70,5 +72,18 @@ namespace Squidex.Infrastructure.CQRS.Commands Assert.False(isHandled); Assert.Equal(1, logger.LogCount); } + + [Fact] + public async Task Should_log_if_command_is_not_handled() + { + var context = new CommandContext(new MyCommand()); + + context.Fail(new InvalidOperationException()); + + var isHandled = await sut.HandleAsync(context); + + Assert.False(isHandled); + Assert.Equal(1, logger.LogCount); + } } -} +} \ No newline at end of file diff --git a/tests/Squidex.Write.Tests/Contents/ContentCommandHandlerTests.cs b/tests/Squidex.Write.Tests/Contents/ContentCommandHandlerTests.cs index d477c9aea..52fe6c375 100644 --- a/tests/Squidex.Write.Tests/Contents/ContentCommandHandlerTests.cs +++ b/tests/Squidex.Write.Tests/Contents/ContentCommandHandlerTests.cs @@ -35,13 +35,13 @@ namespace Squidex.Write.Contents private readonly Mock appEntity = new Mock(); private readonly Guid schemaId = Guid.NewGuid(); private readonly Guid appId = Guid.NewGuid(); - private readonly ContentData data = ContentData.Empty().AddField("my-field", ContentFieldData.New().AddValue(1)); + private readonly ContentData data = ContentData.Empty.AddField("my-field", ContentFieldData.Empty.AddValue(1)); public ContentCommandHandlerTests() { var schema = Schema.Create("my-schema", new SchemaProperties()) - .AddOrUpdateField(new NumberField(1, "field", + .AddOrUpdateField(new NumberField(1, "my-field", new NumberFieldProperties { IsRequired = true })); content = new ContentDomainObject(Id, 0); @@ -58,7 +58,7 @@ namespace Squidex.Write.Contents [Fact] public async Task Create_should_throw_exception_if_data_is_not_valid() { - var command = new CreateContent { AggregateId = Id, AppId = appId, SchemaId = schemaId, Data = ContentData.Empty() }; + var command = new CreateContent { AggregateId = Id, AppId = appId, SchemaId = schemaId, Data = ContentData.Empty }; var context = new CommandContext(command); await TestCreate(content, async _ => @@ -86,7 +86,7 @@ namespace Squidex.Write.Contents { CreateContent(); - var command = new UpdateContent { AggregateId = Id, AppId = appId, SchemaId = schemaId, Data = ContentData.Empty() }; + var command = new UpdateContent { AggregateId = Id, AppId = appId, SchemaId = schemaId, Data = ContentData.Empty }; var context = new CommandContext(command); await TestUpdate(content, async _ => diff --git a/tests/Squidex.Write.Tests/Contents/ContentDomainObjectTests.cs b/tests/Squidex.Write.Tests/Contents/ContentDomainObjectTests.cs index 847ceb976..fb4d47b8e 100644 --- a/tests/Squidex.Write.Tests/Contents/ContentDomainObjectTests.cs +++ b/tests/Squidex.Write.Tests/Contents/ContentDomainObjectTests.cs @@ -26,7 +26,7 @@ namespace Squidex.Write.Contents { private readonly Guid appId = Guid.NewGuid(); private readonly ContentDomainObject sut; - private readonly ContentData data = ContentData.Empty(); + private readonly ContentData data = ContentData.Empty; public ContentDomainObjectTests() { diff --git a/tests/Squidex.Write.Tests/EnrichWithSchemaIdProcessorTests.cs b/tests/Squidex.Write.Tests/EnrichWithSchemaIdProcessorTests.cs new file mode 100644 index 000000000..0ef0d09ca --- /dev/null +++ b/tests/Squidex.Write.Tests/EnrichWithSchemaIdProcessorTests.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// EnrichWithSchemaIdProcessorTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Core.Schemas; +using Squidex.Events; +using Squidex.Infrastructure.CQRS; +using Squidex.Infrastructure.CQRS.Commands; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Write.Schemas; +using Xunit; + +namespace Squidex.Write +{ + public class EnrichWithSchemaIdProcessorTests + { + public sealed class MySchemaCommand : SchemaAggregateCommand + { + } + + public sealed class MyNormalCommand : ICommand + { + } + + public sealed class MyEvent : IEvent + { + } + + private readonly EnrichWithSchemaIdProcessor sut = new EnrichWithSchemaIdProcessor(); + + [Fact] + public async Task Should_not_do_anything_if_not_app_command() + { + var envelope = new Envelope(new MyEvent()); + + await sut.ProcessEventAsync(envelope, null, new MyNormalCommand()); + + Assert.False(envelope.Headers.Contains("SchemaId")); + } + + [Fact] + public async Task Should_attach_app_id_from_domain_object() + { + var appId = Guid.NewGuid(); + + var envelope = new Envelope(new MyEvent()); + + await sut.ProcessEventAsync(envelope, new SchemaDomainObject(appId, 1, new FieldRegistry()), new MyNormalCommand()); + + Assert.Equal(appId, envelope.Headers.SchemaId()); + } + + [Fact] + public async Task Should_attach_app_id_to_event_envelope() + { + var appId = Guid.NewGuid(); + var appCommand = new MySchemaCommand { AggregateId = appId }; + + var envelope = new Envelope(new MyEvent()); + + await sut.ProcessEventAsync(envelope, null, appCommand); + + Assert.Equal(appId, envelope.Headers.SchemaId()); + } + } +}