Browse Source

Continued with draft.

pull/285/head
Sebastian 9 years ago
parent
commit
81eaff9f71
  1. 4
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  2. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs
  3. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs
  4. 6
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs
  5. 131
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  6. 116
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs
  7. 54
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  8. 46
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs
  9. 140
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  10. 16
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs
  11. 70
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  12. 33
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs
  13. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs
  14. 1
      src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs
  15. 1
      src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs
  16. 4
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  17. 76
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  18. 7
      src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  19. 19
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  20. 9
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  21. 4
      src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs
  22. 2
      src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  23. 34
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  24. 16
      src/Squidex.Domain.Apps.Events/Contents/ContentChangesConfirmed.cs
  25. 21
      src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs
  26. 28
      src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs
  27. 7
      src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs
  28. 17
      src/Squidex/app/features/content/pages/content/content-page.component.html
  29. 37
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  30. 2
      src/Squidex/app/features/content/shared/content-item.component.html
  31. 2
      src/Squidex/app/features/content/shared/content-item.component.ts
  32. 8
      src/Squidex/app/features/content/shared/content-status.component.ts
  33. 10
      src/Squidex/app/features/schemas/pages/schema/field.component.html
  34. 24
      src/Squidex/app/shared/services/contents.service.spec.ts
  35. 19
      src/Squidex/app/shared/services/contents.service.ts
  36. 54
      src/Squidex/app/shared/state/contents.state.ts
  37. 66
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs
  38. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs
  39. 32
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs
  40. 12
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/OData/ODataQueryTests.cs

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

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

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()); var entity = SimpleMapper.Map(value, new MongoAssetEntity());
entity.Version = newVersion; 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); 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>> var filters = new List<FilterDefinition<MongoAssetEntity>>
{ {
Filter.Eq(x => x.AppIdId, appId), Filter.Eq(x => x.IndexedAppId, appId),
Filter.Eq(x => x.IsDeleted, false) 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) if (value.Length < 1000)
{ {
stringBuilder.Append(" "); if (stringBuilder.Length > 0)
{
stringBuilder.Append(" ");
}
stringBuilder.Append(text); stringBuilder.Append(text);
} }
} }

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

@ -0,0 +1,131 @@
// ==========================================================================
// 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 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 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));
}
}
}

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

@ -0,0 +1,116 @@
// ==========================================================================
// 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.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<(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;
}
}
}
}
}

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

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

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

@ -0,0 +1,46 @@
// ==========================================================================
// 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;
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 Task UpsertAsync(MongoContentEntity content)
{
content.DataText = content.DataByIds.ToFullText();
content.DataDraftByIds = 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.OData.UriParser; using Microsoft.OData.UriParser;
using MongoDB.Driver; using MongoDB.Driver;
@ -16,146 +15,97 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents 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 IAppProvider appProvider;
private readonly MongoContentDraftCollection contentsDraft;
private readonly MongoContentPublishedCollection contentsPublished;
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider) public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider)
: base(database)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
this.appProvider = appProvider; this.appProvider = appProvider;
}
protected override string CollectionName() contentsDraft = new MongoContentDraftCollection(database);
{ contentsPublished = new MongoContentPublishedCollection(database);
return "States_Contents";
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"); contentsDraft.Initialize();
contentsPublished.Initialize();
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));
} }
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); return contentsPublished.QueryAsync(app, schema, odataQuery);
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);
} }
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")) return contentsDraft.QueryAsync(app, schema, ids, status);
{
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, 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)); if (RequiresPublished(status))
var contentItems = find.ToListAsync();
var contentCount = find.CountAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
{ {
entity.ParseData(schema.SchemaDef); return contentsPublished.FindContentAsync(app, schema, id);
}
else
{
return contentsPublished.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 = return contentsDraft.QueryNotFoundAsync(appId, schemaId, ids);
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();
} }
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
{ {
var contentEntity = return contentsDraft.QueryScheduledWithoutDataAsync(now, callback);
await Collection.Find(x => x.SchemaIdId == schema.Id && x.Id == id && x.IsDeleted == false)
.FirstOrDefaultAsync();
contentEntity?.ParseData(schema.SchemaDef);
return contentEntity;
} }
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback) public Task ClearAsync()
{ {
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted == false) return Task.WhenAll(
.ForEachAsync(c => contentsDraft.ClearAsync(),
{ contentsPublished.ClearAsync());
callback(c);
});
} }
public Task DeleteArchiveAsync() 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) protected Task On(AssetDeleted @event)
{ {
return Collection.UpdateManyAsync( return Task.WhenAll(
Filter.And( contentsDraft.CleanupAsync(@event.AssetId),
Filter.AnyEq(x => x.ReferencedIds, @event.AssetId), contentsPublished.CleanupAsync(@event.AssetId));
Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.AssetId)),
Update.AddToSet(x => x.ReferencedIdsDeleted, @event.AssetId));
} }
protected Task On(ContentDeleted @event) protected Task On(ContentDeleted @event)
{ {
return Collection.UpdateManyAsync( return Task.WhenAll(
Filter.And( contentsDraft.CleanupAsync(@event.ContentId),
Filter.AnyEq(x => x.ReferencedIds, @event.ContentId), contentsPublished.CleanupAsync(@event.ContentId));
Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.ContentId)),
Update.AddToSet(x => x.ReferencedIdsDeleted, @event.ContentId));
} }
Task IEventConsumer.ClearAsync() Task IEventConsumer.ClearAsync()

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

