Browse Source

Merge branch 'release/4.x'

# Conflicts:
#	backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentActions.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs
#	backend/src/Squidex.Domain.Apps.Entities/Q.cs
pull/593/head
Sebastian 5 years ago
parent
commit
d6184d265e
  1. 4
      backend/i18n/frontend_en.json
  2. 4
      backend/i18n/source/frontend_en.json
  3. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs
  4. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs
  5. 6
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  6. 11
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs
  7. 12
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs
  8. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs
  9. 18
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentActions.cs
  10. 77
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs
  11. 26
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  12. 99
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  14. 7
      backend/src/Squidex.Domain.Apps.Entities/Q.cs
  15. 1
      backend/src/Squidex.Domain.Users.MongoDb/MongoUser.cs
  16. 12
      backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs
  17. 1
      backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs
  18. 1
      backend/src/Squidex/Config/Domain/StoreServices.cs
  19. 16
      backend/src/Squidex/wwwroot/scripts/editor-log.html
  20. 72
      backend/src/Squidex/wwwroot/scripts/editor-sdk.js
  21. 23
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs
  22. 127
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  23. 48
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTests.cs
  24. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs
  25. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs
  26. 1
      backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs
  27. 3
      frontend/app/features/content/shared/forms/field-editor.component.html
  28. 1
      frontend/app/features/settings/pages/workflows/workflow.component.html
  29. 20
      frontend/app/framework/angular/forms/editors/iframe-editor.component.ts
  30. 1
      frontend/app/framework/angular/forms/editors/tag-editor.component.html
  31. 14
      frontend/app/framework/angular/forms/editors/tag-editor.component.scss
  32. 3
      frontend/app/framework/angular/forms/editors/tag-editor.component.ts
  33. 87
      frontend/app/framework/angular/routers/router-2-state.spec.ts
  34. 66
      frontend/app/framework/angular/routers/router-2-state.ts
  35. 2
      frontend/app/framework/services/analytics.service.ts
  36. 72
      frontend/app/framework/state.spec.ts
  37. 50
      frontend/app/framework/state.ts
  38. 12
      frontend/app/framework/utils/types.ts
  39. 2
      frontend/app/shared/components/forms/language-selector.component.html
  40. 4
      frontend/app/shared/components/forms/language-selector.component.scss
  41. 2
      frontend/app/shared/state/assets.state.ts
  42. 2
      frontend/app/shared/state/contents.state.ts
  43. 4
      frontend/app/shared/state/query.ts

4
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",

4
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",

4
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<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query)
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced)
{
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))
{
return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.All);
return await queryContentsByQuery.DoAsync(app, schema, query, referenced, SearchScope.All);
}
}

4
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<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query)
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced)
{
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))
{
return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.Published);
return await queryContentsByQuery.DoAsync(app, schema, query, referenced, SearchScope.Published);
}
}

6
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<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope)
public Task<IResultList<IContentEntity>> 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);
}
}

11
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<IResultList<IContentEntity>> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope)
public async Task<IResultList<IContentEntity>> 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<MongoContentEntity> CreateFilter(DomainId appId, DomainId schemaId, ICollection<DomainId>? ids, ClrQuery? query)
private static FilterDefinition<MongoContentEntity> CreateFilter(DomainId appId, DomainId schemaId, ICollection<DomainId>? ids, ClrQuery? query, DomainId? referenced)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
@ -177,6 +177,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
filters.Add(query.Filter.BuildFilter<MongoContentEntity>());
}
if (referenced != null)
{
filters.Add(Filter.AnyEq(x => x.ReferencedIds, referenced.Value));
}
return Filter.And(filters);
}
}

12
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<ISchemaEntity> allSchemas)
@ -71,11 +71,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}
}
private void InitializeContentTypes()
private void InitializeContentTypes(List<ISchemaEntity> 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)

6
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."

18
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<IContentEntity, object?>(c =>
{
var query = c.BuildODataQuery();
var contentId = c.Source.Id;
return ((GraphQLExecutionContext)c.UserContext).QueryReferencingContentsAsync(schemaIdValue, query, c.Source.Id);
});
}
}
public static class Create

77
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<IEnrichedContentEntity>
{
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<ISchemaEntity> 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<ReferencesFieldProperties> 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;
}
}
}

