diff --git a/backend/src/Logitar.Cms.Contracts/ContentTypes/ContentTypeSort.cs b/backend/src/Logitar.Cms.Contracts/ContentTypes/ContentTypeSort.cs new file mode 100644 index 0000000..c1a4837 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/ContentTypes/ContentTypeSort.cs @@ -0,0 +1,8 @@ +namespace Logitar.Cms.Contracts.ContentTypes; + +public enum ContentTypeSort +{ + DisplayName, + UniqueName, + UpdatedOn +} diff --git a/backend/src/Logitar.Cms.Contracts/ContentTypes/ContentTypeSortOption.cs b/backend/src/Logitar.Cms.Contracts/ContentTypes/ContentTypeSortOption.cs new file mode 100644 index 0000000..d607bd6 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/ContentTypes/ContentTypeSortOption.cs @@ -0,0 +1,20 @@ +using Logitar.Cms.Contracts.Search; + +namespace Logitar.Cms.Contracts.ContentTypes; + +public record ContentTypeSortOption : SortOption +{ + public new ContentTypeSort Field + { + get => Enum.Parse(base.Field); + set => base.Field = value.ToString(); + } + + public ContentTypeSortOption() : this(ContentTypeSort.UpdatedOn, isDescending: true) + { + } + + public ContentTypeSortOption(ContentTypeSort field, bool isDescending = false) : base(field.ToString(), isDescending) + { + } +} diff --git a/backend/src/Logitar.Cms.Contracts/ContentTypes/SearchContentTypesPayload.cs b/backend/src/Logitar.Cms.Contracts/ContentTypes/SearchContentTypesPayload.cs new file mode 100644 index 0000000..a1f1cea --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/ContentTypes/SearchContentTypesPayload.cs @@ -0,0 +1,10 @@ +using Logitar.Cms.Contracts.Search; + +namespace Logitar.Cms.Contracts.ContentTypes; + +public record SearchContentTypesPayload : SearchPayload +{ + public bool? IsInvariant { get; set; } + + public new List? Sort { get; set; } +} diff --git a/backend/src/Logitar.Cms.Core/ContentTypes/ContentTypeAggregate.cs b/backend/src/Logitar.Cms.Core/ContentTypes/ContentTypeAggregate.cs index 5ce1d96..8ae9411 100644 --- a/backend/src/Logitar.Cms.Core/ContentTypes/ContentTypeAggregate.cs +++ b/backend/src/Logitar.Cms.Core/ContentTypes/ContentTypeAggregate.cs @@ -1,16 +1,12 @@ using Logitar.Cms.Contracts; -using Logitar.Cms.Core.Configurations; using Logitar.Cms.Core.ContentTypes.Events; using Logitar.EventSourcing; -using Logitar.Identity.Contracts.Settings; using Logitar.Identity.Domain.Shared; namespace Logitar.Cms.Core.ContentTypes; public class ContentTypeAggregate : AggregateRoot { - public static readonly IUniqueNameSettings UniqueNameSettings = new ReadOnlyUniqueNameSettings(); - private ContentTypeUpdatedEvent _updatedEvent = new(); public new ContentTypeId Id => new(base.Id); diff --git a/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeQuerier.cs b/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeQuerier.cs index d7a72d7..71a36fb 100644 --- a/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeQuerier.cs +++ b/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeQuerier.cs @@ -1,4 +1,5 @@ using Logitar.Cms.Contracts.ContentTypes; +using Logitar.Cms.Contracts.Search; namespace Logitar.Cms.Core.ContentTypes; @@ -8,4 +9,6 @@ public interface IContentTypeQuerier Task ReadAsync(ContentTypeId id, CancellationToken cancellationToken = default); Task ReadAsync(Guid id, CancellationToken cancellationToken = default); Task ReadAsync(string uniqueName, CancellationToken cancellationToken = default); + + Task> SearchAsync(SearchContentTypesPayload payload, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeRepository.cs b/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeRepository.cs index 6fae0e0..7b2d397 100644 --- a/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeRepository.cs +++ b/backend/src/Logitar.Cms.Core/ContentTypes/IContentTypeRepository.cs @@ -2,6 +2,7 @@ public interface IContentTypeRepository { + Task> LoadAsync(CancellationToken cancellationToken = default); Task LoadAsync(ContentTypeId id, CancellationToken cancellationToken = default); Task LoadAsync(IdentifierUnit uniqueName, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Cms.Core/ContentTypes/Queries/SearchContentTypesQuery.cs b/backend/src/Logitar.Cms.Core/ContentTypes/Queries/SearchContentTypesQuery.cs new file mode 100644 index 0000000..6f4c569 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/ContentTypes/Queries/SearchContentTypesQuery.cs @@ -0,0 +1,7 @@ +using Logitar.Cms.Contracts.ContentTypes; +using Logitar.Cms.Contracts.Search; +using MediatR; + +namespace Logitar.Cms.Core.ContentTypes.Queries; + +public record SearchContentTypesQuery(SearchContentTypesPayload Payload) : IRequest>; diff --git a/backend/src/Logitar.Cms.Core/ContentTypes/Queries/SearchContentTypesQueryHandler.cs b/backend/src/Logitar.Cms.Core/ContentTypes/Queries/SearchContentTypesQueryHandler.cs new file mode 100644 index 0000000..08c570e --- /dev/null +++ b/backend/src/Logitar.Cms.Core/ContentTypes/Queries/SearchContentTypesQueryHandler.cs @@ -0,0 +1,20 @@ +using Logitar.Cms.Contracts.ContentTypes; +using Logitar.Cms.Contracts.Search; +using MediatR; + +namespace Logitar.Cms.Core.ContentTypes.Queries; + +internal class SearchContentTypesQueryHandler : IRequestHandler> +{ + private readonly IContentTypeQuerier _fieldTypeQuerier; + + public SearchContentTypesQueryHandler(IContentTypeQuerier fieldTypeQuerier) + { + _fieldTypeQuerier = fieldTypeQuerier; + } + + public async Task> Handle(SearchContentTypesQuery query, CancellationToken cancellationToken) + { + return await _fieldTypeQuerier.SearchAsync(query.Payload, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentTypeQuerier.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentTypeQuerier.cs index 1f3d786..fcb0cec 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentTypeQuerier.cs +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentTypeQuerier.cs @@ -1,9 +1,12 @@ using Logitar.Cms.Contracts.Actors; using Logitar.Cms.Contracts.ContentTypes; +using Logitar.Cms.Contracts.Search; using Logitar.Cms.Core.ContentTypes; using Logitar.Cms.EntityFrameworkCore.Actors; using Logitar.Cms.EntityFrameworkCore.Entities; +using Logitar.Data; using Logitar.EventSourcing; +using Logitar.Identity.EntityFrameworkCore.Relational; using Microsoft.EntityFrameworkCore; namespace Logitar.Cms.EntityFrameworkCore.Queriers; @@ -12,11 +15,15 @@ internal class ContentTypeQuerier : IContentTypeQuerier { private readonly IActorService _actorService; private readonly DbSet _contentTypes; + private readonly ISearchHelper _searchHelper; + private readonly ISqlHelper _sqlHelper; - public ContentTypeQuerier(IActorService actorService, CmsContext context) + public ContentTypeQuerier(IActorService actorService, CmsContext context, ISearchHelper searchHelper, ISqlHelper sqlHelper) { _actorService = actorService; _contentTypes = context.ContentTypes; + _searchHelper = searchHelper; + _sqlHelper = sqlHelper; } public async Task ReadAsync(ContentTypeAggregate contentType, CancellationToken cancellationToken) @@ -48,6 +55,56 @@ public async Task ReadAsync(ContentTypeAggregate contentType, Ca return contentType == null ? null : await MapAsync(contentType, cancellationToken); } + public async Task> SearchAsync(SearchContentTypesPayload payload, CancellationToken cancellationToken) + { + IQueryBuilder builder = _sqlHelper.QueryFrom(CmsDb.ContentTypes.Table).SelectAll(CmsDb.ContentTypes.Table) + .ApplyIdInFilter(CmsDb.ContentTypes.UniqueId, payload); + _searchHelper.ApplyTextSearch(builder, payload.Search, CmsDb.ContentTypes.UniqueName, CmsDb.ContentTypes.DisplayName); + + if (payload.IsInvariant.HasValue) + { + builder.Where(CmsDb.ContentTypes.IsInvariant, Operators.IsEqualTo(payload.IsInvariant.Value)); + } + + IQueryable query = _contentTypes.FromQuery(builder).AsNoTracking(); + + long total = await query.LongCountAsync(cancellationToken); + + IOrderedQueryable? ordered = null; + if (payload.Sort != null) + { + foreach (ContentTypeSortOption sort in payload.Sort) + { + switch (sort.Field) + { + case ContentTypeSort.DisplayName: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.DisplayName) : query.OrderBy(x => x.DisplayName)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.DisplayName) : ordered.ThenBy(x => x.DisplayName)); + break; + case ContentTypeSort.UniqueName: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.UniqueName) : query.OrderBy(x => x.UniqueName)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.UniqueName) : ordered.ThenBy(x => x.UniqueName)); + break; + case ContentTypeSort.UpdatedOn: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.UpdatedOn) : query.OrderBy(x => x.UpdatedOn)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.UpdatedOn) : ordered.ThenBy(x => x.UpdatedOn)); + break; + } + } + } + query = ordered ?? query; + + query = query.ApplyPaging(payload); + + ContentTypeEntity[] fieldTypes = await query.ToArrayAsync(cancellationToken); + IEnumerable items = await MapAsync(fieldTypes, cancellationToken); + + return new SearchResults(items, total); + } + private async Task MapAsync(ContentTypeEntity contentType, CancellationToken cancellationToken) => (await MapAsync([contentType], cancellationToken)).Single(); private async Task> MapAsync(IEnumerable contentTypes, CancellationToken cancellationToken) diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentTypeRepository.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentTypeRepository.cs index 4d4c8f6..40d3010 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentTypeRepository.cs +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentTypeRepository.cs @@ -20,6 +20,11 @@ public ContentTypeRepository(IEventBus eventBus, EventContext eventContext, IEve _sqlHelper = sqlHelper; } + public async Task> LoadAsync(CancellationToken cancellationToken) + { + return (await base.LoadAsync(cancellationToken)).ToArray(); + } + public async Task LoadAsync(ContentTypeId id, CancellationToken cancellationToken) { return await base.LoadAsync(id.AggregateId, cancellationToken); diff --git a/backend/src/Logitar.Cms.Web/Controllers/ContentTypeController.cs b/backend/src/Logitar.Cms.Web/Controllers/ContentTypeController.cs index 13d49c1..ca7dab6 100644 --- a/backend/src/Logitar.Cms.Web/Controllers/ContentTypeController.cs +++ b/backend/src/Logitar.Cms.Web/Controllers/ContentTypeController.cs @@ -1,7 +1,9 @@ using Logitar.Cms.Contracts.ContentTypes; +using Logitar.Cms.Contracts.Search; using Logitar.Cms.Core; using Logitar.Cms.Core.ContentTypes.Commands; using Logitar.Cms.Core.ContentTypes.Queries; +using Logitar.Cms.Web.Models.ContentTypes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -41,4 +43,11 @@ public async Task> ReadAsync(string uniqueName, Can CmsContentType? contentType = await _pipeline.ExecuteAsync(new ReadContentTypeQuery(Id: null, uniqueName), cancellationToken); return contentType == null ? NotFound() : Ok(contentType); } + + [HttpGet] + public async Task>> SearchAsync([FromQuery] SearchContentTypesParameters parameters, CancellationToken cancellationToken) + { + SearchResults contentTypes = await _pipeline.ExecuteAsync(new SearchContentTypesQuery(parameters.ToPayload()), cancellationToken); + return Ok(contentTypes); + } } diff --git a/backend/src/Logitar.Cms.Web/Models/ContentTypes/SearchContentTypesParameters.cs b/backend/src/Logitar.Cms.Web/Models/ContentTypes/SearchContentTypesParameters.cs new file mode 100644 index 0000000..39d20f1 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Models/ContentTypes/SearchContentTypesParameters.cs @@ -0,0 +1,37 @@ +using Logitar.Cms.Contracts.ContentTypes; +using Logitar.Cms.Contracts.Search; +using Logitar.Cms.Web.Models.Search; +using Microsoft.AspNetCore.Mvc; + +namespace Logitar.Cms.Web.Models.ContentTypes; + +public record SearchContentTypesParameters : SearchParameters +{ + [FromQuery(Name = "invariant")] + public bool? IsInvariant { get; set; } + + public SearchContentTypesPayload ToPayload() + { + SearchContentTypesPayload payload = new() + { + IsInvariant = IsInvariant + }; + + FillPayload(payload); + + List? sortOptions = ((SearchPayload)payload).Sort; + if (sortOptions != null) + { + payload.Sort = new List(capacity: sortOptions.Count); + foreach (SortOption sort in sortOptions) + { + if (Enum.TryParse(sort.Field, out ContentTypeSort field)) + { + payload.Sort.Add(new ContentTypeSortOption(field, sort.IsDescending)); + } + } + } + + return payload; + } +} diff --git a/backend/tests/Logitar.Cms.Core.IntegrationTests/ContentTypes/Queries/SearchContentTypesQueryHandlerTests.cs b/backend/tests/Logitar.Cms.Core.IntegrationTests/ContentTypes/Queries/SearchContentTypesQueryHandlerTests.cs new file mode 100644 index 0000000..c53b210 --- /dev/null +++ b/backend/tests/Logitar.Cms.Core.IntegrationTests/ContentTypes/Queries/SearchContentTypesQueryHandlerTests.cs @@ -0,0 +1,73 @@ +using Logitar.Cms.Contracts.ContentTypes; +using Logitar.Cms.Contracts.Search; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.Core.ContentTypes.Queries; + +[Trait(Traits.Category, Categories.Integration)] +public class SearchContentTypesQueryHandlerTests : IntegrationTests +{ + private readonly IContentTypeRepository _fieldTypeRepository; + + private readonly ContentTypeAggregate _article; + private readonly ContentTypeAggregate _blog; + private readonly ContentTypeAggregate _author; + private readonly ContentTypeAggregate _magazine; + private readonly ContentTypeAggregate _product; + + public SearchContentTypesQueryHandlerTests() : base() + { + _fieldTypeRepository = ServiceProvider.GetRequiredService(); + + _article = new(new IdentifierUnit("BlogArticle"), isInvariant: false); + _blog = new(new IdentifierUnit("Blog"), isInvariant: false); + _author = new(new IdentifierUnit("BlogAuthor"), isInvariant: true); + _magazine = new(new IdentifierUnit("Magazine"), isInvariant: false); + _product = new(new IdentifierUnit("Product"), isInvariant: false); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + + await _fieldTypeRepository.SaveAsync([_article, _blog, _author, _magazine, _product]); + } + + [Fact(DisplayName = "It should return empty results when no field type matches.")] + public async Task It_should_return_empty_results_when_no_field_type_matches() + { + SearchContentTypesPayload payload = new() + { + Search = new TextSearch([new SearchTerm("%test%")]) + }; + SearchContentTypesQuery query = new(payload); + + SearchResults results = await Pipeline.ExecuteAsync(query); + + Assert.Empty(results.Items); + Assert.Equal(0, results.Total); + } + + [Fact(DisplayName = "It should return the correct matching field types.")] + public async Task It_should_return_the_correct_matching_field_types() + { + SearchContentTypesPayload payload = new() + { + IsInvariant = false, + IdIn = (await _fieldTypeRepository.LoadAsync()).Select(fieldType => fieldType.Id.ToGuid()).ToList(), + Search = new TextSearch([new SearchTerm("blog%"), new SearchTerm("%z%")], SearchOperator.Or), + Sort = [new ContentTypeSortOption(ContentTypeSort.UniqueName, isDescending: true)], + Skip = 1, + Limit = 1 + }; + payload.IdIn.Add(Guid.Empty); + payload.IdIn.Remove(_blog.Id.ToGuid()); + SearchContentTypesQuery query = new(payload); + + SearchResults results = await Pipeline.ExecuteAsync(query); + + Assert.Equal(2, results.Total); + CmsContentType fieldType = Assert.Single(results.Items); + Assert.Equal(_article.Id.ToGuid(), fieldType.Id); + } +}