diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index f35cb2286..b70738f7e 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -21,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public sealed class MongoContentEntity : IContentEntity { private NamedContentData data; + private NamedContentData pendingData; [BsonId] [BsonRequired] @@ -62,6 +63,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents [BsonJson] public IdContentData DataByIds { get; set; } + [BsonIgnoreIfNull] + [BsonElement("dop")] + [BsonJson] + public IdContentData PendingDataByIds { get; set; } + [BsonRequired] [BsonElement("ai2")] public NamedId AppId { get; set; } @@ -116,9 +122,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents get { return data; } } + [BsonIgnore] + public NamedContentData PendingData + { + get { return pendingData; } + } + public void ParseData(Schema schema) { data = DataByIds.ToData(schema, ReferencedIdsDeleted); + + if (PendingDataByIds != null) + { + pendingData = PendingDataByIds.ToData(schema, ReferencedIdsDeleted); + } } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 54e236e4a..233ddc870 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -59,6 +59,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents DocumentId = key.ToString(), DataText = idData?.ToFullText(), DataByIds = idData, + PendingDataByIds = value.PendingData?.ToIdModel(schema.SchemaDef, true), ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef), }); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs index 2ac151ee8..31dad2699 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs @@ -23,19 +23,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History public Guid Id { get { return inner.Id; } - set { } } public Instant Created { get { return inner.Created; } - set { } } public Instant LastModified { get { return inner.LastModified; } - set { } } public RefToken Actor @@ -58,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History get { return inner.Channel; } } + public string EventType + { + get { return inner.Message; } + } + public string Message { get { return message.Value; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs index e722f8d06..cc888be2d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -20,34 +20,43 @@ namespace Squidex.Domain.Apps.Entities.Apps : base(typeNameRegistry) { AddEventMessage( - "assigned {user:[Contributor]} as [Permission]"); + "assigned {user:[Contributor]} as [Permission]."); AddEventMessage( - "removed {user:[Contributor]} from app"); + "removed {user:[Contributor]}."); AddEventMessage( - "added client {[Id]} to app"); + "added client {[Id]}."); AddEventMessage( - "revoked client {[Id]}"); + "revoked client {[Id]}."); AddEventMessage( - "updated client {[Id]}"); + "updated client {[Id]}."); AddEventMessage( - "renamed client {[Id]} to {[Name]}"); + "renamed client {[Id]} to {[Name]}."); AddEventMessage( - "added language {[Language]}"); + "added language {[Language]}."); AddEventMessage( - "removed language {[Language]}"); + "removed language {[Language]}."); AddEventMessage( - "updated language {[Language]}"); + "updated language {[Language]}."); AddEventMessage( - "changed master language to {[Language]}"); + "changed master language to {[Language]}."); + + AddEventMessage( + "added pattern {[Pattern]}."); + + AddEventMessage( + "deleted pattern {[Pattern]}."); + + AddEventMessage( + "updated pattern {[Pattern]}."); } protected Task On(AppContributorRemoved @event) @@ -131,6 +140,33 @@ namespace Squidex.Domain.Apps.Entities.Apps .AddParameter("Language", @event.Language)); } + protected Task On(AppPatternAdded @event) + { + const string channel = "settings.patterns"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Pattern", @event.Name)); + } + + protected Task On(AppPatternDeleted @event) + { + const string channel = "settings.patterns"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Pattern", @event.Name)); + } + + protected Task On(AppPatternUpdated @event) + { + const string channel = "settings.patterns"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Pattern", @event.Name)); + } + protected override Task CreateEventCoreAsync(Envelope @event) { return this.DispatchFuncAsync(@event.Payload, (HistoryEventToStore)null); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs new file mode 100644 index 000000000..518e18e49 --- /dev/null +++ b/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 + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs index 80206cebd..65cbde251 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs @@ -9,5 +9,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public sealed class PatchContent : ContentDataCommand { + public bool AsProposal { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs index 01f642d5c..1762385e8 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs @@ -9,5 +9,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public sealed class UpdateContent : ContentDataCommand { + public bool AsProposal { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 27e1c1895..7bd935591 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -42,6 +42,8 @@ namespace Squidex.Domain.Apps.Entities.Contents public NamedContentData Data { get; set; } + public NamedContentData PendingData { get; set; } + public static ContentEntity Create(CreateContent command, EntityCreatedResult result) { var now = SystemClock.Instance.GetCurrentInstant(); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 0c060bfdc..28e5f5a56 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -61,65 +61,51 @@ 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 UpdateContentAsync(c, c.Data, "Update", c.AsProposal); }); 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 UpdateContentAsync(c, c.Data.MergeInto(Snapshot.PendingData ?? Snapshot.Data), "Patch", c.AsProposal); }); - case ChangeContentStatus patchContent: - return UpdateAsync(patchContent, async c => + case ChangeContentStatus changeContentStatus: + return UpdateReturnAsync(changeContentStatus, c => { - GuardContent.CanChangeContentStatus(Snapshot.Status, c); + GuardContent.CanChangeContentStatus(Snapshot.PendingData, Snapshot.Status, c); - if (!c.DueTime.HasValue) + if (Snapshot.PendingData != null) { - var operationContext = await CreateContext(c, () => "Failed to patch content."); - - await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status); + return UpdateContentAsync(c, Snapshot.PendingData, "Update", false); + } + else + { + return ChangeStatusAsync(c); } - - ChangeStatus(c); }); case DeleteContent deleteContent: @@ -127,18 +113,63 @@ 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.PendingData, c); + + DiscardChanges(c); + }); + default: throw new NotSupportedException(); } } + private async Task ChangeStatusAsync(ChangeContentStatus c) + { + if (!c.DueTime.HasValue) + { + 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); + } + + ChangeStatus(c); + + return new EntitySavedResult(NewVersion); + } + + private async Task 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)) + { + await operationContext.ValidateAsync(data); + + if (asProposal) + { + ProposeUpdate(command, data); + } + else + { + await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", command, data, Snapshot.Data); + + Update(command, data); + } + } + + return new ContentDataChangedResult(data, NewVersion); + } + public void Create(CreateContent command) { RaiseEvent(SimpleMapper.Map(command, new ContentCreated())); @@ -149,12 +180,24 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public void Update(UpdateContent command) + public void DiscardChanges(DiscardChanges command) { - if (!command.Data.Equals(Snapshot.Data)) - { - RaiseEvent(SimpleMapper.Map(command, new ContentUpdated())); - } + RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded())); + } + + public void Delete(DeleteContent command) + { + RaiseEvent(SimpleMapper.Map(command, new ContentDeleted())); + } + + public void Update(ContentCommand command, NamedContentData data) + { + RaiseEvent(SimpleMapper.Map(command, new ContentUpdated { Data = data })); + } + + public void ProposeUpdate(ContentCommand command, NamedContentData data) + { + RaiseEvent(SimpleMapper.Map(command, new ContentUpdateProposed { Data = data })); } public void ChangeStatus(ChangeContentStatus command) @@ -169,25 +212,6 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public void Patch(PatchContent command) - { - var newData = command.Data.MergeInto(Snapshot.Data); - - if (!newData.Equals(Snapshot.Data)) - { - var @event = SimpleMapper.Map(command, new ContentUpdated()); - - @event.Data = newData; - - RaiseEvent(@event); - } - } - - public void Delete(DeleteContent command) - { - RaiseEvent(SimpleMapper.Map(command, new ContentDeleted())); - } - private void RaiseEvent(SchemaEvent @event) { if (@event.AppId == null) @@ -216,13 +240,13 @@ namespace Squidex.Domain.Apps.Entities.Contents ApplySnapshot(Snapshot.Apply(@event)); } - private async Task CreateContext(ContentCommand command, Func message) + private async Task CreateContext(Guid appId, Guid schemaId, Func message) { var operationContext = - await ContentOperationContext.CreateAsync(command, Snapshot, - contentRepository, + await ContentOperationContext.CreateAsync(appId, schemaId, appProvider, assetRepository, + contentRepository, scriptEngine, message); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs index 53b391311..848f70394 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs @@ -20,16 +20,25 @@ namespace Squidex.Domain.Apps.Entities.Contents : base(typeNameRegistry) { AddEventMessage( - "created {[Schema]} content item."); + "created {[Schema]} content."); AddEventMessage( - "updated {[Schema]} content item."); + "updated {[Schema]} content."); AddEventMessage( - "deleted {[Schema]} content item."); + "deleted {[Schema]} content."); + + AddEventMessage( + "discarded pending changes of {[Schema]} content."); + + AddEventMessage( + "proposed update for {[Schema]} content."); AddEventMessage( - "changed status of {[Schema]} content item to {[Status]}."); + "changed status of {[Schema]} content to {[Status]}."); + + AddEventMessage( + "scheduled to change status of {[Schema]} content to {[Status]}."); } protected override Task CreateEventCoreAsync(Envelope @event) @@ -48,6 +57,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); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index 3cc500552..6c0c621d1 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/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 message; public static async Task CreateAsync( - ContentCommand command, - IContentEntity content, - IContentRepository contentRepository, + Guid appId, + Guid schemaId, IAppProvider appProvider, IAssetRepository assetRepository, + IContentRepository contentRepository, IScriptEngine scriptEngine, Func 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,41 @@ namespace Squidex.Domain.Apps.Entities.Contents return context; } - public Task EnrichAsync() - { - if (command is ContentDataCommand dataCommand) - { - dataCommand.Data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver()); - } - - return TaskHelper.Done; - } - - public Task ValidateAsync() + public Task EnrichAsync(NamedContentData data) { - if (command is ContentDataCommand dataCommand) - { - var ctx = CreateValidationContext(); - - return dataCommand.Data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); - } + data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver()); return TaskHelper.Done; } - public Task ValidatePartialAsync() + public Task ValidateAsync(NamedContentData data) { - if (command is ContentDataCommand dataCommand) - { - var ctx = CreateValidationContext(); + var ctx = CreateValidationContext(); - return dataCommand.Data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); - } - - return TaskHelper.Done; + return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); } - public Task ExecuteScriptAndTransformAsync(Func script, object operation) + public Task ExecuteScriptAndTransformAsync(Func 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 script, object operation) + public Task ExecuteScriptAsync(Func 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 +107,12 @@ namespace Squidex.Domain.Apps.Entities.Contents private async Task> QueryAssetsAsync(IEnumerable assetIds) { - return await assetRepository.QueryAsync(appId, new HashSet(assetIds)); + return await assetRepository.QueryAsync(appEntity.Id, new HashSet(assetIds)); } private async Task> QueryContentsAsync(Guid schemaId, IEnumerable contentIds) { - return await contentRepository.QueryNotFoundAsync(appId, schemaId, contentIds.ToList()); + return await contentRepository.QueryNotFoundAsync(appEntity.Id, schemaId, contentIds.ToList()); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index e7cc66f55..cb8cbe3df 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -53,13 +53,32 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards }); } - public static void CanChangeContentStatus(Status status, ChangeContentStatus command) + public static void CanDiscardChanges(NamedContentData pendingData, DiscardChanges command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot discard pending changes.", error => + { + if (pendingData == null) + { + error(new ValidationError("The content has no pending changes.")); + } + }); + } + + public static void CanChangeContentStatus(NamedContentData pendingData, 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 = + command.DueTime == null && + status == command.Status && + status == Status.Published && + pendingData != null; + + 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))); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs index 11a33154c..677cee347 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs @@ -32,5 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents RefToken ScheduledBy { get; } NamedContentData Data { get; } + + NamedContentData PendingData { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index 489a03832..17f91adf8 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -14,6 +14,7 @@ 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,6 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.State [JsonProperty] public NamedContentData Data { get; set; } + [JsonProperty] + public NamedContentData PendingData { get; set; } + [JsonProperty] public Status Status { get; set; } @@ -46,15 +50,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.State protected void On(ContentCreated @event) { - SchemaId = @event.SchemaId; + SimpleMapper.Map(@event, this); + } - Data = @event.Data; + protected void On(ContentUpdateProposed @event) + { + PendingData = @event.Data; + } - AppId = @event.AppId; + protected void On(ContentChangesDiscarded @event) + { + PendingData = null; } protected void On(ContentUpdated @event) { + PendingData = null; + Data = @event.Data; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentStateScheduleItem.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentStateScheduleItem.cs deleted file mode 100644 index a73f6f0a4..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentStateScheduleItem.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Newtonsoft.Json; -using NodaTime; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Contents.State -{ - public sealed class ContentStateScheduleItem : IContentScheduleItem - { - [JsonProperty] - public Instant ScheduledAt { get; set; } - - [JsonProperty] - public RefToken ScheduledBy { get; set; } - - [JsonProperty] - public Status ScheduledTo { get; set; } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs index 0eb3c7562..3990c07b1 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs @@ -20,49 +20,49 @@ namespace Squidex.Domain.Apps.Entities.Schemas : base(typeNameRegistry) { AddEventMessage( - "created schema {[Name]}"); + "created schema {[Name]}."); AddEventMessage( - "updated schema {[Name]}"); + "updated schema {[Name]}."); AddEventMessage( - "deleted schema {[Name]}"); + "deleted schema {[Name]}."); AddEventMessage( - "published schema {[Name]}"); + "published schema {[Name]}."); AddEventMessage( - "unpublished schema {[Name]}"); + "unpublished schema {[Name]}."); AddEventMessage( - "reordered fields of schema {[Name]}"); + "reordered fields of schema {[Name]}."); AddEventMessage( - "added field {[Field]} to schema {[Name]}"); + "added field {[Field]} to schema {[Name]}."); AddEventMessage( - "deleted field {[Field]} from schema {[Name]}"); + "deleted field {[Field]} from schema {[Name]}."); AddEventMessage( - "has locked field {[Field]} of schema {[Name]}"); + "has locked field {[Field]} of schema {[Name]}."); AddEventMessage( - "has hidden field {[Field]} of schema {[Name]}"); + "has hidden field {[Field]} of schema {[Name]}."); AddEventMessage( - "has shown field {[Field]} of schema {[Name]}"); + "has shown field {[Field]} of schema {[Name]}."); AddEventMessage( - "disabled field {[Field]} of schema {[Name]}"); + "disabled field {[Field]} of schema {[Name]}."); AddEventMessage( - "disabled field {[Field]} of schema {[Name]}"); + "disabled field {[Field]} of schema {[Name]}."); AddEventMessage( - "has updated field {[Field]} of schema {[Name]}"); + "has updated field {[Field]} of schema {[Name]}."); AddEventMessage( - "deleted field {[Field]} of schema {[Name]}"); + "deleted field {[Field]} of schema {[Name]}."); } protected override Task CreateEventCoreAsync(Envelope @event) diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs new file mode 100644 index 000000000..152c1f1a6 --- /dev/null +++ b/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 + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs new file mode 100644 index 000000000..7de7ccfcd --- /dev/null +++ b/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; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs index f5a497941..13f10244a 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs @@ -129,6 +129,11 @@ namespace Squidex.Areas.Api.Controllers.Contents itemModel.Data = item.Data.ToApiModel(result.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); } + if (item.PendingData != null && isFrontendClient) + { + itemModel.PendingData = item.PendingData.ToApiModel(result.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + } + return itemModel; }).ToArray() }; @@ -165,7 +170,15 @@ namespace Squidex.Areas.Api.Controllers.Contents { var isFrontendClient = User.IsFrontendClient(); - response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + if (entity.Data != null) + { + response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + } + + if (entity.PendingData != null && isFrontendClient) + { + response.PendingData = entity.PendingData.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + } } Response.Headers["ETag"] = entity.Version.ToString(); @@ -195,15 +208,23 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(1)] public async Task GetContentVersion(string app, string name, Guid id, int version) { - var content = await contentQuery.FindContentAsync(App, name, User, id, version); + var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id, version); - var response = SimpleMapper.Map(content.Content, new ContentDto()); + var response = SimpleMapper.Map(entity, new ContentDto()); - if (content.Content.Data != null) + if (entity.Data != null) { var isFrontendClient = User.IsFrontendClient(); - response.Data = content.Content.Data.ToApiModel(content.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + if (entity.Data != null) + { + response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + } + + if (entity.PendingData != null && isFrontendClient) + { + response.PendingData = entity.PendingData.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + } } Response.Headers["ETag"] = version.ToString(); @@ -251,6 +272,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the schema. /// The id of the content item to update. /// The full data for the content item. + /// Indicates whether the update is a proposal. /// /// 200 => Content updated. /// 404 => Content, schema or app not found. @@ -263,11 +285,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request) + public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false) { await contentQuery.FindSchemaAsync(App, name); - var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() }; + var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal }; var context = await CommandBus.PublishAsync(command); @@ -284,6 +306,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the schema. /// The id of the content item to patch. /// The patch for the content item. + /// Indicates whether the patch is a proposal. /// /// 200 => Content patched. /// 404 => Content, schema or app not found. @@ -296,11 +319,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPatch] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request) + public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false) { await contentQuery.FindSchemaAsync(App, name); - var command = new PatchContent { ContentId = id, Data = request.ToCleaned() }; + var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal }; var context = await CommandBus.PublishAsync(command); @@ -430,6 +453,35 @@ namespace Squidex.Areas.Api.Controllers.Contents return NoContent(); } + /// + /// Discard changes of a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to discard changes. + /// + /// 204 => Content restored. + /// 404 => Content, schema or app not found. + /// 400 => Content was not archived. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/discard/")] + [ApiCosts(1)] + public async Task DiscardChanges(string app, string name, Guid id) + { + await contentQuery.FindSchemaAsync(App, name); + + var command = new DiscardChanges { ContentId = id }; + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + /// /// Delete a content item. /// diff --git a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs index 2be029606..aad5220ca 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs @@ -40,6 +40,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models [Required] public object Data { get; set; } + /// + /// The pending changes of the content item. + /// + public object PendingData { get; set; } + /// /// The scheduled status. /// diff --git a/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs b/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs index 39f8755be..86b4eda20 100644 --- a/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs +++ b/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs @@ -25,6 +25,12 @@ namespace Squidex.Areas.Api.Controllers.History.Models [Required] public string Actor { get; set; } + /// + /// The type of the event. + /// + [Required] + public string EventType { get; set; } + /// /// Gets a unique id for the event. /// diff --git a/src/Squidex/app/features/content/declarations.ts b/src/Squidex/app/features/content/declarations.ts index 7d1afc96f..40fb98d05 100644 --- a/src/Squidex/app/features/content/declarations.ts +++ b/src/Squidex/app/features/content/declarations.ts @@ -11,6 +11,8 @@ export * from './pages/content/content-page.component'; export * from './pages/contents/contents-page.component'; export * from './pages/contents/search-form.component'; 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/references-editor.component'; \ No newline at end of file diff --git a/src/Squidex/app/features/content/module.ts b/src/Squidex/app/features/content/module.ts index aea408271..e6d84e1d4 100644 --- a/src/Squidex/app/features/content/module.ts +++ b/src/Squidex/app/features/content/module.ts @@ -24,6 +24,7 @@ import { ContentHistoryComponent, ContentPageComponent, ContentItemComponent, + ContentStatusComponent, ContentsPageComponent, ReferencesEditorComponent, SchemasPageComponent, @@ -115,6 +116,7 @@ const routes: Routes = [ ContentHistoryComponent, ContentItemComponent, ContentPageComponent, + ContentStatusComponent, ContentsPageComponent, ReferencesEditorComponent, SchemasPageComponent, diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index 12f2deb59..671f8f9f5 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -5,22 +5,48 @@
+ - - - + + + + + + + - +

diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.scss b/src/Squidex/app/features/content/pages/content/content-page.component.scss index fbb752506..5574c32ba 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.scss +++ b/src/Squidex/app/features/content/pages/content/content-page.component.scss @@ -1,2 +1,18 @@ @import '_vars'; -@import '_mixins'; \ No newline at end of file +@import '_mixins'; + +.content-status { + & { + color: $color-text; + } + + &:hover { + background: transparent; + } +} + +.discard-changes { + color: $color-theme-blue !important; + font-size: .9rem; + font-weight: normal; +} \ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index fceb55df8..cc4110496 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -165,15 +165,26 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, this.enableContentForm(); }); } else { - this.contentsService.putContent(this.ctx.appName, this.schema.name, this.content.id, requestDto, this.content.version) + this.contentsService.putContent(this.ctx.appName, this.schema.name, this.content.id, requestDto, !publish, this.content.version) .subscribe(dto => { - const content = this.content.update(dto.payload, this.ctx.userToken, dto.version); + let content = this.content; - this.ctx.notifyInfo('Content saved successfully.'); + if (dto.version.value !== content.version.value) { + if (publish) { + content = this.content.update(dto.payload, this.ctx.userToken, dto.version); + } else { + content = this.content.proposeUpdate(dto.payload, this.ctx.userToken, dto.version); + } + + this.ctx.notifyInfo('Content saved successfully.'); + + this.emitContentUpdated(content); + this.reloadContentForm(content); + } else { + this.ctx.notifyInfo('Content has not changed.'); + } - this.emitContentUpdated(content); this.enableContentForm(); - this.reloadContentForm(content); }, error => { this.ctx.notifyError(error); @@ -185,6 +196,34 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, } } + public confirmChanges() { + this.contentsService.changeContentStatus(this.ctx.appName, this.schema.name, this.content.id, 'publish', null, this.content.version) + .subscribe(dto => { + const content = this.content.confirmChanges(this.ctx.userToken, dto.version); + + this.ctx.notifyInfo('Content changes have been updated.'); + + this.emitContentUpdated(content); + this.reloadContentForm(content); + }, error => { + this.ctx.notifyError(error); + }); + } + + public discardChanges() { + this.contentsService.discardChanges(this.ctx.appName, this.schema.name, this.content.id, this.content.version) + .subscribe(dto => { + const content = this.content.discardChanges(this.ctx.userToken, dto.version); + + this.ctx.notifyInfo('Content changes have been discarded.'); + + this.emitContentUpdated(content); + this.reloadContentForm(content); + }, error => { + this.ctx.notifyError(error); + }); + } + private loadVersion(version: number) { if (!this.isNewMode && this.content) { this.contentsService.getVersionData(this.ctx.appName, this.schema.name, this.content.id, new Version(version.toString())) @@ -274,7 +313,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, if (!this.isNewMode) { for (const field of this.schema.fields) { - const fieldValue = this.content.data[field.name] || {}; + const fieldValue = this.content.displayData[field.name] || {}; const fieldForm = this.contentForm.controls[field.name]; if (field.isLocalizable) { diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts index 7f984b731..6b0af2eeb 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts @@ -94,7 +94,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit { this.contentUpdatedSubscription = this.ctx.bus.of(ContentUpdated) .subscribe(message => { - this.contentItems = this.contentItems.replaceBy('id', message.content, (o, n) => o.update(n.data, n.lastModifiedBy, n.version, n.lastModified)); + this.contentItems = this.contentItems.replaceBy('id', message.content); }); const routeData = allData(this.ctx.route); diff --git a/src/Squidex/app/features/content/shared/content-item.component.html b/src/Squidex/app/features/content/shared/content-item.component.html index ccc5f9ba0..68c773fb9 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.html +++ b/src/Squidex/app/features/content/shared/content-item.component.html @@ -56,21 +56,12 @@

- - - - - - {{content.status}} - - - - - - - - Will be set to '{{content.scheduledTo}}' at {{content.scheduledAt | sqxFullDateTime}} - + + {{content.lastModified | sqxFromNow}} diff --git a/src/Squidex/app/features/content/shared/content-item.component.scss b/src/Squidex/app/features/content/shared/content-item.component.scss index a9239ad91..fd7bf32b0 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.scss +++ b/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; - } } \ No newline at end of file diff --git a/src/Squidex/app/features/content/shared/content-item.component.ts b/src/Squidex/app/features/content/shared/content-item.component.ts index 794b20523..76370eba6 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.ts +++ b/src/Squidex/app/features/content/shared/content-item.component.ts @@ -174,7 +174,7 @@ export class ContentItemComponent implements OnInit, OnChanges { } private getRawValue(field: FieldDto): any { - const contentField = this.content.data[field.name]; + const contentField = this.content.displayData[field.name]; if (contentField) { if (field.isLocalizable) { diff --git a/src/Squidex/app/features/content/shared/content-status.component.html b/src/Squidex/app/features/content/shared/content-status.component.html new file mode 100644 index 000000000..eb969c208 --- /dev/null +++ b/src/Squidex/app/features/content/shared/content-status.component.html @@ -0,0 +1,17 @@ + + + + + + {{displayStatus}} + + + + + + + + Will be set to '{{scheduledTo}}' at {{scheduledAt | sqxFullDateTime}} + + +{{displayStatus}} \ No newline at end of file diff --git a/src/Squidex/app/features/content/shared/content-status.component.scss b/src/Squidex/app/features/content/shared/content-status.component.scss new file mode 100644 index 000000000..333a1444b --- /dev/null +++ b/src/Squidex/app/features/content/shared/content-status.component.scss @@ -0,0 +1,34 @@ +@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; + } + + &-tooltip { + @include border-radius; + background: $color-tooltip; + border: 0; + font-size: .9rem; + font-weight: normal; + color: $color-dark-foreground; + padding: .75rem; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/content/shared/content-status.component.ts b/src/Squidex/app/features/content/shared/content-status.component.ts new file mode 100644 index 000000000..7592ccc16 --- /dev/null +++ b/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 { Component, ChangeDetectionStrategy, Input } from '@angular/core'; + +import { DateTime } from '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 hasPendingChanges: any; + + @Input() + public showLabel = false; + + public get displayStatus() { + return !!this.hasPendingChanges ? 'Pending' : this.status; + } +} + diff --git a/src/Squidex/app/shared/services/contents.service.spec.ts b/src/Squidex/app/shared/services/contents.service.spec.ts index 19f8f4044..7f6e1b360 100644 --- a/src/Squidex/app/shared/services/contents.service.spec.ts +++ b/src/Squidex/app/shared/services/contents.service.spec.ts @@ -28,7 +28,7 @@ describe('ContentDto', () => { const newVersion = new Version('2'); it('should update data property and user info when updating', () => { - const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, version); + const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version); const content_2 = content_1.update({ data: 2 }, modifier, newVersion, modified); expect(content_2.data).toEqual({ data: 2 }); @@ -38,7 +38,7 @@ describe('ContentDto', () => { }); it('should update status property and user info when changing status', () => { - const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, version); + const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version); const content_2 = content_1.changeStatus('Published', null, modifier, newVersion, modified); expect(content_2.status).toEqual('Published'); @@ -48,7 +48,7 @@ describe('ContentDto', () => { }); it('should update schedules property and user info when changing status with due time', () => { - const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, version); + const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version); const content_2 = content_1.changeStatus('Published', dueTime, modifier, newVersion, modified); expect(content_2.status).toEqual('Draft'); @@ -63,7 +63,7 @@ describe('ContentDto', () => { it('should update data property when setting data', () => { const newData = {}; - const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, version); + const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version); const content_2 = content_1.setData(newData); expect(content_2.data).toBe(newData); @@ -142,6 +142,7 @@ describe('ContentsService', () => { 'Scheduler1', DateTime.parseISO_UTC('2018-12-12T10:10'), {}, + undefined, new Version('11')), new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2', DateTime.parseISO_UTC('2016-10-12T10:10'), @@ -150,6 +151,7 @@ describe('ContentsService', () => { null, null, {}, + undefined, new Version('22')) ])); })); @@ -232,6 +234,7 @@ describe('ContentsService', () => { 'Scheduler1', DateTime.parseISO_UTC('2018-12-12T10:10'), {}, + undefined, new Version('2'))); })); @@ -273,6 +276,7 @@ describe('ContentsService', () => { null, null, {}, + undefined, new Version('2'))); })); @@ -302,9 +306,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?asProposal=true'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBe(version.value); diff --git a/src/Squidex/app/shared/services/contents.service.ts b/src/Squidex/app/shared/services/contents.service.ts index 1c2aae0c4..d5b1e6a6c 100644 --- a/src/Squidex/app/shared/services/contents.service.ts +++ b/src/Squidex/app/shared/services/contents.service.ts @@ -29,6 +29,8 @@ export class ContentsDto { } export class ContentDto { + public displayData: any; + constructor( public readonly id: string, public readonly status: string, @@ -40,8 +42,10 @@ export class ContentDto { public readonly scheduledBy: string | null, public readonly scheduledAt: DateTime | null, public readonly data: any, + public readonly pendingData: any, public readonly version: Version ) { + this.displayData = pendingData || data; } public setData(data: any): ContentDto { @@ -56,6 +60,7 @@ export class ContentDto { this.scheduledBy, this.scheduledAt, data, + this.pendingData, this.version); } @@ -70,6 +75,7 @@ export class ContentDto { user, dueTime, this.data, + this.pendingData, version); } else { return new ContentDto( @@ -81,6 +87,7 @@ export class ContentDto { null, null, this.data, + this.pendingData, version); } } @@ -95,6 +102,49 @@ export class ContentDto { this.scheduledBy, this.scheduledAt, data, + this.pendingData, + version); + } + + public proposeUpdate(data: any, user: string, version: Version, now?: DateTime): ContentDto { + return new ContentDto( + this.id, + this.status, + this.createdBy, user, + this.created, now || DateTime.now(), + this.scheduledTo, + this.scheduledBy, + this.scheduledAt, + this.data, + data, + version); + } + + public confirmChanges(user: string, version: Version, now?: DateTime): ContentDto { + return new ContentDto( + this.id, + this.status, + this.createdBy, user, + this.created, now || DateTime.now(), + this.scheduledTo, + this.scheduledBy, + this.scheduledAt, + this.pendingData, + null, + version); + } + + public discardChanges(user: string, version: Version, now?: DateTime): ContentDto { + return new ContentDto( + this.id, + this.status, + this.createdBy, user, + this.created, now || DateTime.now(), + this.scheduledTo, + this.scheduledBy, + this.scheduledAt, + this.data, + null, version); } } @@ -159,6 +209,7 @@ export class ContentsService { item.scheduledBy || null, item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null, item.data, + item.pendingData, new Version(item.version.toString())); })); }) @@ -183,6 +234,7 @@ export class ContentsService { body.scheduledBy || null, body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null, body.data, + body.pendingData, response.version); }) .pretifyError('Failed to load content. Please reload.'); @@ -216,6 +268,7 @@ export class ContentsService { null, null, body.data, + body.pendingData, response.version); }) .do(content => { @@ -224,8 +277,8 @@ export class ContentsService { .pretifyError('Failed to create content. Please reload.'); } - public putContent(appName: string, schemaName: string, id: string, dto: any, version: Version): Observable> { - const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); + public putContent(appName: string, schemaName: string, id: string, dto: any, asProposal: boolean, version: Version): Observable> { + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}?asProposal=${asProposal}`); return HTTP.putVersioned(this.http, url, dto, version) .map(response => { @@ -254,6 +307,16 @@ export class ContentsService { .pretifyError('Failed to update content. Please reload.'); } + public discardChanges(appName: string, schemaName: string, id: string, version: Version): Observable> { + 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> { const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs index f25e736e5..267b0657f 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs @@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards } [Fact] - public void CanRename_not_should_throw_exception_if_name_are_different() + public void CanRename_should_not_throw_exception_if_name_are_different() { var command = new RenameAsset { FileName = "new-name" }; diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs index 587a62955..19ee41c27 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs +++ b/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 }; - Assert.Throws(() => GuardContent.CanChangeContentStatus(Status.Archived, command)); + Assert.Throws(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command)); } [Fact] @@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new ChangeContentStatus { Status = Status.Published }; - Assert.Throws(() => GuardContent.CanChangeContentStatus(Status.Archived, command)); + Assert.Throws(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command)); } [Fact] @@ -87,15 +87,31 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast }; - Assert.Throws(() => GuardContent.CanChangeContentStatus(Status.Draft, command)); + Assert.Throws(() => GuardContent.CanChangeContentStatus(null, Status.Draft, command)); } [Fact] - public void CanChangeContentStatus_not_should_throw_exception_if_status_flow_valid() + public void CanChangeContentStatus_should_throw_exception_republishing_without_pending_changes() { var command = new ChangeContentStatus { Status = Status.Published }; - GuardContent.CanChangeContentStatus(Status.Draft, command); + Assert.Throws(() => GuardContent.CanChangeContentStatus(null, Status.Published, command)); + } + + [Fact] + public void CanChangeContentStatus_should_not_throw_exception_republishing_with_pending_changes() + { + var command = new ChangeContentStatus { Status = Status.Published }; + + GuardContent.CanChangeContentStatus(new NamedContentData(), Status.Published, command); + } + + [Fact] + public void CanChangeContentStatus_should_not_throw_exception_if_status_flow_valid() + { + var command = new ChangeContentStatus { Status = Status.Published }; + + GuardContent.CanChangeContentStatus(null, Status.Draft, command); } [Fact]