26
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<ContentQueryService>())
if (ids == null || ids.Count == 0)
{
if (ids == null || ids.Count == 0)
{
return EmptyContents;
}
return EmptyContents;
}
using (Profiler.TraceMethod<ContentQueryService>())
{
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<IResultList<IContentEntity>> 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<DomainId>(ids), context.Scope());
}
private Task<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, ClrQuery query)
private Task<IResultList<IContentEntity>> 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<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, HashSet<DomainId> ids)

99
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<string, object>
{
private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10);
private readonly ConcurrentDictionary<DomainId, IEnrichedContentEntity?> cachedContents = new ConcurrentDictionary<DomainId, IEnrichedContentEntity?>();
private readonly ConcurrentDictionary<DomainId, IEnrichedAssetEntity?> cachedAssets = new ConcurrentDictionary<DomainId, IEnrichedAssetEntity?>();
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<IResultList<IEnrichedAssetEntity>> QueryAssetsAsync(string query)
public virtual async Task<IResultList<IEnrichedAssetEntity>> QueryAssetsAsync(string odata)
{
var assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithODataQuery(query));
var q = Q.Empty.WithODataQuery(odata);
IResultList<IEnrichedAssetEntity> 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<IResultList<IEnrichedContentEntity>> QueryContentsAsync(string schemaIdOrName, string query)
public virtual async Task<IResultList<IEnrichedContentEntity>> QueryContentsAsync(string schemaIdOrName, string odata)
{
var result = await contentQuery.QueryAsync(context, schemaIdOrName, Q.Empty.WithODataQuery(query));
var q = Q.Empty.WithODataQuery(odata);
IResultList<IEnrichedContentEntity> 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<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(ICollection<DomainId> 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<IEnrichedAssetEntity> 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<IEnrichedContentEntity> 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<IResultList<IEnrichedContentEntity>> 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();
}
}
}
}

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

@ -22,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<DomainId> ids, SearchScope scope);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, DomainId? referenced, SearchScope scope);
Task<IReadOnlyList<(DomainId SchemaId, DomainId Id)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode);

7
backend/src/Squidex.Domain.Apps.Entities/Q.cs

@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities
public IReadOnlyList<DomainId> 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<DomainId> ids)
{
return Clone(c => c.Ids = ids.ToList());

1
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;

12
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();
}
}

1
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;

1
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;

