Browse Source

Check references when deleting. (#583)

* Check references when deleting.

* Tests fixed

* Performance optimizations.
pull/585/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
e3d5029922
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      backend/i18n/frontend_en.json
  2. 5
      backend/i18n/frontend_it.json
  3. 5
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/source/backend_en.json
  5. 5
      backend/i18n/source/frontend_en.json
  6. 32
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Fields.cs
  7. 10
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs
  8. 12
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  9. 32
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs
  10. 11
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs
  11. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs
  13. 17
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs
  14. 45
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrersAsync.cs
  15. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs
  16. 35
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs
  17. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs
  18. 1
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs
  19. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContent.cs
  20. 21
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs
  21. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  22. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  23. 4
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs
  24. 8
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs
  25. 2
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs
  26. 6
      backend/src/Squidex.Shared/Texts.it.resx
  27. 6
      backend/src/Squidex.Shared/Texts.nl.resx
  28. 6
      backend/src/Squidex.Shared/Texts.resx
  29. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  30. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  31. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs
  32. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  33. 30
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs
  34. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderDomainObjectTests.cs
  35. 82
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs
  36. 9
      frontend/app/features/content/pages/content/content-history-page.component.html
  37. 3
      frontend/app/features/content/pages/content/content-page.component.html
  38. 7
      frontend/app/features/content/pages/content/content-page.component.ts
  39. 3
      frontend/app/features/content/pages/contents/contents-page.component.html
  40. 8
      frontend/app/features/content/pages/contents/contents-page.component.ts
  41. 4
      frontend/app/features/content/pages/sidebar/sidebar-page.component.ts
  42. 2
      frontend/app/features/content/shared/due-time-selector.component.ts
  43. 3
      frontend/app/features/content/shared/list/content.component.html
  44. 3
      frontend/app/features/content/shared/references/reference-item.component.html
  45. 3
      frontend/app/features/dashboard/pages/dashboard-config.component.html
  46. 9
      frontend/app/features/rules/pages/rules/rule.component.html
  47. 6
      frontend/app/features/schemas/pages/schema/fields/field.component.html
  48. 3
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html
  49. 2
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts
  50. 3
      frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html
  51. 3
      frontend/app/features/schemas/pages/schema/schema-page.component.html
  52. 3
      frontend/app/features/settings/pages/backups/backup.component.html
  53. 3
      frontend/app/features/settings/pages/clients/client.component.html
  54. 3
      frontend/app/features/settings/pages/contributors/contributor.component.html
  55. 4
      frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts
  56. 3
      frontend/app/features/settings/pages/languages/language.component.html
  57. 3
      frontend/app/features/settings/pages/patterns/pattern.component.html
  58. 1
      frontend/app/features/settings/pages/plans/plan.component.html
  59. 3
      frontend/app/features/settings/pages/roles/role.component.html
  60. 24
      frontend/app/features/settings/pages/workflows/workflow.component.html
  61. 5
      frontend/app/framework/angular/forms/confirm-click.directive.ts
  62. 4
      frontend/app/framework/angular/forms/editors/date-time-editor.component.ts
  63. 2
      frontend/app/framework/angular/forms/forms-helper.ts
  64. 2
      frontend/app/framework/angular/forms/indeterminate-value.directive.ts
  65. 2
      frontend/app/framework/angular/forms/transform-input.directive.ts
  66. 14
      frontend/app/framework/angular/modals/dialog-renderer.component.html
  67. 4
      frontend/app/framework/angular/modals/dialog-renderer.component.ts
  68. 2
      frontend/app/framework/angular/template-wrapper.directive.ts
  69. 131
      frontend/app/framework/services/dialog.service.spec.ts
  70. 58
      frontend/app/framework/services/dialog.service.ts
  71. 2
      frontend/app/framework/services/resource-loader.service.ts
  72. 2
      frontend/app/framework/utils/date-time.ts
  73. 10
      frontend/app/framework/utils/rxjs-extensions.ts
  74. 3
      frontend/app/shared/components/assets/asset-dialog.component.html
  75. 3
      frontend/app/shared/components/assets/asset-folder.component.html
  76. 12
      frontend/app/shared/components/assets/asset.component.html
  77. 1
      frontend/app/shared/components/comments/comment.component.html
  78. 2
      frontend/app/shared/components/forms/references-checkboxes.component.ts
  79. 2
      frontend/app/shared/components/forms/references-dropdown.component.ts
  80. 2
      frontend/app/shared/components/forms/references-tags.component.ts
  81. 2
      frontend/app/shared/guards/load-apps.guard.ts
  82. 2
      frontend/app/shared/guards/load-languages.guard.ts
  83. 8
      frontend/app/shared/interceptors/auth.interceptor.ts
  84. 4
      frontend/app/shared/services/assets.service.spec.ts
  85. 4
      frontend/app/shared/services/assets.service.ts
  86. 4
      frontend/app/shared/services/contents.service.spec.ts
  87. 4
      frontend/app/shared/services/contents.service.ts
  88. 2
      frontend/app/shared/services/users-provider.service.ts
  89. 34
      frontend/app/shared/state/assets.state.spec.ts
  90. 35
      frontend/app/shared/state/assets.state.ts
  91. 2
      frontend/app/shared/state/contents.forms-helpers.ts
  92. 2
      frontend/app/shared/state/contents.forms.visitors.ts
  93. 115
      frontend/app/shared/state/contents.state.ts
  94. 6
      frontend/app/shared/state/contributors.state.spec.ts
  95. 4
      frontend/app/shared/state/rule-events.state.ts
  96. 4
      frontend/app/shared/state/schemas.state.ts
  97. 2
      frontend/app/shell/pages/home/home-page.component.ts

5
backend/i18n/frontend_en.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Delete folder",
"assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?",
"assets.deleteMetadataConfirmTitle": "Remove metadata",
"assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?",
"assets.deleteReferrerConfirmTitle": "Delete asset",
"assets.downloadVersion": "Download this Version",
"assets.dropToUpdate": "Drop to update",
"assets.duplicateFile": "Asset has already been uploaded.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "is not equals to",
"common.queryOperators.startsWith": "starts with",
"common.refresh": "Refresh",
"common.remember": "Remember my decision",
"common.rename": "Rename",
"common.requiredHint": "required",
"common.reset": "Reset",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Delete content",
"contents.deleteFailed": "Failed to delete content. Please reload.",
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?",
"contents.deleteReferrerConfirmTitle": "Delete content",
"contents.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Failed to delete version. Please reload.",
"contents.draftNew": "New Draft",

5
backend/i18n/frontend_it.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Elimina la cartella",
"assets.deleteMetadataConfirmText": "Sei sicuro di voler rimuovere questi metadati?",
"assets.deleteMetadataConfirmTitle": "Rimuovi metadati",
"assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?",
"assets.deleteReferrerConfirmTitle": "Delete asset",
"assets.downloadVersion": "Scarica questa versione",
"assets.dropToUpdate": "Trascina il file per aggiornare",
"assets.duplicateFile": "La risorsa è già stata caricata.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "è uguale a",
"common.queryOperators.startsWith": "inizia con",
"common.refresh": "Aggiorna",
"common.remember": "Remember my decision",
"common.rename": "Rinomina",
"common.requiredHint": "obbligatorio",
"common.reset": "Reimposta",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Elimina il contenuto",
"contents.deleteFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.",
"contents.deleteManyConfirmText": "Sei sicuro di voler eliminare gli elementi del contenuto selezionati?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?",
"contents.deleteReferrerConfirmTitle": "Delete content",
"contents.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Non è stato possibile eliminare la versione. Per favore ricarica.",
"contents.draftNew": "Nuova bozza",

5
backend/i18n/frontend_nl.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Map verwijderen",
"assets.deleteMetadataConfirmText": "Wil je deze metadata echt verwijderen?",
"assets.deleteMetadataConfirmTitle": "Metadata verwijderen",
"assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?",
"assets.deleteReferrerConfirmTitle": "Delete asset",
"assets.downloadVersion": "Download deze versie",
"assets.dropToUpdate": "Zet neer om te updaten",
"assets.duplicateFile": "Asset is al geüpload.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "is not equals to",
"common.queryOperators.startsWith": "starts with",
"common.refresh": "Vernieuwen",
"common.remember": "Remember my decision",
"common.rename": "Hernoemen",
"common.requiredHint": "verplicht",
"common.reset": "Reset",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Inhoud verwijderen",
"contents.deleteFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.",
"contents.deleteManyConfirmText": "Weet je zeker dat je de geselecteerde inhoudsitems wilt verwijderen?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?",
"contents.deleteReferrerConfirmTitle": "Delete content",
"contents.deleteVersionConfirmText": "Wil je deze versie echt verwijderen?",
"contents.deleteVersionFailed": "Verwijderen van versie is mislukt. Laad opnieuw.",
"contents.draftNew": "Nieuw concept",

2
backend/i18n/source/backend_en.json

@ -33,6 +33,7 @@
"assets.folderNotFound": "Asset folder does not exist.",
"assets.folderRecursion": "Cannot add folder to its own child.",
"assets.maxSizeReached": "You have reached your max asset size.",
"assets.referenced": "Assets is referenced by a content and cannot be deleted.",
"backups.alreadyRunning": "Another backup process is already running.",
"backups.maxReached": "You cannot have more than {max} backups.",
"backups.restoreRunning": "A restore operation is already running.",
@ -136,6 +137,7 @@
"contents.invalidNumber": "Invalid json type, expected number.",
"contents.invalidString": "Invalid json type, expected string.",
"contents.listReferences": "{count} Reference(s)",
"contents.referenced": "Content is referenced by another content and cannot be deleted.",
"contents.singletonNotChangeable": "Singleton content cannot be updated.",
"contents.singletonNotCreatable": "Singleton content cannot be created.",
"contents.singletonNotDeletable": "Singleton content cannot be deleted.",

5
backend/i18n/source/frontend_en.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Delete folder",
"assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?",
"assets.deleteMetadataConfirmTitle": "Remove metadata",
"assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?",
"assets.deleteReferrerConfirmTitle": "Delete asset",
"assets.downloadVersion": "Download this Version",
"assets.dropToUpdate": "Drop to update",
"assets.duplicateFile": "Asset has already been uploaded.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "is not equals to",
"common.queryOperators.startsWith": "starts with",
"common.refresh": "Refresh",
"common.remember": "Remember my decision",
"common.rename": "Rename",
"common.requiredHint": "required",
"common.reset": "Reset",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Delete content",
"contents.deleteFailed": "Failed to delete content. Please reload.",
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?",
"contents.deleteReferrerConfirmTitle": "Delete content",
"contents.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Failed to delete version. Please reload.",
"contents.draftNew": "New Draft",

32
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Fields.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using MongoDB.Bson.Serialization;
namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{
internal static class Fields
{
private static readonly Lazy<string> AssetIdField = new Lazy<string>(GetAssetIdField);
private static readonly Lazy<string> AssetFolderIdField = new Lazy<string>(GetAssetFolderIdField);
public static string AssetId => AssetIdField.Value;
public static string AssetFolderId => AssetFolderIdField.Value;
private static string GetAssetIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoAssetEntity)).GetMemberMap(nameof(MongoAssetEntity.Id)).ElementName;
}
private static string GetAssetFolderIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoAssetFolderEntity)).GetMemberMap(nameof(MongoAssetFolderEntity.Id)).ElementName;
}
}
}

