Browse Source

Merge pull request #285 from Squidex/feature-proposal

Content proposal
pull/286/head
Sebastian Stehle 8 years ago
committed by GitHub
parent
commit
775d0cacab
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  2. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  3. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs
  4. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs
  5. 6
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs
  6. 120
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  7. 128
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs
  8. 64
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  9. 63
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs
  10. 140
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  11. 16
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs
  12. 70
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  13. 33
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs
  14. 22
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  15. 3
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs
  16. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs
  17. 13
      src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs
  18. 10
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  19. 185
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  20. 28
      src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs
  21. 75
      src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  22. 81
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  23. 38
      src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerGrain.cs
  24. 22
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  25. 9
      src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs
  26. 2
      src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  27. 33
      src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs
  28. 66
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  29. 30
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs
  30. 2
      src/Squidex.Domain.Apps.Entities/SquidexCommand.cs
  31. 16
      src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs
  32. 16
      src/Squidex.Domain.Apps.Events/Contents/ContentChangesPublished.cs
  33. 16
      src/Squidex.Domain.Apps.Events/Contents/ContentSchedulingCancelled.cs
  34. 18
      src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs
  35. 2
      src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs
  36. 21
      src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs
  37. 91
      src/Squidex.Infrastructure/Caching/AsyncLocalCache.cs
  38. 68
      src/Squidex.Infrastructure/Caching/HttpRequestCache.cs
  39. 6
      src/Squidex.Infrastructure/Caching/ILocalCache.cs
  40. 4
      src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs
  41. 7
      src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs
  42. 2
      src/Squidex.Infrastructure/Commands/IDomainObjectGrain.cs
  43. 35
      src/Squidex.Infrastructure/Commands/ReadonlyCommandMiddleware.cs
  44. 14
      src/Squidex.Infrastructure/Commands/ReadonlyOptions.cs
  45. 3
      src/Squidex.Infrastructure/Orleans/J.cs
  46. 6
      src/Squidex.Infrastructure/Orleans/J{T}.cs
  47. 33
      src/Squidex.Infrastructure/Orleans/LocalCacheFilter.cs
  48. 1
      src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  49. 2
      src/Squidex.Infrastructure/States/IStore.cs
  50. 7
      src/Squidex.Infrastructure/States/Store.cs
  51. 3
      src/Squidex/AppServices.cs
  52. 0
      src/Squidex/Areas/Api/Controllers/Contents/ContentSwaggerController.cs
  53. 45
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  54. 0
      src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs
  55. 0
      src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs
  56. 29
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  57. 0
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  58. 37
      src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs
  59. 6
      src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs
  60. 6
      src/Squidex/Config/Domain/EntitiesServices.cs
  61. 4
      src/Squidex/Config/Domain/InfrastructureServices.cs
  62. 4
      src/Squidex/Config/Domain/StoreServices.cs
  63. 1
      src/Squidex/Config/Orleans/SiloWrapper.cs
  64. 7
      src/Squidex/Config/Web/WebExtensions.cs
  65. 9
      src/Squidex/Config/Web/WebServices.cs
  66. 2
      src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs
  67. 8
      src/Squidex/Pipeline/EnforceHttpsMiddleware.cs
  68. 35
      src/Squidex/Pipeline/LocalCacheMiddleware.cs
  69. 10
      src/Squidex/Pipeline/RequestLogPerformanceMiddleware.cs
  70. 1
      src/Squidex/WebStartup.cs
  71. 24
      src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss
  72. 22
      src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts
  73. 2
      src/Squidex/app/features/content/declarations.ts
  74. 4
      src/Squidex/app/features/content/module.ts
  75. 60
      src/Squidex/app/features/content/pages/content/content-page.component.html
  76. 92
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  77. 31
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  78. 56
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  79. 24
      src/Squidex/app/features/content/shared/content-item.component.html
  80. 28
      src/Squidex/app/features/content/shared/content-item.component.scss
  81. 2
      src/Squidex/app/features/content/shared/content-item.component.ts
  82. 17
      src/Squidex/app/features/content/shared/content-status.component.html
  83. 38
      src/Squidex/app/features/content/shared/content-status.component.scss
  84. 38
      src/Squidex/app/features/content/shared/content-status.component.ts
  85. 30
      src/Squidex/app/features/content/shared/due-time-selector.component.html
  86. 2
      src/Squidex/app/features/content/shared/due-time-selector.component.scss
  87. 52
      src/Squidex/app/features/content/shared/due-time-selector.component.ts
  88. 16
      src/Squidex/app/features/schemas/pages/schema/field.component.html
  89. 4
      src/Squidex/app/framework/angular/forms/date-time-editor.component.html
  90. 2
      src/Squidex/app/framework/angular/modals/tooltip.component.html
  91. 1
      src/Squidex/app/framework/angular/modals/tooltip.component.scss
  92. 3
      src/Squidex/app/framework/angular/modals/tooltip.component.ts
  93. 50
      src/Squidex/app/shared/services/contents.service.spec.ts
  94. 56
      src/Squidex/app/shared/services/contents.service.ts
  95. 126
      src/Squidex/app/shared/state/contents.state.ts
  96. 7
      src/Squidex/appsettings.json
  97. 8
      tests/RunCoverage.ps1
  98. 10
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs
  99. 38
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/ContentChangedTriggerTests.cs
  100. 38
      tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs

4
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs

@ -23,8 +23,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
IUpdateableEntityWithLastModifiedBy
{
[BsonRequired]
[BsonElement]
public Guid AppIdId { get; set; }
[BsonElement("AppIdId")]
public Guid IndexedAppId { get; set; }
[BsonRequired]
[BsonElement]

2
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -84,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
public async Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids)
{
var find = Collection.Find(Filter.In(x => x.Id, ids)).SortByDescending(x => x.LastModified);
var find = Collection.Find(x => ids.Contains(x.Id)).SortByDescending(x => x.LastModified);
var assetItems = find.ToListAsync();
var assetCount = find.CountAsync();

2
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs

@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
var entity = SimpleMapper.Map(value, new MongoAssetEntity());
entity.Version = newVersion;
entity.AppIdId = value.AppId.Id;
entity.IndexedAppId = value.AppId.Id;
await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert);
}

2
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs

@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
{
var filters = new List<FilterDefinition<MongoAssetEntity>>
{
Filter.Eq(x => x.AppIdId, appId),
Filter.Eq(x => x.IndexedAppId, appId),
Filter.Eq(x => x.IsDeleted, false)
};

6
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs

@ -43,7 +43,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
if (value.Length < 1000)
{
stringBuilder.Append(" ");
if (stringBuilder.Length > 0)
{
stringBuilder.Append(" ");
}
stringBuilder.Append(text);
}
}

120
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -0,0 +1,120 @@
// ==========================================================================
// 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.Tasks;
using Microsoft.OData.UriParser;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
internal class MongoContentCollection : MongoRepositoryBase<MongoContentEntity>
{
private readonly string collectionName;
public MongoContentCollection(IMongoDatabase database, string collectionName)
: base(database)
{
this.collectionName = collectionName;
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
{
await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds));
}
protected override string CollectionName()
{
return collectionName;
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ODataUriParser odataQuery, Status[] status = null, bool useDraft = false)
{
try
{
var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef, useDraft);
var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator);
var contentCount = Collection.Find(filter).CountAsync();
var contentItems =
Collection.Find(filter)
.ContentTake(odataQuery)
.ContentSkip(odataQuery)
.ContentSort(odataQuery, propertyCalculator)
.Not(x => x.DataText)
.ToListAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
{
entity.ParseData(schema.SchemaDef);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
}
catch (NotSupportedException)
{
throw new ValidationException("This odata operation is not supported.");
}
catch (NotImplementedException)
{
throw new ValidationException("This odata operation is not supported.");
}
catch (MongoQueryException ex)
{
if (ex.Message.Contains("17406"))
{
throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items.");
}
else
{
throw;
}
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids, Status[] status = null)
{
var find =
status != null && status.Length > 0 ?
Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id) && x.IsDeleted != true && status.Contains(x.Status)) :
Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id));
var contentItems = find.Not(x => x.DataText).ToListAsync();
var contentCount = find.CountAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
{
entity.ParseData(schema.SchemaDef);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
}
public Task CleanupAsync(Guid id)
{
return Collection.UpdateManyAsync(
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, id),
Filter.AnyNe(x => x.ReferencedIdsDeleted, id)),
Update.AddToSet(x => x.ReferencedIdsDeleted, id));
}
}
}

128
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs

@ -0,0 +1,128 @@
// ==========================================================================
// 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.Tasks;
using MongoDB.Driver;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
internal sealed class MongoContentDraftCollection : MongoContentCollection
{
public MongoContentDraftCollection(IMongoDatabase database)
: base(database, "State_Content_Draft")
{
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
{
await collection.Indexes.CreateOneAsync(
Index
.Ascending(x => x.IndexedSchemaId)
.Ascending(x => x.Id)
.Ascending(x => x.IsDeleted));
await collection.Indexes.CreateOneAsync(
Index
.Text(x => x.DataText)
.Ascending(x => x.IndexedSchemaId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Status));
await base.SetupCollectionAsync(collection);
}
public async Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids)
{
var contentEntities =
await Collection.Find(x => x.IndexedSchemaId == schemaId && ids.Contains(x.Id) && x.IsDeleted != true).Only(x => x.Id)
.ToListAsync();
return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList();
}
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
{
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true)
.Not(x => x.DataByIds)
.Not(x => x.DataDraftByIds)
.Not(x => x.DataText)
.ForEachAsync(c =>
{
callback(c);
});
}
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id)
{
var contentEntity =
await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id && x.IsDeleted != true).Not(x => x.DataText)
.FirstOrDefaultAsync();
contentEntity?.ParseData(schema.SchemaDef);
return contentEntity;
}
public async Task<(ContentState Value, long Version)> ReadAsync(Guid key, Func<Guid, Guid, Task<ISchemaEntity>> getSchema)
{
var contentEntity =
await Collection.Find(x => x.Id == key).Not(x => x.DataText)
.FirstOrDefaultAsync();
if (contentEntity != null)
{
var schema = await getSchema(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId);
contentEntity?.ParseData(schema.SchemaDef);
return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version);
}
return (null, EtagVersion.NotFound);
}
public async Task UpsertAsync(MongoContentEntity content, long oldVersion)
{
try
{
content.DataText = content.DataDraftByIds.ToFullText();
await Collection.ReplaceOneAsync(x => x.Id == content.Id && x.Version == oldVersion, content, Upsert);
}
catch (MongoWriteException ex)
{
if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existingVersion =
await Collection.Find(x => x.Id == content.Id).Only(x => x.Id, x => x.Version)
.FirstOrDefaultAsync();
if (existingVersion != null)
{
throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex);
}
}
else
{
throw;
}
}
}
}
}

64
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs

@ -21,21 +21,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public sealed class MongoContentEntity : IContentEntity
{
private NamedContentData data;
private NamedContentData dataDraft;
[BsonId]
[BsonRequired]
[BsonElement]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }
[BsonRequired]
[BsonElement("ai")]
[BsonElement("_ai")]
[BsonRepresentation(BsonType.String)]
public Guid AppIdId { get; set; }
public Guid IndexedAppId { get; set; }
[BsonRequired]
[BsonElement("si")]
[BsonElement("_si")]
[BsonRepresentation(BsonType.String)]
public Guid SchemaIdId { get; set; }
public Guid IndexedSchemaId { get; set; }
[BsonRequired]
[BsonElement("rf")]
@ -48,35 +49,41 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public List<Guid> ReferencedIdsDeleted { get; set; } = new List<Guid>();
[BsonRequired]
[BsonElement("st")]
[BsonElement("ss")]
[BsonRepresentation(BsonType.String)]
public Status Status { get; set; }
[BsonRequired]
[BsonIgnoreIfNull]
[BsonElement("do")]
[BsonJson]
public IdContentData DataByIds { get; set; }
[BsonIgnoreIfNull]
[BsonElement("dd")]
[BsonJson]
public IdContentData DataDraftByIds { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sj")]
[BsonJson]
public ScheduleJob ScheduleJob { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("dt")]
public string DataText { get; set; }
[BsonRequired]
[BsonElement("ai2")]
[BsonElement("ai")]
public NamedId<Guid> AppId { get; set; }
[BsonRequired]
[BsonElement("si2")]
[BsonElement("si")]
public NamedId<Guid> SchemaId { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sdt")]
public Status? ScheduledTo { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sda")]
[BsonElement("sa")]
public Instant? ScheduledAt { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sdb")]
public RefToken ScheduledBy { get; set; }
[BsonRequired]
[BsonElement("ct")]
public Instant Created { get; set; }
@ -85,18 +92,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonElement("mt")]
public Instant LastModified { get; set; }
[BsonRequired]
[BsonElement("dt")]
public string DataText { get; set; }
[BsonRequired]
[BsonElement("vs")]
public long Version { get; set; }
[BsonRequired]
[BsonIgnoreIfDefault]
[BsonElement("dl")]
public bool IsDeleted { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("pd")]
public bool IsPending { get; set; }
[BsonRequired]
[BsonElement("cb")]
public RefToken CreatedBy { get; set; }
@ -111,9 +118,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
get { return data; }
}
[BsonIgnore]
public NamedContentData DataDraft
{
get { return dataDraft; }
}
public void ParseData(Schema schema)
{
data = DataByIds.ToData(schema, ReferencedIdsDeleted);
if (DataDraftByIds != null)
{
dataDraft = DataDraftByIds.ToData(schema, ReferencedIdsDeleted);
}
}
}
}

63
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs

@ -0,0 +1,63 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
internal sealed class MongoContentPublishedCollection : MongoContentCollection
{
public MongoContentPublishedCollection(IMongoDatabase database)
: base(database, "State_Content_Published")
{
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
{
await collection.Indexes.CreateOneAsync(Index.Text(x => x.DataText).Ascending(x => x.IndexedSchemaId));
await collection.Indexes.CreateOneAsync(
Index
.Ascending(x => x.IndexedSchemaId)
.Ascending(x => x.Id));
await base.SetupCollectionAsync(collection);
}
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id)
{
var contentEntity =
await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id).Not(x => x.DataText)
.FirstOrDefaultAsync();
contentEntity?.ParseData(schema.SchemaDef);
return contentEntity;
}
public Task UpsertAsync(MongoContentEntity content)
{
content.DataText = content.DataByIds.ToFullText();
content.DataDraftByIds = null;
content.ScheduleJob = null;
content.ScheduledAt = null;
return Collection.ReplaceOneAsync(x => x.Id == content.Id, content, new UpdateOptions { IsUpsert = true });
}
public Task RemoveAsync(Guid id)
{
return Collection.DeleteOneAsync(x => x.Id == id);
}
}
}