16
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)}>`);
});

72
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;

23
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",

127
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>"") {
id
referencingMySchemaContents(top: 30, skip: 5) {
id
data {
myString {
de
}
}
}
}
}".Replace("<ID>", contentRefId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<IReadOnlyList<DomainId>>._))
.Returns(ResultList.CreateFrom(0, contentRef));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.Id.ToString(), A<Q>.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>"") {
id
referencingMySchemaContentsWithTotal(top: 30, skip: 5) {
total
items {
id
data {
myString {
de
}
}
}
}
}
}".Replace("<ID>", contentRefId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<IReadOnlyList<DomainId>>._))
.Returns(ResultList.CreateFrom(0, contentRef));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.Id.ToString(), A<Q>.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()
{

48
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<SortNode>
{
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<IResultList<IContentEntity>> 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<IResultList<IContentEntity>> 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;
}

6
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<ClrQuery>._, scope))
A.CallTo(() => contentRepository.QueryAsync(ctx.App, schema, A<ClrQuery>._, 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);

10
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<Q> that, string query)
public static Q HasOData(this INegatableArgumentConstraintManager<Q> that, string odata)
{
return that.Matches(x => x.ODataQuery == query);
return that.HasOData(odata, null);
}
public static Q HasOData(this INegatableArgumentConstraintManager<Q> that, string odata, DomainId? reference = null)
{
return that.Matches(x => x.ODataQuery == odata && x.Reference == reference);
}
public static ClrQuery Is(this INegatableArgumentConstraintManager<ClrQuery> that, string query)

1
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]

3
frontend/app/features/content/shared/forms/field-editor.component.html

@ -12,7 +12,8 @@
<sqx-iframe-editor [url]="field.properties.editorUrl"
[context]="formContext"
[formControl]="editorControl"
[formValue]="form?.value | async">
[formValue]="form?.value | async"
[language]="language?.iso2Code">
</sqx-iframe-editor>
</ng-container>

1
frontend/app/features/settings/pages/workflows/workflow.component.html

@ -6,7 +6,6 @@
</div>
<div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds"
styleGray="true"
styleBlank="true"
singleLine="true"
readonly="true">

20
frontend/app/framework/angular/forms/editors/iframe-editor.component.ts

@ -48,6 +48,9 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
@Input()
public formValue: any;
@Input()
public language: string;
@Input()
public url: string;
@ -72,9 +75,13 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
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<State, any>
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<State, any>
}
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) {

1
frontend/app/framework/angular/forms/editors/tag-editor.component.html

@ -1,7 +1,6 @@
<div class="form-container">
<div class="form-control tags" #form (click)="input.focus()"
[class.blank]="styleBlank"
[class.gray]="styleGray"
[class.singleline]="singleLine"
[class.readonly]="readonly"
[class.multiline]="!singleLine"

14
frontend/app/framework/angular/forms/editors/tag-editor.component.scss

@ -115,24 +115,16 @@ div {
padding-left: .25rem;
}
.gray {
.item {
background: $color-border;
color: $color-text;
cursor: default;
}
}
.icon-close {
font-size: .6rem;
}
.item {
& {
background: $color-theme-blue;
background: $color-input;
border: 0;
border-radius: 2px;
color: $color-dark-foreground;
border-radius: 3px;
color: $color-text;
cursor: default;
display: inline-block;
height: $inner-height;

3
frontend/app/framework/angular/forms/editors/tag-editor.component.ts

@ -84,9 +84,6 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
@Input()
public styleBlank = false;
@Input()
public styleGray = false;
@Input()
public placeholder = 'i18n:common.tagAdd';

87
frontend/app/framework/angular/routers/router-2-state.spec.ts

@ -5,9 +5,9 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { NavigationExtras, Params, Router } from '@angular/router';
import { NavigationEnd, NavigationExtras, NavigationStart, Params, Router } from '@angular/router';
import { LocalStoreService, MathHelper, Pager } from '@app/framework/internal';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import { State } from './../../state';
import { PagerSynchronizer, Router2State, StringKeysSynchronizer, StringSynchronizer } from './router-2-state';
@ -208,7 +208,8 @@ describe('Router2State', () => {
describe('Implementation', () => {
let localStore: IMock<LocalStoreService>;
let routerQueryParams: BehaviorSubject<Params>;
let routeActivated: any;
let routerEvents: Subject<any>;
let route: any;
let router: IMock<Router>;
let router2State: Router2State;
let state: State<any>;
@ -217,14 +218,16 @@ describe('Router2State', () => {
beforeEach(() => {
localStore = Mock.ofType<LocalStoreService>();
routerEvents = new Subject<any>();
router = Mock.ofType<Router>();
router.setup(x => x.events).returns(() => routerEvents);
state = new State<any>({});
routerQueryParams = new BehaviorSubject<Params>({});
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' });

66
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<T extends object> implements StateSynchronizerMap<T
private lastSyncedParams: Params | undefined;
private subscriptionChanges: Subscription;
private subscriptionQueryParams: Subscription;
private subscriptionEvents: Subscription;
private isNavigating = false;
private pendingParams?: Params;
constructor(
private readonly state: State<T>,
@ -194,6 +197,23 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
this.subscriptionChanges =
this.state.changes
.subscribe(s => 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<T extends object> implements StateSynchronizerMap<T
this.subscriptionQueryParams?.unsubscribe();
this.subscriptionChanges?.unsubscribe();
this.subscriptionEvents?.unsubscribe();
}
private syncToRoute(state: T) {
@ -224,27 +245,38 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
return;
}
const queryParams: Params = {};
const query: Params = {};
for (const key in this.syncs) {
if (this.syncs.hasOwnProperty(key)) {
const { synchronizer, value } = this.syncs[key];
synchronizer.writeValue(value, queryParams);
synchronizer.writeValue(value, query);
}
}
this.lastSyncedParams = queryParams;
if (this.isNavigating) {
this.pendingParams = query;
} else {
this.syncFromParams(query);
}
}
private syncFromParams(query: Params) {
this.pendingParams = undefined;
this.router.navigate([], {
relativeTo: this.route,
queryParams,
queryParams: query,
queryParamsHandling: 'merge',
replaceUrl: true
});
this.lastSyncedParams = cleanupParams(query);
}
private syncFromRoute(query: Params) {
query = cleanupParams(query);
if (Types.equals(this.lastSyncedParams, query)) {
return;
}
@ -267,10 +299,10 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
update[key] = this.state.snapshot[key];
}
this.state.resetState(update);
for (const action of this.syncDone) {
action();
if (this.state.resetState(update)) {
for (const action of this.syncDone) {
action();
}
}
}
@ -305,4 +337,18 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
return this;
}
}
function cleanupParams(query: Params) {
for (const key in query) {
if (query.hasOwnProperty(key)) {
const value = query[key];
if (Types.isNull(value) || Types.isUndefined(value)) {
delete query[key];
}
}
}
return query;
}

2
frontend/app/framework/services/analytics.service.ts

@ -55,7 +55,7 @@ export class AnalyticsService {
this.gtag('config', this.analyticsId, { anonymize_ip: true });
this.router.events.pipe(
filter(e => Types.is(e, NavigationEnd)))
filter(event => Types.is(event, NavigationEnd)))
.subscribe(() => {
this.gtag('config', this.analyticsId, { page_path: window.location.pathname, anonymize_ip: true });
});

72
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<any>;
beforeEach(() => {
state = new State<any>({});
});
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();
});
});

50
frontend/app/framework/state.ts

@ -164,7 +164,6 @@ export class ResultSet<T> {
export class State<T extends {}> {
private readonly state: BehaviorSubject<Readonly<T>>;
private readonly initialState: Readonly<T>;
public get changes(): Observable<Readonly<T>> {
return this.state;
@ -185,39 +184,54 @@ export class State<T extends {}> {
}
public projectFrom2<M, N, O>(lhs: Observable<M>, rhs: Observable<N>, 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<T>) {
this.initialState = state;
this.state = new BehaviorSubject(state);
constructor(
private readonly initialState: Readonly<T>
) {
this.state = new BehaviorSubject(initialState);
}
public resetState(update?: ((v: T) => Readonly<T>) | Partial<T>) {
let newState = this.initialState;
return this.updateState(this.initialState, update);
}
public next(update: ((v: T) => Readonly<T>) | Partial<T>) {
return this.updateState(this.state.value, update);
}
private updateState(state: T, update?: ((v: T) => Readonly<T>) | Partial<T>) {
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<T>) | Partial<T>) {
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;
}
}

12
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;
}
}

2
frontend/app/shared/components/forms/language-selector.component.html

@ -9,7 +9,7 @@
{{selectedLanguage.iso2Code}}
</button>
<ng-container *sqxModal="dropdown">
<ng-container *sqxModal="dropdown;closeAlways:true">
<div class="dropdown-menu" [sqxAnchoredTo]="button" @fade>
<div class="dropdown-item" *ngFor="let language of languages; trackBy: trackByLanguage" [class.active]="language == selectedLanguage" (click)="selectLanguage(language)">
<strong class="iso-code iso-code-dropdown">{{language.iso2Code}}</strong> ({{language.englishName}})

4
frontend/app/shared/components/forms/language-selector.component.scss

@ -2,6 +2,10 @@
cursor: pointer;
}
.dropdown-toggle {
min-width: 5rem;
}
.iso-code {
font-family: monospace;
}

2
frontend/app/shared/state/assets.state.ts

@ -135,7 +135,7 @@ export class AssetsState extends State<Snapshot> {
.withPager('assetsPager', 'assets', 20)
.withString('parentId', 'parent')
.withStrings('tagsSelected', 'tags')
.withSynchronizer('assetsQuery', new QueryFullTextSynchronizer())
.withSynchronizer('assetsQuery', QueryFullTextSynchronizer.INSTANCE)
.whenSynced(() => this.loadInternal(false))
.build();
}

2
frontend/app/shared/state/contents.state.ts

@ -128,7 +128,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
synchronizer.mapTo(this)
.keep('selectedContent')
.withPager('contentsPager', 'contents', 10)
.withSynchronizer('contentsQuery', new QuerySynchronizer())
.withSynchronizer('contentsQuery', QuerySynchronizer.INSTANCE)
.whenSynced(() => this.loadInternal(false))
.build();
}

4
frontend/app/shared/state/query.ts

@ -119,6 +119,8 @@ const DEFAULT_QUERY = {
};
export class QueryFullTextSynchronizer implements RouteSynchronizer {
public static readonly INSTANCE = new QueryFullTextSynchronizer();
public getValue(params: Params) {
const query = params['query'];
@ -137,6 +139,8 @@ export class QueryFullTextSynchronizer implements RouteSynchronizer {
}
export class QuerySynchronizer implements RouteSynchronizer {
public static readonly INSTANCE = new QuerySynchronizer();
public getValue(params: Params) {
const query = params['query'];

Loading…
Cancel
Save