@ -7,12 +7,11 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
@ -20,27 +19,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
public partial class MongoContentRepository : ISnapshotStore<ContentState, Guid> 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(); return contentsDraft.ReadAsync(key, GetSchemaAsync);
}
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);
} }
public async Task WriteAsync(Guid key, ContentState value, long oldVersion, long newVersion) public async Task WriteAsync(Guid key, ContentState value, long oldVersion, long newVersion)
@ -52,42 +33,28 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); 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, DataByIds = idData,
PendingDataByIds = value.PendingData?.ToIdModel(schema.SchemaDef, true), DataDraftByIds = value.DataDraft?.ToIdModel(schema.SchemaDef, true),
ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef), IsDeleted = value.IsDeleted,
IndexedAppId = value.AppId.Id,
IndexedSchemaId = value.SchemaId.Id,
ReferencedIds = idData.ToReferencedIds(schema.SchemaDef),
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) await contentsPublished.RemoveAsync(content.Id);
{
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;
}
} }
} }
@ -102,5 +69,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
return schema; 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() typeof(MongoContentEntity).GetProperties()
.ToDictionary(x => x.Name, x => x.GetCustomAttribute<BsonElementAttribute>()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase); .ToDictionary(x => x.Name, x => x.GetCustomAttribute<BsonElementAttribute>()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase);
static FindExtensions() public static PropertyCalculator CreatePropertyCalculator(Schema schema, bool useDraft)
{
PropertyMap["Data"] = "do";
}
public static PropertyCalculator CreatePropertyCalculator(Schema schema)
{ {
return propertyNames => return propertyNames =>
{ {
@ -50,7 +45,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
if (propertyNames.Length > 0) 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); var propertyName = string.Join(".", propertyNames);
@ -80,11 +89,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
{ {
var filters = new List<FilterDefinition<MongoContentEntity>> var filters = new List<FilterDefinition<MongoContentEntity>>
{ {
Filter.Eq(x => x.SchemaIdId, schemaId), Filter.Eq(x => x.IndexedSchemaId, schemaId),
Filter.In(x => x.Status, status),
Filter.Eq(x => x.IsDeleted, false)
}; };
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); var filter = query.BuildFilter<MongoContentEntity>(propertyCalculator);
if (filter.Filter != null) if (filter.Filter != null)

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 abstract class ContentDataCommand : ContentCommand
{ {
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
public bool AsDraft { get; set; }
} }
} }

1
src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs

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

1
src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs

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

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

@ -42,7 +42,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
public NamedContentData PendingData { get; set; } public NamedContentData DataDraft { get; set; }
public bool IsPending { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result) public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{ {

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

@ -84,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
GuardContent.CanUpdate(c); GuardContent.CanUpdate(c);
return UpdateContentAsync(c, c.Data, "Update", c.AsProposal); return UpdateAsync(c, x => c.Data, false);
}); });
case PatchContent patchContent: case PatchContent patchContent:
@ -92,21 +92,32 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
GuardContent.CanPatch(c); GuardContent.CanPatch(c);
return UpdateContentAsync(c, c.Data.MergeInto(Snapshot.PendingData ?? Snapshot.Data), "Patch", c.AsProposal); return UpdateAsync(c, c.Data.MergeInto, true);
}); });
case ChangeContentStatus changeContentStatus: case ChangeContentStatus changeContentStatus:
return UpdateReturnAsync(changeContentStatus, c => return UpdateAsync(changeContentStatus, async c =>
{ {
GuardContent.CanChangeContentStatus(Snapshot.PendingData, Snapshot.Status, c); GuardContent.CanChangeContentStatus(Snapshot.IsPending, Snapshot.Status, c);
if (Snapshot.PendingData != null) if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published)
{ {
return UpdateContentAsync(c, Snapshot.PendingData, "Update", false); var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content.");
await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, Snapshot.DataDraft, Snapshot.Data);
ConfirmChanges(c);
} }
else else
{ {
return ChangeStatusAsync(c); if (!c.DueTime.HasValue)
{
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);
} }
}); });
@ -125,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
case DiscardChanges discardChanges: case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c => return UpdateAsync(discardChanges, c =>
{ {
GuardContent.CanDiscardChanges(Snapshot.PendingData, c); GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c); DiscardChanges(c);
}); });
@ -135,41 +146,43 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
private async Task<object> ChangeStatusAsync(ChangeContentStatus c) private async Task<object> UpdateAsync(ContentDataCommand c, Func<NamedContentData, NamedContentData> newDataFunc, bool partial)
{ {
if (!c.DueTime.HasValue) var isProposal = c.AsDraft && Snapshot.Status == Status.Published;
{
var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content.");
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data); var currentData =
} isProposal ?
Snapshot.DataDraft :
ChangeStatus(c); Snapshot.Data;
return new EntitySavedResult(NewVersion); var newData = newDataFunc(currentData);
}
private async Task<object> UpdateContentAsync(ContentCommand command, NamedContentData data, string operation, bool asProposal)
{
var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content.");
if (!Snapshot.Data.Equals(data)) if (!currentData.Equals(newData))
{ {
await operationContext.ValidateAsync(data); var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content.");
if (asProposal) if (partial)
{ {
ProposeUpdate(command, data); await ctx.ValidatePartialAsync(c.Data);
} }
else else
{ {
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", command, data, Snapshot.Data); await ctx.ValidateAsync(c.Data);
}
Update(command, data); if (isProposal)
{
ProposeUpdate(c, newData);
}
else
{
newData = await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, newData, Snapshot.Data);
Update(c, newData);
} }
} }
return new ContentDataChangedResult(data, NewVersion); return new ContentDataChangedResult(newData, NewVersion);
} }
public void Create(CreateContent command) public void Create(CreateContent command)
@ -182,6 +195,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
public void ConfirmChanges(ChangeContentStatus command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentChangesConfirmed()));
}
public void DiscardChanges(DiscardChanges command) public void DiscardChanges(DiscardChanges command)
{ {
RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded())); RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded()));

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