140
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -7,7 +7,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.OData.UriParser;
using MongoDB.Driver;
@ -16,146 +15,97 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public partial class MongoContentRepository : MongoRepositoryBase<MongoContentEntity>, IContentRepository
public partial class MongoContentRepository : IContentRepository, IInitializable
{
private readonly IMongoDatabase database;
private readonly IAppProvider appProvider;
private readonly MongoContentDraftCollection contentsDraft;
private readonly MongoContentPublishedCollection contentsPublished;
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider)
: base(database)
{
Guard.NotNull(appProvider, nameof(appProvider));
this.appProvider = appProvider;
}
protected override string CollectionName()
{
return "States_Contents";
contentsDraft = new MongoContentDraftCollection(database);
contentsPublished = new MongoContentPublishedCollection(database);
this.database = database;
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
public void Initialize()
{
await collection.Indexes.TryDropOneAsync("si_1_st_1_dl_1_dt_text");
await collection.Indexes.CreateOneAsync(
Index
.Text(x => x.DataText)
.Ascending(x => x.SchemaIdId)
.Ascending(x => x.Status)
.Ascending(x => x.IsDeleted));
await collection.Indexes.CreateOneAsync(
Index
.Ascending(x => x.SchemaIdId)
.Ascending(x => x.Id)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Status));
await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds));
contentsDraft.Initialize();
contentsPublished.Initialize();
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery)
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery)
{
try
if (RequiresPublished(status))
{
var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef);
var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator);
var contentCount = Collection.Find(filter).CountAsync();
var contentItems =
Collection.Find(filter)
.ContentTake(odataQuery)
.ContentSkip(odataQuery)
.ContentSort(odataQuery, propertyCalculator)
.ToListAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
{
entity.ParseData(schema.SchemaDef);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
return contentsPublished.QueryAsync(app, schema, odataQuery);
}
catch (NotSupportedException)
else
{
throw new ValidationException("This odata operation is not supported.");
return contentsDraft.QueryAsync(app, schema, odataQuery, status, true);
}
catch (NotImplementedException)
}
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet<Guid> ids)
{
if (RequiresPublished(status))
{
throw new ValidationException("This odata operation is not supported.");
return contentsPublished.QueryAsync(app, schema, ids);
}
catch (MongoQueryException ex)
else
{
if (ex.Message.Contains("17406"))
{
throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items.");
}
else
{
throw;
}
return contentsDraft.QueryAsync(app, schema, ids, status);
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet<Guid> ids)
public Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id)
{
var find = Collection.Find(x => x.SchemaIdId == schema.Id && ids.Contains(x.Id) && x.IsDeleted == false && status.Contains(x.Status));
var contentItems = find.ToListAsync();
var contentCount = find.CountAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
if (RequiresPublished(status))
{
entity.ParseData(schema.SchemaDef);
return contentsPublished.FindContentAsync(app, schema, id);
}
else
{
return contentsDraft.FindContentAsync(app, schema, id);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
}
public async Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids)
public Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids)
{
var contentEntities =
await Collection.Find(x => x.SchemaIdId == schemaId && ids.Contains(x.Id) && x.IsDeleted == false).Only(x => x.Id)
.ToListAsync();
return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList();
return contentsDraft.QueryNotFoundAsync(appId, schemaId, ids);
}
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id)
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
{
var contentEntity =
await Collection.Find(x => x.SchemaIdId == schema.Id && x.Id == id && x.IsDeleted == false)
.FirstOrDefaultAsync();
contentEntity?.ParseData(schema.SchemaDef);
return contentEntity;
return contentsDraft.QueryScheduledWithoutDataAsync(now, callback);
}
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
public Task ClearAsync()
{
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted == false)
.ForEachAsync(c =>
{
callback(c);
});
return Task.WhenAll(
contentsDraft.ClearAsync(),
contentsPublished.ClearAsync());
}
public Task DeleteArchiveAsync()
{
return Database.DropCollectionAsync("States_Contents_Archive");
return database.DropCollectionAsync("States_Contents_Archive");
}
private static bool RequiresPublished(Status[] status)
{
return status?.Length == 1 && status[0] == Status.Published;
}
}
}

16
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs

@ -33,20 +33,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
protected Task On(AssetDeleted @event)
{
return Collection.UpdateManyAsync(
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, @event.AssetId),
Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.AssetId)),
Update.AddToSet(x => x.ReferencedIdsDeleted, @event.AssetId));
return Task.WhenAll(
contentsDraft.CleanupAsync(@event.AssetId),
contentsPublished.CleanupAsync(@event.AssetId));
}
protected Task On(ContentDeleted @event)
{
return Collection.UpdateManyAsync(
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, @event.ContentId),
Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.ContentId)),
Update.AddToSet(x => x.ReferencedIdsDeleted, @event.ContentId));
return Task.WhenAll(
contentsDraft.CleanupAsync(@event.ContentId),
contentsPublished.CleanupAsync(@event.ContentId));
}
Task IEventConsumer.ClearAsync()

70
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs

@ -7,12 +7,11 @@
using System;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
@ -20,27 +19,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public partial class MongoContentRepository : ISnapshotStore<ContentState, Guid>
{
Task ISnapshotStore<ContentState, Guid>.ReadAllAsync(Func<ContentState, long, Task> callback)
public Task<(ContentState Value, long Version)> ReadAsync(Guid key)
{
throw new NotSupportedException();
}
public async Task<(ContentState Value, long Version)> ReadAsync(Guid key)
{
var contentEntity =
await Collection.Find(x => x.Id == key).SortByDescending(x => x.Version)
.FirstOrDefaultAsync();
if (contentEntity != null)
{
var schema = await GetSchemaAsync(contentEntity.AppIdId, contentEntity.SchemaIdId);
contentEntity?.ParseData(schema.SchemaDef);
return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version);
}
return (null, EtagVersion.NotFound);
return contentsDraft.ReadAsync(key, GetSchemaAsync);
}
public async Task WriteAsync(Guid key, ContentState value, long oldVersion, long newVersion)
@ -52,41 +33,29 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id);
var idData = value.Data?.ToIdModel(schema.SchemaDef, true);
var idData = value.Data.ToIdModel(schema.SchemaDef, true);
var document = SimpleMapper.Map(value, new MongoContentEntity
var content = SimpleMapper.Map(value, new MongoContentEntity
{
AppIdId = value.AppId.Id,
SchemaIdId = value.SchemaId.Id,
IsDeleted = value.IsDeleted,
DataText = idData?.ToFullText(),
DataByIds = idData,
ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef),
DataDraftByIds = value.DataDraft?.ToIdModel(schema.SchemaDef, true),
IsDeleted = value.IsDeleted,
IndexedAppId = value.AppId.Id,
IndexedSchemaId = value.SchemaId.Id,
ReferencedIds = idData.ToReferencedIds(schema.SchemaDef),
ScheduledAt = value.ScheduleJob?.DueTime,
Version = newVersion
});
document.Version = newVersion;
await contentsDraft.UpsertAsync(content, oldVersion);
try
if (value.Status == Status.Published && !value.IsDeleted)
{
await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, document, Upsert);
await contentsPublished.UpsertAsync(content);
}
catch (MongoWriteException ex)
else
{
if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existingVersion =
await Collection.Find(x => x.Id == key).Only(x => x.Id, x => x.Version)
.FirstOrDefaultAsync();
if (existingVersion != null)
{
throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex);
}
}
else
{
throw;
}
await contentsPublished.RemoveAsync(content.Id);
}
}
@ -101,5 +70,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
return schema;
}
Task ISnapshotStore<ContentState, Guid>.ReadAllAsync(Func<ContentState, long, Task> callback)
{
throw new NotSupportedException();
}
}
}

33
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs

@ -27,12 +27,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
typeof(MongoContentEntity).GetProperties()
.ToDictionary(x => x.Name, x => x.GetCustomAttribute<BsonElementAttribute>()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase);
static FindExtensions()
{
PropertyMap["Data"] = "do";
}
public static PropertyCalculator CreatePropertyCalculator(Schema schema)
public static PropertyCalculator CreatePropertyCalculator(Schema schema, bool useDraft)
{
return propertyNames =>
{
@ -50,7 +45,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
if (propertyNames.Length > 0)
{
propertyNames[0] = PropertyMap[propertyNames[0]];
if (propertyNames[0].Equals("Data", StringComparison.CurrentCultureIgnoreCase))
{
if (useDraft)
{
propertyNames[0] = "dd";
}
else
{
propertyNames[0] = "do";
}
}
else
{
propertyNames[0] = PropertyMap[propertyNames[0]];
}
}
var propertyName = string.Join(".", propertyNames);
@ -80,11 +89,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
Filter.Eq(x => x.SchemaIdId, schemaId),
Filter.In(x => x.Status, status),
Filter.Eq(x => x.IsDeleted, false)
Filter.Eq(x => x.IndexedSchemaId, schemaId),
};
if (status != null)
{
filters.Add(Filter.Ne(x => x.IsDeleted, true));
filters.Add(Filter.In(x => x.Status, status));
}
var filter = query.BuildFilter<MongoContentEntity>(propertyCalculator);
if (filter.Filter != null)

22
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -23,20 +23,20 @@ namespace Squidex.Domain.Apps.Entities
public sealed class AppProvider : IAppProvider
{
private readonly IGrainFactory grainFactory;
private readonly IRequestCache requestCache;
private readonly ILocalCache localCache;
public AppProvider(IGrainFactory grainFactory, IRequestCache requestCache)
public AppProvider(IGrainFactory grainFactory, ILocalCache localCache)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(requestCache, nameof(requestCache));
Guard.NotNull(localCache, nameof(localCache));
this.grainFactory = grainFactory;
this.requestCache = requestCache;
this.localCache = localCache;
}
public Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id)
{
return requestCache.GetOrCreateAsync($"GetAppWithSchemaAsync({appId}, {id})", async () =>
return localCache.GetOrCreateAsync($"GetAppWithSchemaAsync({appId}, {id})", async () =>
{
using (Profile.Method<AppProvider>())
{
@ -61,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities
public Task<IAppEntity> GetAppAsync(string appName)
{
return requestCache.GetOrCreateAsync($"GetAppAsync({appName})", async () =>
return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () =>
{
using (Profile.Method<AppProvider>())
{
@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities
public Task<ISchemaEntity> GetSchemaAsync(Guid appId, string name)
{
return requestCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {name})", async () =>
return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {name})", async () =>
{
using (Profile.Method<AppProvider>())
{
@ -104,7 +104,7 @@ namespace Squidex.Domain.Apps.Entities
public Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false)
{
return requestCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {id}, {allowDeleted})", async () =>
return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {id}, {allowDeleted})", async () =>
{
using (Profile.Method<AppProvider>())
{
@ -122,7 +122,7 @@ namespace Squidex.Domain.Apps.Entities
public Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId)
{
return requestCache.GetOrCreateAsync($"GetSchemasAsync({appId})", async () =>
return localCache.GetOrCreateAsync($"GetSchemasAsync({appId})", async () =>
{
using (Profile.Method<AppProvider>())
{
@ -139,7 +139,7 @@ namespace Squidex.Domain.Apps.Entities
public Task<List<IRuleEntity>> GetRulesAsync(Guid appId)
{
return requestCache.GetOrCreateAsync($"GetRulesAsync({appId})", async () =>
return localCache.GetOrCreateAsync($"GetRulesAsync({appId})", async () =>
{
using (Profile.Method<AppProvider>())
{
@ -156,7 +156,7 @@ namespace Squidex.Domain.Apps.Entities
public Task<List<IAppEntity>> GetUserApps(string userId)
{
return requestCache.GetOrCreateAsync($"GetUserApps({userId})", async () =>
return localCache.GetOrCreateAsync($"GetUserApps({userId})", async () =>
{
using (Profile.Method<AppProvider>())
{

3
src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// =========================================================================
using System;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
@ -15,5 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
public Status Status { get; set; }
public Instant? DueTime { get; set; }
public Guid? JobId { get; set; }
}
}

2
src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs

@ -12,5 +12,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
public abstract class ContentDataCommand : ContentCommand
{
public NamedContentData Data { get; set; }
public bool AsDraft { get; set; }
}
}

13
src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public sealed class DiscardChanges : ContentCommand
{
}
}

10
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -30,11 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Status Status { get; set; }
public Status? ScheduledTo { get; set; }
public Instant? ScheduledAt { get; set; }
public RefToken ScheduledBy { get; set; }
public ScheduleJob ScheduleJob { get; set; }
public RefToken CreatedBy { get; set; }
@ -42,6 +38,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
public NamedContentData Data { get; set; }
public NamedContentData DataDraft { get; set; }
public bool IsPending { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{
var now = SystemClock.Instance.GetCurrentInstant();

185
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -63,65 +63,76 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
GuardContent.CanCreate(c);
var operationContext = await CreateContext(c, () => "Failed to create content.");
var operationContext = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content.");
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create", c, c.Data, null);
await operationContext.EnrichAsync(c.Data);
await operationContext.ValidateAsync(c.Data);
if (c.Publish)
{
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, "Published");
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, "Published", c, c.Data, null);
}
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create");
await operationContext.EnrichAsync();
await operationContext.ValidateAsync();
Create(c);
return EntityCreatedResult.Create(c.Data, NewVersion);
});
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, async c =>
return UpdateReturnAsync(updateContent, c =>
{
GuardContent.CanUpdate(c);
var operationContext = await CreateContext(c, () => "Failed to update content.");
await operationContext.ValidateAsync();
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update");
Update(c);
return new ContentDataChangedResult(Snapshot.Data, NewVersion);
return UpdateAsync(c, x => c.Data, false);
});
case PatchContent patchContent:
return UpdateReturnAsync(patchContent, async c =>
return UpdateReturnAsync(patchContent, c =>
{
GuardContent.CanPatch(c);
var operationContext = await CreateContext(c, () => "Failed to patch content.");
await operationContext.ValidatePartialAsync();
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Patch");
Patch(c);
return new ContentDataChangedResult(Snapshot.Data, NewVersion);
return UpdateAsync(c, c.Data.MergeInto, true);
});
case ChangeContentStatus patchContent:
return UpdateAsync(patchContent, async c =>
case ChangeContentStatus changeContentStatus:
return UpdateAsync(changeContentStatus, async c =>
{
GuardContent.CanChangeContentStatus(Snapshot.Status, c);
if (!c.DueTime.HasValue)
try
{
var operationContext = await CreateContext(c, () => "Failed to patch content.");
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status);
GuardContent.CanChangeContentStatus(Snapshot.IsPending, Snapshot.Status, c);
if (c.DueTime.HasValue)
{
ScheduleStatus(c);
}
else
{
if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published)
{
ConfirmChanges(c);
}
else
{
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content.");
await ctx.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data);
ChangeStatus(c);
}
}
}
catch (Exception)
{
if (c.JobId.HasValue && Snapshot?.ScheduleJob.Id == c.JobId)
{
CancelScheduling(c);
}
else
{
throw;
}
}
ChangeStatus(c);
});
case DeleteContent deleteContent:
@ -129,18 +140,65 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
GuardContent.CanDelete(c);
var operationContext = await CreateContext(c, () => "Failed to delete content.");
var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to delete content.");
await operationContext.ExecuteScriptAsync(x => x.ScriptDelete, "Delete");
await operationContext.ExecuteScriptAsync(x => x.ScriptDelete, "Delete", c, Snapshot.Data);
Delete(c);
});
case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c);
});
default:
throw new NotSupportedException();
}
}
private async Task<object> UpdateAsync(ContentDataCommand c, Func<NamedContentData, NamedContentData> newDataFunc, bool partial)
{
var isProposal = c.AsDraft && Snapshot.Status == Status.Published;
var currentData =
isProposal ?
Snapshot.DataDraft :
Snapshot.Data;
var newData = newDataFunc(currentData);
if (!currentData.Equals(newData))
{
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content.");
if (partial)
{
await ctx.ValidatePartialAsync(c.Data);
}
else
{
await ctx.ValidateAsync(c.Data);
}
newData = await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, newData, Snapshot.Data);
if (isProposal)
{
ProposeUpdate(c, newData);
}
else
{
Update(c, newData);
}
}
return new ContentDataChangedResult(newData, NewVersion);
}
public void Create(CreateContent command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentCreated()));
@ -151,43 +209,44 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public void Update(UpdateContent command)
public void ConfirmChanges(ChangeContentStatus command)
{
if (!command.Data.Equals(Snapshot.Data))
{
RaiseEvent(SimpleMapper.Map(command, new ContentUpdated()));
}
RaiseEvent(SimpleMapper.Map(command, new ContentChangesPublished()));
}
public void ChangeStatus(ChangeContentStatus command)
public void DiscardChanges(DiscardChanges command)
{
if (command.DueTime.HasValue)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value }));
}
else
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged()));
}
RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded()));
}
public void Patch(PatchContent command)
public void Delete(DeleteContent command)
{
var newData = command.Data.MergeInto(Snapshot.Data);
RaiseEvent(SimpleMapper.Map(command, new ContentDeleted()));
}
if (!newData.Equals(Snapshot.Data))
{
var @event = SimpleMapper.Map(command, new ContentUpdated());
public void Update(ContentCommand command, NamedContentData data)
{
RaiseEvent(SimpleMapper.Map(command, new ContentUpdated { Data = data }));
}
@event.Data = newData;
public void ProposeUpdate(ContentCommand command, NamedContentData data)
{
RaiseEvent(SimpleMapper.Map(command, new ContentUpdateProposed { Data = data }));
}
RaiseEvent(@event);
}
public void CancelScheduling(ChangeContentStatus command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentSchedulingCancelled()));
}
public void Delete(DeleteContent command)
public void ScheduleStatus(ChangeContentStatus command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentDeleted()));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value }));
}
public void ChangeStatus(ChangeContentStatus command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged()));
}
private void RaiseEvent(SchemaEvent @event)
@ -218,13 +277,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
ApplySnapshot(Snapshot.Apply(@event));
}
private async Task<ContentOperationContext> CreateContext(ContentCommand command, Func<string> message)
private async Task<ContentOperationContext> CreateContext(Guid appId, Guid schemaId, Func<string> message)
{
var operationContext =
await ContentOperationContext.CreateAsync(command, Snapshot,
contentRepository,
await ContentOperationContext.CreateAsync(appId, schemaId,
appProvider,
assetRepository,
contentRepository,
scriptEngine,
message);

28
src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs

@ -20,16 +20,31 @@ namespace Squidex.Domain.Apps.Entities.Contents
: base(typeNameRegistry)
{
AddEventMessage<ContentCreated>(
"created {[Schema]} content item.");
"created {[Schema]} content.");
AddEventMessage<ContentUpdated>(
"updated {[Schema]} content item.");
"updated {[Schema]} content.");
AddEventMessage<ContentDeleted>(
"deleted {[Schema]} content item.");
"deleted {[Schema]} content.");
AddEventMessage<ContentChangesDiscarded>(
"discarded pending changes of {[Schema]} content.");
AddEventMessage<ContentChangesPublished>(
"published changes of {[Schema]} content.");
AddEventMessage<ContentUpdateProposed>(
"proposed update for {[Schema]} content.");
AddEventMessage<ContentSchedulingCancelled>(
"failed to schedule status change for {[Schema]} content.");
AddEventMessage<ContentStatusChanged>(
"changed status of {[Schema]} content item to {[Status]}.");
"changed status of {[Schema]} content to {[Status]}.");
AddEventMessage<ContentStatusScheduled>(
"scheduled to change status of {[Schema]} content to {[Status]}.");
}
protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event)
@ -48,6 +63,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
result = result.AddParameter("Status", contentStatusChanged.Status);
}
if (@event.Payload is ContentStatusScheduled contentStatusScheduled)
{
result = result.AddParameter("Status", contentStatusScheduled.Status);
}
return Task.FromResult(result);
}
}

75
src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs

@ -24,44 +24,29 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentOperationContext
{
private ContentCommand command;
private IContentRepository contentRepository;
private IContentEntity content;
private IAssetRepository assetRepository;
private IScriptEngine scriptEngine;
private ISchemaEntity schemaEntity;
private IAppEntity appEntity;
private Guid appId;
private Func<string> message;
public static async Task<ContentOperationContext> CreateAsync(
ContentCommand command,
IContentEntity content,
IContentRepository contentRepository,
Guid appId,
Guid schemaId,
IAppProvider appProvider,
IAssetRepository assetRepository,
IContentRepository contentRepository,
IScriptEngine scriptEngine,
Func<string> message)
{
var a = content.AppId;
var s = content.SchemaId;
if (command is CreateContent createContent)
{
a = a ?? createContent.AppId;
s = s ?? createContent.SchemaId;
}
var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(a.Id, s.Id);
var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(appId, schemaId);
var context = new ContentOperationContext
{
appEntity = appEntity,
appId = a.Id,
assetRepository = assetRepository,
contentRepository = contentRepository,
content = content,
command = command,
message = message,
schemaEntity = schemaEntity,
scriptEngine = scriptEngine
@ -70,64 +55,48 @@ namespace Squidex.Domain.Apps.Entities.Contents
return context;
}
public Task EnrichAsync()
public Task EnrichAsync(NamedContentData data)
{
if (command is ContentDataCommand dataCommand)
{
dataCommand.Data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver());
}
data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver());
return TaskHelper.Done;
}
public Task ValidateAsync()
public Task ValidateAsync(NamedContentData data)
{
if (command is ContentDataCommand dataCommand)
{
var ctx = CreateValidationContext();
var ctx = CreateValidationContext();
return dataCommand.Data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
return TaskHelper.Done;
return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
public Task ValidatePartialAsync()
public Task ValidatePartialAsync(NamedContentData data)
{
if (command is ContentDataCommand dataCommand)
{
var ctx = CreateValidationContext();
return dataCommand.Data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
var ctx = CreateValidationContext();
return TaskHelper.Done;
return data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
public Task ExecuteScriptAndTransformAsync(Func<ISchemaEntity, string> script, object operation)
public Task<NamedContentData> ExecuteScriptAndTransformAsync(Func<ISchemaEntity, string> script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null)
{
if (command is ContentDataCommand dataCommand)
{
var ctx = CreateScriptContext(operation, dataCommand.Data);
var ctx = CreateScriptContext(operation, command, data, oldData);
dataCommand.Data = scriptEngine.ExecuteAndTransform(ctx, script(schemaEntity));
}
var result = scriptEngine.ExecuteAndTransform(ctx, script(schemaEntity));
return TaskHelper.Done;
return Task.FromResult(result);
}
public Task ExecuteScriptAsync(Func<ISchemaEntity, string> script, object operation)
public Task ExecuteScriptAsync(Func<ISchemaEntity, string> script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null)
{
var ctx = CreateScriptContext(operation, content.Data);
var ctx = CreateScriptContext(operation, command, data, oldData);
scriptEngine.Execute(ctx, script(schemaEntity));
return TaskHelper.Done;
}
private ScriptContext CreateScriptContext(object operation, NamedContentData data = null)
private ScriptContext CreateScriptContext(object operation, ContentCommand command, NamedContentData data, NamedContentData oldData)
{
return new ScriptContext { ContentId = command.ContentId, OldData = content.Data, Data = data, User = command.User, Operation = operation.ToString() };
return new ScriptContext { ContentId = command.ContentId, OldData = oldData, Data = data, User = command.User, Operation = operation.ToString() };
}
private ValidationContext CreateValidationContext()
@ -145,12 +114,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
private async Task<IReadOnlyList<IAssetInfo>> QueryAssetsAsync(IEnumerable<Guid> assetIds)
{
return await assetRepository.QueryAsync(appId, new HashSet<Guid>(assetIds));
return await assetRepository.QueryAsync(appEntity.Id, new HashSet<Guid>(assetIds));
}
private async Task<IReadOnlyList<Guid>> QueryContentsAsync(Guid schemaId, IEnumerable<Guid> contentIds)
{
return await contentRepository.QueryNotFoundAsync(appId, schemaId, contentIds.ToList());
return await contentRepository.QueryNotFoundAsync(appEntity.Id, schemaId, contentIds.ToList());
}
}
}

81
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs

@ -27,6 +27,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentQueryService : IContentQueryService
{
private static readonly Status[] StatusAll = { Status.Archived, Status.Draft, Status.Published };
private static readonly Status[] StatusArchived = { Status.Archived };
private static readonly Status[] StatusPublished = { Status.Published };
private static readonly Status[] StatusDraftOrPublished = { Status.Draft, Status.Published };
private readonly IContentRepository contentRepository;
private readonly IContentVersionLoader contentVersionLoader;
private readonly IAppProvider appProvider;
@ -67,22 +71,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
var schema = await GetSchemaAsync(app, schemaIdOrName);
var isFrontendClient = IsFrontendClient(user);
var isVersioned = version > EtagVersion.Empty;
var isFrontend = IsFrontendClient(user);
var parsedStatus = isFrontend ? StatusAll : StatusPublished;
var content =
isVersioned ?
await FindContentByVersionAsync(id, version) :
await FindContentAsync(app, id, schema);
await FindContentAsync(app, id, parsedStatus, schema);
if (content == null || (content.Status != Status.Published && !isFrontendClient) || content.SchemaId.Id != schema.Id)
if (content == null || (content.Status != Status.Published && !isFrontend) || content.SchemaId.Id != schema.Id)
{
throw new DomainObjectNotFoundException(id.ToString(), typeof(ISchemaEntity));
}
content = TransformContent(app, schema, user, Enumerable.Repeat(content, 1), isVersioned, isFrontendClient).FirstOrDefault();
return content;
return TransformContent(app, schema, user, content, isFrontend, isVersioned);
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, string query)
@ -93,14 +97,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
var schema = await GetSchemaAsync(app, schemaIdOrName);
var isFrontendClient = IsFrontendClient(user);
var isFrontend = IsFrontendClient(user);
var parsedQuery = ParseQuery(app, query, schema);
var parsedStatus = ParseStatus(isFrontendClient, archived);
var parsedStatus = ParseStatus(isFrontend, archived);
var contents = await contentRepository.QueryAsync(app, schema, parsedStatus.ToArray(), parsedQuery);
var contents = await contentRepository.QueryAsync(app, schema, parsedStatus, parsedQuery);
return TransformContents(app, schema, user, contents, false, isFrontendClient);
return TransformContents(app, schema, user, contents, false, isFrontend);
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, HashSet<Guid> ids)
@ -112,13 +116,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
var schema = await GetSchemaAsync(app, schemaIdOrName);
var isFrontendClient = IsFrontendClient(user);
var isFrontend = IsFrontendClient(user);
var parsedStatus = ParseStatus(isFrontendClient, archived);
var parsedStatus = ParseStatus(isFrontend, archived);
var contents = await contentRepository.QueryAsync(app, schema, parsedStatus.ToArray(), ids);
var contents = await contentRepository.QueryAsync(app, schema, parsedStatus, ids);
return TransformContents(app, schema, user, contents, false, isFrontendClient);
return TransformContents(app, schema, user, contents, false, isFrontend);
}
private IContentEntity TransformContent(IAppEntity app, ISchemaEntity schema, ClaimsPrincipal user,
IContentEntity content,
bool isFrontend,
bool isVersioned)
{
return TransformContents(app, schema, user, Enumerable.Repeat(content, 1), isVersioned, isFrontend).FirstOrDefault();
}
private IResultList<IContentEntity> TransformContents(IAppEntity app, ISchemaEntity schema, ClaimsPrincipal user,
@ -126,12 +138,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
bool isTypeChecking,
bool isFrontendClient)
{
var transformed = TransformContent(app, schema, user, contents, isTypeChecking, isFrontendClient);
var transformed = TransformContents(app, schema, user, (IEnumerable<IContentEntity>)contents, isTypeChecking, isFrontendClient);
return ResultList.Create(transformed, contents.Total);
}
private IEnumerable<IContentEntity> TransformContent(IAppEntity app, ISchemaEntity schema, ClaimsPrincipal user,
private IEnumerable<IContentEntity> TransformContents(IAppEntity app, ISchemaEntity schema, ClaimsPrincipal user,
IEnumerable<IContentEntity> contents,
bool isTypeChecking,
bool isFrontendClient)
@ -144,12 +156,20 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var result = SimpleMapper.Map(content, new ContentEntity());
if (!isFrontendClient && isScripting)
if (result.Data != null)
{
result.Data = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText);
if (!isFrontendClient && isScripting)
{
result.Data = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText);
}
result.Data = result.Data.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking);
}
result.Data = result.Data.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking);
if (result.DataDraft != null)
{
result.DataDraft = result.DataDraft.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking);
}
yield return result;
}
@ -193,28 +213,19 @@ namespace Squidex.Domain.Apps.Entities.Contents
return schema;
}
private static List<Status> ParseStatus(bool isFrontendClient, bool archived)
private static Status[] ParseStatus(bool isFrontendClient, bool archived)
{
var status = new List<Status>();
if (isFrontendClient)
{
if (archived)
{
status.Add(Status.Archived);
}
else
{
status.Add(Status.Draft);
status.Add(Status.Published);
return StatusArchived;
}
}
else
{
status.Add(Status.Published);
return StatusDraftOrPublished;
}
return status;
return StatusPublished;
}
private Task<IContentEntity> FindContentByVersionAsync(Guid id, long version)
@ -222,9 +233,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
return contentVersionLoader.LoadAsync(id, version);
}
private Task<IContentEntity> FindContentAsync(IAppEntity app, Guid id, ISchemaEntity schema)
private Task<IContentEntity> FindContentAsync(IAppEntity app, Guid id, Status[] status, ISchemaEntity schema)
{
return contentRepository.FindContentAsync(app, schema, id);
return contentRepository.FindContentAsync(app, schema, status, id);
}
private static bool IsFrontendClient(ClaimsPrincipal user)

