From 0acdbeec5dbe0328469e0e7ff1201c8c06614435 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 30 Aug 2022 20:20:45 +0200 Subject: [PATCH] Provide translation status. (#916) * Provide translation status. * Fix tests. * Fix average calculation. --- .../Contents/TranslationStatus.cs | 68 ++++++++ .../FieldDescriptions.Designer.cs | 18 ++ .../FieldDescriptions.resx | 6 + .../Partitioning.cs | 17 +- .../GenerateFilters/ContentQueryModel.cs | 23 +++ .../HandleRules/EventJsonSchemaGenerator.cs | 2 +- .../ContentWrapper/ContentFieldObject.cs | 2 +- .../Extensions/ContentFluidExtension.cs | 8 +- .../Contents/MongoContentEntity.cs | 16 +- .../MongoContentRepository_SnapshotStore.cs | 6 +- .../Apps/Plans/ConfigAppPlansProvider.cs | 4 +- .../Assets/AssetUsageTracker_EventHandling.cs | 2 +- .../Contents/GraphQL/Types/Builder.cs | 6 +- .../Types/Contents/ComponentUnionGraphType.cs | 2 +- .../Types/Contents/ContentResolvers.cs | 2 +- .../Types/Contents/ReferenceUnionGraphType.cs | 2 +- .../Text/State/CachingTextIndexerState.cs | 2 +- .../DomainObject/Guards/GuardSchema.cs | 2 +- .../CollectionExtensions.cs | 5 - .../src/Squidex.Infrastructure/Language.cs | 4 +- .../Reflection/TypeNameRegistry.cs | 16 +- .../src/Squidex.Web/ApiExceptionConverter.cs | 2 +- .../Config/DynamicApplicationStore.cs | 2 +- .../Model/Contents/TranslationStatusTests.cs | 103 ++++++++++++ .../ConvertContent/FieldConvertersTests.cs | 26 +-- .../DefaultValues/DefaultValuesTests.cs | 6 +- .../ValidateContent/ContentValidationTests.cs | 42 ++--- .../Contents/MongoDb/ContentMappingTests.cs | 4 +- .../Contents/MongoDb/ContentQueryTests.cs | 4 +- .../DomainObject/SchemaDomainObjectTests.cs | 4 +- .../CollectionExtensionsTests.cs | 15 -- .../pages/content/content-page.component.html | 20 ++- .../editor/content-field.component.html | 4 +- .../editor/field-languages.component.html | 7 +- .../editor/field-languages.component.ts | 6 +- .../contents/contents-page.component.html | 3 +- .../pages/contents/contents-page.component.ts | 8 +- .../references/content-creator.component.html | 8 +- .../forms/editors/tag-editor.stories.ts | 2 + .../framework/angular/forms/forms-helper.ts | 6 +- .../angular/language-selector.component.html | 58 ++++--- .../angular/language-selector.component.scss | 21 ++- .../angular/language-selector.component.ts | 19 ++- .../angular/language-selector.stories.tsx | 135 +++++++++++++++ .../app/framework/utils/modal-positioner.ts | 1 + .../shared/state/contents.forms-helpers.ts | 61 +++++++ .../app/shared/state/contents.forms.spec.ts | 155 +++++++++++++++++- .../src/app/shared/state/contents.forms.ts | 10 +- 48 files changed, 769 insertions(+), 176 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Core.Model/Contents/TranslationStatus.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/TranslationStatusTests.cs create mode 100644 frontend/src/app/framework/angular/language-selector.stories.tsx diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/TranslationStatus.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/TranslationStatus.cs new file mode 100644 index 000000000..c3d821a05 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/TranslationStatus.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public class TranslationStatus : Dictionary + { + public TranslationStatus() + { + } + + public TranslationStatus(int capacity) + : base(capacity) + { + } + + public static TranslationStatus Create(ContentData data, Schema schema, LanguagesConfig languages) + { + Guard.NotNull(data); + Guard.NotNull(schema); + Guard.NotNull(languages); + + var result = new TranslationStatus(languages.Languages.Count); + + var localizedFields = schema.Fields.Where(x => x.Partitioning == Partitioning.Language).ToList(); + + foreach (var language in languages.AllKeys) + { + var percent = 0; + + foreach (var field in localizedFields) + { + if (IsValidValue(data.GetValueOrDefault(field.Name)?.GetValueOrDefault(language))) + { + percent++; + } + } + + if (localizedFields.Count > 0) + { + percent = (int)Math.Round(100 * (double)percent / localizedFields.Count); + } + else + { + percent = 100; + } + + result[language] = percent; + } + + return result; + } + + private static bool IsValidValue(JsonValue? value) + { + return value != null && value.Value.Type != JsonValueType.Null; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs index a7754d8df..96461a015 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs @@ -888,6 +888,24 @@ namespace Squidex.Domain.Apps.Core { } } + /// + /// Looks up a localized string similar to The translation status.. + /// + public static string TranslationStatus { + get { + return ResourceManager.GetString("TranslationStatus", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The translation status ({0}).. + /// + public static string TranslationStatusLanguage { + get { + return ResourceManager.GetString("TranslationStatusLanguage", resourceCulture); + } + } + /// /// Looks up a localized string similar to The current number of calls.. /// diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx index b8a267523..bda49992c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx @@ -393,6 +393,12 @@ The text of this field. + + The translation status. + + + The translation status ({0}). + The current number of calls. diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs index 8a4a4fb0a..8bdf84d79 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs @@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Core { public delegate IFieldPartitioning PartitionResolver(Partitioning key); - public sealed class Partitioning : IEquatable + public sealed record Partitioning { public static readonly Partitioning Invariant = new Partitioning("invariant"); public static readonly Partitioning Language = new Partitioning("language"); @@ -27,21 +27,6 @@ namespace Squidex.Domain.Apps.Core Key = key; } - public override bool Equals(object? obj) - { - return Equals(obj as Partitioning); - } - - public bool Equals(Partitioning? other) - { - return string.Equals(other?.Key, Key, StringComparison.OrdinalIgnoreCase); - } - - public override int GetHashCode() - { - return Key.GetHashCode(StringComparison.Ordinal); - } - public override string ToString() { return Key; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateFilters/ContentQueryModel.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateFilters/ContentQueryModel.cs index 40afa0e6a..e05831a2e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateFilters/ContentQueryModel.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateFilters/ContentQueryModel.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Globalization; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Queries; @@ -55,6 +56,10 @@ namespace Squidex.Domain.Apps.Core.GenerateFilters } }; + var translationStatusSchema = BuildTranslationStatus(partitionResolver); + + fields.Add(new FilterField(translationStatusSchema, "translationStatus")); + if (schema != null) { var dataSchema = schema.BuildDataSchema(partitionResolver, components); @@ -72,5 +77,23 @@ namespace Squidex.Domain.Apps.Core.GenerateFilters return new QueryModel { Schema = filterSchema }; } + + private static FilterSchema BuildTranslationStatus(PartitionResolver partitionResolver) + { + var fields = new List(); + + foreach (var key in partitionResolver(Partitioning.Language).AllKeys) + { + fields.Add(new FilterField(FilterSchema.Number, key) + { + Description = string.Format(CultureInfo.InvariantCulture, FieldDescriptions.TranslationStatusLanguage, key) + }); + } + + return new FilterSchema(FilterSchemaType.Object) + { + Fields = fields.ToReadonlyList() + }; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs index 0ac51c3de..a94886777 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs @@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules { Guard.NotNull(typeName); - return schemas.Value.GetOrDefault(typeName); + return schemas.Value.GetValueOrDefault(typeName); } private Dictionary GenerateSchemas() diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs index 34232897c..3717e4843 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs @@ -129,7 +129,7 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper return PropertyDescriptor.Undefined; } - return valueProperties?.GetOrDefault(propertyName) ?? PropertyDescriptor.Undefined; + return valueProperties?.GetValueOrDefault(propertyName) ?? PropertyDescriptor.Undefined; } public override IEnumerable> GetOwnProperties() diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/ContentFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/ContentFluidExtension.cs index 4e1ab3284..2fc4865df 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/ContentFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/ContentFluidExtension.cs @@ -48,20 +48,20 @@ namespace Squidex.Domain.Apps.Core.Templates.Extensions { if (value.Value is JsonObject o) { - return o.GetOrDefault(name); + return o.GetValueOrDefault(name); } return null; }); memberAccessStrategy.Register( - (value, name) => value.GetOrDefault(name)); + (value, name) => value.GetValueOrDefault(name)); memberAccessStrategy.Register( - (value, name) => value.GetOrDefault(name).Value); + (value, name) => value.GetValueOrDefault(name).Value); memberAccessStrategy.Register( - (value, name) => value.GetOrDefault(name).Value); + (value, name) => value.GetValueOrDefault(name).Value); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index d67af9d37..a0b79ffa6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -100,6 +100,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents [BsonElement("mb")] public RefToken LastModifiedBy { get; set; } + [BsonIgnoreIfNull] + [BsonElement("ts")] + public TranslationStatus? TranslationStatus { get; set; } + public DomainId UniqueId { get => DocumentId; @@ -134,7 +138,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return entity; } - public static async Task CreateAsync(SnapshotWriteJob job, IAppProvider appProvider) + public static async Task CreateCompleteAsync(SnapshotWriteJob job, IAppProvider appProvider) { var entity = await CreateContentAsync(job.Value.Data, job, appProvider); @@ -158,16 +162,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents entity.ReferencedIds ??= new HashSet(); entity.Version = job.NewVersion; - if (data.CanHaveReference()) - { - var schema = await appProvider.GetSchemaAsync(job.Value.AppId.Id, job.Value.SchemaId.Id, true); + var (app, schema) = await appProvider.GetAppWithSchemaAsync(job.Value.AppId.Id, job.Value.SchemaId.Id, true); - if (schema != null) + if (schema?.SchemaDef != null && app != null) + { + if (data.CanHaveReference()) { var components = await appProvider.GetComponentsAsync(schema); entity.Data.AddReferencedIds(schema.SchemaDef, entity.ReferencedIds, components); } + + entity.TranslationStatus = TranslationStatus.Create(data, schema.SchemaDef, app.Languages); } return entity; diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 7c9bb60d5..cd778f809 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -139,7 +139,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents if (isValid) { await collectionComplete.AddCollectionsAsync( - await MongoContentEntity.CreateAsync(job, appProvider), add, ct); + await MongoContentEntity.CreateCompleteAsync(job, appProvider), add, ct); } } @@ -191,7 +191,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private async Task UpsertCompleteAsync(SnapshotWriteJob job, CancellationToken ct) { - var entityJob = job.As(await MongoContentEntity.CreateAsync(job, appProvider)); + var entityJob = job.As(await MongoContentEntity.CreateCompleteAsync(job, appProvider)); await collectionComplete.UpsertAsync(entityJob, ct); } @@ -199,7 +199,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private async Task UpsertVersionedCompleteAsync(IClientSessionHandle session, SnapshotWriteJob job, CancellationToken ct) { - var entityJob = job.As(await MongoContentEntity.CreateAsync(job, appProvider)); + var entityJob = job.As(await MongoContentEntity.CreateCompleteAsync(job, appProvider)); await collectionComplete.UpsertVersionedAsync(session, entityJob, ct); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs index 8fdaf15c3..377b5f10e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs @@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans public IAppLimitsPlan? GetPlan(string? planId) { - return plansById.GetOrDefault(planId ?? string.Empty); + return plansById.GetValueOrDefault(planId ?? string.Empty); } public IAppLimitsPlan GetFreePlan() @@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans private ConfigAppLimitsPlan GetPlanCore(string? planId) { - return plansById.GetOrDefault(planId ?? string.Empty) ?? freePlan; + return plansById.GetValueOrDefault(planId ?? string.Empty) ?? freePlan; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs index c3367537b..b61391b5c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Assets foreach (var tag in tagIds) { - perApp[tag] = perApp.GetOrDefault(tag) + count; + perApp[tag] = perApp.GetValueOrDefault(tag) + count; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs index b675053d1..101b5030d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs @@ -136,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public IObjectGraphType GetContentResultType(SchemaInfo schemaId) { - return contentResultTypes.GetOrDefault(schemaId); + return contentResultTypes.GetValueOrDefault(schemaId)!; } public IObjectGraphType? GetContentType(DomainId schemaId) @@ -146,7 +146,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public IObjectGraphType GetContentType(SchemaInfo schemaId) { - return contentTypes.GetOrDefault(schemaId); + return contentTypes.GetValueOrDefault(schemaId)!; } public IObjectGraphType? GetComponentType(DomainId schemaId) @@ -158,7 +158,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return null; } - return componentTypes.GetOrDefault(schema); + return componentTypes.GetValueOrDefault(schema); } public EmbeddableStringGraphType GetEmbeddableString(FieldInfo fieldInfo, StringFieldProperties properties) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs index cdfc5fba5..f5ddd0c96 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs @@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { if (value is JsonObject json && Component.IsValid(json, out var schemaId)) { - return types.GetOrDefault(schemaId); + return types.GetValueOrDefault(schemaId); } return null; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs index 01c005ccc..b61d8df86 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { var fieldName = fieldContext.FieldDefinition.SourceName(); - return content?.GetOrDefault(fieldName); + return content?.GetValueOrDefault(fieldName); }); public static readonly IFieldResolver Url = Resolve((content, _, context) => diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs index 81c783199..ca609f4f5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs @@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { if (value is IContentEntity content) { - return types.GetOrDefault(content.SchemaId.Id); + return types.GetValueOrDefault(content.SchemaId.Id); } return null; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs index d5b48c70a..35c831314 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs @@ -65,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.State foreach (var id in missingIds) { - var state = fromInner.GetOrDefault(id); + var state = fromInner.GetValueOrDefault(id); cache.Set(id, Tuple.Create(state)); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs index 81f6e5558..68be5cf91 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs @@ -230,7 +230,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards { var fieldPrefix = $"{path}[{fieldIndex}]"; - var field = schema.FieldsByName.GetOrDefault(fieldName ?? string.Empty); + var field = schema.FieldsByName.GetValueOrDefault(fieldName ?? string.Empty); if (string.IsNullOrWhiteSpace(fieldName)) { diff --git a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs index 6638ce005..501342d03 100644 --- a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -317,11 +317,6 @@ namespace Squidex.Infrastructure return dictionary.ToDictionary(x => x.Key, x => x.Value); } - public static TValue GetOrDefault(this IReadOnlyDictionary dictionary, TKey key) where TKey : notnull - { - return dictionary.GetOrCreate(key, _ => default!); - } - public static TValue GetOrAddDefault(this IDictionary dictionary, TKey key) where TKey : notnull { return dictionary.GetOrAdd(key, _ => default!); diff --git a/backend/src/Squidex.Infrastructure/Language.cs b/backend/src/Squidex.Infrastructure/Language.cs index 45931a7b9..fd2313617 100644 --- a/backend/src/Squidex.Infrastructure/Language.cs +++ b/backend/src/Squidex.Infrastructure/Language.cs @@ -37,12 +37,12 @@ namespace Squidex.Infrastructure public string EnglishName { - get => NamesEnglish.GetOrDefault(Iso2Code) ?? string.Empty; + get => NamesEnglish.GetValueOrDefault(Iso2Code) ?? string.Empty; } public string NativeName { - get => NamesNative.GetOrDefault(Iso2Code) ?? string.Empty; + get => NamesNative.GetValueOrDefault(Iso2Code) ?? string.Empty; } private Language(string iso2Code) diff --git a/backend/src/Squidex.Infrastructure/Reflection/TypeNameRegistry.cs b/backend/src/Squidex.Infrastructure/Reflection/TypeNameRegistry.cs index 8e274c211..b4e4da245 100644 --- a/backend/src/Squidex.Infrastructure/Reflection/TypeNameRegistry.cs +++ b/backend/src/Squidex.Infrastructure/Reflection/TypeNameRegistry.cs @@ -115,28 +115,24 @@ namespace Squidex.Infrastructure.Reflection return GetName(typeof(T)); } - public string GetNameOrNull() + public string? GetNameOrNull() { return GetNameOrNull(typeof(T)); } - public string GetNameOrNull(Type type) + public string? GetNameOrNull(Type type) { - var result = namesByType.GetOrDefault(type); - - return result; + return namesByType.GetValueOrDefault(type); } public Type? GetTypeOrNull(string name) { - var result = typesByName.GetOrDefault(name); - - return result; + return typesByName.GetValueOrDefault(name); } public string GetName(Type type) { - var result = namesByType.GetOrDefault(type); + var result = namesByType.GetValueOrDefault(type); if (result == null) { @@ -148,7 +144,7 @@ namespace Squidex.Infrastructure.Reflection public Type GetType(string name) { - var result = typesByName.GetOrDefault(name); + var result = typesByName.GetValueOrDefault(name); if (result == null) { diff --git a/backend/src/Squidex.Web/ApiExceptionConverter.cs b/backend/src/Squidex.Web/ApiExceptionConverter.cs index e2d5b5b78..b7a335f44 100644 --- a/backend/src/Squidex.Web/ApiExceptionConverter.cs +++ b/backend/src/Squidex.Web/ApiExceptionConverter.cs @@ -73,7 +73,7 @@ namespace Squidex.Web error.StatusCode = 500; } - error.Type = Links.GetOrDefault(error.StatusCode); + error.Type = Links.GetValueOrDefault(error.StatusCode); } private static (ErrorDto Error, Exception? Unhandled) CreateError(Exception exception) diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/DynamicApplicationStore.cs b/backend/src/Squidex/Areas/IdentityServer/Config/DynamicApplicationStore.cs index 8bae47afc..f679f960c 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Config/DynamicApplicationStore.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Config/DynamicApplicationStore.cs @@ -67,7 +67,7 @@ namespace Squidex.Areas.IdentityServer.Config { var app = await appProvider.GetAppAsync(appName, true); - var appClient = app?.Clients.GetOrDefault(appClientId); + var appClient = app?.Clients.GetValueOrDefault(appClientId); if (appClient != null) { diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/TranslationStatusTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/TranslationStatusTests.cs new file mode 100644 index 000000000..ce22a438a --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/TranslationStatusTests.cs @@ -0,0 +1,103 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Model.Contents +{ + public class TranslationStatusTests + { + private readonly LanguagesConfig languages = LanguagesConfig.English.Set(Language.DE).Set(Language.IT); + + [Fact] + public void Should_create_info_for_empty_schema() + { + var schema = new Schema("my-schema"); + + var result = TranslationStatus.Create(new ContentData(), schema, languages); + + Assert.Equal(new TranslationStatus + { + [Language.EN] = 100, + [Language.DE] = 100, + [Language.IT] = 100 + }, result); + } + + [Fact] + public void Should_create_info_for_schema_without_localized_field() + { + var schema = + new Schema("my-schema") + .AddString(1, "field1", Partitioning.Invariant); + + var result = TranslationStatus.Create(new ContentData(), schema, languages); + + Assert.Equal(new TranslationStatus + { + [Language.EN] = 100, + [Language.DE] = 100, + [Language.IT] = 100 + }, result); + } + + [Fact] + public void Should_create_info_for_schema_with_localized_field() + { + var schema = + new Schema("my-schema") + .AddString(1, "field1", Partitioning.Language); + + var result = TranslationStatus.Create(new ContentData(), schema, languages); + + Assert.Equal(new TranslationStatus + { + [Language.EN] = 0, + [Language.DE] = 0, + [Language.IT] = 0 + }, result); + } + + [Fact] + public void Should_create_translation_info() + { + var schema = + new Schema("my-schema") + .AddString(1, "field1", Partitioning.Language) + .AddString(2, "field2", Partitioning.Language) + .AddString(3, "field3", Partitioning.Language) + .AddString(4, "field4", Partitioning.Invariant); + + var data = + new ContentData() + .AddField("field1", + new ContentFieldData() + .AddLocalized(Language.EN, "en") + .AddLocalized(Language.DE, "de")) + .AddField("field2", + new ContentFieldData() + .AddLocalized(Language.EN, "en") + .AddLocalized(Language.DE, "de")) + .AddField("field3", + new ContentFieldData() + .AddLocalized(Language.EN, "en")); + + var result = TranslationStatus.Create(data, schema, languages); + + Assert.Equal(new TranslationStatus + { + [Language.EN] = 100, + [Language.DE] = 67, + [Language.IT] = 0 + }, result); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/FieldConvertersTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/FieldConvertersTests.cs index 527bdcb5b..d4d2a587f 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/FieldConvertersTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/FieldConvertersTests.cs @@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent { public class FieldConvertersTests { - private readonly LanguagesConfig languagesConfig = LanguagesConfig.English.Set(Language.DE); + private readonly LanguagesConfig languages = LanguagesConfig.English.Set(Language.DE); private static IEnumerable InvalidValues() { @@ -181,7 +181,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveLanguages(languagesConfig)) + .Add(new ResolveLanguages(languages)) .Convert(source); var expected = @@ -213,7 +213,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveLanguages(languagesConfig, true, new[] { Language.DE })) + .Add(new ResolveLanguages(languages, true, new[] { Language.DE })) .Convert(source); var expected = @@ -249,7 +249,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveLanguages(languagesConfig)) + .Add(new ResolveLanguages(languages)) .Convert(source); var expected = @@ -285,7 +285,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveLanguages(languagesConfig)) + .Add(new ResolveLanguages(languages)) .Convert(source); var expected = @@ -318,7 +318,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveLanguages(languagesConfig)) + .Add(new ResolveLanguages(languages)) .Convert(source); var expected = source; @@ -350,7 +350,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveInvariant(languagesConfig)) + .Add(new ResolveInvariant(languages)) .Convert(source); var expected = @@ -386,7 +386,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveInvariant(languagesConfig)) + .Add(new ResolveInvariant(languages)) .Convert(source); var expected = @@ -414,7 +414,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveLanguages(languagesConfig)) + .Add(new ResolveLanguages(languages)) .Convert(source); var expected = source; @@ -485,7 +485,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var result = new ContentConverter(ResolvedComponents.Empty, schema) - .Add(new ResolveLanguages(languagesConfig, true, Language.IT)) + .Add(new ResolveLanguages(languages, true, Language.IT)) .Convert(source); var expected = @@ -505,7 +505,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var source = new ContentFieldData(); var result = - new ResolveLanguages(languagesConfig) + new ResolveLanguages(languages) .ConvertFieldAfter(field, source); Assert.Same(source, result); @@ -519,7 +519,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var source = new ContentFieldData(); var result = - new ResolveLanguages(languagesConfig, true, Array.Empty()) + new ResolveLanguages(languages, true, Array.Empty()) .ConvertFieldAfter(field, source); Assert.Same(source, result); @@ -533,7 +533,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent var source = new ContentFieldData(); var result = - new ResolveLanguages(languagesConfig) + new ResolveLanguages(languages) .ConvertFieldAfter(field, source); Assert.Same(source, result); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs index 2d21abdb8..daa487652 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Core.Operations.DefaultValues public class DefaultValuesTests { private readonly Instant now = Instant.FromUtc(2017, 10, 12, 16, 30, 10); - private readonly LanguagesConfig languagesConfig = LanguagesConfig.English.Set(Language.DE); + private readonly LanguagesConfig languages = LanguagesConfig.English.Set(Language.DE); private readonly Language language = Language.DE; private readonly Schema schema; @@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Core.Operations.DefaultValues new ContentFieldData() .AddInvariant(456)); - data.GenerateDefaultValues(schema, languagesConfig.ToResolver()); + data.GenerateDefaultValues(schema, languages.ToResolver()); Assert.Equal(456, data["myNumber"]!["iv"].AsNumber); @@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Core.Operations.DefaultValues new ContentFieldData() .AddInvariant(456)); - data.GenerateDefaultValues(schema, languagesConfig.ToResolver()); + data.GenerateDefaultValues(schema, languages.ToResolver()); Assert.Equal(string.Empty, data["myString"]!["de"].AsString); Assert.Equal("en-string", data["myString"]!["en"].AsString); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs index 66214b04d..09d69095e 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs @@ -21,7 +21,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent { public class ContentValidationTests : IClassFixture { - private readonly LanguagesConfig languagesConfig = LanguagesConfig.English.Set(Language.DE); + private readonly LanguagesConfig languages = LanguagesConfig.English.Set(Language.DE); private readonly List errors = new List(); private Schema schema = new Schema("my-schema"); @@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new ContentFieldData() .AddInvariant(1000)); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema, factory: validatorFactory); + await data.ValidateAsync(languages.ToResolver(), errors, schema, factory: validatorFactory); errors.Should().BeEquivalentTo( new List @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new ContentFieldData() .AddInvariant(1000)); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema, factory: validatorFactory); + await data.ValidateAsync(languages.ToResolver(), errors, schema, factory: validatorFactory); errors.Should().BeEquivalentTo( new List @@ -95,7 +95,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddField("unknown", new ContentFieldData()); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -116,7 +116,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new ContentFieldData() .AddInvariant(1000)); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddLocalized("es", 1) .AddLocalized("it", 1)); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -156,7 +156,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new ContentData(); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -175,7 +175,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new ContentData(); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -193,7 +193,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new ContentData(); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -214,7 +214,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddLocalized("de", 1) .AddLocalized("ru", 1)); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -258,7 +258,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddLocalized("es", 1) .AddLocalized("it", 1)); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -276,7 +276,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddField("unknown", new ContentFieldData()); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -297,7 +297,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new ContentFieldData() .AddInvariant(1000)); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -318,7 +318,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddLocalized("es", 1) .AddLocalized("it", 1)); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -337,7 +337,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new ContentData(); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); Assert.Empty(errors); } @@ -351,7 +351,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new ContentData(); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); Assert.Empty(errors); } @@ -368,7 +368,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddLocalized("de", 1) .AddLocalized("ru", 1)); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -389,7 +389,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddLocalized("es", 1) .AddLocalized("it", 1)); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -415,7 +415,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new JsonObject().Add("myNested", 1), new JsonObject()))); - await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidatePartialAsync(languages.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List @@ -433,7 +433,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent var data = new ContentData(); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); Assert.Empty(errors); } @@ -452,7 +452,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent JsonValue.Array( new JsonObject()))); - await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); + await data.ValidateAsync(languages.ToResolver(), errors, schema); Assert.Empty(errors); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs index 4964288e8..5c864db38 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs @@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var source = CreateContentWithoutNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreateAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreateCompleteAsync(snapshotJob, appProvider); Assert.Equal(source.CurrentVersion.Data, snapshot.Data); Assert.Null(snapshot.DraftData); @@ -61,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var source = CreateContentWithNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreateAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreateCompleteAsync(snapshotJob, appProvider); Assert.Equal(source.NewVersion?.Data, snapshot.Data); Assert.Equal(source.CurrentVersion.Data, snapshot.DraftData); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentQueryTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentQueryTests.cs index ac1b5b1a4..e31b51ad5 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentQueryTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentQueryTests.cs @@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb { private readonly DomainId appId = DomainId.NewGuid(); private readonly Schema schemaDef; - private readonly LanguagesConfig languagesConfig = LanguagesConfig.English.Set(Language.DE); + private readonly LanguagesConfig languages = LanguagesConfig.English.Set(Language.DE); static ContentQueryTests() { @@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var app = A.Dummy(); A.CallTo(() => app.Id).Returns(DomainId.NewGuid()); A.CallTo(() => app.Version).Returns(3); - A.CallTo(() => app.Languages).Returns(languagesConfig); + A.CallTo(() => app.Languages).Returns(languages); } [Fact] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/DomainObject/SchemaDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/DomainObject/SchemaDomainObjectTests.cs index b5a525b0f..7194499c9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/DomainObject/SchemaDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/DomainObject/SchemaDomainObjectTests.cs @@ -755,12 +755,12 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject private IField GetField(int id) { - return sut.Snapshot.SchemaDef.FieldsById.GetOrDefault(id); + return sut.Snapshot.SchemaDef.FieldsById.GetValueOrDefault(id)!; } private IField GetNestedField(int parentId, int childId) { - return ((IArrayField)sut.Snapshot.SchemaDef.FieldsById[parentId]).FieldsById.GetOrDefault(childId); + return ((IArrayField)sut.Snapshot.SchemaDef.FieldsById[parentId]).FieldsById.GetValueOrDefault(childId)!; } private static StringFieldProperties ValidProperties() diff --git a/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs index eef18c15b..f66f90efe 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs @@ -62,21 +62,6 @@ namespace Squidex.Infrastructure Assert.Equal(-1, index); } - [Fact] - public void GetOrDefault_should_return_value_if_key_exists() - { - valueDictionary[12] = 34; - - Assert.Equal(34, valueDictionary.GetOrDefault(12)); - } - - [Fact] - public void GetOrDefault_should_return_default_and_not_add_it_if_key_not_exists() - { - Assert.Equal(0, valueDictionary.GetOrDefault(12)); - Assert.False(valueDictionary.ContainsKey(12)); - } - [Fact] public void GetOrAddDefault_should_return_value_if_key_exists() { diff --git a/frontend/src/app/features/content/pages/content/content-page.component.html b/frontend/src/app/features/content/pages/content/content-page.component.html index 36dc4d6d6..29ee03077 100644 --- a/frontend/src/app/features/content/pages/content/content-page.component.html +++ b/frontend/src/app/features/content/pages/content/content-page.component.html @@ -55,13 +55,12 @@ - - - - + + diff --git a/frontend/src/app/features/content/pages/content/editor/content-field.component.html b/frontend/src/app/features/content/pages/content/editor/content-field.component.html index 5e00c3306..bbd25f49f 100644 --- a/frontend/src/app/features/content/pages/content/editor/content-field.component.html +++ b/frontend/src/app/features/content/pages/content/editor/content-field.component.html @@ -8,7 +8,7 @@
- - +
diff --git a/frontend/src/app/features/content/pages/content/editor/field-languages.component.ts b/frontend/src/app/features/content/pages/content/editor/field-languages.component.ts index db99eda91..6bd98e1af 100644 --- a/frontend/src/app/features/content/pages/content/editor/field-languages.component.ts +++ b/frontend/src/app/features/content/pages/content/editor/field-languages.component.ts @@ -6,10 +6,10 @@ */ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { AppLanguageDto, RootFieldDto } from '@app/shared'; +import { AppLanguageDto, FieldForm } from '@app/shared'; @Component({ - selector: 'sqx-field-languages[field][language][languages]', + selector: 'sqx-field-languages[formModel][language][languages]', styleUrls: ['./field-languages.component.scss'], templateUrl: './field-languages.component.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -31,7 +31,7 @@ export class FieldLanguagesComponent { public languages!: ReadonlyArray; @Input() - public field!: RootFieldDto; + public formModel!: FieldForm; public toggleShowAllControls() { this.showAllControlsChange.emit(!this.showAllControls); diff --git a/frontend/src/app/features/content/pages/contents/contents-page.component.html b/frontend/src/app/features/content/pages/contents/contents-page.component.html index ea6b20bfe..341119f37 100644 --- a/frontend/src/app/features/content/pages/contents/contents-page.component.html +++ b/frontend/src/app/features/content/pages/contents/contents-page.component.html @@ -27,7 +27,8 @@ + [languages]="languages" + [percents]="translationStatus">
diff --git a/frontend/src/app/features/content/pages/contents/contents-page.component.ts b/frontend/src/app/features/content/pages/contents/contents-page.component.ts index 88189a72c..2e8200954 100644 --- a/frontend/src/app/features/content/pages/contents/contents-page.component.ts +++ b/frontend/src/app/features/content/pages/contents/contents-page.component.ts @@ -10,7 +10,7 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators'; -import { AppLanguageDto, AppsState, ContentDto, ContentsState, ContributorsState, defined, LanguagesState, LocalStoreService, ModalModel, Queries, Query, QuerySynchronizer, ResourceOwner, Router2State, SchemaDto, SchemasService, SchemasState, Settings, switchSafe, TableSettings, TempService, UIState } from '@app/shared'; +import { AppLanguageDto, AppsState, contentsTranslationStatus, ContentDto, ContentsState, ContributorsState, defined, LanguagesState, LocalStoreService, ModalModel, Queries, Query, QuerySynchronizer, ResourceOwner, Router2State, SchemaDto, SchemasService, SchemasState, Settings, switchSafe, TableSettings, TempService, TranslationStatus, UIState } from '@app/shared'; import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component'; @Component({ @@ -41,6 +41,8 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { public language!: AppLanguageDto; public languages!: ReadonlyArray; + public translationStatus?: TranslationStatus; + public get disableScheduler() { return this.appsState.snapshot.selectedSettings?.hideScheduler === true; } @@ -115,8 +117,10 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { this.own( this.contentsState.contents - .subscribe(() => { + .subscribe(contents => { this.updateSelectionSummary(); + + this.translationStatus = contentsTranslationStatus(contents.map(x => x.data), this.schema, this.languages); })); } diff --git a/frontend/src/app/features/content/shared/references/content-creator.component.html b/frontend/src/app/features/content/shared/references/content-creator.component.html index 731ad1246..b8b0774d4 100644 --- a/frontend/src/app/features/content/shared/references/content-creator.component.html +++ b/frontend/src/app/features/content/shared/references/content-creator.component.html @@ -11,7 +11,13 @@
- + +
diff --git a/frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts b/frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts index 2c04edf42..c8d0a7497 100644 --- a/frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts +++ b/frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts @@ -7,6 +7,7 @@ */ import { Component } from '@angular/core'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { moduleMetadata } from '@storybook/angular'; import { Meta, Story } from '@storybook/angular/types-6-0'; import { LocalizerService, SqxFrameworkModule, TagEditorComponent } from '@app/framework'; @@ -61,6 +62,7 @@ export default { TestComponent, ], imports: [ + BrowserAnimationsModule, SqxFrameworkModule, SqxFrameworkModule.forRoot(), ], diff --git a/frontend/src/app/framework/angular/forms/forms-helper.ts b/frontend/src/app/framework/angular/forms/forms-helper.ts index 1e28f280b..f2f668446 100644 --- a/frontend/src/app/framework/angular/forms/forms-helper.ts +++ b/frontend/src/app/framework/angular/forms/forms-helper.ts @@ -104,11 +104,11 @@ export function valueProjection$(form: AbstractControl, projection: (va } export function hasValue$(form: AbstractControl): Observable { - return valueProjection$(form, v => isValid(v)); + return valueProjection$(form, v => isValidValue(v)); } export function hasNoValue$(form: AbstractControl): Observable { - return valueProjection$(form, v => !isValid(v)); + return valueProjection$(form, v => !isValidValue(v)); } export function changed$(lhs: AbstractControl, rhs: AbstractControl) { @@ -155,7 +155,7 @@ export function touchedChange$(form: AbstractControl) { }); } -function isValid(value: any) { +export function isValidValue(value: any) { return !Types.isNull(value) && !Types.isUndefined(value); } diff --git a/frontend/src/app/framework/angular/language-selector.component.html b/frontend/src/app/framework/angular/language-selector.component.html index 2de094009..4a08388ea 100644 --- a/frontend/src/app/framework/angular/language-selector.component.html +++ b/frontend/src/app/framework/angular/language-selector.component.html @@ -1,24 +1,38 @@ -
- -
- - - - - - - - - - - - - -
-
+ + + + + + + + + + + + + + + +
+
+
+ + +
+ +
+
\ No newline at end of file diff --git a/frontend/src/app/framework/angular/language-selector.component.scss b/frontend/src/app/framework/angular/language-selector.component.scss index 68162fc30..983b3c8bb 100644 --- a/frontend/src/app/framework/angular/language-selector.component.scss +++ b/frontend/src/app/framework/angular/language-selector.component.scss @@ -3,6 +3,7 @@ .dropdown-menu { max-height: 20rem; + min-width: auto; overflow-x: inherit; overflow-y: auto; } @@ -23,6 +24,22 @@ tr { color: $color-text; } -.iso-code { - font-family: monospace; +.missing { + &:not(.active) { + td { + opacity: .6; + } + + span { + opacity: .6; + } + } +} + +.text-language { + font-weight: bold; +} + +.text-right { + text-align: right; } \ No newline at end of file diff --git a/frontend/src/app/framework/angular/language-selector.component.ts b/frontend/src/app/framework/angular/language-selector.component.ts index b8b0ad47c..0b55c2464 100644 --- a/frontend/src/app/framework/angular/language-selector.component.ts +++ b/frontend/src/app/framework/angular/language-selector.component.ts @@ -6,7 +6,7 @@ */ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; -import { ModalModel } from '@app/framework/internal'; +import { ModalModel, RelativePosition } from '@app/framework/internal'; export interface Language { iso2Code: string; englishName: string; isMasterLanguage?: boolean } @@ -27,17 +27,18 @@ export class LanguageSelectorComponent implements OnChanges, OnInit { public languages: ReadonlyArray = []; @Input() - public size: 'sm' | 'md' | 'lg' = 'md'; + public exists?: { [language: string]: boolean } | null; - public dropdown = new ModalModel(); + @Input() + public percents?: { [language: string]: number } | null; - public get isSmallMode(): boolean { - return this.languages && this.languages.length > 0 && this.languages.length <= 3; - } + @Input() + public dropdownPosition: RelativePosition = 'bottom-right'; - public get isLargeMode(): boolean { - return this.languages && this.languages.length > 3; - } + @Input() + public size: 'sm' | 'md' | 'lg' = 'md'; + + public dropdown = new ModalModel(); public ngOnChanges() { this.update(); diff --git a/frontend/src/app/framework/angular/language-selector.stories.tsx b/frontend/src/app/framework/angular/language-selector.stories.tsx new file mode 100644 index 000000000..37a2930e1 --- /dev/null +++ b/frontend/src/app/framework/angular/language-selector.stories.tsx @@ -0,0 +1,135 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { moduleMetadata } from '@storybook/angular'; +import { Meta, Story } from '@storybook/angular/types-6-0'; +import { LanguageSelectorComponent, SqxFrameworkModule } from '@app/framework'; + +export default { + title: 'Framework/Language-Selector', + component: LanguageSelectorComponent, + argTypes: { + size: { + control: 'enum', + options: [ + 'sm', + 'md', + 'lg', + ], + }, + }, + decorators: [ + moduleMetadata({ + imports: [ + BrowserAnimationsModule, + SqxFrameworkModule, + SqxFrameworkModule.forRoot(), + ], + }), + ], +} as Meta; + +const Template: Story = (args: LanguageSelectorComponent) => ({ + props: args, + template: ` + +
+ + +
+
+ `, +}); + +export const Empty = Template.bind({}); + +Empty.args = { + languages: [], +}; + +export const OneLanguage = Template.bind({}); + +OneLanguage.args = { + languages: [ + { iso2Code: 'en', englishName: 'English' }, + ], +}; + +export const FewLanguages = Template.bind({}); + +FewLanguages.args = { + languages: [ + { iso2Code: 'en', englishName: 'English' }, + { iso2Code: 'it', englishName: 'Italian' }, + { iso2Code: 'es', englishName: 'Spanish' }, + ], +}; + +export const FewLanguagesWithExists = Template.bind({}); + +FewLanguagesWithExists.args = { + languages: [ + { iso2Code: 'en', englishName: 'English' }, + { iso2Code: 'it', englishName: 'Italian' }, + { iso2Code: 'es', englishName: 'Spanish' }, + ], + exists: { + en: true, + it: false, + es: true, + }, +}; + +export const ManyLanguages = Template.bind({}); + +ManyLanguages.args = { + languages: [ + { iso2Code: 'en', englishName: 'English' }, + { iso2Code: 'it', englishName: 'Italian' }, + { iso2Code: 'es', englishName: 'Spanish' }, + { iso2Code: 'de', englishName: 'German' }, + { iso2Code: 'ru', englishName: 'Russian' }, + ], +}; + +export const ManyLanguagesWithExists = Template.bind({}); + +ManyLanguagesWithExists.args = { + languages: [ + { iso2Code: 'en', englishName: 'English' }, + { iso2Code: 'it', englishName: 'Italian' }, + { iso2Code: 'es', englishName: 'Spanish' }, + { iso2Code: 'de', englishName: 'German' }, + { iso2Code: 'ru', englishName: 'Russian' }, + ], + exists: { + en: true, + it: false, + es: true, + de: false, + ru: true, + }, +}; + +export const WithPercents = Template.bind({}); + +WithPercents.args = { + languages: [ + { iso2Code: 'en', englishName: 'English' }, + { iso2Code: 'it', englishName: 'Italian' }, + { iso2Code: 'es', englishName: 'Spanish' }, + ], + percents: { + 'en': 100, + 'it': 67, + }, +}; \ No newline at end of file diff --git a/frontend/src/app/framework/utils/modal-positioner.ts b/frontend/src/app/framework/utils/modal-positioner.ts index 0398f3a2c..9f962cfdd 100644 --- a/frontend/src/app/framework/utils/modal-positioner.ts +++ b/frontend/src/app/framework/utils/modal-positioner.ts @@ -13,6 +13,7 @@ export type AnchorX = 'left-to-left' | 'right-to-left' | 'right-to-right'; + export type AnchorY = 'bottom-to-bottom' | 'bottom-to-top' | diff --git a/frontend/src/app/shared/state/contents.forms-helpers.ts b/frontend/src/app/shared/state/contents.forms-helpers.ts index f4e889149..3fbe3ec65 100644 --- a/frontend/src/app/shared/state/contents.forms-helpers.ts +++ b/frontend/src/app/shared/state/contents.forms-helpers.ts @@ -11,11 +11,72 @@ import { AbstractControl, ValidatorFn } from '@angular/forms'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { isValidValue, Language } from './../internal'; import { AppLanguageDto } from './../services/app-languages.service'; import { FieldDto, RootFieldDto, SchemaDto } from './../services/schemas.service'; import { fieldInvariant } from './../services/schemas.types'; import { CompiledRules, RuleContext, RulesProvider } from './contents.form-rules'; +export type TranslationStatus = { [language: string]: number }; + +export function contentsTranslationStatus(datas: any[], schema: SchemaDto, languages: ReadonlyArray) { + const result: TranslationStatus = {}; + + for (const data of datas) { + const status = contentTranslationStatus(data, schema, languages); + + for (const language of languages) { + const iso2Code = language.iso2Code; + + result[iso2Code] = (result[iso2Code] || 0) + status[iso2Code]; + } + } + + for (const language of languages) { + const iso2Code = language.iso2Code; + + result[iso2Code] = Math.round(result[iso2Code] / datas.length); + } + + return result; +} + +export function contentTranslationStatus(data: any, schema: SchemaDto, languages: ReadonlyArray) { + const result: TranslationStatus = {}; + + const localizedFields = schema.fields.filter(x => x.isLocalizable); + + for (const language of languages) { + let percent = 0; + + for (const field of localizedFields) { + if (isValidValue(data?.[field.name]?.[language.iso2Code])) { + percent++; + } + } + + if (localizedFields.length > 0) { + percent = Math.round(100 * percent / localizedFields.length); + } else { + percent = 100; + } + + result[language.iso2Code] = percent; + } + + return result; +} + +export function fieldTranslationStatus(data: any) { + const result: { [field: string]: boolean } = {}; + + for (const [key, value] of Object.entries(data)) { + result[key] = isValidValue(value); + } + + return result; +} + export abstract class Hidden { private readonly hidden$ = new BehaviorSubject(false); diff --git a/frontend/src/app/shared/state/contents.forms.spec.ts b/frontend/src/app/shared/state/contents.forms.spec.ts index 3a5468e23..5704efd8c 100644 --- a/frontend/src/app/shared/state/contents.forms.spec.ts +++ b/frontend/src/app/shared/state/contents.forms.spec.ts @@ -13,7 +13,7 @@ import { AppLanguageDto, createProperties, EditContentForm, getContentValue, Htm import { FieldRule, SchemaDto } from './../services/schemas.service'; import { TestValues } from './_test-helpers'; import { ComponentForm, FieldArrayForm } from './contents.forms'; -import { PartitionConfig } from './contents.forms-helpers'; +import { contentsTranslationStatus, contentTranslationStatus, fieldTranslationStatus, PartitionConfig } from './contents.forms-helpers'; const { createField, @@ -21,6 +21,159 @@ const { createSchema, } = TestValues; +describe('TranslationStatus', () => { + const languages = [ + { iso2Code: 'en' }, + { iso2Code: 'de' }, + { iso2Code: 'it' }, + ]; + + it('should create field status', () => { + const data = { + en: '', + de: 'field2', + it: true, + es: null, + }; + + const result = fieldTranslationStatus(data); + + expect(result).toEqual({ + en: true, + de: true, + it: true, + es: false, + }); + }); + + it('should create content status for empty schema', () => { + const schema = { + fields: [], + } as any; + + const result = contentTranslationStatus({}, schema, languages as any); + + expect(result).toEqual({ + en: 100, + de: 100, + it: 100, + }); + }); + + it('should create content status for schema without localized field', () => { + const schema = { + fields: [{ + isLocalizable: false, + }], + } as any; + + const result = contentTranslationStatus({}, schema, languages as any); + + expect(result).toEqual({ + en: 100, + de: 100, + it: 100, + }); + }); + + it('should create content status for schema with localized field', () => { + const schema = { + fields: [{ + isLocalizable: true, + }], + } as any; + + const result = contentTranslationStatus({}, schema, languages as any); + + expect(result).toEqual({ + en: 0, + de: 0, + it: 0, + }); + }); + + it('should create content status for schema with mixed fields', () => { + const schema = { + fields: [{ + name: 'field1', isLocalizable: true, + }, { + name: 'field2', isLocalizable: true, + }, { + name: 'field3', isLocalizable: true, + }, { + name: 'field4', + }], + } as any; + + const data = { + field1: { + en: 'en', + de: 'de', + }, + field2: { + en: 'en', + de: 'de', + }, + field3: { + en: 'en', + }, + }; + + const result = contentTranslationStatus(data, schema, languages as any); + + expect(result).toEqual({ + en: 100, + de: 67, + it: 0, + }); + }); + + it('should create contents status', () => { + const schema = { + fields: [{ + name: 'field1', isLocalizable: true, + }, { + name: 'field2', isLocalizable: true, + }, { + name: 'field3', isLocalizable: true, + }, { + name: 'field4', + }], + } as any; + + const data1 = { + field1: { + en: 'en', + de: 'de', + }, + field2: { + en: 'en', + de: 'de', + }, + field3: { + en: 'en', + }, + }; + + const data2 = { + field1: { + de: 'de', + }, + field3: { + en: 'en', + }, + }; + + const result = contentsTranslationStatus([data1, data2], schema, languages as any); + + expect(result).toEqual({ + en: 67, + de: 50, + it: 0, + }); + }); +}); + describe('GetContentValue', () => { const language = new LanguageDto('en', 'English'); const fieldInvariant = createField({ properties: createProperties('Number'), partitioning: 'invariant' }); diff --git a/frontend/src/app/shared/state/contents.forms.ts b/frontend/src/app/shared/state/contents.forms.ts index 616cbb9b8..7478cac1d 100644 --- a/frontend/src/app/shared/state/contents.forms.ts +++ b/frontend/src/app/shared/state/contents.forms.ts @@ -7,7 +7,7 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; import { BehaviorSubject, Observable } from 'rxjs'; -import { distinctUntilChanged } from 'rxjs/operators'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import { debounceTimeSafe, ExtendedFormGroup, Form, FormArrayTemplate, TemplatedFormArray, Types, value$ } from '@app/framework'; import { FormGroupTemplate, TemplatedFormGroup } from '@app/framework/angular/forms/templated-form-group'; import { AppLanguageDto } from './../services/app-languages.service'; @@ -15,7 +15,7 @@ import { LanguageDto } from './../services/languages.service'; import { FieldDto, RootFieldDto, SchemaDto, TableField } from './../services/schemas.service'; import { ComponentFieldPropertiesDto, fieldInvariant } from './../services/schemas.types'; import { ComponentRulesProvider, RootRulesProvider, RulesProvider } from './contents.form-rules'; -import { AbstractContentForm, AbstractContentFormState, FieldSection, FormGlobals, groupFields, PartitionConfig } from './contents.forms-helpers'; +import { AbstractContentForm, AbstractContentFormState, contentTranslationStatus, FieldSection, fieldTranslationStatus, FormGlobals, groupFields, PartitionConfig } from './contents.forms-helpers'; import { FieldDefaultValue, FieldsValidators } from './contents.forms.visitors'; type SaveQueryFormType = { name: string; user: boolean }; @@ -89,6 +89,9 @@ export class EditContentForm extends Form { return this.valueChange$.value; } + public readonly translationStatus = + this.valueChange$.pipe(map(x => contentTranslationStatus(x, this.schema, this.languages))); + constructor( public readonly languages: ReadonlyArray, public readonly schema: SchemaDto, schemas: { [id: string ]: SchemaDto }, @@ -203,6 +206,9 @@ export class FieldForm extends AbstractContentForm { private readonly partitions: { [partition: string]: FieldItemForm } = {}; private isRequired: boolean; + public readonly translationStatus = + value$(this.form).pipe(map(x => fieldTranslationStatus(x))); + constructor( globals: FormGlobals, field: RootFieldDto,