@ -69,6 +69,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
} }
public Task ValidatePartialAsync(NamedContentData data)
{
var ctx = CreateValidationContext();
return data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
public Task<NamedContentData> ExecuteScriptAndTransformAsync(Func<ISchemaEntity, string> script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null) public Task<NamedContentData> ExecuteScriptAndTransformAsync(Func<ISchemaEntity, string> script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null)
{ {
var ctx = CreateScriptContext(operation, command, data, oldData); var ctx = CreateScriptContext(operation, command, data, oldData);

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

@ -70,10 +70,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
var isFrontendClient = IsFrontendClient(user); var isFrontendClient = IsFrontendClient(user);
var isVersioned = version > EtagVersion.Empty; var isVersioned = version > EtagVersion.Empty;
var parsedStatus = isFrontendClient ? new[] { Status.Published } : null;
var content = var content =
isVersioned ? isVersioned ?
await FindContentByVersionAsync(id, version) : 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 && !isFrontendClient) || content.SchemaId.Id != schema.Id)
{ {
@ -144,12 +146,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var result = SimpleMapper.Map(content, new ContentEntity()); 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); result.DataDraft = result.DataDraft.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking);
yield return result; yield return result;
} }
@ -222,9 +229,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
return contentVersionLoader.LoadAsync(id, version); 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) private static bool IsFrontendClient(ClaimsPrincipal user)

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

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

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

@ -33,6 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
NamedContentData Data { get; } NamedContentData Data { get; }
NamedContentData PendingData { get; } NamedContentData DataDraft { 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<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); Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback);
} }

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