38
src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerGrain.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using System.Threading;
using System.Threading.Tasks;
using NodaTime;
using Orleans;
@ -14,6 +15,7 @@ using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
@ -23,23 +25,30 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly Lazy<IContentRepository> contentRepository;
private readonly Lazy<ICommandBus> commandBus;
private readonly IClock clock;
private readonly ISemanticLog log;
private TaskScheduler scheduler;
public ContentSchedulerGrain(
Lazy<IContentRepository> contentRepository,
Lazy<ICommandBus> commandBus,
IClock clock)
IClock clock,
ISemanticLog log)
{
Guard.NotNull(contentRepository, nameof(contentRepository));
Guard.NotNull(commandBus, nameof(commandBus));
Guard.NotNull(clock, nameof(clock));
Guard.NotNull(log, nameof(log));
this.contentRepository = contentRepository;
this.commandBus = commandBus;
this.clock = clock;
this.log = log;
}
public override Task OnActivateAsync()
{
scheduler = TaskScheduler.Current;
DelayDeactivation(TimeSpan.FromDays(1));
RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(10));
@ -59,9 +68,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
return contentRepository.Value.QueryScheduledWithoutDataAsync(now, content =>
{
var command = new ChangeContentStatus { ContentId = content.Id, Status = content.ScheduledTo.Value, Actor = content.ScheduledBy };
return Dispatch(async () =>
{
try
{
var job = content.ScheduleJob;
if (job != null)
{
var command = new ChangeContentStatus { ContentId = content.Id, Status = job.Status, Actor = job.ScheduledBy, JobId = job.Id };
return commandBus.Value.PublishAsync(command);
await commandBus.Value.PublishAsync(command);
}
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "ChangeStatusScheduled")
.WriteProperty("status", "Failed")
.WriteProperty("contentId", content.Id.ToString()));
}
});
});
}
@ -69,5 +96,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
return TaskHelper.Done;
}
private Task Dispatch(Func<Task> task)
{
return Task<Task>.Factory.StartNew(() => task(), CancellationToken.None, TaskCreationOptions.None, scheduler ?? TaskScheduler.Default).Unwrap();
}
}
}

22
src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -53,13 +53,31 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
});
}
public static void CanChangeContentStatus(Status status, ChangeContentStatus command)
public static void CanDiscardChanges(bool isPending, DiscardChanges command)
{
Guard.NotNull(command, nameof(command));
Validate.It(() => "Cannot discard pending changes.", error =>
{
if (!isPending)
{
error(new ValidationError("The content has no pending changes."));
}
});
}
public static void CanChangeContentStatus(bool isPending, Status status, ChangeContentStatus command)
{
Guard.NotNull(command, nameof(command));
Validate.It(() => "Cannot change status.", error =>
{
if (!StatusFlow.Exists(command.Status) || !StatusFlow.CanChange(status, command.Status))
var isAllowedPendingUpdate =
status == command.Status &&
status == Status.Published &&
isPending;
if (!StatusFlow.Exists(command.Status) || (!StatusFlow.CanChange(status, command.Status) && !isAllowedPendingUpdate))
{
error(new ValidationError($"Content cannot be changed from status {status} to {command.Status}.", nameof(command.Status)));
}

9
src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs

@ -7,7 +7,6 @@
// ==========================================================================
using System;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
@ -25,12 +24,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
Status Status { get; }
Status? ScheduledTo { get; }
ScheduleJob ScheduleJob { get; }
Instant? ScheduledAt { get; }
NamedContentData Data { get; }
RefToken ScheduledBy { get; }
NamedContentData DataDraft { get; }
NamedContentData Data { get; }
bool IsPending { get; }
}
}

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

@ -25,7 +25,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids);
Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id);
Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id);
Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback);
}

33
src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ScheduleJob
{
public Guid Id { get; }
public Status Status { get; }
public RefToken ScheduledBy { get; }
public Instant DueTime { get; }
public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime)
{
Id = id;
ScheduledBy = scheduledBy;
Status = status;
DueTime = dueTime;
}
}
}

66
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -7,13 +7,13 @@
using System;
using Newtonsoft.Json;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Contents.State
{
@ -29,48 +29,80 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
public NamedContentData Data { get; set; }
[JsonProperty]
public Status Status { get; set; }
public NamedContentData DataDraft { get; set; }
[JsonProperty]
public Status? ScheduledTo { get; set; }
public Status Status { get; set; }
[JsonProperty]
public Instant? ScheduledAt { get; set; }
public ScheduleJob ScheduleJob { get; set; }
[JsonProperty]
public RefToken ScheduledBy { get; set; }
public bool IsPending { get; set; }
[JsonProperty]
public bool IsDeleted { get; set; }
protected void On(ContentCreated @event)
{
SchemaId = @event.SchemaId;
Data = @event.Data;
SimpleMapper.Map(@event, this);
AppId = @event.AppId;
DataDraft = @event.Data;
}
protected void On(ContentUpdated @event)
{
Data = @event.Data;
DataDraft = @event.Data;
if (Data != null)
{
Data = @event.Data;
}
}
protected void On(ContentStatusScheduled @event)
protected void On(ContentUpdateProposed @event)
{
DataDraft = @event.Data;
IsPending = true;
}
protected void On(ContentChangesDiscarded @event)
{
ScheduledAt = @event.DueTime;
ScheduledBy = @event.Actor;
ScheduledTo = @event.Status;
DataDraft = Data;
IsPending = false;
}
protected void On(ContentChangesPublished @event)
{
ScheduleJob = null;
Data = DataDraft;
IsPending = false;
}
protected void On(ContentStatusChanged @event)
{
ScheduleJob = null;
Status = @event.Status;
ScheduledAt = null;
ScheduledBy = null;
ScheduledTo = null;
if (@event.Status == Status.Published)
{
Data = DataDraft;
}
}
protected void On(ContentSchedulingCancelled @event)
{
ScheduleJob = null;
}
protected void On(ContentStatusScheduled @event)
{
ScheduleJob = new ScheduleJob(Guid.NewGuid(), @event.Status, @event.Actor, @event.DueTime);
}
protected void On(ContentDeleted @event)

30
src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs

@ -20,49 +20,49 @@ namespace Squidex.Domain.Apps.Entities.Schemas
: base(typeNameRegistry)
{
AddEventMessage<SchemaCreated>(
"created schema {[Name]}");
"created schema {[Name]}.");
AddEventMessage<SchemaUpdated>(
"updated schema {[Name]}");
"updated schema {[Name]}.");
AddEventMessage<SchemaDeleted>(
"deleted schema {[Name]}");
"deleted schema {[Name]}.");
AddEventMessage<SchemaPublished>(
"published schema {[Name]}");
"published schema {[Name]}.");
AddEventMessage<SchemaUnpublished>(
"unpublished schema {[Name]}");
"unpublished schema {[Name]}.");
AddEventMessage<SchemaFieldsReordered>(
"reordered fields of schema {[Name]}");
"reordered fields of schema {[Name]}.");
AddEventMessage<FieldAdded>(
"added field {[Field]} to schema {[Name]}");
"added field {[Field]} to schema {[Name]}.");
AddEventMessage<FieldDeleted>(
"deleted field {[Field]} from schema {[Name]}");
"deleted field {[Field]} from schema {[Name]}.");
AddEventMessage<FieldLocked>(
"has locked field {[Field]} of schema {[Name]}");
"has locked field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldHidden>(
"has hidden field {[Field]} of schema {[Name]}");
"has hidden field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldShown>(
"has shown field {[Field]} of schema {[Name]}");
"has shown field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldDisabled>(
"disabled field {[Field]} of schema {[Name]}");
"disabled field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldEnabled>(
"disabled field {[Field]} of schema {[Name]}");
"disabled field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldUpdated>(
"has updated field {[Field]} of schema {[Name]}");
"has updated field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldDeleted>(
"deleted field {[Field]} of schema {[Name]}");
"deleted field {[Field]} of schema {[Name]}.");
}
protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event)

2
src/Squidex.Domain.Apps.Entities/SquidexCommand.cs

@ -17,6 +17,6 @@ namespace Squidex.Domain.Apps.Entities
public ClaimsPrincipal User { get; set; }
public long ExpectedVersion { get; set; }
public long ExpectedVersion { get; set; } = EtagVersion.Any;
}
}

16
src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Contents
{
[EventType(nameof(ContentChangesDiscarded))]
public sealed class ContentChangesDiscarded : ContentEvent
{
}
}

16
src/Squidex.Domain.Apps.Events/Contents/ContentChangesPublished.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Contents
{
[EventType(nameof(ContentChangesPublished))]
public sealed class ContentChangesPublished : ContentEvent
{
}
}

16
src/Squidex.Domain.Apps.Events/Contents/ContentSchedulingCancelled.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Contents
{
[EventType(nameof(ContentSchedulingCancelled))]
public sealed class ContentSchedulingCancelled : ContentEvent
{
}
}

18
src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Contents
{
[EventType(nameof(ContentUpdateProposed))]
public sealed class ContentUpdateProposed : ContentEvent
{
public NamedContentData Data { get; set; }
}
}

2
src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs

@ -36,7 +36,7 @@ namespace Squidex.Domain.Users.MongoDb
public void StoreElement(XElement element, string friendlyName)
{
Collection.UpdateOne(Filter.Eq(x => x.Id, friendlyName),
Collection.UpdateOne(x => x.Id == friendlyName,
Update.Set(x => x.Xml, element.ToString()),
Upsert);
}

21
src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs

@ -74,6 +74,27 @@ namespace Squidex.Infrastructure.MongoDb
return find.Project<BsonDocument>(Builders<TDocument>.Projection.Include(include1).Include(include2).Include(include3));
}
public static IFindFluent<TDocument, TDocument> Not<TDocument>(this IFindFluent<TDocument, TDocument> find,
Expression<Func<TDocument, object>> exclude)
{
return find.Project<TDocument>(Builders<TDocument>.Projection.Exclude(exclude));
}
public static IFindFluent<TDocument, TDocument> Not<TDocument>(this IFindFluent<TDocument, TDocument> find,
Expression<Func<TDocument, object>> exclude1,
Expression<Func<TDocument, object>> exclude2)
{
return find.Project<TDocument>(Builders<TDocument>.Projection.Exclude(exclude1).Exclude(exclude2));
}
public static IFindFluent<TDocument, TDocument> Not<TDocument>(this IFindFluent<TDocument, TDocument> find,
Expression<Func<TDocument, object>> exclude1,
Expression<Func<TDocument, object>> exclude2,
Expression<Func<TDocument, object>> exclude3)
{
return find.Project<TDocument>(Builders<TDocument>.Projection.Exclude(exclude1).Exclude(exclude2).Exclude(exclude3));
}
public static async Task UpsertVersionedAsync<T, TKey>(this IMongoCollection<T> collection, TKey key, long oldVersion, long newVersion, Func<UpdateDefinition<T>, UpdateDefinition<T>> updater) where T : IVersionedEntity<TKey>
{
try

91
src/Squidex.Infrastructure/Caching/AsyncLocalCache.cs

@ -0,0 +1,91 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Concurrent;
using System.Threading;
namespace Squidex.Infrastructure.Caching
{
public sealed class AsyncLocalCache : ILocalCache
{
private static readonly AsyncLocal<ConcurrentDictionary<object, object>> Cache = new AsyncLocal<ConcurrentDictionary<object, object>>();
private static readonly AsyncLocalCleaner Cleaner;
private sealed class AsyncLocalCleaner : IDisposable
{
private readonly AsyncLocal<ConcurrentDictionary<object, object>> cache;
public AsyncLocalCleaner(AsyncLocal<ConcurrentDictionary<object, object>> cache)
{
this.cache = cache;
}
public void Dispose()
{
cache.Value = null;
}
}
static AsyncLocalCache()
{
Cleaner = new AsyncLocalCleaner(Cache);
}
public IDisposable StartContext()
{
Cache.Value = new ConcurrentDictionary<object, object>();
return Cleaner;
}
public void Add(object key, object value)
{
var cacheKey = GetCacheKey(key);
var cache = Cache.Value;
if (cache != null)
{
cache[cacheKey] = value;
}
}
public void Remove(object key)
{
var cacheKey = GetCacheKey(key);
var cache = Cache.Value;
if (cache != null)
{
cache.TryRemove(cacheKey, out var value);
}
}
public bool TryGetValue(object key, out object value)
{
var cacheKey = GetCacheKey(key);
var cache = Cache.Value;
if (cache != null)
{
return cache.TryGetValue(cacheKey, out value);
}
value = null;
return false;
}
private static string GetCacheKey(object key)
{
return $"CACHE_{key}";
}
}
}

68
src/Squidex.Infrastructure/Caching/HttpRequestCache.cs

@ -1,68 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Http;
namespace Squidex.Infrastructure.Caching
{
public sealed class HttpRequestCache : IRequestCache
{
private readonly IHttpContextAccessor httpContextAccessor;
public HttpRequestCache(IHttpContextAccessor httpContextAccessor)
{
Guard.NotNull(httpContextAccessor, nameof(httpContextAccessor));
this.httpContextAccessor = httpContextAccessor;
}
public void Add(object key, object value)
{
var cacheKey = GetCacheKey(key);
var items = httpContextAccessor.HttpContext?.Items;
if (items != null)
{
items[cacheKey] = value;
}
}
public void Remove(object key)
{
var cacheKey = GetCacheKey(key);
var items = httpContextAccessor.HttpContext?.Items;
if (items != null)
{
items?.Remove(cacheKey);
}
}
public bool TryGetValue(object key, out object value)
{
var cacheKey = GetCacheKey(key);
var items = httpContextAccessor.HttpContext?.Items;
if (items != null)
{
return items.TryGetValue(cacheKey, out value);
}
value = null;
return false;
}
private static string GetCacheKey(object key)
{
return $"CACHE_{key}";
}
}
}

6
src/Squidex.Infrastructure/Caching/IRequestCache.cs → src/Squidex.Infrastructure/Caching/ILocalCache.cs

@ -5,10 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Infrastructure.Caching
{
public interface IRequestCache
public interface ILocalCache
{
IDisposable StartContext();
void Add(object key, object value);
void Remove(object key);

4
src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs

@ -12,7 +12,7 @@ namespace Squidex.Infrastructure.Caching
{
public static class RequestCacheExtensions
{
public static async Task<T> GetOrCreateAsync<T>(this IRequestCache cache, object key, Func<Task<T>> task)
public static async Task<T> GetOrCreateAsync<T>(this ILocalCache cache, object key, Func<Task<T>> task)
{
if (cache.TryGetValue(key, out var value) && value is T typedValue)
{
@ -26,7 +26,7 @@ namespace Squidex.Infrastructure.Caching
return typedValue;
}
public static T GetOrCreate<T>(this IRequestCache cache, object key, Func<T> task)
public static T GetOrCreate<T>(this ILocalCache cache, object key, Func<T> task)
{
if (cache.TryGetValue(key, out var value) && value is T typedValue)
{

7
src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs

@ -105,13 +105,6 @@ namespace Squidex.Infrastructure.Commands
{
}
public Task WriteSnapshotAsync()
{
snapshot.Version = persistence.Version;
return persistence.WriteSnapshotAsync(snapshot);
}
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler, false);

2
src/Squidex.Infrastructure/Commands/IDomainObjectGrain.cs

@ -13,8 +13,6 @@ namespace Squidex.Infrastructure.Commands
{
public interface IDomainObjectGrain : IGrainWithGuidKey
{
Task WriteSnapshotAsync();
Task<J<object>> ExecuteAsync(J<IAggregateCommand> command);
}
}

35
src/Squidex.Infrastructure/Commands/ReadonlyCommandMiddleware.cs

@ -0,0 +1,35 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
namespace Squidex.Infrastructure.Commands
{
public sealed class ReadonlyCommandMiddleware : ICommandMiddleware
{
private readonly IOptions<ReadonlyOptions> options;
public ReadonlyCommandMiddleware(IOptions<ReadonlyOptions> options)
{
Guard.NotNull(options, nameof(options));
this.options = options;
}
public Task HandleAsync(CommandContext context, Func<Task> next)
{
if (options.Value.IsReadonly)
{
throw new DomainException("Application is in readonly mode at the moment.");
}
return next();
}
}
}

14
src/Squidex.Infrastructure/Commands/ReadonlyOptions.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.Commands
{
public sealed class ReadonlyOptions
{
public bool IsReadonly { get; set; }
}
}

3
src/Squidex.Infrastructure/Orleans/J.cs

@ -6,11 +6,14 @@
// ==========================================================================
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace Squidex.Infrastructure.Orleans
{
public static class J
{
internal static readonly JsonSerializer DefaultSerializer = JsonSerializer.CreateDefault();
public static J<T> AsJ<T>(this T value)
{
return new J<T>(value);

6
src/Squidex.Infrastructure/Orleans/J{T}.cs

@ -18,8 +18,6 @@ namespace Squidex.Infrastructure.Orleans
{
public struct J<T>
{
private static readonly JsonSerializer DefaultSerializer = JsonSerializer.CreateDefault();
public T Value { get; }
[JsonConstructor]
@ -100,11 +98,11 @@ namespace Squidex.Infrastructure.Orleans
{
try
{
return context?.ServiceProvider?.GetService<JsonSerializer>() ?? DefaultSerializer;
return context?.ServiceProvider?.GetService<JsonSerializer>() ?? J.DefaultSerializer;
}
catch
{
return DefaultSerializer;
return J.DefaultSerializer;
}
}
}

33
src/Squidex.Infrastructure/Orleans/LocalCacheFilter.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure.Caching;
namespace Squidex.Infrastructure.Orleans
{
public sealed class LocalCacheFilter : IIncomingGrainCallFilter
{
private readonly ILocalCache localCache;
public LocalCacheFilter(ILocalCache localCache)
{
Guard.NotNull(localCache, nameof(localCache));
this.localCache = localCache;
}
public async Task Invoke(IIncomingGrainCallContext context)
{
using (localCache.StartContext())
{
await context.Invoke();
}
}
}
}

1
src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -8,7 +8,6 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="2.0.1" />
<PackageReference Include="Microsoft.Orleans.Core" Version="2.0.0" />

2
src/Squidex.Infrastructure/States/IStore.cs

@ -18,5 +18,7 @@ namespace Squidex.Infrastructure.States
IPersistence<TState> WithSnapshots<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot);
IPersistence<TState> WithSnapshotsAndEventSourcing<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent);
Task ClearSnapshotsAsync<TState>();
}
}

7
src/Squidex.Infrastructure/States/Store.cs

@ -57,5 +57,12 @@ namespace Squidex.Infrastructure.States
return new Persistence<TState, TKey>(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, mode, applySnapshot, applyEvent);
}
public Task ClearSnapshotsAsync<TState>()
{
var snapshotStore = (ISnapshotStore<TState, TKey>)services.GetService(typeof(ISnapshotStore<TState, TKey>));
return snapshotStore.ClearAsync();
}
}
}

3
src/Squidex/AppServices.cs

@ -13,6 +13,7 @@ using Squidex.Config;
using Squidex.Config.Authentication;
using Squidex.Config.Domain;
using Squidex.Config.Web;
using Squidex.Infrastructure.Commands;
namespace Squidex
{
@ -40,6 +41,8 @@ namespace Squidex
services.AddMySwaggerSettings();
services.AddMySubscriptionServices(config);
services.Configure<ReadonlyOptions>(
config.GetSection("mode"));
services.Configure<MyUrlsOptions>(
config.GetSection("urls"));
services.Configure<MyIdentityOptions>(

0
src/Squidex/Areas/Api/Controllers/Content/ContentSwaggerController.cs → src/Squidex/Areas/Api/Controllers/Contents/ContentSwaggerController.cs

45
src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs → src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -119,7 +119,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var response = new ContentsDto
{
Total = result.Total,
Items = result.Take(200).Select(item => SimpleMapper.Map(item, new ContentDto { Data = item.Data })).ToArray()
Items = result.Take(200).Select(ContentDto.FromContent).ToArray()
};
Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id));
@ -148,7 +148,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
var content = await contentQuery.FindContentAsync(App, name, User, id);
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data });
var response = ContentDto.FromContent(content);
Response.Headers["ETag"] = content.Version.ToString();
Response.Headers["Surrogate-Key"] = content.Id.ToString();
@ -179,7 +179,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
var content = await contentQuery.FindContentAsync(App, name, User, id, version);
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data });
var response = ContentDto.FromContent(content);
Response.Headers["ETag"] = content.Version.ToString();
Response.Headers["Surrogate-Key"] = content.Id.ToString();
@ -227,6 +227,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to update.</param>
/// <param name="request">The full data for the content item.</param>
/// <param name="asDraft">Indicates whether the update is a proposal.</param>
/// <returns>
/// 200 => Content updated.
/// 404 => Content, schema or app not found.
@ -239,11 +240,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut]
[Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request)
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() };
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>();
@ -259,6 +260,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to patch.</param>
/// <param name="request">The patch for the content item.</param>
/// <param name="asDraft">Indicates whether the patch is a proposal.</param>
/// <returns>
/// 200 => Content patched.
/// 404 => Content, schema or app not found.
@ -271,11 +273,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPatch]
[Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request)
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned() };
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>();
@ -404,6 +406,35 @@ namespace Squidex.Areas.Api.Controllers.Contents
return NoContent();
}
/// <summary>
/// Discard changes of a content item.
/// </summary>
/// <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 discard changes.</param>
/// <returns>
/// 204 => Content restored.
/// 404 => Content, schema or app not found.
/// 400 => Content was not archived.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs
/// </remarks>
[MustBeAppEditor]
[HttpPut]
[Route("content/{app}/{name}/{id}/discard/")]
[ApiCosts(1)]
public async Task<IActionResult> DiscardChanges(string app, string name, Guid id)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new DiscardChanges { ContentId = id };
await CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Delete a content item.
/// </summary>

0
src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs → src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs

0
src/Squidex/Areas/Api/Controllers/Content/Generator/SchemasSwaggerGenerator.cs → src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs

29
src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs → src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -9,9 +9,11 @@ using System;
using System.ComponentModel.DataAnnotations;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Contents.Models
{
@ -41,19 +43,19 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
public object Data { get; set; }
/// <summary>
/// The scheduled status.
/// The pending changes of the content item.
/// </summary>
public Status? ScheduledTo { get; set; }
public object DataDraft { get; set; }
/// <summary>
/// The scheduled date.
/// Indicates if the draft data is pending.
/// </summary>
public Instant? ScheduledAt { get; set; }
public bool IsPending { get; set; }
/// <summary>
/// The user that has scheduled the content.
/// The scheduled status.
/// </summary>
public RefToken ScheduledBy { get; set; }
public ScheduleJobDto ScheduleJob { get; set; }
/// <summary>
/// The date and time when the content item has been created.
@ -93,5 +95,20 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
return response;
}
public static ContentDto FromContent(IContentEntity content)
{
var response = SimpleMapper.Map(content, new ContentDto());
response.Data = content.Data;
response.DataDraft = content.DataDraft;
if (content.ScheduleJob != null)
{
response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto());
}
return response;
}
}
}

0
src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs → src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

37
src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs

@ -0,0 +1,37 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Areas.Api.Controllers.Contents.Models
{
public sealed class ScheduleJobDto
{
/// <summary>
/// The id of the schedule job.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The new status.
/// </summary>
public Status Status { get; set; }
/// <summary>
/// The user who schedule the content.
/// </summary>
public RefToken ScheduledBy { get; set; }
/// <summary>
/// The target date and time when the content should be scheduled.
/// </summary>
public Instant DueTime { get; set; }
}
}

6
src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs

@ -27,6 +27,12 @@ namespace Squidex.Areas.Api.Controllers.History.Models
[Required]
public string Actor { get; set; }
/// <summary>
/// The type of the event.
/// </summary>
[Required]
public string EventType { get; set; }
/// <summary>
/// Gets a unique id for the event.
/// </summary>

6
src/Squidex/Config/Domain/EntitiesServices.cs

@ -83,6 +83,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<InMemoryCommandBus>()
.As<ICommandBus>();
services.AddSingletonAs<ReadonlyCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<ETagCommandMiddleware>()
.As<ICommandMiddleware>();
@ -172,9 +175,6 @@ namespace Squidex.Config.Domain
services.AddTransientAs<ConvertEventStoreAppId>()
.As<IMigration>();
services.AddTransientAs<DeleteArchiveCollectionSetup>()
.As<IMigration>();
services.AddTransientAs<PopulateGrainIndexes>()
.As<IMigration>();

4
src/Squidex/Config/Domain/InfrastructureServices.cs

@ -32,8 +32,8 @@ namespace Squidex.Config.Domain
services.AddSingletonAs(c => new CachingUsageTracker(c.GetRequiredService<BackgroundUsageTracker>(), c.GetRequiredService<IMemoryCache>()))
.As<IUsageTracker>();
services.AddSingletonAs<HttpRequestCache>()
.As<IRequestCache>();
services.AddSingletonAs<AsyncLocalCache>()
.As<ILocalCache>();
services.AddSingletonAs<HttpContextAccessor>()
.As<IHttpContextAccessor>();

4
src/Squidex/Config/Domain/StoreServices.cs

@ -112,10 +112,10 @@ namespace Squidex.Config.Domain
.As<IEventConsumer>()
.As<IInitializable>();
services.AddTransientAs(c => new DeleteArchiveCollectionSetup(mongoContentDatabase))
services.AddTransientAs<ConvertOldSnapshotStores>()
.As<IMigration>();
services.AddTransientAs<ConvertOldSnapshotStores>()
services.AddTransientAs(c => new DeleteContentCollections(mongoContentDatabase))
.As<IMigration>();
}
});

1
src/Squidex/Config/Orleans/SiloWrapper.cs