10
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
@ -22,8 +21,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{
public sealed partial class MongoAssetFolderRepository : MongoRepositoryBase<MongoAssetFolderEntity>, IAssetFolderRepository
{
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
public MongoAssetFolderRepository(IMongoDatabase database)
: base(database)
{
@ -67,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id)
.ToListAsync();
return assetFolderEntities.Select(x => Guid.Parse(x[IdField.Value].AsString)).ToList();
return assetFolderEntities.Select(x => Guid.Parse(x[Fields.AssetFolderId].AsString)).ToList();
}
}
@ -87,10 +84,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
return assetFolderEntity;
}
}
private static string GetIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoAssetFolderEntity)).GetMemberMap(nameof(MongoAssetFolderEntity.Id)).ElementName;
}
}
}

12
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
@ -27,8 +26,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{
public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository
{
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
public MongoAssetRepository(IMongoDatabase database)
: base(database)
{
@ -105,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Collection.Find(BuildFilter(appId, ids)).Only(x => x.Id)
.ToListAsync();
return assetEntities.Select(x => Guid.Parse(x[IdField.Value].AsString)).ToList();
return assetEntities.Select(x => Guid.Parse(x[Fields.AssetId].AsString)).ToList();
}
}
@ -117,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id)
.ToListAsync();
return assetEntities.Select(x => Guid.Parse(x[IdField.Value].AsString)).ToList();
return assetEntities.Select(x => Guid.Parse(x[Fields.AssetId].AsString)).ToList();
}
}
@ -176,10 +173,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
Filter.In(x => x.Id, ids),
Filter.Ne(x => x.IsDeleted, true));
}
private static string GetIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoAssetEntity)).GetMemberMap(nameof(MongoAssetEntity.Id)).ElementName;
}
}
}

32
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using MongoDB.Bson.Serialization;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
internal static class Fields
{
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
private static readonly Lazy<string> SchemaIdField = new Lazy<string>(GetSchemaIdField);
public static string Id => IdField.Value;
public static string SchemaId => SchemaIdField.Value;
private static string GetIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.Id)).ElementName;
}
private static string GetSchemaIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.IndexedSchemaId)).ElementName;
}
}
}

11
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs

@ -30,6 +30,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
private readonly QueryContentsByIds queryContentsById;
private readonly QueryContentsByQuery queryContentsByQuery;
private readonly QueryIdsAsync queryIdsAsync;
private readonly QueryReferrersAsync queryReferrersAsync;
private readonly QueryScheduledContents queryScheduledItems;
public MongoContentCollectionAll(IMongoDatabase database, IAppProvider appProvider, ITextIndex indexer, DataConverter converter)
@ -39,6 +40,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
queryContentsById = new QueryContentsByIds(converter, appProvider);
queryContentsByQuery = new QueryContentsByQuery(converter, indexer);
queryIdsAsync = new QueryIdsAsync(appProvider);
queryReferrersAsync = new QueryReferrersAsync();
queryScheduledItems = new QueryScheduledContents();
}
@ -58,6 +60,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
await queryContentsById.PrepareAsync(collection, ct);
await queryContentsByQuery.PrepareAsync(collection, ct);
await queryIdsAsync.PrepareAsync(collection, ct);
await queryReferrersAsync.PrepareAsync(collection, ct);
await queryScheduledItems.PrepareAsync(collection, ct);
}
@ -125,6 +128,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
}
}
public async Task<bool> HasReferrersAsync(Guid contentId)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryReferrersAsync.DoAsync(contentId);
}
}
public Task ResetScheduledAsync(Guid id)
{
return Collection.UpdateOneAsync(x => x.Id == id, Update.Unset(x => x.ScheduleJob).Unset(x => x.ScheduledAt));

5
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -129,6 +129,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
return collectionAll.QueryIdsAsync(appId, schemaId, filterNode);
}
public Task<bool> HasReferrersAsync(Guid contentId)
{
return collectionAll.HasReferrersAsync(contentId);
}
public IEnumerable<IMongoCollection<MongoContentEntity>> GetInternalCollections()
{
yield return collectionAll.GetInternalCollection();

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs

@ -8,7 +8,6 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{
@ -16,7 +15,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{
protected static readonly SortDefinitionBuilder<MongoContentEntity> Sort = Builders<MongoContentEntity>.Sort;
protected static readonly UpdateDefinitionBuilder<MongoContentEntity> Update = Builders<MongoContentEntity>.Update;
protected static readonly FieldDefinitionBuilder<MongoContentEntity> Fields = FieldDefinitionBuilder<MongoContentEntity>.Instance;
protected static readonly FilterDefinitionBuilder<MongoContentEntity> Filter = Builders<MongoContentEntity>.Filter;
protected static readonly IndexKeysDefinitionBuilder<MongoContentEntity> Index = Builders<MongoContentEntity>.IndexKeys;
protected static readonly ProjectionDefinitionBuilder<MongoContentEntity> Projection = Builders<MongoContentEntity>.Projection;

17
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.MongoDb.Queries;
@ -21,8 +20,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
internal sealed class QueryIdsAsync : OperationBase
{
private static readonly List<(Guid SchemaId, Guid Id)> EmptyIds = new List<(Guid SchemaId, Guid Id)>();
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
private static readonly Lazy<string> SchemaIdField = new Lazy<string>(GetSchemaIdField);
private readonly IAppProvider appProvider;
public QueryIdsAsync(IAppProvider appProvider)
@ -52,7 +49,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId)
.ToListAsync();
return contentEntities.Select(x => (Guid.Parse(x[SchemaIdField.Value].AsString), Guid.Parse(x[IdField.Value].AsString))).ToList();
return contentEntities.Select(x => (Guid.Parse(x[Fields.SchemaId].AsString), Guid.Parse(x[Fields.Id].AsString))).ToList();
}
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> DoAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode)
@ -70,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId)
.ToListAsync();
return contentEntities.Select(x => (Guid.Parse(x[SchemaIdField.Value].AsString), Guid.Parse(x[IdField.Value].AsString))).ToList();
return contentEntities.Select(x => (Guid.Parse(x[Fields.SchemaId].AsString), Guid.Parse(x[Fields.Id].AsString))).ToList();
}
public static FilterDefinition<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filterNode, Guid schemaId)
@ -88,15 +85,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return Filter.And(filters);
}
private static string GetIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.Id)).ElementName;
}
private static string GetSchemaIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.IndexedSchemaId)).ElementName;
}
}
}

45
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrersAsync.cs

@ -0,0 +1,45 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{
internal sealed class QueryReferrersAsync : OperationBase
{
protected override Task PrepareAsync(CancellationToken ct = default)
{
var index =
new CreateIndexModel<MongoContentEntity>(Index
.Ascending(x => x.ReferencedIds)
.Ascending(x => x.IsDeleted));
return Collection.Indexes.CreateOneAsync(index, cancellationToken: ct);
}
public async Task<bool> DoAsync(Guid id)
{
var filter =
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, id),
Filter.Ne(x => x.IsDeleted, true),
Filter.Ne(x => x.Id, id));
var hasReferrerAsync =
await Collection.Find(filter).Only(x => x.Id)
.AnyAsync();
return hasReferrerAsync;
}
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs

@ -33,10 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
private readonly IAppPlanBillingManager appPlansBillingManager;
private readonly IUserResolver userResolver;
public AppDomainObject(
public AppDomainObject(IStore<Guid> store, ISemanticLog log,
InitialPatterns initialPatterns,
IStore<Guid> store,
ISemanticLog log,
IAppPlansProvider appPlansProvider,
IAppPlanBillingManager appPlansBillingManager,
IUserResolver userResolver)

35
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.Guards;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
@ -21,23 +22,29 @@ using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Translations;
using IAssetTagService = Squidex.Domain.Apps.Core.Tags.ITagService;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetDomainObject : LogSnapshotDomainObject<AssetState>
{
private readonly ITagService tagService;
private readonly IContentRepository contentRepository;
private readonly IAssetTagService assetTags;
private readonly IAssetQueryService assetQuery;
public AssetDomainObject(IStore<Guid> store, ITagService tagService, IAssetQueryService assetQuery, ISemanticLog log)
public AssetDomainObject(IStore<Guid> store, ISemanticLog log,
IAssetTagService assetTags,
IAssetQueryService assetQuery,
IContentRepository contentRepository)
: base(store, log)
{
Guard.NotNull(tagService, nameof(tagService));
Guard.NotNull(assetTags, nameof(assetTags));
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentRepository, nameof(contentRepository));
this.tagService = tagService;
this.assetTags = assetTags;
this.assetQuery = assetQuery;
this.contentRepository = contentRepository;
}
public override Task<object?> ExecuteAsync(IAggregateCommand command)
@ -91,7 +98,17 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
GuardAsset.CanDelete(c);
await tagService.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, null, Snapshot.Tags);
if (c.CheckReferrers)
{
var hasReferrer = await contentRepository.HasReferrersAsync(c.AssetId);
if (hasReferrer)
{
throw new DomainException(T.Get("assets.referenced"));
}
}
await assetTags.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, null, Snapshot.Tags);
Delete(c);
});
@ -107,7 +124,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return null;
}
var normalized = await tagService.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags);
var normalized = await assetTags.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags);
return new HashSet<string>(normalized.Values);
}
@ -116,10 +133,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
var @event = SimpleMapper.Map(command, new AssetCreated
{
MimeType = command.File.MimeType,
FileName = command.File.FileName,
FileSize = command.File.FileSize,
FileVersion = 0,
MimeType = command.File.MimeType,
Slug = command.File.FileName.ToAssetSlug()
});
@ -132,9 +149,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
var @event = SimpleMapper.Map(command, new AssetUpdated
{
MimeType = command.File.MimeType,
FileVersion = Snapshot.FileVersion + 1,
FileSize = command.File.FileSize,
MimeType = command.File.MimeType
});
RaiseEvent(@event);

3
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs

@ -26,7 +26,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
private readonly IAssetQueryService assetQuery;
public AssetFolderDomainObject(IStore<Guid> store, IAssetQueryService assetQuery, ISemanticLog log)
public AssetFolderDomainObject(ISemanticLog log, IStore<Guid> store,
IAssetQueryService assetQuery)
: base(store, log)
{
Guard.NotNull(assetQuery, nameof(assetQuery));

1
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs

@ -9,5 +9,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public sealed class DeleteAsset : AssetCommand
{
public bool CheckReferrers { get; set; }
}
}

1
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContent.cs

@ -9,5 +9,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public sealed class DeleteContent : ContentCommand
{
public bool CheckReferrers { get; set; }
}
}

21
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Guards;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents;
@ -28,15 +29,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
public class ContentDomainObject : LogSnapshotDomainObject<ContentState>
{
private readonly IContentWorkflow contentWorkflow;
private readonly IContentRepository contentRepository;
private readonly ContentOperationContext context;
public ContentDomainObject(IStore<Guid> store, IContentWorkflow contentWorkflow, ContentOperationContext context, ISemanticLog log)
public ContentDomainObject(IStore<Guid> store, ISemanticLog log,
IContentWorkflow contentWorkflow,
IContentRepository contentRepository,
ContentOperationContext context)
: base(store, log)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(contentRepository, nameof(contentRepository));
Guard.NotNull(contentWorkflow, nameof(contentWorkflow));
Guard.NotNull(context, nameof(context));
this.contentWorkflow = contentWorkflow;
this.contentRepository = contentRepository;
this.context = context;
}
@ -204,6 +211,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
});
}
if (c.CheckReferrers)
{
var hasReferrer = await contentRepository.HasReferrersAsync(c.ContentId);
if (hasReferrer)
{
throw new DomainException(T.Get("contents.referenced"));
}
}
Delete(c);
});

8
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs

@ -16,9 +16,11 @@ using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
#pragma warning disable IDE0016 // Use 'throw' expression
@ -43,7 +45,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
private ContentCommand command;
private ValidationContext validationContext;
public ContentOperationContext(IAppProvider appProvider, IEnumerable<IValidatorsFactory> factories, IScriptEngine scriptEngine, ISemanticLog log)
public ContentOperationContext(
IAppProvider appProvider,
IEnumerable<IValidatorsFactory> factories,
IScriptEngine scriptEngine,
ISemanticLog log)
{
this.appProvider = appProvider;
this.factories = factories;

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

@ -28,6 +28,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids, SearchScope scope);
Task<bool> HasReferrersAsync(Guid contentId);
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, SearchScope scope);
Task ResetScheduledAsync(Guid contentId);

4
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs

@ -27,14 +27,14 @@ namespace Squidex.Domain.Apps.Entities.Rules
private readonly IAppProvider appProvider;
private readonly IRuleEnqueuer ruleEnqueuer;
public RuleDomainObject(IStore<Guid> store, ISemanticLog log, IAppProvider appProvider, IRuleEnqueuer ruleEnqueuer)
public RuleDomainObject(IStore<Guid> store, ISemanticLog log,
IAppProvider appProvider, IRuleEnqueuer ruleEnqueuer)
: base(store, log)
{
Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(ruleEnqueuer, nameof(ruleEnqueuer));
this.appProvider = appProvider;
this.ruleEnqueuer = ruleEnqueuer;
}

8
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs

@ -15,10 +15,10 @@ namespace Squidex.Infrastructure.EventSourcing
{
public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IEventStore
{
private static readonly FieldDefinition<MongoEventCommit, BsonTimestamp> TimestampField = Fields.Build(x => x.Timestamp);
private static readonly FieldDefinition<MongoEventCommit, long> EventsCountField = Fields.Build(x => x.EventsCount);
private static readonly FieldDefinition<MongoEventCommit, long> EventStreamOffsetField = Fields.Build(x => x.EventStreamOffset);
private static readonly FieldDefinition<MongoEventCommit, string> EventStreamField = Fields.Build(x => x.EventStream);
private static readonly FieldDefinition<MongoEventCommit, BsonTimestamp> TimestampField = FieldBuilder.Build(x => x.Timestamp);
private static readonly FieldDefinition<MongoEventCommit, long> EventsCountField = FieldBuilder.Build(x => x.EventsCount);
private static readonly FieldDefinition<MongoEventCommit, long> EventStreamOffsetField = FieldBuilder.Build(x => x.EventStreamOffset);
private static readonly FieldDefinition<MongoEventCommit, string> EventStreamField = FieldBuilder.Build(x => x.EventStream);
private readonly IEventNotifier notifier;
public IMongoCollection<BsonDocument> RawCollection

2
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs

@ -23,7 +23,7 @@ namespace Squidex.Infrastructure.MongoDb
protected static readonly ReplaceOptions UpsertReplace = new ReplaceOptions { IsUpsert = true };
protected static readonly SortDefinitionBuilder<TEntity> Sort = Builders<TEntity>.Sort;
protected static readonly UpdateDefinitionBuilder<TEntity> Update = Builders<TEntity>.Update;
protected static readonly FieldDefinitionBuilder<TEntity> Fields = FieldDefinitionBuilder<TEntity>.Instance;
protected static readonly FieldDefinitionBuilder<TEntity> FieldBuilder = FieldDefinitionBuilder<TEntity>.Instance;
protected static readonly FilterDefinitionBuilder<TEntity> Filter = Builders<TEntity>.Filter;
protected static readonly IndexKeysDefinitionBuilder<TEntity> Index = Builders<TEntity>.IndexKeys;
protected static readonly ProjectionDefinitionBuilder<TEntity> Projection = Builders<TEntity>.Projection;

6
backend/src/Squidex.Shared/Texts.it.resx

@ -184,6 +184,9 @@
<data name="assets.maxSizeReached" xml:space="preserve">
<value>Hai raggiunto la dimensione massima consentito per le risorse.</value>
</data>
<data name="assets.referenced" xml:space="preserve">
<value>Assets is referenced by a content and cannot be deleted.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>E' in esecuzione una altro processo di backup.</value>
</data>
@ -493,6 +496,9 @@
<data name="contents.listReferences" xml:space="preserve">
<value>{count} Collegamenti(s)</value>
</data>
<data name="contents.referenced" xml:space="preserve">
<value>Content is referenced by another content and cannot be deleted.</value>
</data>
<data name="contents.singletonNotChangeable" xml:space="preserve">
<value>Il contenuto singleton non può essere aggiornato</value>
</data>

6
backend/src/Squidex.Shared/Texts.nl.resx

@ -184,6 +184,9 @@
<data name="assets.maxSizeReached" xml:space="preserve">
<value>Je hebt jouw maximale assetgrootte bereikt.</value>
</data>
<data name="assets.referenced" xml:space="preserve">
<value>Assets is referenced by a content and cannot be deleted.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>Er wordt al een ander back-upproces uitgevoerd.</value>
</data>
@ -493,6 +496,9 @@
<data name="contents.listReferences" xml:space="preserve">
<value>{count} referentie (s)</value>
</data>
<data name="contents.referenced" xml:space="preserve">
<value>Content is referenced by another content and cannot be deleted.</value>
</data>
<data name="contents.singletonNotChangeable" xml:space="preserve">
<value>Singleton-inhoud kan niet worden bijgewerkt.</value>
</data>

6
backend/src/Squidex.Shared/Texts.resx

@ -184,6 +184,9 @@
<data name="assets.maxSizeReached" xml:space="preserve">
<value>You have reached your max asset size.</value>
</data>
<data name="assets.referenced" xml:space="preserve">
<value>Assets is referenced by a content and cannot be deleted.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>Another backup process is already running.</value>
</data>
@ -493,6 +496,9 @@
<data name="contents.listReferences" xml:space="preserve">
<value>{count} Reference(s)</value>
</data>
<data name="contents.referenced" xml:space="preserve">
<value>Content is referenced by another content and cannot be deleted.</value>
</data>
<data name="contents.singletonNotChangeable" xml:space="preserve">
<value>Singleton content cannot be updated.</value>
</data>

5
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -284,6 +284,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the asset to delete.</param>
/// <param name="checkReferrers">True to check referrers of this asset.</param>
/// <returns>
/// 204 => Asset deleted.
/// 404 => Asset or app not found.
@ -292,9 +293,9 @@ namespace Squidex.Areas.Api.Controllers.Assets
[Route("apps/{app}/assets/{id}/")]
[ApiPermissionOrAnonymous(Permissions.AppAssetsDelete)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteAsset(string app, Guid id)
public async Task<IActionResult> DeleteAsset(string app, Guid id, [FromQuery] bool checkReferrers = false)
{
await CommandBus.PublishAsync(new DeleteAsset { AssetId = id });
await CommandBus.PublishAsync(new DeleteAsset { AssetId = id, CheckReferrers = checkReferrers });
return NoContent();
}

5
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -551,6 +551,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to delete.</param>
/// <param name="checkReferrers">True to check referrers of this content.</param>
/// <returns>
/// 204 => Content deleted.
/// 404 => Content, schema or app not found.
@ -562,9 +563,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
[Route("content/{app}/{name}/{id}/")]
[ApiPermissionOrAnonymous(Permissions.AppContentsDelete)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteContent(string app, string name, Guid id)
public async Task<IActionResult> DeleteContent(string app, string name, Guid id, [FromQuery] bool checkReferrers = false)
{
var command = new DeleteContent { ContentId = id };
var command = new DeleteContent { ContentId = id, CheckReferrers = checkReferrers };
await CommandBus.PublishAsync(command);

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs

@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ patternId2, new AppPattern("Numbers", "[0-9]*") }
};
sut = new AppDomainObject(initialPatterns, Store, A.Dummy<ISemanticLog>(), appPlansProvider, appPlansBillingManager, userResolver);
sut = new AppDomainObject(Store, A.Dummy<ISemanticLog>(), initialPatterns, appPlansProvider, appPlansBillingManager, userResolver);
sut.Setup(Id);
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs

@ -15,6 +15,7 @@ using Orleans;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
@ -30,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly IAssetMetadataSource assetMetadataSource = A.Fake<IAssetMetadataSource>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IServiceProvider serviceProvider = A.Fake<IServiceProvider>();
@ -53,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
file = new NoopAssetFile();
var assetDomainObject = new AssetDomainObject(Store, tagService, assetQuery, A.Dummy<ISemanticLog>());
var assetDomainObject = new AssetDomainObject(Store, A.Dummy<ISemanticLog>(), tagService, assetQuery, contentRepository);
A.CallTo(() => serviceProvider.GetService(typeof(AssetDomainObject)))
.Returns(assetDomainObject);

30
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs

@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
@ -26,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetDomainObjectTests : HandlerTestBase<AssetState>
{
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly Guid parentId = Guid.NewGuid();
@ -46,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
A.CallTo(() => tagService.NormalizeTagsAsync(AppId, TagGroups.Assets, A<HashSet<string>>._, A<HashSet<string>>._))
.ReturnsLazily(x => Task.FromResult(x.GetArgument<HashSet<string>>(2)?.ToDictionary(x => x)!));
sut = new AssetDomainObject(Store, tagService, assetQuery, A.Dummy<ISemanticLog>());
sut = new AssetDomainObject(Store, A.Dummy<ISemanticLog>(), tagService, assetQuery, contentRepository);
sut.Setup(Id);
}
@ -246,6 +248,32 @@ namespace Squidex.Domain.Apps.Entities.Assets
);
}
[Fact]
public async Task Delete_should_throw_exception_if_referenced_by_other_item()
{
var command = new DeleteAsset { CheckReferrers = true };
await ExecuteCreateAsync();
A.CallTo(() => contentRepository.HasReferrersAsync(Id))
.Returns(true);
await Assert.ThrowsAsync<DomainException>(() => PublishAsync(command));
}
[Fact]
public async Task Delete_should_not_throw_exception_if_referenced_by_other_item_but_forced()
{
var command = new DeleteAsset();
await ExecuteCreateAsync();
A.CallTo(() => contentRepository.HasReferrersAsync(Id))
.Returns(true);
await PublishAsync(command);
}
private Task ExecuteCreateAsync()
{
return PublishAsync(new CreateAsset { File = file });

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderDomainObjectTests.cs

@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
A.CallTo(() => assetQuery.FindAssetFolderAsync(parentId))
.Returns(new List<IAssetFolderEntity> { A.Fake<IAssetFolderEntity>() });
sut = new AssetFolderDomainObject(Store, assetQuery, A.Dummy<ISemanticLog>());
sut = new AssetFolderDomainObject(A.Dummy<ISemanticLog>(), Store, assetQuery);
sut.Setup(Id);
}

82
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs

@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
@ -35,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IAppEntity app;
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>(x => x.Wrapping(new DefaultContentWorkflow()));
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly ISchemaEntity schema;
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
@ -105,9 +107,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
patched = patch.MergeInto(data);
var context = new ContentOperationContext(appProvider, Enumerable.Repeat(new DefaultValidatorsFactory(), 1), scriptEngine, A.Fake<ISemanticLog>());
var validators = Enumerable.Repeat(new DefaultValidatorsFactory(), 1);
sut = new ContentDomainObject(Store, contentWorkflow, context, A.Dummy<ISemanticLog>());
var context = new ContentOperationContext(appProvider, validators, scriptEngine, A.Fake<ISemanticLog>());
sut = new ContentDomainObject(Store, A.Dummy<ISemanticLog>(), contentWorkflow, contentRepository, context);
sut.Setup(Id);
}
@ -125,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var command = new CreateContent { Data = data };
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -148,7 +152,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var command = new CreateContent { Data = data, Publish = true };
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -171,7 +175,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var command = new CreateContent { Data = invalidData };
await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(CreateContentCommand(command)));
await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(command));
}
[Fact]
@ -181,7 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -205,7 +209,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync();
await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -227,7 +231,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -244,7 +248,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(CreateContentCommand(command)));
await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(command));
}
[Fact]
@ -254,7 +258,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -278,7 +282,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync();
await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -300,7 +304,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -317,7 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -339,7 +343,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -362,7 +366,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -386,7 +390,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync();
await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -410,7 +414,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -441,7 +445,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>._, Status.Draft, Status.Archived, User))
.Returns(true);
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -469,7 +473,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>._, Status.Draft, Status.Published, User))
.Returns(false);
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -491,7 +495,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(new EntitySavedResult(1));
@ -506,6 +510,32 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened();
}
[Fact]
public async Task Delete_should_throw_exception_if_referenced_by_other_item()
{
var command = new DeleteContent { CheckReferrers = true };
await ExecuteCreateAsync();
A.CallTo(() => contentRepository.HasReferrersAsync(Id))
.Returns(true);
await Assert.ThrowsAsync<DomainException>(() => PublishAsync(command));
}
[Fact]
public async Task Delete_should_not_throw_exception_if_referenced_by_other_item_but_forced()
{
var command = new DeleteContent();
await ExecuteCreateAsync();
A.CallTo(() => contentRepository.HasReferrersAsync(Id))
.Returns(true);
await PublishAsync(command);
}
[Fact]
public async Task CreateDraft_should_create_events_and_update_new_state()
{
@ -514,7 +544,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot);
@ -535,7 +565,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync();
await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command));
var result = await PublishAsync(command);
result.ShouldBeEquivalent(new EntitySavedResult(3));
@ -549,22 +579,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
private Task ExecuteCreateAsync()
{
return PublishAsync(CreateContentCommand(new CreateContent { Data = data }));
return PublishAsync(new CreateContent { Data = data });
}
private Task ExecuteUpdateAsync()
{
return PublishAsync(CreateContentCommand(new UpdateContent { Data = otherData }));
return PublishAsync(new UpdateContent { Data = otherData });
}
private Task ExecuteCreateDraftAsync()
{
return PublishAsync(CreateContentCommand(new CreateContentDraft()));
return PublishAsync(new CreateContentDraft());
}
private Task ExecuteChangeStatusAsync(Status status, Instant? dueTime = null)
{
return PublishAsync(CreateContentCommand(new ChangeContentStatus { Status = status, DueTime = dueTime }));
return PublishAsync(new ChangeContentStatus { Status = status, DueTime = dueTime });
}
private Task ExecuteDeleteAsync()