@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
[JsonProperty] [JsonProperty]
public NamedContentData PendingData { get; set; } public NamedContentData DataDraft { get; set; }
[JsonProperty] [JsonProperty]
public Status Status { get; set; } public Status Status { get; set; }
@ -44,29 +44,44 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
[JsonProperty] [JsonProperty]
public RefToken ScheduledBy { get; set; } public RefToken ScheduledBy { get; set; }
[JsonProperty]
public bool IsPending { get; set; }
[JsonProperty] [JsonProperty]
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
protected void On(ContentCreated @event) protected void On(ContentCreated @event)
{ {
SimpleMapper.Map(@event, this); SimpleMapper.Map(@event, this);
DataDraft = @event.Data;
}
protected void On(ContentUpdated @event)
{
Data = @event.Data;
DataDraft = @event.Data;
} }
protected void On(ContentUpdateProposed @event) protected void On(ContentUpdateProposed @event)
{ {
PendingData = @event.Data; DataDraft = @event.Data;
IsPending = true;
} }
protected void On(ContentChangesDiscarded @event) protected void On(ContentChangesDiscarded @event)
{ {
PendingData = null; DataDraft = Data;
IsPending = false;
} }
protected void On(ContentUpdated @event) protected void On(ContentChangesConfirmed @event)
{ {
PendingData = null; Data = DataDraft;
Data = @event.Data; IsPending = false;
} }
protected void On(ContentStatusScheduled @event) protected void On(ContentStatusScheduled @event)
@ -83,6 +98,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
ScheduledAt = null; ScheduledAt = null;
ScheduledBy = null; ScheduledBy = null;
ScheduledTo = null; ScheduledTo = null;
if (@event.Status == Status.Published)
{
Data = DataDraft;
IsPending = false;
}
} }
protected void On(ContentDeleted @event) protected void On(ContentDeleted @event)

16
src/Squidex.Domain.Apps.Events/Contents/ContentChangesConfirmed.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(ContentChangesConfirmed))]
public sealed class ContentChangesConfirmed : ContentEvent
{
}
}

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)); 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> 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 try

28
src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs

@ -119,7 +119,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var response = new ContentsDto var response = new ContentsDto
{ {
Total = result.Total, Total = result.Total,
Items = result.Take(200).Select(item => SimpleMapper.Map(item, new ContentDto { Data = item.Data })).ToArray() Items = result.Take(200).Select(item => SimpleMapper.Map(item, new ContentDto { Data = item.Data, DataDraft = item.DataDraft })).ToArray()
}; };
Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id)); Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id));
@ -148,10 +148,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var content = await contentQuery.FindContentAsync(App, name, User, id); var content = await contentQuery.FindContentAsync(App, name, User, id);
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data }); var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data, DataDraft = content.DataDraft });
Response.Headers["ETag"] = entity.Version.ToString(); Response.Headers["ETag"] = content.Version.ToString();
Response.Headers["Surrogate-Key"] = entity.Id.ToString(); Response.Headers["Surrogate-Key"] = content.Id.ToString();
return Ok(response); return Ok(response);
} }
@ -177,9 +177,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetContentVersion(string app, string name, Guid id, int version) public async Task<IActionResult> GetContentVersion(string app, string name, Guid id, int version)
{ {
var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id, version); var content = await contentQuery.FindContentAsync(App, name, User, id, version);
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data }); var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data, DataDraft = content.DataDraft });
Response.Headers["ETag"] = content.Version.ToString(); Response.Headers["ETag"] = content.Version.ToString();
Response.Headers["Surrogate-Key"] = content.Id.ToString(); Response.Headers["Surrogate-Key"] = content.Id.ToString();
@ -227,7 +227,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param> /// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to update.</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="request">The full data for the content item.</param>
/// <param name="asProposal">Indicates whether the update is a proposal.</param> /// <param name="asDraft">Indicates whether the update is a proposal.</param>
/// <returns> /// <returns>
/// 200 => Content updated. /// 200 => Content updated.
/// 404 => Content, schema or app not found. /// 404 => Content, schema or app not found.
@ -240,11 +240,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut] [HttpPut]
[Route("content/{app}/{name}/{id}/")] [Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false) public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{ {
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name); await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal }; var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>(); var result = context.Result<ContentDataChangedResult>();
@ -260,7 +260,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param> /// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to patch.</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="request">The patch for the content item.</param>
/// <param name="asProposal">Indicates whether the patch is a proposal.</param> /// <param name="asDraft">Indicates whether the patch is a proposal.</param>
/// <returns> /// <returns>
/// 200 => Content patched. /// 200 => Content patched.
/// 404 => Content, schema or app not found. /// 404 => Content, schema or app not found.
@ -273,11 +273,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPatch] [HttpPatch]
[Route("content/{app}/{name}/{id}/")] [Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false) public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{ {
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name); await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal }; var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>(); var result = context.Result<ContentDataChangedResult>();
@ -426,7 +426,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> DiscardChanges(string app, string name, Guid id) public async Task<IActionResult> DiscardChanges(string app, string name, Guid id)
{ {
await contentQuery.FindSchemaAsync(App, name); await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new DiscardChanges { ContentId = id }; var command = new DiscardChanges { ContentId = id };

7
src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs

@ -43,7 +43,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// <summary> /// <summary>
/// The pending changes of the content item. /// The pending changes of the content item.
/// </summary> /// </summary>
public object PendingData { get; set; } public object DataDraft { get; set; }
/// <summary>
/// Indicates if the draft data is pending.
/// </summary>
public bool IsPending { get; set; }
/// <summary> /// <summary>
/// The scheduled status. /// The scheduled status.

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

@ -20,18 +20,31 @@
<ng-container menu> <ng-container menu>
<ng-container *ngIf="!content; else notNew"> <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 Save as Draft
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" title="CTRL + S">
Save and Publish Save and Publish
</button> </button>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut> <sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut>
</ng-container> </ng-container>
<ng-template #notNew> <ng-template #notNew>
<span class="btn btn btn-outline-secondary">
<sqx-content-status
[status]="content.status"
[scheduledTo]="content.scheduledTo"
[scheduledAt]="content.scheduledAt"
[isPending]="content.isPending">
</sqx-content-status>
</span>
<ng-container *ngIf="content.status !== 'Archived'"> <ng-container *ngIf="content.status !== 'Archived'">
<button type="button" class="btn btn-secondary" (click)="saveAsProposal()">
Save as Proposal
</button>
<button type="submit" class="btn btn-primary" title="CTRL + S"> <button type="submit" class="btn btn-primary" title="CTRL + S">
Save Save
</button> </button>

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

@ -87,7 +87,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
.subscribe(content => { .subscribe(content => {
this.content = content; this.content = content;
this.loadContent(content.data); this.loadContent(content.dataDraft);
}); });
this.contentVersionSelectedSubscription = this.contentVersionSelectedSubscription =
@ -106,14 +106,18 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
} }
public saveAndPublish() { public saveAndPublish() {
this.saveContent(true); this.saveContent(true, false);
} }
public saveAsDraft() { public saveAsProposal() {
this.saveContent(false); 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') { if (this.content && this.content.status === 'Archived') {
return; return;
} }
@ -122,12 +126,21 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (value) { if (value) {
if (this.content) { if (this.content) {
this.contentsState.update(this.content, value) if (asProposal) {
.subscribe(dto => { this.contentsState.proposeUpdate(this.content, value)
this.contentForm.submitCompleted(); .subscribe(dto => {
}, error => { this.contentForm.submitCompleted();
this.contentForm.submitFailed(error); }, error => {
}); this.contentForm.submitFailed(error);
});
} else {
this.contentsState.update(this.content, value)
.subscribe(dto => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
}
} else { } else {
this.contentsState.create(value, publish) this.contentsState.create(value, publish)
.subscribe(dto => { .subscribe(dto => {
@ -168,7 +181,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (this.contentVersion) { if (this.contentVersion) {
this.contentVersion = null; this.contentVersion = null;
this.loadContent(this.content.data); this.loadContent(this.content.dataDraft);
} }
} }
} }

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

@ -60,7 +60,7 @@
[status]="content.status" [status]="content.status"
[scheduledTo]="content.scheduledTo" [scheduledTo]="content.scheduledTo"
[scheduledAt]="content.scheduledAt" [scheduledAt]="content.scheduledAt"
[hasPendingChanges]="content.pendingData"> [isPending]="content.isPending">
</sqx-content-status> </sqx-content-status>
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small> <small class="item-modified">{{content.lastModified | sqxFromNow}}</small>

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 { private getRawValue(field: FieldDto): any {
const contentField = this.content.displayData[field.name]; const contentField = this.content.dataDraft[field.name];
if (contentField) { if (contentField) {
if (field.isLocalizable) { if (field.isLocalizable) {

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

@ -5,9 +5,9 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { DateTime } from 'shared'; import { DateTime } from '@app/shared';
@Component({ @Component({
selector: 'sqx-content-status', selector: 'sqx-content-status',
@ -26,13 +26,13 @@ export class ContentStatusComponent {
public scheduledAt?: DateTime; public scheduledAt?: DateTime;
@Input() @Input()
public hasPendingChanges: any; public isPending: any;
@Input() @Input()
public showLabel = false; public showLabel = false;
public get displayStatus() { public get displayStatus() {
return !!this.hasPendingChanges ? 'Pending' : this.status; return !!this.isPending ? 'Pending' : this.status;
} }
} }

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

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

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

@ -65,8 +65,10 @@ describe('ContentsService', () => {
scheduledTo: 'Draft', scheduledTo: 'Draft',
scheduledBy: 'Scheduler1', scheduledBy: 'Scheduler1',
scheduledAt: '2018-12-12T10:10', scheduledAt: '2018-12-12T10:10',
isPending: true,
version: 11, version: 11,
data: {} data: {},
dataDraft: {}
}, },
{ {
id: 'id2', id: 'id2',
@ -76,7 +78,8 @@ describe('ContentsService', () => {
lastModified: '2017-10-12T10:10', lastModified: '2017-10-12T10:10',
lastModifiedBy: 'LastModifiedBy2', lastModifiedBy: 'LastModifiedBy2',
version: 22, version: 22,
data: {} data: {},
dataDraft: {}
} }
] ]
}); });
@ -89,8 +92,9 @@ describe('ContentsService', () => {
'Draft', 'Draft',
'Scheduler1', 'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'), DateTime.parseISO_UTC('2018-12-12T10:10'),
true,
{},
{}, {},
undefined,
new Version('11')), new Version('11')),
new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2', new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2',
DateTime.parseISO_UTC('2016-10-12T10:10'), DateTime.parseISO_UTC('2016-10-12T10:10'),
@ -98,8 +102,9 @@ describe('ContentsService', () => {
null, null,
null, null,
null, null,
false,
{},
{}, {},
undefined,
new Version('22')) new Version('22'))
])); ]));
})); }));
@ -167,7 +172,8 @@ describe('ContentsService', () => {
scheduledTo: 'Draft', scheduledTo: 'Draft',
scheduledBy: 'Scheduler1', scheduledBy: 'Scheduler1',
scheduledAt: '2018-12-12T10:10', scheduledAt: '2018-12-12T10:10',
data: {} data: {},
dataDraft: {}
}, { }, {
headers: { headers: {
etag: '2' etag: '2'
@ -181,8 +187,9 @@ describe('ContentsService', () => {
'Draft', 'Draft',
'Scheduler1', 'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'), DateTime.parseISO_UTC('2018-12-12T10:10'),
true,
{},
{}, {},
undefined,
new Version('2'))); new Version('2')));
})); }));
@ -223,8 +230,9 @@ describe('ContentsService', () => {
null, null,
null, null,
null, null,
{}, true,
undefined, undefined,
{},
new Version('2'))); new Version('2')));
})); }));
@ -256,7 +264,7 @@ describe('ContentsService', () => {
contentsService.putContent('my-app', 'my-schema', 'content1', dto, true, 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?asProposal=true'); 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.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);

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

@ -29,8 +29,6 @@ export class ContentsDto {
} }
export class ContentDto { export class ContentDto {
public displayData: any;
constructor( constructor(
public readonly id: string, public readonly id: string,
public readonly status: string, public readonly status: string,
@ -41,11 +39,11 @@ export class ContentDto {
public readonly scheduledTo: string | null, public readonly scheduledTo: string | null,
public readonly scheduledBy: string | null, public readonly scheduledBy: string | null,
public readonly scheduledAt: DateTime | null, public readonly scheduledAt: DateTime | null,
public readonly isPending: boolean,
public readonly data: any, public readonly data: any,
public readonly pendingData: any, public readonly dataDraft: any,
public readonly version: Version public readonly version: Version
) { ) {
this.displayData = pendingData || data;
} }
} }
@ -108,8 +106,9 @@ export class ContentsService {
item.scheduledTo || null, item.scheduledTo || null,
item.scheduledBy || null, item.scheduledBy || null,
item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null, item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null,
item.isPending,
item.data, item.data,
item.pendingData, item.dataDraft,
new Version(item.version.toString())); new Version(item.version.toString()));
})); }));
}) })
@ -133,8 +132,9 @@ export class ContentsService {
body.scheduledTo || null, body.scheduledTo || null,
body.scheduledBy || null, body.scheduledBy || null,
body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null, body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null,
body.isPending,
body.data, body.data,
body.pendingData, body.dataDraft,
response.version); response.version);
}) })
.pretifyError('Failed to load content. Please reload.'); .pretifyError('Failed to load content. Please reload.');
@ -167,8 +167,9 @@ export class ContentsService {
null, null,
null, null,
null, null,
true,
null,
body.data, body.data,
body.pendingData,
response.version); response.version);
}) })
.do(content => { .do(content => {
@ -177,8 +178,8 @@ export class ContentsService {
.pretifyError('Failed to create content. Please reload.'); .pretifyError('Failed to create content. Please reload.');
} }
public putContent(appName: string, schemaName: string, id: string, dto: any, asProposal: boolean, version: Version): Observable<Versioned<any>> { 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}?asProposal=${asProposal}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}?asDraft=${asDraft}`);
return HTTP.putVersioned(this.http, url, dto, version) return HTTP.putVersioned(this.http, url, dto, version)
.map(response => { .map(response => {

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

@ -289,7 +289,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
} }
public update(content: ContentDto, request: any, now?: DateTime): Observable<any> { 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 => { .do(dto => {
this.dialogs.notifyInfo('Contents updated successfully.'); this.dialogs.notifyInfo('Contents updated successfully.');
@ -298,10 +298,30 @@ export abstract class ContentsStateBase extends State<Snapshot> {
.notify(this.dialogs); .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 revertProposal(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(revertDataDraft(content, this.user, dto.version, now));
})
.notify(this.dialogs);
}
public patch(content: ContentDto, request: any, now?: DateTime): Observable<any> { public patch(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.patchContent(this.appName, this.schemaName, content.id, request, content.version) return this.contentsService.patchContent(this.appName, this.schemaName, content.id, request, content.version)
.do(dto => { .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)); this.replaceContent(updateData(content, dto.payload, this.user, dto.version, now));
}) })
@ -400,5 +420,35 @@ const updateData = (content: ContentDto, data: any, user: string, version: Versi
content.scheduledTo, content.scheduledTo,
content.scheduledBy, content.scheduledBy,
content.scheduledAt, content.scheduledAt,
content.isPending,
data, 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.scheduledTo,
content.scheduledBy,
content.scheduledAt,
true,
content.data,
data,
version);
const revertDataDraft = (content: ContentDto, 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,
false,
content.data,
content.data,
version); version);

66
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs

@ -94,17 +94,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => schema.ScriptUpdate).Returns("<update-script>"); A.CallTo(() => schema.ScriptUpdate).Returns("<update-script>");
A.CallTo(() => schema.ScriptDelete).Returns("<delete-script>"); A.CallTo(() => schema.ScriptDelete).Returns("<delete-script>");
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, data)), A<string>.Ignored)) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, A<string>.Ignored))
.Returns(data); .ReturnsLazily(x => x.GetArgument<ScriptContext>(0).Data);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, invalidData)), A<string>.Ignored))
.Returns(invalidData);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, patch)), A<string>.Ignored))
.Returns(patch);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, otherData)), A<string>.Ignored))
.Returns(otherData);
patched = patch.MergeInto(data); patched = patch.MergeInto(data);
@ -190,6 +181,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact]
public async Task Update_should_create_proposal_events_and_update_state()
{
var command = new UpdateContent { Data = otherData, AsDraft = true };
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new ContentDataChangedResult(otherData, 2));
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentUpdateProposed { Data = otherData })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustNotHaveHappened();
}
[Fact] [Fact]
public async Task Update_should_not_create_event_for_same_data() public async Task Update_should_not_create_event_for_same_data()
{ {
@ -207,7 +219,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
); );
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>")) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
@ -229,7 +241,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var result = await sut.ExecuteAsync(CreateContentCommand(command)); var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new ContentDataChangedResult(otherData, 1)); result.ShouldBeEquivalent(new ContentDataChangedResult(patched, 1));
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
@ -240,6 +252,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact]
public async Task Patch_should_create_proposal_events_and_update_state()
{
var command = new PatchContent { Data = patch, AsDraft = true };
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new ContentDataChangedResult(otherData, 2));
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentUpdateProposed { Data = patched })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustNotHaveHappened();
}
[Fact] [Fact]
public async Task Patch_should_not_create_event_for_same_data() public async Task Patch_should_not_create_event_for_same_data()
{ {
@ -257,7 +290,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
); );
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>")) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
@ -345,6 +378,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return sut.ExecuteAsync(CreateContentCommand(new DeleteContent())); return sut.ExecuteAsync(CreateContentCommand(new DeleteContent()));
} }
private Task ExecutePublishAsync()
{
return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Published }));
}
protected T CreateContentEvent<T>(T @event) where T : ContentEvent protected T CreateContentEvent<T>(T @event) where T : ContentEvent
{ {
@event.ContentId = contentId; @event.ContentId = contentId;

4
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs

@ -99,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema); .Returns(schema);
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId)) A.CallTo(() => contentRepository.FindContentAsync(app, schema, null, contentId))
.Returns(content); .Returns(content);
A.CallTo(() => schema.ScriptQuery) A.CallTo(() => schema.ScriptQuery)
@ -140,7 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema); .Returns(schema);
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId)) A.CallTo(() => contentRepository.FindContentAsync(app, schema, new[] { Status.Published }, contentId))
.Returns((IContentEntity)null); .Returns((IContentEntity)null);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(async () => await sut.FindContentAsync(app, schemaId.ToString(), user, contentId)); await Assert.ThrowsAsync<DomainObjectNotFoundException>(async () => await sut.FindContentAsync(app, schemaId.ToString(), user, contentId));

32
tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs

@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{ {
var command = new ChangeContentStatus { Status = (Status)10 }; var command = new ChangeContentStatus { Status = (Status)10 };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command)); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Archived, command));
} }
[Fact] [Fact]
@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{ {
var command = new ChangeContentStatus { Status = Status.Published }; var command = new ChangeContentStatus { Status = Status.Published };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command)); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Archived, command));
} }
[Fact] [Fact]
@ -87,23 +87,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{ {
var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast }; var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Draft, command)); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Draft, command));
} }
[Fact] [Fact]
public void CanChangeContentStatus_should_throw_exception_republishing_without_pending_changes() public void CanChangeContentStatus_should_throw_exception_if_publishing_without_pending_changes()
{ {
var command = new ChangeContentStatus { Status = Status.Published }; var command = new ChangeContentStatus { Status = Status.Published };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Published, command)); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Published, command));
} }
[Fact] [Fact]
public void CanChangeContentStatus_should_not_throw_exception_republishing_with_pending_changes() public void CanChangeContentStatus_should_not_throw_exception_if_publishing_with_pending_changes()
{ {
var command = new ChangeContentStatus { Status = Status.Published }; var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(new NamedContentData(), Status.Published, command); GuardContent.CanChangeContentStatus(true, Status.Published, command);
} }
[Fact] [Fact]
@ -111,7 +111,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{ {
var command = new ChangeContentStatus { Status = Status.Published }; var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(null, Status.Draft, command); GuardContent.CanChangeContentStatus(false, Status.Draft, command);
}
[Fact]
public void CanDiscardChanges_should_throw_exception_if_pending()
{
var command = new DiscardChanges();
Assert.Throws<ValidationException>(() => GuardContent.CanDiscardChanges(false, command));
}
[Fact]
public void CanDiscardChanges_should_not_throw_exception_if_pending()
{
var command = new DiscardChanges();
GuardContent.CanDiscardChanges(true, command);
} }
[Fact] [Fact]

12
tests/Squidex.Domain.Apps.Entities.Tests/Contents/OData/ODataQueryTests.cs

@ -30,9 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.OData
{ {
public class ODataQueryTests public class ODataQueryTests
{ {
private static readonly IBsonSerializerRegistry Registry = BsonSerializer.SerializerRegistry;
private static readonly IBsonSerializer<MongoContentEntity> Serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoContentEntity>();
private readonly Schema schemaDef; private readonly Schema schemaDef;
private readonly IBsonSerializerRegistry registry = BsonSerializer.SerializerRegistry;
private readonly IBsonSerializer<MongoContentEntity> serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoContentEntity>();
private readonly IEdmModel edmModel; private readonly IEdmModel edmModel;
private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.EN, Language.DE); private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.EN, Language.DE);
@ -417,10 +417,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.OData
A.CallTo(() => cursor.Sort(A<SortDefinition<MongoContentEntity>>.Ignored)) A.CallTo(() => cursor.Sort(A<SortDefinition<MongoContentEntity>>.Ignored))
.Invokes((SortDefinition<MongoContentEntity> sortDefinition) => .Invokes((SortDefinition<MongoContentEntity> sortDefinition) =>
{ {
i = sortDefinition.Render(serializer, registry).ToString(); i = sortDefinition.Render(Serializer, Registry).ToString();
}); });
cursor.ContentSort(parser, FindExtensions.CreatePropertyCalculator(schemaDef)); cursor.ContentSort(parser, FindExtensions.CreatePropertyCalculator(schemaDef, false));
return i; return i;
} }
@ -430,8 +430,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.OData
var parser = edmModel.ParseQuery(value); var parser = edmModel.ParseQuery(value);
var query = var query =
parser.BuildFilter<MongoContentEntity>(FindExtensions.CreatePropertyCalculator(schemaDef)) parser.BuildFilter<MongoContentEntity>(FindExtensions.CreatePropertyCalculator(schemaDef, false))
.Filter.Render(serializer, registry).ToString(); .Filter.Render(Serializer, Registry).ToString();
return query; return query;
} }

Loading…
Cancel
Save