@ -55,6 +55,7 @@ namespace Squidex.Config.Orleans
{
var hostBuilder = new SiloHostBuilder()
.UseDashboard(options => options.HostSelf = false)
.AddIncomingGrainCallFilter<LocalCacheFilter>()
.AddStartupTask<Bootstrap<IContentSchedulerGrain>>()
.AddStartupTask<Bootstrap<IEventConsumerManagerGrain>>()
.AddStartupTask<Bootstrap<IRuleDequeuerGrain>>()

7
src/Squidex/Config/Web/WebExtensions.cs

@ -13,6 +13,13 @@ namespace Squidex.Config.Web
{
public static class WebExtensions
{
public static IApplicationBuilder UseMyLocalCache(this IApplicationBuilder app)
{
app.UseMiddleware<LocalCacheMiddleware>();
return app;
}
public static IApplicationBuilder UseMyTracking(this IApplicationBuilder app)
{
app.UseMiddleware<RequestLogPerformanceMiddleware>();

9
src/Squidex/Config/Web/WebServices.cs

@ -24,6 +24,15 @@ namespace Squidex.Config.Web
services.AddSingletonAs<ApiCostsFilter>()
.AsSelf();
services.AddSingletonAs<EnforceHttpsMiddleware>()
.AsSelf();
services.AddSingletonAs<LocalCacheMiddleware>()
.AsSelf();
services.AddSingletonAs<RequestLogPerformanceMiddleware>()
.AsSelf();
services.AddMvc().AddMySerializers();
services.AddCors();
services.AddRouting();

2
src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs

@ -27,6 +27,8 @@ namespace Squidex.Pipeline.CommandMiddlewares
{
if (httpContextAccessor.HttpContext == null)
{
await next();
return;
}

8
src/Squidex/Pipeline/EnforceHttpsMiddleware.cs

@ -13,18 +13,16 @@ using Squidex.Config;
namespace Squidex.Pipeline
{
public sealed class EnforceHttpsMiddleware
public sealed class EnforceHttpsMiddleware : IMiddleware
{
private readonly RequestDelegate next;
private readonly IOptions<MyUrlsOptions> urls;
public EnforceHttpsMiddleware(RequestDelegate next, IOptions<MyUrlsOptions> urls)
public EnforceHttpsMiddleware(IOptions<MyUrlsOptions> urls)
{
this.next = next;
this.urls = urls;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!urls.Value.EnforceHTTPS)
{

35
src/Squidex/Pipeline/LocalCacheMiddleware.cs

@ -0,0 +1,35 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
namespace Squidex.Pipeline
{
public sealed class LocalCacheMiddleware : IMiddleware
{
private readonly ILocalCache localCache;
public LocalCacheMiddleware(ILocalCache localCache)
{
Guard.NotNull(localCache, nameof(localCache));
this.localCache = localCache;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
using (localCache.StartContext())
{
await next(context);
}
}
}
}

10
src/Squidex/Pipeline/RequestLogPerformanceMiddleware.cs

@ -8,25 +8,23 @@
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Infrastructure.Log;
namespace Squidex.Pipeline
{
public sealed class RequestLogPerformanceMiddleware : ActionFilterAttribute
public sealed class RequestLogPerformanceMiddleware : IMiddleware
{
private readonly RequestLogProfilerSessionProvider requestSession;
private readonly RequestDelegate next;
private readonly ISemanticLog log;
public RequestLogPerformanceMiddleware(RequestLogProfilerSessionProvider requestSession, RequestDelegate next, ISemanticLog log)
public RequestLogPerformanceMiddleware(RequestLogProfilerSessionProvider requestSession, ISemanticLog log)
{
this.requestSession = requestSession;
this.next = next;
this.log = log;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var stopWatch = Stopwatch.StartNew();

1
src/Squidex/WebStartup.cs

@ -46,6 +46,7 @@ namespace Squidex
app.ApplicationServices.RunMigrate();
app.ApplicationServices.RunRunnables();
app.UseMyLocalCache();
app.UseMyCors();
app.UseMyForwardingRules();
app.UseMyTracking();

24
src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss

@ -1,19 +1,21 @@
@import '_vars';
@import '_mixins';
@import '~graphiql/graphiql';
:host /deep/ {
@import '~graphiql/graphiql';
.graphiql-container {
& {
@include absolute(0, 0, 0, 0);
}
.graphiql-container {
& {
@include absolute(0, 0, 0, 0);
}
& * {
box-sizing: content-box;
}
& * {
box-sizing: content-box;
}
// sass-lint:disable class-name-format
& .editorWrap {
overflow: hidden;
// sass-lint:disable class-name-format
& .editorWrap {
overflow: hidden;
}
}
}

22
src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, ElementRef, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Observable } from 'rxjs';
import * as React from 'react';
@ -13,19 +13,12 @@ import * as ReactDOM from 'react-dom';
const GraphiQL = require('graphiql');
/* tslint:disable:use-view-encapsulation */
import {
AppsState,
GraphQlService,
LocalStoreService
} from '@app/shared';
import { AppsState, GraphQlService } from '@app/shared';
@Component({
selector: 'sqx-graphql-page',
styleUrls: ['./graphql-page.component.scss'],
templateUrl: './graphql-page.component.html',
encapsulation: ViewEncapsulation.None
templateUrl: './graphql-page.component.html'
})
export class GraphQLPageComponent implements OnInit {
@ViewChild('graphiQLContainer')
@ -33,8 +26,7 @@ export class GraphQLPageComponent implements OnInit {
constructor(
public readonly appsState: AppsState,
private readonly graphQlService: GraphQlService,
private readonly localStoreService: LocalStoreService
private readonly graphQlService: GraphQlService
) {
}
@ -43,11 +35,7 @@ export class GraphQLPageComponent implements OnInit {
React.createElement(GraphiQL, {
fetcher: (params: any) => {
return this.request(params);
},
onEditQuery: (query: string) => {
this.localStoreService.set('graphiQlQuery', query);
},
query: this.localStoreService.get('graphiQlQuery')
}
}),
this.graphiQLContainer.nativeElement
);

2
src/Squidex/app/features/content/declarations.ts

@ -14,5 +14,7 @@ export * from './pages/schemas/schemas-page.component';
export * from './shared/assets-editor.component';
export * from './shared/content-item.component';
export * from './shared/content-status.component';
export * from './shared/contents-selector.component';
export * from './shared/due-time-selector.component';
export * from './shared/references-editor.component';

4
src/Squidex/app/features/content/module.ts

@ -27,6 +27,8 @@ import {
ContentPageComponent,
ContentsPageComponent,
ContentsSelectorComponent,
ContentStatusComponent,
DueTimeSelectorComponent,
ReferencesEditorComponent,
SchemasPageComponent,
SearchFormComponent
@ -89,8 +91,10 @@ const routes: Routes = [
ContentHistoryComponent,
ContentItemComponent,
ContentPageComponent,
ContentStatusComponent,
ContentsPageComponent,
ContentsSelectorComponent,
DueTimeSelectorComponent,
ReferencesEditorComponent,
SchemasPageComponent,
SearchFormComponent

60
src/Squidex/app/features/content/pages/content/content-page.component.html

@ -20,18 +20,70 @@
<ng-container menu>
<ng-container *ngIf="!content; else notNew">
<button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
<button type="button" class="btn btn-secondary" (click)="save()">
Save as Draft
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save and Publish
</button>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut>
</ng-container>
<ng-template #notNew>
<div class="dropdown dropdown-options" *ngIf="content">
<button type="button" class="btn btn-outline-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #optionsButton>
<sqx-content-status
[status]="content.status"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending"
showLabel="true">
</sqx-content-status>
</button>
<div class="dropdown-menu" *sqxModalView="dropdown" [sqxModalTarget]="optionsButton" @fade>
<ng-container *ngIf="content.isPending">
<a class="dropdown-item" (click)="discardChanges()">
Discard changes
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" (click)="publishChanges()">
Publish changes
</a>
</ng-container>
<a class="dropdown-item" (click)="publish()" *ngIf="content.status === 'Draft'">
Publish
</a>
<a class="dropdown-item" (click)="unpublish()" *ngIf="content.status === 'Published'">
Unpublish
</a>
<a class="dropdown-item" (click)="archive()" *ngIf="content.status !== 'Archived'">
Archive
</a>
<a class="dropdown-item" (click)="restore()" *ngIf="content.status === 'Archived'">
Restore
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item dropdown-item-delete"
(sqxConfirmClick)="delete()"
confirmTitle="Delete content"
confirmText="Do you really want to delete the content?">
Delete
</a>
</div>
</div>
<ng-container *ngIf="content.status !== 'Archived'">
<button type="button" class="btn btn-secondary" (click)="saveAsProposal()" *ngIf="content.status === 'Published'">
Save as Draft
</button>
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save
</button>
@ -73,4 +125,6 @@
</sqx-panel>
</form>
<router-outlet></router-outlet>
<router-outlet></router-outlet>
<sqx-due-time-selector #dueTimeSelector></sqx-due-time-selector>

92
src/Squidex/app/features/content/pages/content/content-page.component.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable, Subscription } from 'rxjs';
@ -19,18 +19,25 @@ import {
ContentsState,
DialogService,
EditContentForm,
fadeAnimation,
ImmutableArray,
LanguagesState,
MessageBus,
ModalView,
SchemaDetailsDto,
SchemasState,
Version
} from '@app/shared';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@Component({
selector: 'sqx-content-page',
styleUrls: ['./content-page.component.scss'],
templateUrl: './content-page.component.html'
templateUrl: './content-page.component.html',
animations: [
fadeAnimation
]
})
export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, OnInit {
private languagesSubscription: Subscription;
@ -44,9 +51,14 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
public contentVersion: Version | null;
public contentForm: EditContentForm;
public dropdown = new ModalView(false, true);
public language: AppLanguageDto;
public languages: ImmutableArray<AppLanguageDto>;
@ViewChild('dueTimeSelector')
public dueTimeSelector: DueTimeSelectorComponent;
constructor(
public readonly appsState: AppsState,
private readonly contentsState: ContentsState,
@ -87,7 +99,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
.subscribe(content => {
this.content = content;
this.loadContent(content.data);
this.loadContent(content.dataDraft);
});
this.contentVersionSelectedSubscription =
@ -106,14 +118,18 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
}
public saveAndPublish() {
this.saveContent(true);
this.saveContent(true, false);
}
public saveAsDraft() {
this.saveContent(false);
public saveAsProposal() {
this.saveContent(false, true);
}
private saveContent(publish: boolean) {
public save() {
this.saveContent(false, false);
}
private saveContent(publish: boolean, asProposal: boolean) {
if (this.content && this.content.status === 'Archived') {
return;
}
@ -122,12 +138,21 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (value) {
if (this.content) {
this.contentsState.update(this.content, value)
.subscribe(dto => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
if (asProposal) {
this.contentsState.proposeUpdate(this.content, value)
.subscribe(dto => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
} else {
this.contentsState.update(this.content, value)
.subscribe(dto => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
}
} else {
this.contentsState.create(value, publish)
.subscribe(dto => {
@ -149,6 +174,45 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
this.contentForm.loadData(data, this.content && this.content.status === 'Archived');
}
public discardChanges() {
this.contentsState.discardChanges(this.content).onErrorResumeNext().subscribe();
}
public publish() {
this.changeContentItems('Publish', 'Published');
}
public unpublish() {
this.changeContentItems('Unpublish', 'Draft');
}
public archive() {
this.changeContentItems('Archive', 'Archived');
}
public restore() {
this.changeContentItems('Restore', 'Draft');
}
public delete() {
this.contentsState.deleteMany([this.content]).onErrorResumeNext()
.subscribe(() => {
this.back();
});
}
public publishChanges() {
this.dueTimeSelector.selectDueTime('Publish')
.switchMap(d => this.contentsState.publishChanges(this.content, d)).onErrorResumeNext()
.subscribe();
}
private changeContentItems(action: string, status: string) {
this.dueTimeSelector.selectDueTime(action)
.switchMap(d => this.contentsState.changeStatus(this.content, action, status, d)).onErrorResumeNext()
.subscribe();
}
private loadVersion(version: Version) {
if (this.content) {
this.contentsState.loadVersion(this.content, version)
@ -168,7 +232,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (this.contentVersion) {
this.contentVersion = null;
this.loadContent(this.content.data);
this.loadContent(this.content.dataDraft);
}
}
}

31
src/Squidex/app/features/content/pages/contents/contents-page.component.html

@ -118,33 +118,4 @@
</ng-container>
</sqx-panel>
<ng-container *sqxModalView="dueTimeDialog;onRoot:true">
<sqx-modal-dialog (closed)="cancelStatusChange()">
<ng-container title>
{{dueTimeAction}} content item(s)
</ng-container>
<ng-container content>
<div class="form-check">
<input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Immediately" id="immediately">
<label class="form-check-label" for="immediately">
{{dueTimeAction}} content item(s) immediately.
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Scheduled" id="scheduled">
<label class="form-check-label" for="scheduled">
{{dueTimeAction}} content item(s) at a later point date and time.
</label>
</div>
<sqx-date-time-editor [disabled]="dueTimeMode === 'Immediately'" mode="DateTime" hideClear="true" [(ngModel)]="dueTime"></sqx-date-time-editor>
</ng-container>
<ng-container footer>
<button type="button" class="float-left btn btn-secondary" (click)="cancelStatusChange()">Cancel</button>
<button type="button" class="float-right btn btn-primary" [disabled]="dueTimeMode === 'Scheduled' && !dueTime" (click)="confirmStatusChange()" sqxFocusOnInit>Confirm</button>
</ng-container>
</sqx-modal-dialog>
</ng-container>
<sqx-due-time-selector #dueTimeSelector></sqx-due-time-selector>

56
src/Squidex/app/features/content/pages/contents/contents-page.component.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Subscription } from 'rxjs';
import {
@ -20,6 +20,8 @@ import {
SchemasState
} from '@app/shared';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@Component({
selector: 'sqx-contents-page',
styleUrls: ['./contents-page.component.scss'],
@ -34,12 +36,6 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
public searchModal = new ModalView();
public dueTimeDialog = new ModalView();
public dueTime: string | null = '';
public dueTimeFunction: Function | null;
public dueTimeAction: string | null = '';
public dueTimeMode = 'Immediately';
public selectedItems: { [id: string]: boolean; } = {};
public selectionCount = 0;
@ -51,6 +47,9 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
public isAllSelected = false;
@ViewChild('dueTimeSelector')
public dueTimeSelector: DueTimeSelectorComponent;
constructor(
public readonly appsState: AppsState,
public readonly contentsState: ContentsState,
@ -99,7 +98,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
}
public publishSelected(scheduled: boolean) {
this.changeContentItems(this.s(c => c.status !== 'Published'), 'Publish', false);
this.changeContentItems(this.select(c => c.status !== 'Published'), 'Publish', false);
}
public unpublish(content: ContentDto) {
@ -107,7 +106,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
}
public unpublishSelected(scheduled: boolean) {
this.changeContentItems(this.s(c => c.status === 'Published'), 'Unpublish', false);
this.changeContentItems(this.select(c => c.status === 'Published'), 'Unpublish', false);
}
public archive(content: ContentDto) {
@ -115,7 +114,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
}
public archiveSelected(scheduled: boolean) {
this.changeContentItems(this.s(), 'Archive', true);
this.changeContentItems(this.select(), 'Archive', true);
}
public restore(content: ContentDto) {
@ -123,7 +122,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
}
public restoreSelected(scheduled: boolean) {
this.changeContentItems(this.s(), 'Restore', true);
this.changeContentItems(this.select(), 'Restore', true);
}
private changeContentItems(contents: ContentDto[], action: string, reload: boolean) {
@ -131,26 +130,24 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
return;
}
this.dueTimeFunction = () => {
this.resetSelection();
this.contentsState.changeStatus(contents, action, this.dueTime).onErrorResumeNext().subscribe();
};
this.dueTimeAction = action;
this.dueTimeDialog.show();
this.dueTimeSelector.selectDueTime(action)
.do(() => {
this.resetSelection();
})
.switchMap(d => this.contentsState.changeManyStatus(contents, action, d)).onErrorResumeNext()
.subscribe();
}
public deleteSelected() {
this.resetSelection();
this.contentsState.delete(this.s()).onErrorResumeNext().subscribe();
this.contentsState.deleteMany(this.select()).onErrorResumeNext().subscribe();
}
public delete(content: ContentDto) {
this.resetSelection();
this.contentsState.delete([content]).onErrorResumeNext().subscribe();
this.contentsState.deleteMany([content]).onErrorResumeNext().subscribe();
}
public goArchive(isArchive: boolean) {
@ -203,26 +200,11 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.updateSelectionSummary();
}
public confirmStatusChange() {
this.dueTimeFunction!();
this.dueTimeFunction = null;
this.dueTimeMode = 'Immediately';
this.dueTimeDialog.hide();
this.dueTime = null;
}
public cancelStatusChange() {
this.dueTimeMode = 'Immediately';
this.dueTimeDialog.hide();
this.dueTimeFunction = null;
this.dueTime = null;
}
public trackByContent(content: ContentDto): string {
return content.id;
}
private s(predicate?: (content: ContentDto) => boolean) {
private select(predicate?: (content: ContentDto) => boolean) {
return this.contentsState.snapshot.contents.values.filter(c => this.selectedItems[c.id] && (!predicate || predicate(c)));
}

24
src/Squidex/app/features/content/shared/content-item.component.html

@ -56,21 +56,12 @@
</div>
</td>
<td class="cell-time" (click)="shouldStop($event)">
<ng-container *ngIf="!content.scheduledTo">
<span class="content-status content-status-{{content.status | lowercase}}" #statusIcon>
<i class="icon-circle"></i>
</span>
<sqx-tooltip [target]="statusIcon">{{content.status}}</sqx-tooltip>
</ng-container>
<ng-container *ngIf="content.scheduledTo">
<span class="content-status content-status-{{content.scheduledTo | lowercase}}" #statusIcon>
<i class="icon-clock"></i>
</span>
<sqx-tooltip [target]="statusIcon">Will be set to '{{content.scheduledTo}}' at {{content.scheduledAt | sqxFullDateTime}}</sqx-tooltip>
</ng-container>
<sqx-content-status
[status]="content.status"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending">
</sqx-content-status>
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small>
</td>
@ -108,6 +99,9 @@
<a class="dropdown-item" (click)="restoring.emit(); $event.stopPropagation()" *ngIf="content.status === 'Archived'">
Restore
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item dropdown-item-delete"
(sqxConfirmClick)="deleting.emit()"
confirmTitle="Delete content"

28
src/Squidex/app/features/content/shared/content-item.component.scss

@ -3,32 +3,4 @@
.truncate {
@include truncate;
}
.content-status {
& {
vertical-align: middle;
}
&-published {
color: $color-theme-green;
}
&-draft {
color: $color-text-decent;
}
&-archived {
color: $color-theme-error;
}
&-tooltip {
@include border-radius;
background: $color-tooltip;
border: 0;
font-size: .9rem;
font-weight: normal;
color: $color-dark-foreground;
padding: .75rem;
}
}

2
src/Squidex/app/features/content/shared/content-item.component.ts

@ -137,7 +137,7 @@ export class ContentItemComponent implements OnChanges {
}
private getRawValue(field: FieldDto): any {
const contentField = this.content.data[field.name];
const contentField = this.content.dataDraft[field.name];
if (contentField) {
if (field.isLocalizable) {

17
src/Squidex/app/features/content/shared/content-status.component.html

@ -0,0 +1,17 @@
<span *ngIf="!scheduledTo">
<span class="content-status content-status-{{displayStatus | lowercase}}" #statusIcon>
<i class="icon-circle"></i>
</span>
<sqx-tooltip [target]="statusIcon">{{displayStatus}}</sqx-tooltip>
</span>
<span *ngIf="scheduledTo">
<span class="content-status content-status-{{scheduledTo | lowercase}}" #statusIcon>
<i class="icon-clock"></i>
</span>
<sqx-tooltip position="topRight" [target]="statusIcon">Will be set to '{{scheduledTo}}' at {{scheduledAt | sqxFullDateTime}}</sqx-tooltip>
</span>
<span class="content-status-label" *ngIf="showLabel">{{displayStatus}}</span>

38
src/Squidex/app/features/content/shared/content-status.component.scss

@ -0,0 +1,38 @@
@import '_vars';
@import '_mixins';
.content-status {
& {
vertical-align: middle;
}
&-published {
color: $color-theme-green;
}
&-draft {
color: $color-text-decent;
}
&-archived {
color: $color-theme-error;
}
&-pending {
color: $color-dark-black;
}
&-label {
color: $color-text;
}
&-tooltip {
@include border-radius;
background: $color-tooltip;
border: 0;
font-size: .9rem;
font-weight: normal;
color: $color-dark-foreground;
padding: .75rem;
}
}

38
src/Squidex/app/features/content/shared/content-status.component.ts

@ -0,0 +1,38 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { DateTime } from '@app/shared';
@Component({
selector: 'sqx-content-status',
styleUrls: ['./content-status.component.scss'],
templateUrl: './content-status.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentStatusComponent {
@Input()
public status: string;
@Input()
public scheduledTo?: string;
@Input()
public scheduledAt?: DateTime;
@Input()
public isPending: any;
@Input()
public showLabel = false;
public get displayStatus() {
return !!this.isPending ? 'Pending' : this.status;
}
}

30
src/Squidex/app/features/content/shared/due-time-selector.component.html

@ -0,0 +1,30 @@
<ng-container *sqxModalView="dueTimeDialog;onRoot:true">
<sqx-modal-dialog (closed)="cancelStatusChange()">
<ng-container title>
{{dueTimeAction}} content item(s)
</ng-container>
<ng-container content>
<div class="form-check">
<input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Immediately" id="immediately">
<label class="form-check-label" for="immediately">
{{dueTimeAction}} content item(s) immediately.
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Scheduled" id="scheduled">
<label class="form-check-label" for="scheduled">
{{dueTimeAction}} content item(s) at a later point date and time.
</label>
</div>
<sqx-date-time-editor [disabled]="dueTimeMode === 'Immediately'" mode="DateTime" hideClear="true" [(ngModel)]="dueTime"></sqx-date-time-editor>
</ng-container>
<ng-container footer>
<button type="button" class="float-left btn btn-secondary" (click)="cancelStatusChange()">Cancel</button>
<button type="button" class="float-right btn btn-primary" [disabled]="dueTimeMode === 'Scheduled' && !dueTime" (click)="confirmStatusChange()" sqxFocusOnInit>Confirm</button>
</ng-container>
</sqx-modal-dialog>
</ng-container>

2
src/Squidex/app/features/content/shared/due-time-selector.component.scss

@ -0,0 +1,2 @@
@import '_vars';
@import '_mixins';

52
src/Squidex/app/features/content/shared/due-time-selector.component.ts

@ -0,0 +1,52 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { fadeAnimation, ModalView } from '@app/shared';
@Component({
selector: 'sqx-due-time-selector',
styleUrls: ['./due-time-selector.component.scss'],
templateUrl: './due-time-selector.component.html',
animations: [
fadeAnimation
]
})
export class DueTimeSelectorComponent {
public dueTimeDialog = new ModalView();
public dueTime: string | null = '';
public dueTimeFunction: Subject<string | null>;
public dueTimeAction: string | null = '';
public dueTimeMode = 'Immediately';
public selectDueTime(action: string): Observable<string | null> {
this.dueTimeAction = action;
this.dueTimeFunction = new Subject<string | null>();
this.dueTimeDialog.show();
return this.dueTimeFunction;
}
public confirmStatusChange() {
const result = this.dueTimeMode === 'Immediately' ? null : this.dueTime;
this.dueTimeFunction.next(result);
this.dueTimeFunction.complete();
this.cancelStatusChange();
}
public cancelStatusChange() {
this.dueTimeMode = 'Immediately';
this.dueTimeDialog.hide();
this.dueTimeFunction = null!;
this.dueTime = null;
}
}

16
src/Squidex/app/features/schemas/pages/schema/field.component.html

@ -28,23 +28,29 @@
</button>
<div class="dropdown-menu" *sqxModalView="dropdown" [sqxModalTarget]="optionsButton" @fade>
<a class="dropdown-item" (click)="enableField()" *ngIf="field.isDisabled" [class.disabled]="field.isLocked">
Enable
Enable in UI
</a>
<a class="dropdown-item" (click)="disableField()" *ngIf="!field.isDisabled" [class.disabled]="field.isLocked">
Disable
Disable in UI
</a>
<a class="dropdown-item" (click)="hideField()" *ngIf="!field.isHidden" [class.disabled]="field.isLocked">
Hide
Hide in API
</a>
<a class="dropdown-item" (click)="showField()" *ngIf="field.isHidden" [class.disabled]="field.isLocked">
Show
Show in API
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" *ngIf="!field.isLocked"
(sqxConfirmClick)="lockField()"
confirmTitle="Lock field"
confirmText="Do you really want to lock the field? Lock fields cannot be deleted or changed.">
Lock
Lock and prevent changes
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item dropdown-item-delete" [class.disabled]="field.isLocked"
(sqxConfirmClick)="deleteField()"
confirmTitle="Delete field"

4
src/Squidex/app/framework/angular/forms/date-time-editor.component.html

@ -7,10 +7,10 @@
<input type="text" class="form-control" [formControl]="timeControl" (blur)="touched()" />
</div>
<div class="form-group" *ngIf="showTime">
<button class="btn btn-secondary" (click)="writeNow()">Now</button>
<button class="btn btn-secondary" [disabled]="isDisabled" (click)="writeNow()">Now</button>
</div>
<div class="form-group" *ngIf="!showTime">
<button class="btn btn-secondary" (click)="writeNow()">Today</button>
<button class="btn btn-secondary" [disabled]="isDisabled" (click)="writeNow()">Today</button>
</div>
<div class="form-group" [class.hidden]="!hasValue" *ngIf="!hideClear">
<button class="btn btn-link clear" [disabled]="isDisabled" (click)="reset()">Clear</button>

2
src/Squidex/app/framework/angular/modals/tooltip.component.html

@ -1,3 +1,3 @@
<div class="tooltip-container" *sqxModalView="modal;onRoot:true;closeAuto:false" [sqxModalTarget]="target" position="topLeft">
<div class="tooltip-container" *sqxModalView="modal;onRoot:true;closeAuto:false" [sqxModalTarget]="target" [position]="position">
<ng-content></ng-content>
</div>

1
src/Squidex/app/framework/angular/modals/tooltip.component.scss

@ -7,6 +7,7 @@
border: 0;
font-size: .9rem;
font-weight: normal;
white-space: nowrap;
color: $color-dark-foreground;
padding: .5rem;
}

3
src/Squidex/app/framework/angular/modals/tooltip.component.ts

@ -27,6 +27,9 @@ export class TooltipComponent implements OnDestroy, OnInit {
@Input()
public target: any;
@Input()
public position = 'topLeft';
public modal = new ModalView(false, false);
constructor(

50
src/Squidex/app/shared/services/contents.service.spec.ts

@ -15,6 +15,7 @@ import {
ContentsDto,
ContentsService,
DateTime,
ScheduleDto,
Version
} from './../';
@ -62,11 +63,15 @@ describe('ContentsService', () => {
createdBy: 'Created1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
scheduledTo: 'Draft',
scheduledBy: 'Scheduler1',
scheduledAt: '2018-12-12T10:10',
scheduleJob: {
status: 'Draft',
scheduledBy: 'Scheduler1',
dueTime: '2018-12-12T10:10'
},
isPending: true,
version: 11,
data: {}
data: {},
dataDraft: {}
},
{
id: 'id2',
@ -76,7 +81,8 @@ describe('ContentsService', () => {
lastModified: '2017-10-12T10:10',
lastModifiedBy: 'LastModifiedBy2',
version: 22,
data: {}
data: {},
dataDraft: {}
}
]
});
@ -86,17 +92,17 @@ describe('ContentsService', () => {
new ContentDto('id1', 'Published', 'Created1', 'LastModifiedBy1',
DateTime.parseISO_UTC('2016-12-12T10:10'),
DateTime.parseISO_UTC('2017-12-12T10:10'),
'Draft',
'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'),
new ScheduleDto('Draft', 'Scheduler1', DateTime.parseISO_UTC('2018-12-12T10:10')),
true,
{},
{},
new Version('11')),
new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2',
DateTime.parseISO_UTC('2016-10-12T10:10'),
DateTime.parseISO_UTC('2017-10-12T10:10'),
null,
null,
null,
false,
{},
{},
new Version('22'))
]));
@ -162,10 +168,14 @@ describe('ContentsService', () => {
createdBy: 'Created1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
scheduledTo: 'Draft',
scheduledBy: 'Scheduler1',
scheduledAt: '2018-12-12T10:10',
data: {}
scheduleJob: {
status: 'Draft',
scheduledBy: 'Scheduler1',
dueTime: '2018-12-12T10:10'
},
isPending: true,
data: {},
dataDraft: {}
}, {
headers: {
etag: '2'
@ -176,9 +186,9 @@ describe('ContentsService', () => {
new ContentDto('id1', 'Published', 'Created1', 'LastModifiedBy1',
DateTime.parseISO_UTC('2016-12-12T10:10'),
DateTime.parseISO_UTC('2017-12-12T10:10'),
'Draft',
'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'),
new ScheduleDto('Draft', 'Scheduler1', DateTime.parseISO_UTC('2018-12-12T10:10')),
true,
{},
{},
new Version('2')));
}));
@ -218,7 +228,7 @@ describe('ContentsService', () => {
DateTime.parseISO_UTC('2016-12-12T10:10'),
DateTime.parseISO_UTC('2017-12-12T10:10'),
null,
null,
true,
null,
{},
new Version('2')));
@ -250,9 +260,9 @@ describe('ContentsService', () => {
const dto = {};
contentsService.putContent('my-app', 'my-schema', 'content1', dto, version).subscribe();
contentsService.putContent('my-app', 'my-schema', 'content1', dto, 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?asDraft=true');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value);

56
src/Squidex/app/shared/services/contents.service.ts

@ -28,6 +28,16 @@ export class ContentsDto {
}
}
export class ScheduleDto {
constructor(
public readonly status: string,
public readonly scheduledBy: string,
public readonly dueTime: DateTime
) {
}
}
export class ContentDto {
constructor(
public readonly id: string,
@ -36,10 +46,10 @@ export class ContentDto {
public readonly lastModifiedBy: string,
public readonly created: DateTime,
public readonly lastModified: DateTime,
public readonly scheduledTo: string | null,
public readonly scheduledBy: string | null,
public readonly scheduledAt: DateTime | null,
public readonly data: any,
public readonly scheduleJob: ScheduleDto | null,
public readonly isPending: boolean,
public readonly data: object | any,
public readonly dataDraft: object,
public readonly version: Version
) {
}
@ -101,10 +111,15 @@ export class ContentsService {
item.lastModifiedBy,
DateTime.parseISO_UTC(item.created),
DateTime.parseISO_UTC(item.lastModified),
item.scheduledTo || null,
item.scheduledBy || null,
item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null,
item.scheduleJob
? new ScheduleDto(
item.scheduleJob.status,
item.scheduleJob.scheduledBy,
DateTime.parseISO_UTC(item.scheduleJob.dueTime))
: null,
item.isPending === true,
item.data,
item.dataDraft,
new Version(item.version.toString()));
}));
})
@ -125,10 +140,15 @@ export class ContentsService {
body.lastModifiedBy,
DateTime.parseISO_UTC(body.created),
DateTime.parseISO_UTC(body.lastModified),
body.scheduledTo || null,
body.scheduledBy || null,
body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null,
body.scheduleJob
? new ScheduleDto(
body.scheduleJob.status,
body.scheduleJob.scheduledBy,
DateTime.parseISO_UTC(body.scheduleJob.dueTime))
: null,
body.isPending === true,
body.data,
body.dataDraft,
response.version);
})
.pretifyError('Failed to load content. Please reload.');
@ -159,7 +179,7 @@ export class ContentsService {
DateTime.parseISO_UTC(body.created),
DateTime.parseISO_UTC(body.lastModified),
null,
null,
true,
null,
body.data,
response.version);
@ -170,8 +190,8 @@ export class ContentsService {
.pretifyError('Failed to create content. Please reload.');
}
public putContent(appName: string, schemaName: string, id: string, dto: any, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);
public putContent(appName: string, schemaName: string, id: string, dto: any, asDraft: boolean, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}?asDraft=${asDraft}`);
return HTTP.putVersioned(this.http, url, dto, version)
.map(response => {
@ -200,6 +220,16 @@ export class ContentsService {
.pretifyError('Failed to update content. Please reload.');
}
public discardChanges(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/discard`);
return HTTP.putVersioned(this.http, url, version)
.do(() => {
this.analytics.trackEvent('Content', 'Discarded', appName);
})
.pretifyError('Failed to discard changes. Please reload.');
}
public deleteContent(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);

126
src/Squidex/app/shared/state/contents.state.ts

@ -29,7 +29,7 @@ import { fieldInvariant, SchemaDetailsDto, SchemaDto } from './../services/schem
import { AppsState } from './apps.state';
import { SchemasState } from './schemas.state';
import { ContentDto, ContentsService } from './../services/contents.service';
import { ContentDto, ContentsService, ScheduleDto } from './../services/contents.service';
export class EditContentForm extends Form<FormGroup> {
constructor(
@ -254,7 +254,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
.notify(this.dialogs);
}
public changeStatus(contents: ContentDto[], action: string, dueTime: string | null): Observable<any> {
public changeManyStatus(contents: ContentDto[], action: string, dueTime: string | null): Observable<any> {
return Observable.forkJoin(
contents.map(c =>
this.contentsService.changeContentStatus(this.appName, this.schemaName, c.id, action, dueTime, c.version)
@ -271,7 +271,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
.switchMap(() => this.loadInternal());
}
public delete(contents: ContentDto[]): Observable<any> {
public deleteMany(contents: ContentDto[]): Observable<any> {
return Observable.forkJoin(
contents.map(c =>
this.contentsService.deleteContent(this.appName, this.schemaName, c.id, c.version)
@ -288,20 +288,68 @@ export abstract class ContentsStateBase extends State<Snapshot> {
.switchMap(() => this.loadInternal());
}
public publishChanges(content: ContentDto, dueTime: string | null, now?: DateTime): Observable<any> {
return this.contentsService.changeContentStatus(this.appName, this.schemaName, content.id, 'Publish', dueTime, content.version)
.do(dto => {
this.dialogs.notifyInfo('Content updated successfully.');
if (dueTime) {
this.replaceContent(changeScheduleStatus(content, 'Published', dueTime, this.user, dto.version, now));
} else {
this.replaceContent(confirmChanges(content, this.user, dto.version, now));
}
})
.notify(this.dialogs);
}
public changeStatus(content: ContentDto, action: string, status: string, dueTime: string | null, now?: DateTime): Observable<any> {
return this.contentsService.changeContentStatus(this.appName, this.schemaName, content.id, action, dueTime, content.version)
.do(dto => {
this.dialogs.notifyInfo('Content updated successfully.');
if (dueTime) {
this.replaceContent(changeScheduleStatus(content, status, dueTime, this.user, dto.version, now));
} else {
this.replaceContent(changeStatus(content, status, this.user, dto.version, now));
}
})
.notify(this.dialogs);
}
public update(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, content.version)
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, false, content.version)
.do(dto => {
this.dialogs.notifyInfo('Contents updated successfully.');
this.dialogs.notifyInfo('Content updated successfully.');
this.replaceContent(updateData(content, dto.payload, this.user, dto.version, now));
})
.notify(this.dialogs);
}
public proposeUpdate(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, true, content.version)
.do(dto => {
this.dialogs.notifyInfo('Content updated successfully.');
this.replaceContent(updateDataDraft(content, dto.payload, this.user, dto.version, now));
})
.notify(this.dialogs);
}
public discardChanges(content: ContentDto, now?: DateTime): Observable<any> {
return this.contentsService.discardChanges(this.appName, this.schemaName, content.id, content.version)
.do(dto => {
this.dialogs.notifyInfo('Content updated successfully.');
this.replaceContent(discardChanges(content, this.user, dto.version, now));
})
.notify(this.dialogs);
}
public patch(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.patchContent(this.appName, this.schemaName, content.id, request, content.version)
.do(dto => {
this.dialogs.notifyInfo('Contents updated successfully.');
this.dialogs.notifyInfo('Content updated successfully.');
this.replaceContent(updateData(content, dto.payload, this.user, dto.version, now));
})
@ -391,14 +439,74 @@ export class ManualContentsState extends ContentsStateBase {
}
}
const changeStatus = (content: ContentDto, status: string, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
status,
content.createdBy, user,
content.created, now || DateTime.now(),
null,
content.isPending,
content.data,
content.dataDraft,
version);
const changeScheduleStatus = (content: ContentDto, status: string, dueTime: string, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
new ScheduleDto(status, user, DateTime.parseISO_UTC(dueTime)),
content.isPending,
content.data,
content.dataDraft,
version);
const updateData = (content: ContentDto, data: any, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
content.scheduledTo,
content.scheduledBy,
content.scheduledAt,
content.scheduleJob,
content.isPending,
data,
data,
version);
const updateDataDraft = (content: ContentDto, data: any, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
content.scheduleJob,
true,
content.data,
data,
version);
const confirmChanges = (content: ContentDto, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
null,
false,
content.dataDraft,
content.dataDraft,
version);
const discardChanges = (content: ContentDto, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
content.scheduleJob,
false,
content.data,
content.data,
version);

7
src/Squidex/appsettings.json

@ -1,4 +1,11 @@
{
"mode": {
/*
* Use this flag to set Squidex to readonly, e.g. when you deploy a second instance for migration.
*/
"isReadonly": false
},
"urls": {
/*
* Set the base url of your application, to generate correct urls in background process.

8
tests/RunCoverage.ps1

@ -26,7 +26,7 @@ if ($all -Or $infrastructure) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Infrastructure.Tests\Squidex.Infrastructure.Tests.csproj" `
-filter:"+[Squidex.Infrastructure*]* -[Squidex.Infrastructure*]*CodeGen*" `
-filter:"+[Squidex.*]* -[Squidex.Infrastructure*]*CodeGen*" `
-skipautoprops `
-output:"$folderWorking\$folderReports\Infrastructure.xml" `
-oldStyle
@ -37,7 +37,7 @@ if ($all -Or $appsCore) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Domain.Apps.Core.Tests\Squidex.Domain.Apps.Core.Tests.csproj" `
-filter:"+[Squidex.Domain.Apps.Core*]*" `
-filter:"+[Squidex.*]*" `
-skipautoprops `
-output:"$folderWorking\$folderReports\Core.xml" `
-oldStyle
@ -48,7 +48,7 @@ if ($all -Or $appsEntities) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Domain.Apps.Entities.Tests\Squidex.Domain.Apps.Entities.Tests.csproj" `
-filter:"+[Squidex.Domain.Apps.Entities*]* -[Squidex.Domain.Apps.Entities*]*CodeGen*" `
-filter:"+[Squidex.*]* -[Squidex.Domain.Apps.Entities*]*CodeGen*" `
-skipautoprops `
-output:"$folderWorking\$folderReports\Entities.xml" `
-oldStyle
@ -59,7 +59,7 @@ if ($all -Or $users) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj" `
-filter:"+[Squidex.Domain.Users*]*" `
-filter:"+[Squidex.*]*" `
-skipautoprops `
-output:"$folderWorking\$folderReports\Users.xml" `
-oldStyle

10
tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs

@ -109,6 +109,16 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
Assert.Empty(result);
}
[Fact]
public void Should_empty_list_from_non_references_field()
{
var sut = new StringField(1, "my-string", Partitioning.Invariant);
var result = sut.ExtractReferences("invalid").ToArray();
Assert.Empty(result);
}
[Fact]
public void Should_return_null_from_assets_field_when_removing_references_from_null_array()
{

38
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/ContentChangedTriggerTests.cs

@ -20,6 +20,8 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Xunit;
#pragma warning disable SA1401 // Fields must be private
namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
public class ContentChangedTriggerTests
@ -28,28 +30,22 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
private static readonly NamedId<Guid> SchemaMatch = new NamedId<Guid>(Guid.NewGuid(), "my-schema1");
private static readonly NamedId<Guid> SchemaNonMatch = new NamedId<Guid>(Guid.NewGuid(), "my-schema2");
public static IEnumerable<object[]> TestData
public static IEnumerable<object[]> TestData = new[]
{
get
{
return new[]
{
new object[] { 0, 1, 1, 1, 1, new RuleCreated() },
new object[] { 0, 1, 1, 1, 1, new ContentCreated { SchemaId = SchemaNonMatch } },
new object[] { 1, 1, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 1, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 1, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 0, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } },
new object[] { 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Archived } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Draft } },
new object[] { 0, 1, 1, 1, 1, new SchemaCreated { SchemaId = SchemaNonMatch } }
};
}
}
new object[] { 0, 1, 1, 1, 1, new RuleCreated() },
new object[] { 0, 1, 1, 1, 1, new ContentCreated { SchemaId = SchemaNonMatch } },
new object[] { 1, 1, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 1, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 1, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 0, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } },
new object[] { 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Archived } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Draft } },
new object[] { 0, 1, 1, 1, 1, new SchemaCreated { SchemaId = SchemaNonMatch } }
};
[Fact]
public void Should_return_false_when_trigger_contains_no_schemas()

38
tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs

@ -6,9 +6,11 @@
// ==========================================================================
using System;
using System.Security.Claims;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Xunit;
namespace Squidex.Domain.Apps.Core.Operations.Scripting
@ -202,5 +204,41 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
Assert.Equal(expected, result);
}
[Fact]
public void Should_transform_content_with_old_content()
{
var content =
new NamedContentData()
.AddField("number0",
new ContentFieldData()
.AddValue("iv", 3.0));
var oldContent =
new NamedContentData()
.AddField("number0",
new ContentFieldData()
.AddValue("iv", 5.0));
var expected =
new NamedContentData()
.AddField("number0",
new ContentFieldData()
.AddValue("iv", 13.0));
var userIdentity = new ClaimsIdentity();
var userPrincipal = new ClaimsPrincipal(userIdentity);
userIdentity.AddClaim(new Claim(OpenIdClaims.ClientId, "2"));
var context = new ScriptContext { Data = content, OldData = oldContent, User = userPrincipal };
var result = scriptEngine.ExecuteAndTransform(context, @"
ctx.data.number0.iv = ctx.data.number0.iv + ctx.oldData.number0.iv * parseInt(ctx.user.id, 10);
replace(ctx.data);");
Assert.Equal(expected, result);
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save