diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 73118fb70..6423c9df4 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -384,8 +384,8 @@ "contents.localizedFieldDescription": "The '{fieldName}' field of the content item (localized).", "contents.newStatusFieldDescription": "The new status of the content item.", "contents.noReference": "- No Reference -", - "contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will loose them.\n\n**Do you want to continue anyway?**", - "contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will loose them.\n\n**Do you want to continue anyway?**", + "contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will lose them.\n\n**Do you want to continue anyway?**", + "contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will lose them.\n\n**Do you want to continue anyway?**", "contents.pendingChangesTitle": "Unsaved changes", "contents.referencesCreateNew": "Add New", "contents.referencesCreatePublish": "Create and Publish", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 73118fb70..6423c9df4 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -384,8 +384,8 @@ "contents.localizedFieldDescription": "The '{fieldName}' field of the content item (localized).", "contents.newStatusFieldDescription": "The new status of the content item.", "contents.noReference": "- No Reference -", - "contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will loose them.\n\n**Do you want to continue anyway?**", - "contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will loose them.\n\n**Do you want to continue anyway?**", + "contents.pendingChangesTextToChange": "You have unsaved changes.\n\nWhen you change the status you will lose them.\n\n**Do you want to continue anyway?**", + "contents.pendingChangesTextToClose": "You have unsaved changes.\n\nWhen you close the current content view you will lose them.\n\n**Do you want to continue anyway?**", "contents.pendingChangesTitle": "Unsaved changes", "contents.referencesCreateNew": "Add New", "contents.referencesCreatePublish": "Create and Publish", diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs index 0d09015ac..cd817bf01 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs @@ -64,11 +64,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await queryScheduledItems.PrepareAsync(collection, ct); } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query) + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced) { using (Profiler.TraceMethod("QueryAsyncByQuery")) { - return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.All); + return await queryContentsByQuery.DoAsync(app, schema, query, referenced, SearchScope.All); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs index 231b94836..b8bcfa644 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs @@ -65,11 +65,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await queryIdsAsync.PrepareAsync(collection, ct); } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query) + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced) { using (Profiler.TraceMethod("QueryAsyncByQuery")) { - return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.Published); + return await queryContentsByQuery.DoAsync(app, schema, query, referenced, SearchScope.Published); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index e522509aa..1f0a2fce3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -54,15 +54,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await collectionPublished.InitializeAsync(ct); } - public Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope) + public Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope) { if (scope == SearchScope.All) { - return collectionAll.QueryAsync(app, schema, query); + return collectionAll.QueryAsync(app, schema, query, referenced); } else { - return collectionPublished.QueryAsync(app, schema, query); + return collectionPublished.QueryAsync(app, schema, query, referenced); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs index da70459a9..736dd22d9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs @@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations await Collection.Indexes.CreateOneAsync(indexBySchema, cancellationToken: ct); } - public async Task> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope) + public async Task> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope) { Guard.NotNull(app, nameof(app)); Guard.NotNull(schema, nameof(schema)); @@ -90,7 +90,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations } } - var filter = CreateFilter(schema.AppId.Id, schema.Id, fullTextIds, query); + var filter = CreateFilter(schema.AppId.Id, schema.Id, fullTextIds, query, referenced); var contentCount = Collection.Find(filter).CountDocumentsAsync(); var contentItems = FindContentsAsync(query, filter); @@ -153,7 +153,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return query.Sort?.All(x => x.Path.ToString() == "mt" && x.Order == SortOrder.Descending) == true; } - private static FilterDefinition CreateFilter(DomainId appId, DomainId schemaId, ICollection? ids, ClrQuery? query) + private static FilterDefinition CreateFilter(DomainId appId, DomainId schemaId, ICollection? ids, ClrQuery? query, DomainId? referenced) { var filters = new List> { @@ -177,6 +177,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations filters.Add(query.Filter.BuildFilter()); } + if (referenced != null) + { + filters.Add(Filter.AnyEq(x => x.ReferencedIds, referenced.Value)); + } + return Filter.And(filters); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs index 511a31c1d..e62f2b17e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL graphQLSchema.RegisterValueConverter(JsonConverter.Instance); graphQLSchema.RegisterValueConverter(InstantConverter.Instance); - InitializeContentTypes(); + InitializeContentTypes(allSchemas, pageSizeContents); } private void BuildSchemas(List allSchemas) @@ -71,11 +71,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } } - private void InitializeContentTypes() + private void InitializeContentTypes(List allSchemas, int pageSize) { + var i = 0; + foreach (var contentType in contentTypes.Values) { - contentType.Initialize(this); + var schema = allSchemas[i]; + + contentType.Initialize(this, schema, allSchemas, pageSize); + + i++; } foreach (var contentType in contentTypes.Values) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs index cfbf746bf..498e1e33e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs @@ -85,12 +85,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types private void AddContentQueries(DomainId schemaId, string schemaType, string schemaName, IGraphType contentType, int pageSize) { - var resolver = ContentActions.Query.Resolver(schemaId); + var resolver = ContentActions.QueryOrReferencing.Query(schemaId); AddField(new FieldType { Name = $"query{schemaType}Contents", - Arguments = ContentActions.Query.Arguments(pageSize), + Arguments = ContentActions.QueryOrReferencing.Arguments(pageSize), ResolvedType = new ListGraphType(new NonNullGraphType(contentType)), Resolver = resolver, Description = $"Query {schemaName} content items." @@ -99,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types AddField(new FieldType { Name = $"query{schemaType}ContentsWithTotal", - Arguments = ContentActions.Query.Arguments(pageSize), + Arguments = ContentActions.QueryOrReferencing.Arguments(pageSize), ResolvedType = new ContentsResultGraphType(schemaType, schemaName, contentType), Resolver = resolver, Description = $"Query {schemaName} content items with total count." diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentActions.cs index 1581c371d..d8f960d04 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentActions.cs @@ -82,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - public static class Query + public static class QueryOrReferencing { private static QueryArguments? arguments; @@ -128,7 +128,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }; } - public static IFieldResolver Resolver(DomainId schemaId) + public static IFieldResolver Query(DomainId schemaId) { var schemaIdValue = schemaId.ToString(); @@ -139,6 +139,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return ((GraphQLExecutionContext)c.UserContext).QueryContentsAsync(schemaIdValue, query); }); } + + public static IFieldResolver Referencing(DomainId schemaId) + { + var schemaIdValue = schemaId.ToString(); + + return new FuncFieldResolver(c => + { + var query = c.BuildODataQuery(); + + var contentId = c.Source.Id; + + return ((GraphQLExecutionContext)c.UserContext).QueryReferencingContentsAsync(schemaIdValue, query, c.Source.Id); + }); + } } public static class Create diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs index 952ee944f..c8e857fac 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -5,24 +5,25 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Linq; using GraphQL.Types; +using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class ContentGraphType : ObjectGraphType { - private readonly ISchemaEntity schema; - private readonly string schemaType; - private readonly string schemaName; + private readonly DomainId schemaId; public ContentGraphType(ISchemaEntity schema) { - this.schema = schema; + this.schemaId = schema.Id; - schemaType = schema.TypeName(); - schemaName = schema.DisplayName(); + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); Name = $"{schemaType}"; @@ -99,11 +100,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types private bool CheckType(object value) { - return value is IContentEntity content && content.SchemaId?.Id == schema.Id; + return value is IContentEntity content && content.SchemaId?.Id == schemaId; } - public void Initialize(IGraphModel model) + public void Initialize(IGraphModel model, ISchemaEntity schema, IEnumerable all, int pageSize) { + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + AddField(new FieldType { Name = "url", @@ -137,6 +141,63 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The flat data of the {schemaName} content." }); } + + foreach (var other in all.Where(x => References(x, schema))) + { + var referencingId = other.Id; + var referencingType = other.TypeName(); + var referencingName = other.DisplayName(); + + var contentType = model.GetContentType(referencingId); + + AddReferencingQueries(referencingId, referencingType, referencingName, contentType, pageSize); + } + } + + private void AddReferencingQueries(DomainId referencingId, string referencingType, string referencingName, IGraphType contentType, int pageSize) + { + var resolver = ContentActions.QueryOrReferencing.Referencing(referencingId); + + AddField(new FieldType + { + Name = $"referencing{referencingType}Contents", + Arguments = ContentActions.QueryOrReferencing.Arguments(pageSize), + ResolvedType = new ListGraphType(new NonNullGraphType(contentType)), + Resolver = resolver, + Description = $"Query {referencingName} content items." + }); + + AddField(new FieldType + { + Name = $"referencing{referencingType}ContentsWithTotal", + Arguments = ContentActions.QueryOrReferencing.Arguments(pageSize), + ResolvedType = new ContentsResultGraphType(referencingType, referencingName, contentType), + Resolver = resolver, + Description = $"Query {referencingName} content items with total count." + }); + } + + private static bool References(ISchemaEntity other, ISchemaEntity schema) + { + var id = schema.Id; + + return other.SchemaDef.Fields.Any(x => References(x, id)); + } + + private static bool References(IField field, DomainId id) + { + switch (field) + { + case IField reference: + return + reference.Properties.SchemaIds == null || + reference.Properties.SchemaIds.Count == 0 || + reference.Properties.SchemaIds.Contains(id); + case IArrayField arrayField: + return arrayField.Fields.Any(x => References(x, id)); + } + + return false; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs index da927167c..3341793f4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -55,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { Guard.NotNull(context, nameof(context)); + if (id == default) + { + throw new DomainObjectNotFoundException(id.ToString()); + } + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); CheckPermission(context, schema); @@ -85,6 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { Guard.NotNull(context, nameof(context)); + if (query == null) + { + return EmptyContents; + } + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); CheckPermission(context, schema); @@ -110,13 +120,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { Guard.NotNull(context, nameof(context)); - using (Profiler.TraceMethod()) + if (ids == null || ids.Count == 0) { - if (ids == null || ids.Count == 0) - { - return EmptyContents; - } + return EmptyContents; + } + using (Profiler.TraceMethod()) + { var contents = await QueryCoreAsync(context, ids); var filtered = @@ -215,7 +225,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { var parsedQuery = await queryParser.ParseQueryAsync(context, schema, query); - return await QueryCoreAsync(context, schema, parsedQuery); + return await QueryCoreAsync(context, schema, parsedQuery, query.Reference); } private async Task> QueryByIdsAsync(Context context, ISchemaEntity schema, Q query) @@ -230,9 +240,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries return contentRepository.QueryAsync(context.App, new HashSet(ids), context.Scope()); } - private Task> QueryCoreAsync(Context context, ISchemaEntity schema, ClrQuery query) + private Task> QueryCoreAsync(Context context, ISchemaEntity schema, ClrQuery query, DomainId? referenced) { - return contentRepository.QueryAsync(context.App, schema, query, context.Scope()); + return contentRepository.QueryAsync(context.App, schema, query, referenced, context.Scope()); } private Task> QueryCoreAsync(Context context, ISchemaEntity schema, HashSet ids) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index f2cdcfa70..a9d66c14b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -8,6 +8,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; @@ -16,6 +17,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { public class QueryExecutionContext : Dictionary { + private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10); private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); private readonly IContentQueryService contentQuery; @@ -44,7 +46,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries if (asset == null) { - asset = await assetQuery.FindAssetAsync(context, id); + await maxRequests.WaitAsync(); + try + { + asset = await assetQuery.FindAssetAsync(context, id); + } + finally + { + maxRequests.Release(); + } if (asset != null) { @@ -61,7 +71,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries if (content == null) { - content = await contentQuery.FindContentAsync(context, schemaId.ToString(), id); + await maxRequests.WaitAsync(); + try + { + content = await contentQuery.FindContentAsync(context, schemaId.ToString(), id); + } + finally + { + maxRequests.Release(); + } if (content != null) { @@ -72,9 +90,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries return content; } - public virtual async Task> QueryAssetsAsync(string query) + public virtual async Task> QueryAssetsAsync(string odata) { - var assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithODataQuery(query)); + var q = Q.Empty.WithODataQuery(odata); + + IResultList assets; + + await maxRequests.WaitAsync(); + try + { + assets = await assetQuery.QueryAsync(context, null, q); + } + finally + { + maxRequests.Release(); + } foreach (var asset in assets) { @@ -84,16 +114,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries return assets; } - public virtual async Task> QueryContentsAsync(string schemaIdOrName, string query) + public virtual async Task> QueryContentsAsync(string schemaIdOrName, string odata) { - var result = await contentQuery.QueryAsync(context, schemaIdOrName, Q.Empty.WithODataQuery(query)); + var q = Q.Empty.WithODataQuery(odata); + + IResultList contents; + + await maxRequests.WaitAsync(); + try + { + contents = await contentQuery.QueryAsync(context, schemaIdOrName, q); + } + finally + { + maxRequests.Release(); + } - foreach (var content in result) + foreach (var content in contents) { cachedContents[content.Id] = content; } - return result; + return contents; } public virtual async Task> GetReferencedAssetsAsync(ICollection ids) @@ -104,7 +146,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries if (notLoadedAssets.Count > 0) { - var assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithIds(notLoadedAssets)); + IResultList assets; + + await maxRequests.WaitAsync(); + try + { + assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithIds(notLoadedAssets)); + } + finally + { + maxRequests.Release(); + } foreach (var asset in assets) { @@ -123,9 +175,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries if (notLoadedContents.Count > 0) { - var result = await contentQuery.QueryAsync(context, notLoadedContents); + IResultList contents; - foreach (var content in result) + await maxRequests.WaitAsync(); + try + { + contents = await contentQuery.QueryAsync(context, notLoadedContents); + } + finally + { + maxRequests.Release(); + } + + foreach (var content in contents) { cachedContents[content.Id] = content; } @@ -133,5 +195,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries return ids.Select(cachedContents.GetOrDefault).NotNull().ToList(); } + + public async Task> QueryReferencingContentsAsync(string schemaIdOrName, string odata, DomainId reference) + { + var q = Q.Empty.WithODataQuery(odata).WithReference(reference); + + await maxRequests.WaitAsync(); + try + { + return await contentQuery.QueryAsync(context, schemaIdOrName, q); + } + finally + { + maxRequests.Release(); + } + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index c094e401c..bef1b4053 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -22,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories Task> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet ids, SearchScope scope); - Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope); + Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope); Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Q.cs b/backend/src/Squidex.Domain.Apps.Entities/Q.cs index b2ae1f4c6..f0010a92f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Q.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Q.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities public IReadOnlyList Ids { get; private set; } + public DomainId? Reference { get; private set; } + public string? ODataQuery { get; private set; } public string? JsonQuery { get; private set; } @@ -52,6 +54,11 @@ namespace Squidex.Domain.Apps.Entities return Clone(c => c.Ids = ids.ToList()); } + public Q WithReference(DomainId? reference) + { + return Clone(c => c.Reference = reference); + } + public Q WithIds(IEnumerable ids) { return Clone(c => c.Ids = ids.ToList()); diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs index 7e722a156..7a511b75b 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs +++ b/backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections.Generic; -using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Identity; using MongoDB.Bson.Serialization.Attributes; diff --git a/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs b/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs index 18e9c682b..8ed6c8c33 100644 --- a/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs +++ b/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs @@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Assets.ImageSharp { public sealed class ImageSharpAssetThumbnailGenerator : IAssetThumbnailGenerator { - private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(Math.Max(Environment.ProcessorCount / 4, 1)); + private readonly SemaphoreSlim maxTasks = new SemaphoreSlim(Math.Max(Environment.ProcessorCount / 4, 1)); public async Task CreateThumbnailAsync(Stream source, Stream destination, ResizeOptions options) { @@ -42,8 +42,7 @@ namespace Squidex.Infrastructure.Assets.ImageSharp var w = options.Width ?? 0; var h = options.Height ?? 0; - await semaphoreSlim.WaitAsync(); - + await maxTasks.WaitAsync(); try { using (var image = Image.Load(source, out var format)) @@ -89,7 +88,7 @@ namespace Squidex.Infrastructure.Assets.ImageSharp } finally { - semaphoreSlim.Release(); + maxTasks.Release(); } } @@ -156,8 +155,7 @@ namespace Squidex.Infrastructure.Assets.ImageSharp Guard.NotNull(source, nameof(source)); Guard.NotNull(destination, nameof(destination)); - await semaphoreSlim.WaitAsync(); - + await maxTasks.WaitAsync(); try { using (var image = Image.Load(source, out var format)) @@ -178,7 +176,7 @@ namespace Squidex.Infrastructure.Assets.ImageSharp } finally { - semaphoreSlim.Release(); + maxTasks.Release(); } } diff --git a/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs b/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs index 3db47b4f3..82b1fbcd8 100644 --- a/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; diff --git a/backend/src/Squidex/Config/Domain/StoreServices.cs b/backend/src/Squidex/Config/Domain/StoreServices.cs index e6978b12d..40b3e3075 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -8,7 +8,6 @@ using System; using System.Linq; using IdentityServer4.Stores; -using Microsoft.AspNetCore.DataProtection.Repositories; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/backend/src/Squidex/wwwroot/scripts/editor-log.html b/backend/src/Squidex/wwwroot/scripts/editor-log.html index 897dde0b4..16be55e0c 100644 --- a/backend/src/Squidex/wwwroot/scripts/editor-log.html +++ b/backend/src/Squidex/wwwroot/scripts/editor-log.html @@ -22,7 +22,7 @@ var field = new SquidexFormField(); function logState(message) { - console.log(`${message}. Value: <${JSON.stringify(field.getValue(), 2)}>, Form Value: <${JSON.stringify(field.getFormValue())}>`); + console.log(`${message}.\n\Language: ${field.getLanguage()}\n\nValue\n<${JSON.stringify(field.getValue(), 2)}>\n\nForm Value\n<${JSON.stringify(field.getFormValue())}>\n\nDisabled: ${field.isDisabled()}`); } logState('Setup'); @@ -41,12 +41,18 @@ logState('Init'); }); - // Handle the value change event and set the text to the editor. - field.onValueChanged(function (value) { - logState(`Value changed: <${JSON.stringify(value, 2)}>`); + field.onValueChanged(function () { + logState('Value changed'); + }); + + field.onFormValueChanged(function () { + logState('Form value changed'); + }); + + field.onLanguageChanged(function () { + logState('Field language changed'); }); - // Disable the editor when it should be disabled. field.onDisabled(function (disabled) { logState(`Disabled: <${JSON.stringify(disabled, 2)}>`); }); diff --git a/backend/src/Squidex/wwwroot/scripts/editor-sdk.js b/backend/src/Squidex/wwwroot/scripts/editor-sdk.js index baa25ec4e..34bbc0260 100644 --- a/backend/src/Squidex/wwwroot/scripts/editor-sdk.js +++ b/backend/src/Squidex/wwwroot/scripts/editor-sdk.js @@ -86,7 +86,7 @@ function SquidexPlugin() { }, /** - * Register the init handler. + * Register an function that is called when the sidebar is initialized. */ onInit: function (callback) { initHandler = callback; @@ -95,7 +95,9 @@ function SquidexPlugin() { }, /** - * Register the content changed handler. + * Register an function that is called whenever the value of the content has changed. + * + * The callback has one argument with the value of the content (any). */ onContentChanged: function (callback) { contentHandler = callback; @@ -128,6 +130,8 @@ function SquidexFormField() { var fullscreenHandler = false; var valueHandler; var value; + var languageHandler; + var language; var formValueHandler; var formValue; var context; @@ -151,6 +155,12 @@ function SquidexFormField() { } } + function raiseLanguageChanged() { + if (languageHandler && language) { + languageHandler(language); + } + } + function raiseFullscreen() { if (fullscreenHandler) { fullscreenHandler(fullscreen); @@ -186,6 +196,10 @@ function SquidexFormField() { fullscreen = event.data.fullscreen; raiseFullscreen(); + } else if (type === 'languageChanged') { + language = event.data.language; + + raiseLanguageChanged(); } else if (type === 'init') { context = event.data.context; @@ -220,6 +234,27 @@ function SquidexFormField() { return formValue; }, + /* + * Get the current field language. + */ + getLanguage: function () { + return language; + }, + + /* + * Get the disabled state. + */ + isDisabled: function () { + return disabled; + }, + + /* + * Get the fullscreen state. + */ + isFullscreen: function () { + return fullscreen; + }, + /** * Notifies the control container that the editor has been touched. */ @@ -265,7 +300,7 @@ function SquidexFormField() { }, /** - * Register the init handler. + * Register an function that is called when the field is initialized. */ onInit: function (callback) { initHandler = callback; @@ -274,7 +309,9 @@ function SquidexFormField() { }, /** - * Register the disabled handler. + * Register an function that is called whenever the field is disabled or enabled. + * + * The callback has one argument with disabled state (disabled = true, enabled = false). */ onDisabled: function (callback) { disabledHandler = callback; @@ -283,25 +320,42 @@ function SquidexFormField() { }, /** - * Register the value changed handler. + * Register an function that is called whenever the field language is changed. + * + * The callback has one argument with the language of the field (string). + */ + onLanguageChanged: function (callback) { + languageHandler = callback; + + raiseLanguageChanged(); + }, + + /** + * Register an function that is called whenever the value of the field has changed. + * + * The callback has one argument with the value of the field (any). */ onValueChanged: function (callback) { valueHandler = callback; raiseValueChanged(); }, - + /** - * Register the form value changed handler. + * Register an function that is called whenever the value of the content has changed. + * + * The callback has one argument with the value of the content (any). */ onFormValueChanged: function (callback) { formValueHandler = callback; raiseFormValueChanged(); }, - + /** - * Register the fullscreen changed handler. + * Register an function that is called whenever the fullscreen mode has changed. + * + * The callback has one argument with fullscreen state (fullscreen on = true, fullscreen off = false). */ onFullscreen: function (callback) { fullscreenHandler = callback; diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs index 5f38269c0..275e01151 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs @@ -494,6 +494,29 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules Assert.Equal("null", result); } + [Theory] + [Expressions( + "$CONTENT_DATA.country.zh-TW", + "${CONTENT_DATA.country.zh-TW}", + "${event.data.country['zh-TW']}", + "{{event.data.country.zh-TW}}" + )] + public async Task Should_return_country_based_culture(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("country", + new ContentFieldData() + .AddValue("zh-TW", "Berlin")) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Berlin", result); + } + [Theory] [Expressions( "$CONTENT_DATA.country.iv", diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index ba4cafe94..7b935b738 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -459,6 +459,133 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL AssertResult(expected, result); } + [Fact] + public async Task Should_also_fetch_referencing_contents_when_field_is_included_in_query() + { + var contentRefId = DomainId.NewGuid(); + var contentRef = TestContent.CreateRef(schemaRefId1, contentRefId, "ref1-field", "ref1"); + + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(appId, schemaId, contentId, contentRefId, DomainId.Empty); + + var query = @" + query { + findMyRefSchema1Content(id: """") { + id + referencingMySchemaContents(top: 30, skip: 5) { + id + data { + myString { + de + } + } + } + } + }".Replace("", contentRefId.ToString()); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A>._)) + .Returns(ResultList.CreateFrom(0, contentRef)); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.Id.ToString(), A.That.HasOData("?$top=30&$skip=5", contentRefId))) + .Returns(ResultList.CreateFrom(1, content)); + + var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + findMyRefSchema1Content = new + { + id = contentRefId, + referencingMySchemaContents = new[] + { + new + { + id = contentId, + data = new + { + myString = new + { + de = "value" + } + } + } + } + } + } + }; + + AssertResult(expected, result); + } + + [Fact] + public async Task Should_also_fetch_referencing_contents_with_total_when_field_is_included_in_query() + { + var contentRefId = DomainId.NewGuid(); + var contentRef = TestContent.CreateRef(schemaRefId1, contentRefId, "ref1-field", "ref1"); + + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(appId, schemaId, contentId, contentRefId, DomainId.Empty); + + var query = @" + query { + findMyRefSchema1Content(id: """") { + id + referencingMySchemaContentsWithTotal(top: 30, skip: 5) { + total + items { + id + data { + myString { + de + } + } + } + } + } + }".Replace("", contentRefId.ToString()); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A>._)) + .Returns(ResultList.CreateFrom(0, contentRef)); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.Id.ToString(), A.That.HasOData("?$top=30&$skip=5", contentRefId))) + .Returns(ResultList.CreateFrom(1, content)); + + var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + findMyRefSchema1Content = new + { + id = contentRefId, + referencingMySchemaContentsWithTotal = new + { + total = 1, + items = new[] + { + new + { + id = contentId, + data = new + { + myString = new + { + de = "value" + } + } + } + } + } + } + } + }; + + AssertResult(expected, result); + } + [Fact] public async Task Should_also_fetch_union_contents_when_field_is_included_in_query() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTests.cs index 948b4250f..7f632d3e2 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTests.cs @@ -59,7 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb } [Fact] - public async Task Should_query_contents_by_filter() + public async Task Should_query_contents_ids_by_filter() { var filter = F.Eq("data.value.iv", 12); @@ -68,6 +68,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb Assert.NotEmpty(contents); } + [Fact] + public async Task Should_query_contents_by_filter() + { + var query = new ClrQuery + { + Sort = new List + { + new SortNode("lastModified", SortOrder.Descending) + }, + Filter = F.Eq("data.value.iv", 12) + }; + + var contents = await _.ContentRepository.QueryAsync(_.RandomApp(), _.RandomSchema(), query, null, SearchScope.Published); + + Assert.NotEmpty(contents); + } + [Fact] public async Task Should_query_contents_scheduled() { @@ -77,7 +94,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb } [Fact] - public async Task Should_query_contents_by_default() + public async Task Should_query_contents_with_default_query() { var query = new ClrQuery(); @@ -86,6 +103,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb Assert.NotEmpty(contents); } + [Fact] + public async Task Should_query_contents_with_default_query_and_id() + { + var query = new ClrQuery(); + + var contents = await QueryAsync(query, id: DomainId.NewGuid()); + + Assert.NotEmpty(contents); + } + [Fact] public async Task Should_query_contents_with_large_skip() { @@ -128,7 +155,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb Assert.NotEmpty(contents); } - private async Task> QueryAsync(ClrQuery clrQuery, int take = 1000, int skip = 100) + [Fact] + public async Task Should_query_contents_with_query_filter_and_id() + { + var query = new ClrQuery + { + Filter = F.Eq("data.value.iv", 12) + }; + + var contents = await QueryAsync(query, 1000, 0, id: DomainId.NewGuid()); + + Assert.Empty(contents); + } + + private async Task> QueryAsync(ClrQuery clrQuery, int take = 1000, int skip = 100, DomainId? id = null) { if (clrQuery.Take == long.MaxValue) { @@ -148,7 +188,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb }; } - var contents = await _.ContentRepository.QueryAsync(_.RandomApp(), _.RandomSchema(), clrQuery, SearchScope.All); + var contents = await _.ContentRepository.QueryAsync(_.RandomApp(), _.RandomSchema(), clrQuery, id, SearchScope.All); return contents; } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs index f075dee21..903e0dd74 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs @@ -182,16 +182,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [InlineData(0, 0, SearchScope.Published)] public async Task QueryAsync_should_return_contents(int isFrontend, int unpublished, SearchScope scope) { + var reference = DomainId.NewGuid(); + var ctx = CreateContext(isFrontend: isFrontend == 1, allowSchema: true) .WithUnpublished(unpublished == 1); var content = CreateContent(contentId); - A.CallTo(() => contentRepository.QueryAsync(ctx.App, schema, A._, scope)) + A.CallTo(() => contentRepository.QueryAsync(ctx.App, schema, A._, reference, scope)) .Returns(ResultList.CreateFrom(5, content)); - var result = await sut.QueryAsync(ctx, schemaId.Name, Q.Empty); + var result = await sut.QueryAsync(ctx, schemaId.Name, Q.Empty.WithReference(reference)); Assert.Equal(contentData, result[0].Data); Assert.Equal(contentId, result[0].Id); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs index 429b4475f..ef6df0429 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Linq; using FakeItEasy; +using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.TestHelpers @@ -19,9 +20,14 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers return that.Matches(x => x.Query!.ToString() == query); } - public static Q HasOData(this INegatableArgumentConstraintManager that, string query) + public static Q HasOData(this INegatableArgumentConstraintManager that, string odata) { - return that.Matches(x => x.ODataQuery == query); + return that.HasOData(odata, null); + } + + public static Q HasOData(this INegatableArgumentConstraintManager that, string odata, DomainId? reference = null) + { + return that.Matches(x => x.ODataQuery == odata && x.Reference == reference); } public static ClrQuery Is(this INegatableArgumentConstraintManager that, string query) diff --git a/backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs index 9f4986620..9fbba778f 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs @@ -11,6 +11,7 @@ using Xunit; namespace Squidex.Infrastructure.Email { + [Trait("Category", "Dependencies")] public class SmtpEmailSenderTests { [Fact] diff --git a/frontend/app/features/content/shared/forms/field-editor.component.html b/frontend/app/features/content/shared/forms/field-editor.component.html index 438372450..857ab7afc 100644 --- a/frontend/app/features/content/shared/forms/field-editor.component.html +++ b/frontend/app/features/content/shared/forms/field-editor.component.html @@ -12,7 +12,8 @@ + [formValue]="form?.value | async" + [language]="language?.iso2Code"> diff --git a/frontend/app/features/settings/pages/workflows/workflow.component.html b/frontend/app/features/settings/pages/workflows/workflow.component.html index 0522a6567..a854574da 100644 --- a/frontend/app/features/settings/pages/workflows/workflow.component.html +++ b/frontend/app/features/settings/pages/workflows/workflow.component.html @@ -6,7 +6,6 @@
diff --git a/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts b/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts index b3fa7063a..3fd9bf35d 100644 --- a/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts +++ b/frontend/app/framework/angular/forms/editors/iframe-editor.component.ts @@ -48,6 +48,9 @@ export class IFrameEditorComponent extends StatefulControlComponent @Input() public formValue: any; + @Input() + public language: string; + @Input() public url: string; @@ -72,9 +75,13 @@ export class IFrameEditorComponent extends StatefulControlComponent this.setupUrl(); } - if (changes['formValue'] && this.formValue) { + if (changes['formValue']) { this.sendFormValue(); } + + if (changes['language']) { + this.sendLanguage(); + } } } @@ -96,6 +103,7 @@ export class IFrameEditorComponent extends StatefulControlComponent this.sendInit(); this.sendFullscreen(); this.sendFormValue(); + this.sendLanguage(); this.sendDisabled(); this.sendValue(); } else if (type === 'resize') { @@ -158,7 +166,15 @@ export class IFrameEditorComponent extends StatefulControlComponent } private sendFormValue() { - this.sendMessage('formValueChanged', { formValue: this.formValue }); + if (this.formValue) { + this.sendMessage('formValueChanged', { formValue: this.formValue }); + } + } + + private sendLanguage() { + if (this.language) { + this.sendMessage('languageChanged', { language: this.language }); + } } private toggleFullscreen(isFullscreen: boolean) { diff --git a/frontend/app/framework/angular/forms/editors/tag-editor.component.html b/frontend/app/framework/angular/forms/editors/tag-editor.component.html index 96da5f9fa..b6571521d 100644 --- a/frontend/app/framework/angular/forms/editors/tag-editor.component.html +++ b/frontend/app/framework/angular/forms/editors/tag-editor.component.html @@ -1,7 +1,6 @@
{ describe('Implementation', () => { let localStore: IMock; let routerQueryParams: BehaviorSubject; - let routeActivated: any; + let routerEvents: Subject; + let route: any; let router: IMock; let router2State: Router2State; let state: State; @@ -217,14 +218,16 @@ describe('Router2State', () => { beforeEach(() => { localStore = Mock.ofType(); + routerEvents = new Subject(); router = Mock.ofType(); + router.setup(x => x.events).returns(() => routerEvents); state = new State({}); routerQueryParams = new BehaviorSubject({}); - routeActivated = { queryParams: routerQueryParams, id: MathHelper.guid() }; - router2State = new Router2State(routeActivated, router.object, localStore.object); + route = { queryParams: routerQueryParams, id: MathHelper.guid() }; + router2State = new Router2State(route, router.object, localStore.object); router2State.mapTo(state) .keep('keep') .withString('state1', 'key1') @@ -242,8 +245,9 @@ describe('Router2State', () => { it('should unsubscribe from route and state', () => { router2State.ngOnDestroy(); - expect(state.changes['observers'].length).toBe(0); - expect(routeActivated.queryParams.observers.length).toBe(0); + expect(state.changes['observers'].length).toEqual(0); + expect(route.queryParams.observers.length).toEqual(0); + expect(routerEvents.observers.length).toEqual(0); }); it('Should sync from route', () => { @@ -279,7 +283,7 @@ describe('Router2State', () => { expect(invoked).toEqual(1); }); - it('Should sync again when new query changed', () => { + it('Should not sync again when no value has changed', () => { routerQueryParams.next({ key1: 'hello', key2: 'squidex' @@ -288,12 +292,43 @@ describe('Router2State', () => { routerQueryParams.next({ key1: 'hello', key2: 'squidex', + key3: undefined, + key4: null + }); + + expect(invoked).toEqual(1); + }); + + it('Should sync again when new query changed', () => { + routerQueryParams.next({ + key1: 'hello', + key2: 'squidex' + }); + + routerQueryParams.next({ + key1: 'hello', + key2: 'cms', key3: '!' }); expect(invoked).toEqual(2); }); + it('Should not sync again when no state as changed', () => { + routerQueryParams.next({ + key1: 'hello', + key2: 'squidex' + }); + + routerQueryParams.next({ + key1: 'hello', + key2: 'squidex', + key3: '!' + }); + + expect(invoked).toEqual(1); + }); + it('Should reset other values when synced from route', () => { state.next({ other: 123 }); @@ -327,7 +362,41 @@ describe('Router2State', () => { state2: 'squidex' }); - expect(routeExtras!.relativeTo).toBeDefined(); + expect(routeExtras!.replaceUrl).toBeTrue(); + expect(routeExtras!.queryParamsHandling).toBe('merge'); + expect(routeExtras!.queryParams).toEqual({ key1: 'hello', key2: 'squidex' }); + }); + + it('Should not sync when navigating', () => { + routerEvents.next(new NavigationStart(0, '')); + + state.next({ + state1: 'hello', + state2: 'squidex' + }); + + router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.never()); + + expect().nothing(); + }); + + it('Should sync from state delayed when navigating', () => { + let routeExtras: NavigationExtras; + + router.setup(x => x.navigate([], It.isAny())) + .callback((_, extras) => { routeExtras = extras; }); + + routerEvents.next(new NavigationStart(0, '')); + + state.next({ + state1: 'hello', + state2: 'squidex' + }); + + router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.never()); + + routerEvents.next(new NavigationEnd(0, '', '')); + expect(routeExtras!.replaceUrl).toBeTrue(); expect(routeExtras!.queryParamsHandling).toBe('merge'); expect(routeExtras!.queryParams).toEqual({ key1: 'hello', key2: 'squidex' }); diff --git a/frontend/app/framework/angular/routers/router-2-state.ts b/frontend/app/framework/angular/routers/router-2-state.ts index d47616305..0e934f73f 100644 --- a/frontend/app/framework/angular/routers/router-2-state.ts +++ b/frontend/app/framework/angular/routers/router-2-state.ts @@ -8,7 +8,7 @@ // tslint:disable: readonly-array import { Injectable, OnDestroy } from '@angular/core'; -import { ActivatedRoute, Params, Router } from '@angular/router'; +import { ActivatedRoute, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, Router } from '@angular/router'; import { LocalStoreService, Pager, Types } from '@app/framework/internal'; import { State } from '@app/framework/state'; import { Subscription } from 'rxjs'; @@ -177,6 +177,9 @@ export class Router2StateMap implements StateSynchronizerMap, @@ -194,6 +197,23 @@ export class Router2StateMap implements StateSynchronizerMap this.syncToRoute(s)); + + this.subscriptionEvents = + this.router.events + .subscribe(event => { + if (Types.is(event, NavigationStart)) { + this.isNavigating = true; + } else if ( + Types.is(event, NavigationEnd) || + Types.is(event, NavigationCancel) || + Types.is(event, NavigationError)) { + this.isNavigating = false; + + if (this.pendingParams) { + this.syncFromParams(this.pendingParams); + } + } + }); } public destroy() { @@ -201,6 +221,7 @@ export class Router2StateMap implements StateSynchronizerMap implements StateSynchronizerMap implements StateSynchronizerMap implements StateSynchronizerMap Types.is(e, NavigationEnd))) + filter(event => Types.is(event, NavigationEnd))) .subscribe(() => { this.gtag('config', this.analyticsId, { page_path: window.location.pathname, anonymize_ip: true }); }); diff --git a/frontend/app/framework/state.spec.ts b/frontend/app/framework/state.spec.ts new file mode 100644 index 000000000..2b56a3f25 --- /dev/null +++ b/frontend/app/framework/state.spec.ts @@ -0,0 +1,72 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { State } from './state'; + +describe('State', () => { + let state: State; + + beforeEach(() => { + state = new State({}); + }); + + it('should update state with new value', () => { + let updateCount = 0; + + state.changes.subscribe(() => { + updateCount++; + }); + + const updated = state.next({ value: 1 }); + + expect(updateCount).toEqual(2); + expect(updated).toBeTruthy(); + }); + + it('should reset state with new value', () => { + let updateCount = 0; + + state.changes.subscribe(() => { + updateCount++; + }); + + const updated = state.resetState({ value: 1 }); + + expect(updateCount).toEqual(2); + expect(updated).toBeTruthy(); + }); + + it('should not update state when nothing changed', () => { + let updateCount = 0; + + state.changes.subscribe(() => { + updateCount++; + }); + + state.next({ value: 1 }); + + const updated = state.next({ value: 1 }); + + expect(updateCount).toEqual(2); + expect(updated).toBeFalsy(); + }); + + it('should not reset state when nothing changed', () => { + let updateCount = 0; + + state.changes.subscribe(() => { + updateCount++; + }); + + state.resetState({ value: 1 }); + + const updated = state.resetState({ value: 1 }); + + expect(updateCount).toEqual(2); + expect(updated).toBeFalsy(); + }); +}); \ No newline at end of file diff --git a/frontend/app/framework/state.ts b/frontend/app/framework/state.ts index 5280f9e75..d62f22e1f 100644 --- a/frontend/app/framework/state.ts +++ b/frontend/app/framework/state.ts @@ -164,7 +164,6 @@ export class ResultSet { export class State { private readonly state: BehaviorSubject>; - private readonly initialState: Readonly; public get changes(): Observable> { return this.state; @@ -185,39 +184,54 @@ export class State { } public projectFrom2(lhs: Observable, rhs: Observable, project: (l: M, r: N) => O, compare?: (x: O, y: O) => boolean) { - return combineLatest(lhs, rhs, (x, y) => project(x, y)).pipe( - distinctUntilChanged(compare), shareReplay(1)); + return combineLatest([lhs, rhs]).pipe( + map(([x, y]) => project(x, y)), distinctUntilChanged(compare), shareReplay(1)); } - constructor(state: Readonly) { - this.initialState = state; - - this.state = new BehaviorSubject(state); + constructor( + private readonly initialState: Readonly + ) { + this.state = new BehaviorSubject(initialState); } public resetState(update?: ((v: T) => Readonly) | Partial) { - let newState = this.initialState; + return this.updateState(this.initialState, update); + } + + public next(update: ((v: T) => Readonly) | Partial) { + return this.updateState(this.state.value, update); + } + + private updateState(state: T, update?: ((v: T) => Readonly) | Partial) { + let newState = state; if (update) { if (Types.isFunction(update)) { - newState = update(this.initialState); + newState = update(state); } else { - newState = { ...this.initialState, ...update }; + newState = { ...state, ...update }; } } - this.state.next(newState); - } + let isChanged = false; - public next(update: ((v: T) => Readonly) | Partial) { - let newState: T; + const newKeys = Object.keys(newState); - if (Types.isFunction(update)) { - newState = update(this.state.value); + if (newKeys.length !== Object.keys(this.snapshot).length) { + isChanged = true; } else { - newState = { ...this.state.value, ...update }; + for (const key of newKeys) { + if (newState[key] !== this.snapshot[key]) { + isChanged = true; + break; + } + } + } + + if (isChanged) { + this.state.next(newState); } - this.state.next(newState); + return isChanged; } } \ No newline at end of file diff --git a/frontend/app/framework/utils/types.ts b/frontend/app/framework/utils/types.ts index 86dd9a830..65d1cccf7 100644 --- a/frontend/app/framework/utils/types.ts +++ b/frontend/app/framework/utils/types.ts @@ -162,15 +162,15 @@ export module Types { return true; } else if (Types.isObject(lhs) && Types.isObject(rhs)) { - if (Object.keys(lhs).length !== Object.keys(rhs).length) { + const lhsKeys = Object.keys(lhs); + + if (lhsKeys.length !== Object.keys(rhs).length) { return false; } - for (const key in lhs) { - if (lhs.hasOwnProperty(key)) { - if (!equals(lhs[key], rhs[key], lazyString)) { - return false; - } + for (const key of lhsKeys) { + if (!equals(lhs[key], rhs[key], lazyString)) { + return false; } } diff --git a/frontend/app/shared/components/forms/language-selector.component.html b/frontend/app/shared/components/forms/language-selector.component.html index e24ec3cf3..21e7221d2 100644 --- a/frontend/app/shared/components/forms/language-selector.component.html +++ b/frontend/app/shared/components/forms/language-selector.component.html @@ -9,7 +9,7 @@ {{selectedLanguage.iso2Code}} - +