9
frontend/app/features/content/pages/content/content-history-page.component.html

@ -36,7 +36,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDraftDelete"
(sqxConfirmClick)="deleteDraft()"
confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteVersionConfirmText">
confirmText="i18n:contents.deleteVersionConfirmText"
confirmRememberKey="deleteDraft">
{{ 'contents.versionDelete' | sqxTranslate }}
</a>
@ -45,7 +46,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText">
confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }}
</a>
</div>
@ -85,7 +87,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText">
confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }}
</a>
</div>

3
frontend/app/features/content/pages/content/content-page.component.html

@ -36,7 +36,8 @@
<a class="dropdown-item dropdown-item-delete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText">
confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }}
</a>
</div>

7
frontend/app/features/content/pages/content/content-page.component.ts

@ -11,7 +11,7 @@ import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, DialogService, EditContentForm, fadeAnimation, FieldForm, FieldSection, LanguagesState, ModalModel, ResourceOwner, RootFieldDto, SchemaDetailsDto, SchemasState, TempService, valueAll$, Version } from '@app/shared';
import { Observable, of } from 'rxjs';
import { debounceTime, filter, onErrorResumeNext, tap } from 'rxjs/operators';
import { debounceTime, filter, tap } from 'rxjs/operators';
@Component({
selector: 'sqx-content-page',
@ -192,10 +192,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
const content = this.content;
if (content) {
this.contentsState.deleteMany([content]).pipe(onErrorResumeNext())
.subscribe(() => {
this.back();
});
this.contentsState.deleteMany([content]);
}
}

3
frontend/app/features/content/pages/contents/contents-page.component.html

@ -55,7 +55,8 @@
<button type="button" class="btn btn-danger" *ngIf="selectionCanDelete"
(sqxConfirmClick)="deleteSelected()"
confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteManyConfirmText">
confirmText="i18n:contents.deleteManyConfirmText"
confirmRememberKey="deleteContents">
{{ 'common.delete' | sqxTranslate }}
</button>
</div>

8
frontend/app/features/content/pages/contents/contents-page.component.ts

@ -75,7 +75,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.own(
this.route.params.pipe(
switchMap(x => this.schemasState.selectedSchema), distinctUntilChanged())
switchMap(() => this.schemasState.selectedSchema), distinctUntilChanged())
.subscribe(schema => {
this.resetSelection();
@ -202,8 +202,10 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.selectionCount++;
for (const action in this.nextStatuses) {
if (!content.statusUpdates.find(x => x.status === action)) {
delete this.nextStatuses[action];
if (this.nextStatuses.hasOwnProperty(action)) {
if (!content.statusUpdates.find(x => x.status === action)) {
delete this.nextStatuses[action];
}
}
}

4
frontend/app/features/content/pages/sidebar/sidebar-page.component.ts

@ -18,9 +18,9 @@ import { combineLatest } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SidebarPageComponent extends ResourceOwner implements AfterViewInit {
private isInitialized = false;
private context: any;
private readonly context: any;
private content: any;
private isInitialized = false;
@ViewChild('iframe', { static: false })
public iframe: ElementRef<HTMLIFrameElement>;

2
frontend/app/features/content/shared/due-time-selector.component.ts

@ -17,7 +17,7 @@ const OPTION_IMMEDIATELY = 'Immediately';
templateUrl: './due-time-selector.component.html'
})
export class DueTimeSelectorComponent {
private disabled: boolean;
private readonly disabled: boolean;
private dueTimeResult: Subject<string | null>;
public dueTimeDialog = new DialogModel();

3
frontend/app/features/content/shared/list/content.component.html

@ -41,7 +41,8 @@
<a class="dropdown-item dropdown-item-delete"
(sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText">
confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }}
</a>
</div>

3
frontend/app/features/content/shared/references/reference-item.component.html

@ -29,7 +29,8 @@
<button type="button" class="btn btn-text-secondary"
(sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:contents.removeConfirmTitle"
confirmText="i18n:contents.removeConfirmText">
confirmText="i18n:contents.removeConfirmText"
confirmRememberKey="removeReference">
<i class="icon-close"></i>
</button>
</div>

3
frontend/app/features/dashboard/pages/dashboard-config.component.html

@ -26,7 +26,8 @@
<a class="dropdown-item dropdown-item-delete" (beforeClick)="dropdownModal.hide()"
(sqxConfirmClick)="resetConfig()"
confirmTitle="i18n:dashboard.resetConfigConfirmTitle"
confirmText="i18n:dashboard.resetConfigConfirmText">
confirmText="i18n:dashboard.resetConfigConfirmText"
confirmRememberKey="resetConfig">
{{ 'common.reset' | sqxTranslate }}
</a>
</div>

9
frontend/app/features/rules/pages/rules/rule.component.html

@ -20,14 +20,16 @@
<a class="dropdown-item" *ngIf="rule.canRun"
(sqxConfirmClick)="run()"
confirmTitle="i18n:rules.runRuleConfirmTitle"
confirmText="i18n:rules.runRuleConfirmText">
confirmText="i18n:rules.runRuleConfirmText"
confirmRememberKey="runRule">
{{ 'rules.run' | sqxTranslate }}
</a>
<a class="dropdown-item dropdown-item-delete" *ngIf="rule.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:rules.deleteConfirmTitle"
confirmText="i18n:rules.deleteConfirmText">
confirmText="i18n:rules.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }}
</a>
</div>
@ -58,7 +60,8 @@
<button class="btn btn-secondary" [disabled]="!rule.canTrigger"
(sqxConfirmClick)="trigger()"
confirmTitle="i18n:rules.triggerConfirmTitle"
confirmText="i18n:rules.triggerConfirmText">
confirmText="i18n:rules.triggerConfirmText"
confirmRememberKey="triggerRule">
<i class="icon-play-line"></i>
</button>
</ng-container>

6
frontend/app/features/schemas/pages/schema/fields/field.component.html

@ -69,7 +69,8 @@
<a class="dropdown-item"
(sqxConfirmClick)="lockField()"
confirmTitle="i18n:schemas.field.lockConfirmText"
confirmText="i18n:schemas.field.lockConfirmText">
confirmText="i18n:schemas.field.lockConfirmText"
confirmRememberKey="lockField">
{{ 'schemas.field.lock' | sqxTranslate }}
</a>
</ng-container>
@ -80,7 +81,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!field.canDelete"
(sqxConfirmClick)="deleteField()"
confirmTitle="i18n:schemas.field.deleteConfirmTitle"
confirmText="i18n:schemas.field.deleteConfirmText">
confirmText="i18n:schemas.field.deleteConfirmText"
confirmRememberKey="deleteField">
{{ 'common.delete' | sqxTranslate }}
</a>
</ng-container>

3
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html

@ -29,7 +29,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.remove(i)"
confirmTitle="i18n:schemas.deleteUrlConfirmTitle"
confirmText="i18n:schemas.deleteUrlConfirmText">
confirmText="i18n:schemas.deleteUrlConfirmText"
confirmRememberKey="removePreviewUrl">
<i class="icon-bin2"></i>
</button>
</div>

2
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts

@ -48,7 +48,7 @@ export class SchemaPreviewUrlsFormComponent implements OnChanges {
if (value) {
this.schemasState.configurePreviewUrls(this.schema, value)
.subscribe(update => {
.subscribe(() => {
this.editForm.submitCompleted({ noReset: true });
}, error => {
this.editForm.submitFailed(error);

3
frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html

@ -39,7 +39,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.remove(i)"
confirmTitle="i18n:schemas.deleteRuleConfirmTitle"
confirmText="i18n:schemas.deleteRuleConfirmText">
confirmText="i18n:schemas.deleteRuleConfirmText"
confirmRememberKey="deleteFieldRule">
<i class="icon-bin2"></i>
</button>
</div>

3
frontend/app/features/schemas/pages/schema/schema-page.component.html

@ -40,7 +40,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!schema.canDelete"
(sqxConfirmClick)="deleteSchema()"
confirmTitle="i18n:schemas.deleteConfirmTitle"
confirmText="i18n:schemas.deleteConfirmText">
confirmText="i18n:schemas.deleteConfirmText"
confirmRememberKey="deleteSchema">
{{ 'common.delete' | sqxTranslate }}
</a>
</ng-container>

3
frontend/app/features/settings/pages/backups/backup.component.html

@ -40,7 +40,8 @@
<button type="button" class="btn btn-text-danger mt-1" [disabled]="!backup.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:backups.deleteConfirmTitle"
confirmText="i18n:backups.deleteConfirmText">
confirmText="i18n:backups.deleteConfirmText"
confirmRememberKey="deleteBackup">
<i class="icon-bin2"></i>
</button>
</div>

3
frontend/app/features/settings/pages/clients/client.component.html

@ -12,7 +12,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!client.canRevoke"
(sqxConfirmClick)="revoke()"
confirmTitle="i18n:clients.deleteConfirmTitle"
confirmText="i18n:clients.deleteConfirmText">
confirmText="i18n:clients.deleteConfirmText"
confirmRememberKey="revokeClient">
<i class="icon-bin2"></i>
</button>
</div>

3
frontend/app/features/settings/pages/contributors/contributor.component.html

@ -14,7 +14,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!contributor.canRevoke"
(sqxConfirmClick)="remove()"
confirmTitle="i18n:contributors.deleteConfirmTitle"
confirmText="i18n:contributors.deleteConfirmText">
confirmText="i18n:contributors.deleteConfirmText"
confirmRememberKey="removeContributor">
<i class="icon-bin2"></i>
</button>
</td>

4
frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts

@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { ContributorsState, ErrorDto, ImportContributorsForm, RoleDto } from '@app/shared';
import { empty, of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { catchError, mergeMap, tap } from 'rxjs/operators';
type ImportStatus = {
@ -79,7 +79,7 @@ export class ImportContributorsDialogComponent {
status.result = 'Failed';
}
return empty();
return EMPTY;
})
), 1)
).subscribe();

3
frontend/app/features/settings/pages/languages/language.component.html

@ -16,7 +16,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!language.canDelete"
(sqxConfirmClick)="remove()"
confirmTitle="i18n:languages.deleteConfirmTitle"
confirmText="i18n:languages.deleteConfirmText">
confirmText="i18n:languages.deleteConfirmText"
confirmRememberKey="removeLanguage">
<i class="icon-bin2"></i>
</button>
</div>

3
frontend/app/features/settings/pages/patterns/pattern.component.html

@ -27,7 +27,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isDeletable"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:patterns.deleteConfirmTitle"
confirmText="i18n:patterns.deleteConfirmText">
confirmText="i18n:patterns.deleteConfirmText"
confirmRememberKey="deletePattern">
<i class="icon-bin2"></i>
</button>
</div>

1
frontend/app/features/settings/pages/plans/plan.component.html

@ -27,6 +27,7 @@
<button *ngIf="!planInfo.isSelected" class="btn btn-block btn-success" [disabled]="plansState.isDisabled | async"
(sqxConfirmClick)="changeMonthly()"
confirmRememberKey="changePlan"
confirmTitle="i18n:plans.changeConfirmTitle"
[confirmText]="planInfo.plan.confirmText"
[confirmRequired]="planInfo.plan.confirmText">

3
frontend/app/features/settings/pages/roles/role.component.html

@ -19,7 +19,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!role.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:roles.deleteConfirmTitle"
confirmText="i18n:roles.deleteConfirmText">
confirmText="i18n:roles.deleteConfirmText"
confirmRememberKey="deleteRole">
<i class="icon-bin2"></i>
</button>
</div>

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

@ -5,7 +5,11 @@
<span class="workflow-name">{{workflow.displayName}}</span>
</div>
<div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds" styleGray="true" styleBlank="true" singleLine="true" readonly="true">
<sqx-tag-editor [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds"
styleGray="true"
styleBlank="true"
singleLine="true"
readonly="true">
</sqx-tag-editor>
</div>
<div class="col-options">
@ -17,7 +21,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!workflow.canDelete"
(sqxConfirmClick)="remove()"
confirmTitle="i18n:workflows.deleteConfirmTitle"
confirmText="i18n:workflows.deleteConfirmText">
confirmText="i18n:workflows.deleteConfirmText"
confirmRememberKey="deleteWorkflow">
<i class="icon-bin2"></i>
</button>
</div>
@ -62,7 +67,10 @@
<label class="col-form-label" for="{{workflow.id}}_schemas">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds" (ngModelChange)="changeSchemaIds($event)" [suggestions]="(schemasSource.converter | async)?.suggestions">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" [converter]="schemasSource.converter | async"
[ngModel]="workflow.schemaIds"
(ngModelChange)="changeSchemaIds($event)"
[suggestions]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor>
<sqx-form-hint>
@ -71,7 +79,15 @@
</div>
</div>
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep" [step]="step" [workflow]="workflow" [disabled]="!workflow.canUpdate" (makeInitial)="setInitial(step)" (rename)="renameStep(step, $event)" (remove)="removeStep(step)" (transitionAdd)="addTransiton(step, $event)" (transitionRemove)="removeTransition(step, $event)" (transitionUpdate)="updateTransition($event)" (update)="updateStep(step, $event)" [roles]="roles">
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep" [step]="step" [workflow]="workflow"
[disabled]="!workflow.canUpdate"
(makeInitial)="setInitial(step)"
(rename)="renameStep(step, $event)"
(remove)="removeStep(step)"
(transitionAdd)="addTransiton(step, $event)"
(transitionRemove)="removeTransition(step, $event)"
(transitionUpdate)="updateTransition($event)"
(update)="updateStep(step, $event)" [roles]="roles">
</sqx-workflow-step>
<button class="btn btn-success" (click)="addStep()" *ngIf="workflow.canUpdate">

5
frontend/app/framework/angular/forms/confirm-click.directive.ts

@ -22,6 +22,9 @@ export class ConfirmClickDirective {
@Input()
public confirmText: string;
@Input()
public confirmRememberKey: string;
@Input()
public confirmRequired = true;
@ -48,7 +51,7 @@ export class ConfirmClickDirective {
this.beforeClick.emit();
this.dialogs.confirm(this.confirmTitle, this.confirmText).pipe(take(1))
this.dialogs.confirm(this.confirmTitle, this.confirmText, this.confirmRememberKey).pipe(take(1))
.subscribe(confirmed => {
if (confirmed) {
for (const observer of observers) {

4
frontend/app/framework/angular/forms/editors/date-time-editor.component.ts

@ -29,10 +29,10 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DateTimeEditorComponent extends StatefulControlComponent<{}, string | null> implements OnInit, AfterViewInit, FocusComponent {
private readonly hideDateButtonsSettings: boolean;
private readonly hideDateTimeModeButtonSetting: boolean;
private picker: any;
private dateTime: DateTime | null;
private hideDateButtonsSettings: boolean;
private hideDateTimeModeButtonSetting: boolean;
private suppressEvents = false;
@Input()

2
frontend/app/framework/angular/forms/forms-helper.ts

@ -33,7 +33,7 @@ export function value$<T = any>(form: AbstractControl): Observable<T> {
}
export function valueAll$<T = any>(form: AbstractControl): Observable<T> {
return form.valueChanges.pipe(map(_ => getRawValue(form)), startWith(getRawValue(form)), distinctUntilChanged());
return form.valueChanges.pipe(map(() => getRawValue(form)), startWith(getRawValue(form)), distinctUntilChanged());
}
export function hasValue$(form: AbstractControl): Observable<boolean> {

2
frontend/app/framework/angular/forms/indeterminate-value.directive.ts

@ -20,7 +20,7 @@ export const SQX_INDETERMINATE_VALUE_CONTROL_VALUE_ACCESSOR: any = {
]
})
export class IndeterminateValueDirective implements ControlValueAccessor {
private callChange = (v: any) => { /* NOOP */ };
private callChange = (_: any) => { /* NOOP */ };
private callTouched = () => { /* NOOP */ };
constructor(

2
frontend/app/framework/angular/forms/transform-input.directive.ts

@ -29,7 +29,7 @@ export const SQX_TRANSFORM_INPUT_VALUE_ACCESSOR: any = {
]
})
export class TransformInputDirective implements ControlValueAccessor {
private callChange = (v: any) => { /* NOOP */ };
private callChange = (_: any) => { /* NOOP */ };
private callTouched = () => { /* NOOP */ };
private transformer: Transform;

14
frontend/app/framework/angular/modals/dialog-renderer.component.html

@ -1,13 +1,21 @@
<ng-content></ng-content>
<ng-container *sqxModal="dialogView">
<sqx-modal-dialog showClose="false" (close)="cancel()">
<sqx-modal-dialog showClose="false" (close)="cancel()" *ngIf="snapshot.dialogRequest; let request">
<ng-container title>
{{snapshot.dialogRequest?.title | sqxTranslate}}
{{request.title | sqxTranslate}}
</ng-container>
<ng-container content>
<span [innerHTML]="snapshot.dialogRequest?.text | sqxTranslate | sqxMarkdown"></span>
<span [innerHTML]="request.text | sqxTranslate | sqxMarkdown"></span>
<div class="form-check mt-4" *ngIf="request.canRemember">
<input class="form-check-input" type="checkbox" id="remember" [(ngModel)]="request.remember">
<label class="form-check-label" for="remember">
{{ 'common.remember' | sqxTranslate}}
</label>
</div>
</ng-container>
<ng-container footer>

4
frontend/app/framework/angular/modals/dialog-renderer.component.ts

@ -95,9 +95,7 @@ export class DialogRendererComponent extends StatefulComponent<State> implements
private finishRequest(value: boolean) {
this.next(s => {
if (s.dialogRequest) {
s.dialogRequest.complete(value);
}
s.dialogRequest?.complete(value);
return { ...s, dialogRequest: null };
});

2
frontend/app/framework/angular/template-wrapper.directive.ts

@ -26,7 +26,7 @@ export class TemplateWrapperDirective implements OnDestroy, OnInit, OnChanges {
public view: EmbeddedViewRef<any>;
public constructor(
private viewContainer: ViewContainerRef
private readonly viewContainer: ViewContainerRef
) {
}

131
frontend/app/framework/services/dialog.service.spec.ts

@ -5,18 +5,25 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { IMock, It, Mock, Times } from 'typemoq';
import { DialogRequest, DialogService, DialogServiceFactory, Notification, Tooltip } from './dialog.service';
import { LocalStoreService } from './local-store.service';
describe('DialogService', () => {
let localStore: IMock<LocalStoreService>;
beforeEach(() => {
localStore = Mock.ofType<LocalStoreService>();
});
it('should instantiate from factory', () => {
const dialogService = DialogServiceFactory();
const dialogService = DialogServiceFactory(localStore.object);
expect(dialogService).toBeDefined();
});
it('should instantiate', () => {
const dialogService = new DialogService();
const dialogService = new DialogService(localStore.object);
expect(dialogService).toBeDefined();
});
@ -37,33 +44,115 @@ describe('DialogService', () => {
expect(notification.messageType).toBe('info');
});
it('should create dialog request', () => {
const dialog = new DialogRequest('MyTitle', 'MyText');
[true, false].map(confirmed => {
it(`should confirm dialog with ${confirmed}`, () => {
const dialogService = new DialogService(localStore.object);
let isCompleted = false;
let isNext: boolean;
dialogService.dialogs.subscribe(dialog => {
dialog.complete(confirmed);
});
expect(dialog.title).toBe('MyTitle');
expect(dialog.text).toBe('MyText');
dialogService.confirm('MyTitle', 'MyText').subscribe(result => {
isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
localStore.verify(x => x.setInt(It.isAnyString(), It.isAnyNumber()), Times.never());
});
});
it('should confirm dialog', () => {
const dialog = new DialogRequest('MyTitle', 'MyText');
[true, false].map(confirmed => {
it(`should confirm dialog with '${confirmed}' but not remember`, () => {
const dialogService = new DialogService(localStore.object);
let isCompleted = false;
let isNext = false;
let isCompleted = false;
let isNext: boolean;
dialog.closed.subscribe(result => {
isNext = result;
}, undefined, () => {
isCompleted = true;
dialogService.dialogs.subscribe(dialog => {
dialog.complete(confirmed);
});
dialogService.confirm('MyTitle', 'MyText').subscribe(result => {
isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
localStore.verify(x => x.setInt(It.isAnyString(), It.isAnyNumber()), Times.never());
});
});
[
{ confirmed: true, saved: 1 },
{ confirmed: false, saved: 2 }
].map(({ confirmed, saved }) => {
it(`should confirm dialog with '${confirmed}' and remember when remembered`, () => {
const dialogService = new DialogService(localStore.object);
let isCompleted = false;
let isNext: boolean;
dialog.complete(true);
dialogService.dialogs.subscribe(dialog => {
dialog.remember = true;
dialog.complete(confirmed);
});
expect(isCompleted).toBeTruthy();
expect(isNext).toBeTruthy();
dialogService.confirm('MyTitle', 'MyText', 'MyKey').subscribe(result => {
isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
localStore.verify(x => x.setInt(`dialogs.confirm.MyKey`, saved), Times.once());
});
});
[
{ confirmed: true, saved: 1 },
{ confirmed: false, saved: 2 }
].map(({ confirmed, saved }) => {
it(`should confirm dialog with '${confirmed}' from local store when saved`, () => {
const dialogService = new DialogService(localStore.object);
localStore.setup(x => x.getInt(`dialogs.confirm.MyKey`))
.returns(() => saved);
let requestCount = 0;
let isCompleted = false;
let isNext: boolean;
dialogService.dialogs.subscribe(_ => {
requestCount++;
});
dialogService.confirm('MyTitle', 'MyText', 'MyKey').subscribe(result => {
isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
expect(requestCount).toEqual(0);
});
});
it('should publish tooltip', () => {
const dialogService = new DialogService();
const dialogService = new DialogService(localStore.object);
const tooltip = new Tooltip('target', 'text', 'left');
@ -79,7 +168,7 @@ describe('DialogService', () => {
});
it('should publish notification', () => {
const dialogService = new DialogService();
const dialogService = new DialogService(localStore.object);
const notification = Notification.error('Message');
@ -95,7 +184,7 @@ describe('DialogService', () => {
});
it('should publish dialog request', () => {
const dialogService = new DialogService();
const dialogService = new DialogService(localStore.object);
let pushedDialog: DialogRequest;
@ -105,6 +194,6 @@ describe('DialogService', () => {
dialogService.confirm('MyTitle', 'MyText');
expect(pushedDialog!).toEqual(new DialogRequest('MyTitle', 'MyText'));
expect(pushedDialog!).toBeDefined();
});
});

58
frontend/app/framework/services/dialog.service.ts

@ -6,29 +6,56 @@
*/
import { Injectable } from '@angular/core';
import { Observable, Subject, throwError } from 'rxjs';
import { Observable, ReplaySubject, Subject, throwError } from 'rxjs';
import { ErrorDto } from './../utils/error';
import { Types } from './../utils/types';
import { LocalStoreService } from './local-store.service';
export const DialogServiceFactory = () => {
return new DialogService();
export const DialogServiceFactory = (localStore: LocalStoreService) => {
return new DialogService(localStore);
};
export class DialogRequest {
private readonly resultStream$ = new Subject<boolean>();
private readonly resultStream$ = new ReplaySubject<boolean>();
public get closed(): Observable<boolean> {
public get result(): Observable<boolean> {
return this.resultStream$;
}
public get isCompleted() {
return this.resultStream$.isStopped;
}
public get canRemember() {
return !!this.rememberKey;
}
public remember: boolean;
constructor(
public readonly title: string,
public readonly text: string
public readonly text: string,
private readonly rememberKey: string | undefined,
private readonly localStore: LocalStoreService
) {
if (rememberKey) {
this.rememberKey = `dialogs.confirm.${rememberKey}`;
const isConfirmed = this.localStore.getInt(this.rememberKey);
if (isConfirmed > 0) {
this.resultStream$.next(isConfirmed === 1);
this.resultStream$.complete();
}
}
}
public complete(value: boolean) {
this.resultStream$.next(value);
public complete(confirmed: boolean) {
if (this.rememberKey && this.remember) {
this.localStore.setInt(this.rememberKey, confirmed ? 1 : 2);
}
this.resultStream$.next(confirmed);
this.resultStream$.complete();
}
}
@ -77,6 +104,11 @@ export class DialogService {
return this.notificationsStream$;
}
constructor(
private readonly localStore: LocalStoreService
) {
}
public notifyError(error: string | ErrorDto) {
if (Types.is(error, ErrorDto)) {
this.notify(Notification.error(error));
@ -99,11 +131,15 @@ export class DialogService {
this.tooltipStream$.next(tooltip);
}
public confirm(title: string, text: string): Observable<boolean> {
const request = new DialogRequest(title, text);
public confirm(title: string, text: string, rememberKey?: string): Observable<boolean> {
const request = new DialogRequest(title, text, rememberKey, this.localStore);
if (request.isCompleted) {
return request.result;
}
this.requestStream$.next(request);
return request.closed;
return request.result;
}
}

2
frontend/app/framework/services/resource-loader.service.ts

@ -22,7 +22,7 @@ export class ResourceLoaderService {
let result = this.cache[key];
if (!result) {
result = new Promise((resolve, reject) => {
result = new Promise(resolve => {
const style = this.renderer.createElement('link');
this.renderer.listen(style, 'load', () => resolve());

2
frontend/app/framework/utils/date-time.ts

@ -95,7 +95,7 @@ export class DateTime {
if (value.length === DATE_FORMAT.length) {
date = parse(value, DATE_FORMAT, new Date());
} else {
date = date = parseISO(value);
date = parseISO(value);
}
if (isNaN(date.getTime())) {

10
frontend/app/framework/utils/rxjs-extensions.ts

@ -7,7 +7,7 @@
// tslint:disable: only-arrow-functions
import { empty, Observable } from 'rxjs';
import { EMPTY, Observable, throwError } from 'rxjs';
import { catchError, distinctUntilChanged, filter, map, onErrorResumeNext, publishReplay, refCount, switchMap } from 'rxjs/operators';
import { DialogService } from './../services/dialog.service';
import { Version, versioned, Versioned } from './version';
@ -20,7 +20,7 @@ export function mapVersioned<T = any, R = any>(project: (value: T, version: Vers
};
}
type Options = { silent?: boolean };
type Options = { silent?: boolean, throw?: boolean };
export function shareSubscribed<T>(dialogs: DialogService, options?: Options) {
return shareMapSubscribed<T, T>(dialogs, x => x, options);
@ -36,7 +36,11 @@ export function shareMapSubscribed<T, R = T>(dialogs: DialogService, project: (v
dialogs.notifyError(error);
}
return empty();
if (options?.throw) {
return throwError(error);
}
return EMPTY;
}))
.subscribe();

3
frontend/app/shared/components/assets/asset-dialog.component.html

@ -94,7 +94,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="annotateForm.removeMetadata(i)"
confirmTitle="i18n:assets.deleteMetadataConfirmTitle"
confirmText="i18n:assets.deleteMetadataConfirmText">
confirmText="i18n:assets.deleteMetadataConfirmText"
confirmRememberKey="removeAssetMetadata">
<i class="icon-bin2"></i>
</button>
</div>

3
frontend/app/shared/components/assets/asset-folder.component.html

@ -26,7 +26,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!canDelete"
(sqxConfirmClick)="emitDelete()"
confirmTitle="i18n:assets.deleteFolderConfirmTitle"
confirmText="i18n:assets.deleteFolderConfirmText">
confirmText="i18n:assets.deleteFolderConfirmText"
confirmRememberKey="deleteAssetFolder">
{{ 'common.delete' | sqxTranslate }}
</a>
</div>

12
frontend/app/shared/components/assets/asset.component.html

@ -33,14 +33,16 @@
<a class="file-delete ml-2" *ngIf="!removeMode && asset.canDelete"
(sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:assets.deleteConfirmTitle"
confirmText="i18n:assets.deleteConfirmText">
confirmText="i18n:assets.deleteConfirmText"
confirmRememberKey="deleteAsset">
<i class="icon-delete"></i>
</a>
<a class="file-delete ml-2" *ngIf="removeMode"
(sqxConfirmClick)="remove.emit()"
confirmTitle="i18n:assets.removeConfirmTitle"
confirmText="i18n:assets.removeConfirmText">
confirmText="i18n:assets.removeConfirmText"
confirmRememberKey="removeAsset">
<i class="icon-close"></i>
</a>
</ng-container>
@ -134,14 +136,16 @@
<button type="button" class="btn btn-text-danger" *ngIf="!removeMode && asset.canDelete"
(sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:assets.deleteConfirmTitle"
confirmText="i18n:assets.deleteConfirmText">
confirmText="i18n:assets.deleteConfirmText"
confirmRememberKey="deleteAsset">
<i class="icon-bin2"></i>
</button>
<button type="button" class="btn btn-text-secondary" *ngIf="removeMode"
(sqxConfirmClick)="remove.emit()"
confirmTitle="i18n:assets.removeConfirmTitle"
confirmText="i18n:assets.removeConfirmText">
confirmText="i18n:assets.removeConfirmText"
confirmRememberKey="removeAsset">
<i class="icon-close"></i>
</button>
</td>

1
frontend/app/shared/components/comments/comment.component.html

@ -49,6 +49,7 @@
(sqxConfirmClick)="delete()"
confirmTitle="i18n:comments.deleteConfirmTitle"
confirmText="i18n:comments.deleteConfirmText"
confirmRememberKey="deleteComment"
[confirmRequired]="confirmDelete">
<i class="icon-bin2"></i>
</button>

2
frontend/app/shared/components/forms/references-checkboxes.component.ts

@ -31,7 +31,7 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReferencesCheckboxesComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges {
private itemCount: number;
private readonly itemCount: number;
private contentItems: ReadonlyArray<ContentDto> | null = null;
@Input()

2
frontend/app/shared/components/forms/references-dropdown.component.ts

@ -38,9 +38,9 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReferencesDropdownComponent extends StatefulControlComponent<State, ReadonlyArray<string> | string> implements OnChanges {
private readonly itemCount: number;
private languageField: LanguageDto;
private selectedId: string | undefined;
private itemCount: number;
@Input()
public schemaId: string;

2
frontend/app/shared/components/forms/references-tags.component.ts

@ -31,7 +31,7 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReferencesTagsComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges {
private itemCount: number;
private readonly itemCount: number;
private contentItems: ReadonlyArray<ContentDto> | null = null;
@Input()

2
frontend/app/shared/guards/load-apps.guard.ts

@ -19,6 +19,6 @@ export class LoadAppsGuard implements CanActivate {
}
public canActivate(): Observable<boolean> {
return this.appsState.load().pipe(map(a => true));
return this.appsState.load().pipe(map(_ => true));
}
}

2
frontend/app/shared/guards/load-languages.guard.ts

@ -19,6 +19,6 @@ export class LoadLanguagesGuard implements CanActivate {
}
public canActivate(): Observable<boolean> {
return this.languagesState.load().pipe(map(a => true));
return this.languagesState.load().pipe(map(_ => true));
}
}

8
frontend/app/shared/interceptors/auth.interceptor.ts

@ -9,13 +9,13 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { ApiUrlConfig, ErrorDto } from '@app/framework';
import { empty, Observable, throwError } from 'rxjs';
import { EMPTY, Observable, throwError } from 'rxjs';
import { catchError, switchMap, take } from 'rxjs/operators';
import { AuthService, Profile } from './../services/auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private baseUrl: string;
private readonly baseUrl: string;
constructor(apiUrlConfig: ApiUrlConfig,
private readonly authService: AuthService,
@ -52,7 +52,7 @@ export class AuthInterceptor implements HttpInterceptor {
catchError(() => {
this.authService.logoutRedirect();
return empty();
return EMPTY;
}),
switchMap(u => this.makeRequest(req, next, u)));
} else if (error.status === 401 || error.status === 403) {
@ -63,7 +63,7 @@ export class AuthInterceptor implements HttpInterceptor {
this.router.navigate(['/forbidden'], { replaceUrl: true });
}
return empty();
return EMPTY;
} else {
return throwError(new ErrorDto(403, 'i18n:common.errorNoPermission'));
}

4
frontend/app/shared/services/assets.service.spec.ts

@ -376,9 +376,9 @@ describe('AssetsService', () => {
}
};
assetsService.deleteAssetItem('my-app', resource, version).subscribe();
assetsService.deleteAssetItem('my-app', resource, true, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123?checkReferrers=true');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toEqual(version.value);

4
frontend/app/shared/services/assets.service.ts

@ -394,10 +394,10 @@ export class AssetsService {
pretifyError('i18n:assets.moveFailed'));
}
public deleteAssetItem(appName: string, asset: Resource, version: Version): Observable<Versioned<any>> {
public deleteAssetItem(appName: string, asset: Resource, checkReferrers: boolean, version: Version): Observable<Versioned<any>> {
const link = asset._links['delete'];
const url = this.apiUrl.buildUrl(link.href);
const url = this.apiUrl.buildUrl(link.href) + `?checkReferrers=${checkReferrers}`;
return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => {

4
frontend/app/shared/services/contents.service.spec.ts

@ -389,9 +389,9 @@ describe('ContentsService', () => {
}
};
contentsService.deleteContent('my-app', resource, version).subscribe();
contentsService.deleteContent('my-app', resource, true, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1');
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1?checkReferrers=true');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toEqual(version.value);

4
frontend/app/shared/services/contents.service.ts

@ -316,10 +316,10 @@ export class ContentsService {
pretifyError(`Failed to ${status} content. Please reload.`));
}
public deleteContent(appName: string, resource: Resource, version: Version): Observable<Versioned<any>> {
public deleteContent(appName: string, resource: Resource, checkReferrers: boolean, version: Version): Observable<Versioned<any>> {
const link = resource._links['delete'];
const url = this.apiUrl.buildUrl(link.href);
const url = this.apiUrl.buildUrl(link.href) + `?checkReferrers=${checkReferrers}`;
return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => {

2
frontend/app/shared/services/users-provider.service.ts

@ -27,7 +27,7 @@ export class UsersProviderService {
if (!result) {
const request =
this.usersService.getUser(id).pipe(
catchError(error => {
catchError(() => {
return of(new UserDto('Unknown', 'Unknown'));
}),
publishLast());

34
frontend/app/shared/state/assets.state.spec.ts

@ -1,3 +1,4 @@
import { ErrorDto } from '@app/framework';
/*
* Squidex Headless CMS
*
@ -331,7 +332,7 @@ describe('AssetsState', () => {
});
it('should remove asset from snapshot when deleted', () => {
assetsService.setup(x => x.deleteAssetItem(app, asset1, asset1.version))
assetsService.setup(x => x.deleteAssetItem(app, asset1, true, asset1.version))
.returns(() => of(versioned(newVersion)));
assetsState.deleteAsset(asset1).subscribe();
@ -341,8 +342,37 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.tagsAvailable).toEqual({ shared: 1, tag2: 1 });
});
it('should remove asset from snapshot when when referenced and not confirmed', () => {
assetsService.setup(x => x.deleteAssetItem(app, asset1, false, asset1.version))
.returns(() => throwError(new ErrorDto(404, 'Referenced')));
assetsService.setup(x => x.deleteAssetItem(app, asset1, true, asset1.version))
.returns(() => of(versioned(newVersion)));
dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString(), It.isAnyString()))
.returns(() => of(true));
assetsState.deleteAsset(asset1).subscribe();
expect(assetsState.snapshot.assets.length).toBe(1);
expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(199);
expect(assetsState.snapshot.tagsAvailable).toEqual({ shared: 1, tag2: 1 });
});
it('should not remove asset when referenced and not confirmed', () => {
assetsService.setup(x => x.deleteAssetItem(app, asset1, true, asset1.version))
.returns(() => throwError(new ErrorDto(404, 'Referenced')));
dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString(), It.isAnyString()))
.returns(() => of(false));
assetsState.deleteAsset(asset1).pipe(onErrorResumeNext()).subscribe();
expect(assetsState.snapshot.assets.length).toBe(2);
});
it('should remove asset folder from snapshot when deleted', () => {
assetsService.setup(x => x.deleteAssetItem(app, assetFolder1, assetFolder1.version))
assetsService.setup(x => x.deleteAssetItem(app, assetFolder1, false, assetFolder1.version))
.returns(() => of(versioned(newVersion)));
assetsState.deleteAssetFolder(assetFolder1).subscribe();

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

@ -6,9 +6,9 @@
*/
import { Injectable } from '@angular/core';
import { compareStrings, DialogService, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework';
import { empty, forkJoin, Observable, of, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { compareStrings, DialogService, ErrorDto, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework';
import { EMPTY, forkJoin, Observable, of, throwError } from 'rxjs';
import { catchError, finalize, switchMap, tap } from 'rxjs/operators';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service';
import { AppsState } from './apps.state';
import { Query, QueryFullTextSynchronizer } from './query';
@ -274,7 +274,7 @@ export class AssetsState extends State<Snapshot> {
public moveAsset(asset: AssetDto, parentId?: string) {
if (asset.parentId === parentId) {
return empty();
return EMPTY;
}
this.next(s => {
@ -298,7 +298,7 @@ export class AssetsState extends State<Snapshot> {
public moveAssetFolder(assetFolder: AssetFolderDto, parentId?: string) {
if (assetFolder.id === parentId || assetFolder.parentId === parentId) {
return empty();
return EMPTY;
}
this.next(s => {
@ -320,8 +320,27 @@ export class AssetsState extends State<Snapshot> {
shareSubscribed(this.dialogs));
}
public deleteAsset(asset: AssetDto): Observable<any> {
return this.assetsService.deleteAssetItem(this.appName, asset, asset.version).pipe(
public deleteAsset(asset: AssetDto) {
return this.assetsService.deleteAssetItem(this.appName, asset, true, asset.version).pipe(
catchError((error: ErrorDto) => {
if (error.statusCode === 400) {
return this.dialogs.confirm(
'i18n:assets.deleteReferrerConfirmTitle',
'i18n:assets.deleteReferrerConfirmText',
'deleteReferencedAsset'
).pipe(
switchMap(confirmed => {
if (confirmed) {
return this.assetsService.deleteAssetItem(this.appName, asset, false, asset.version);
} else {
return EMPTY;
}
})
);
} else {
return throwError(error);
}
}),
tap(() => {
this.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id);
@ -336,7 +355,7 @@ export class AssetsState extends State<Snapshot> {
}
public deleteAssetFolder(assetFolder: AssetFolderDto): Observable<any> {
return this.assetsService.deleteAssetItem(this.appName, assetFolder, assetFolder.version).pipe(
return this.assetsService.deleteAssetItem(this.appName, assetFolder, false, assetFolder.version).pipe(
tap(() => {
this.next(s => {
const assetFolders = s.assetFolders.filter(x => x.id !== assetFolder.id);

2
frontend/app/shared/state/contents.forms-helpers.ts

@ -71,7 +71,7 @@ export class PartitionConfig {
}
export class CompiledRule {
private function: Function;
private readonly function: Function;
public get field() {
return this.rule.field;

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

@ -39,7 +39,7 @@ export function getContentValue(content: ContentDto, language: LanguageDto, fiel
fieldValue = reference[fieldInvariant];
}
let value: string | undefined = undefined;
let value: string | undefined;
if (Types.isObject(fieldValue)) {
value = fieldValue[language.iso2Code];

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

@ -7,8 +7,8 @@
import { Injectable } from '@angular/core';
import { DialogService, ErrorDto, Pager, shareSubscribed, State, StateSynchronizer, Types, Version, Versioned } from '@app/framework';
import { empty, forkJoin, Observable, of } from 'rxjs';
import { catchError, finalize, switchMap, tap } from 'rxjs/operators';
import { EMPTY, forkJoin, Observable, of } from 'rxjs';
import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators';
import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service';
import { SchemaDto } from './../services/schemas.service';
import { AppsState } from './apps.state';
@ -16,6 +16,8 @@ import { SavedQuery } from './queries';
import { Query, QuerySynchronizer } from './query';
import { SchemasState } from './schemas.state';
type Updated = { content: ContentDto, error?: ErrorDto };
interface Snapshot {
// The current comments.
contents: ReadonlyArray<ContentDto>;
@ -141,7 +143,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
public loadIfNotLoaded(): Observable<any> {
if (this.snapshot.isLoaded) {
return empty();
return EMPTY;
}
return this.loadInternal(false);
@ -153,7 +155,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
private loadInternalCore(isReload: boolean) {
if (!this.appName || !this.schemaName) {
return empty();
return EMPTY;
}
this.next({ isLoading: true });
@ -221,39 +223,82 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs, {silent: true}));
}
public changeManyStatus(contents: ReadonlyArray<ContentDto>, status: string, dueTime: string | null): Observable<any> {
return forkJoin(
contents.map(c =>
this.contentsService.putStatus(this.appName, c, status, dueTime, c.version).pipe(
catchError(error => of(error))))).pipe(
public changeManyStatus(contentsToChange: ReadonlyArray<ContentDto>, status: string, dueTime: string | null): Observable<any> {
return this.updateManyStatus(contentsToChange, status, dueTime).pipe(
tap(results => {
const error = results.find(x => x instanceof ErrorDto);
const errors = results.filter(x => !!x.error);
if (errors.length > 0) {
const errror = errors[0].error!;
if (error) {
this.dialogs.notifyError(error);
if (errors.length === contentsToChange.length) {
throw errror;
} else {
this.dialogs.notifyError(errror);
}
}
return of(error);
this.next(s => {
let contents = s.contents;
for (const updated of results.filter(x => !x.error).map(x => x.content)) {
contents = contents.replaceBy('id', updated);
}
return { ...s, contents };
});
}),
switchMap(() => this.loadInternalCore(false)),
shareSubscribed(this.dialogs, { silent: true }));
shareSubscribed(this.dialogs));
}
public deleteMany(contents: ReadonlyArray<ContentDto>): Observable<any> {
return forkJoin(
contents.map(c =>
this.contentsService.deleteContent(this.appName, c, c.version).pipe(
catchError(error => of(error))))).pipe(
public deleteMany(contentsToDelete: ReadonlyArray<ContentDto>) {
return this.deleteManyCore(contentsToDelete, true).pipe(
switchMap(results => {
const referenced = results.filter(x => x.error?.statusCode === 400).map(x => x.content);
if (referenced.length > 0) {
return this.dialogs.confirm(
'i18n:contents.deleteReferrerConfirmTitle',
'i18n:contents.deleteReferrerConfirmText',
'deleteReferencedAsset'
).pipe(
switchMap(confirmed => {
if (confirmed) {
return this.deleteManyCore(referenced, false);
} else {
return of([]);
}
})
);
} else {
return of(results);
}
}),
tap(results => {
const error = results.find(x => x instanceof ErrorDto);
const errors = results.filter(x => !!x.error);
if (errors.length > 0) {
const errror = errors[0].error!;
if (error) {
this.dialogs.notifyError(error);
if (errors.length === contentsToDelete.length) {
throw errror;
} else {
this.dialogs.notifyError(errror);
}
}
return of(error);
this.next(s => {
let contents = s.contents;
let contentsPager = s.contentsPager;
for (const content of results.filter(x => !x.error).map(x => x.content)) {
contents = contents.filter(x => x.id !== content.id);
contentsPager = contentsPager.decrementCount();
}
return { ...s, contents, contentsPager };
});
}),
switchMap(() => this.loadInternal(false)),
shareSubscribed(this.dialogs, { silent: true }));
}
@ -329,6 +374,26 @@ export abstract class ContentsStateBase extends State<Snapshot> {
}
}
private deleteManyCore(contents: ReadonlyArray<ContentDto>, checkReferrers: boolean): Observable<ReadonlyArray<Updated>> {
return forkJoin(
contents.map(c => this.deleteCore(c, checkReferrers)));
}
private updateManyStatus(contents: ReadonlyArray<ContentDto>, status: string, dueTime: string | null): Observable<ReadonlyArray<Updated>> {
return forkJoin(
contents.map(c => this.updateStatus(c, status, dueTime)));
}
private deleteCore(content: ContentDto, checkReferrers: boolean): Observable<Updated> {
return this.contentsService.deleteContent(this.appName, content, checkReferrers, content.version).pipe(
map(() => ({ content })), catchError(error => of({ content, error })));
}
private updateStatus(content: ContentDto, status: string, dueTime: string | null): Observable<Updated> {
return this.contentsService.putStatus(this.appName, content, status, dueTime, content.version).pipe(
map(x => ({ content: x })), catchError(error => of({ content, error })));
}
public abstract get schemaId(): string;
public abstract get schemaName(): string;

6
frontend/app/shared/state/contributors.state.spec.ts

@ -7,7 +7,7 @@
import { ErrorDto } from '@app/framework';
import { ContributorDto, ContributorsPayload, ContributorsService, ContributorsState, DialogService, Pager, versioned } from '@app/shared/internal';
import { empty, of, throwError } from 'rxjs';
import { EMPTY, of, throwError } from 'rxjs';
import { catchError, onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq';
import { createContributors } from './../services/contributors.service.spec';
@ -164,7 +164,7 @@ describe('ContributorsState', () => {
catchError(err => {
error = err;
return empty();
return EMPTY;
})
).subscribe();
@ -183,7 +183,7 @@ describe('ContributorsState', () => {
catchError(err => {
error = err;
return empty();
return EMPTY;
})
).subscribe();

4
frontend/app/shared/state/rule-events.state.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core';
import { DialogService, Pager, Router2State, shareSubscribed, State } from '@app/framework';
import { empty, Observable } from 'rxjs';
import { EMPTY, Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators';
import { RuleEventDto, RulesService } from './../services/rules.service';
import { AppsState } from './apps.state';
@ -122,7 +122,7 @@ export class RuleEventsState extends State<Snapshot> {
public filterByRule(ruleId?: string) {
if (ruleId === this.snapshot.ruleId) {
return empty();
return EMPTY;
}
this.next(s => ({ ...s, ruleEventsPager: s.ruleEventsPager.reset(), ruleId }));

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

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core';
import { compareStrings, defined, DialogService, shareMapSubscribed, shareSubscribed, State, Types, Version } from '@app/framework';
import { empty, Observable, of } from 'rxjs';
import { EMPTY, Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { AddFieldDto, CreateSchemaDto, FieldDto, FieldRule, NestedFieldDto, RootFieldDto, SchemaDetailsDto, SchemaDto, SchemasService, UpdateFieldDto, UpdateSchemaDto, UpdateUIFields } from './../services/schemas.service';
import { AppsState } from './apps.state';
@ -125,7 +125,7 @@ export class SchemasState extends State<Snapshot> {
public loadIfNotLoaded(): Observable<any> {
if (this.snapshot.isLoaded) {
return empty();
return EMPTY;
}
return this.loadInternal(false);

2
frontend/app/shell/pages/home/home-page.component.ts

@ -30,7 +30,7 @@ export class HomePageComponent {
this.authService.loginPopup()
.subscribe(() => {
this.router.navigate(['/app']);
}, error => {
}, _ => {
this.showLoginError = true;
});
}

Loading…
Cancel
Save