diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentRestored.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentRestored.cs new file mode 100644 index 000000000..f2a7659ed --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentRestored.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// ContentRestored.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Domain.Apps.Events.Contents +{ + [EventType(nameof(ContentRestored))] + public sealed class ContentRestored : ContentEvent + { + } +} diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentEntity.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentEntity.cs index 97112ef82..74acd25db 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentEntity.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentEntity.cs @@ -47,6 +47,10 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents [BsonElement("pu")] public bool IsPublished { get; set; } + [BsonRequired] + [BsonElement("dl")] + public bool IsDeleted { get; set; } + [BsonRequired] [BsonElement("dt")] public string DataText { get; set; } diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs index 2c3ef026e..918593ea8 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs @@ -62,6 +62,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.SchemaId).Descending(x => x.LastModified)); await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds)); await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.IsPublished)); + await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.IsDeleted)); await collection.Indexes.CreateOneAsync(Index.Text(x => x.DataText)); }); } @@ -114,6 +115,17 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents }); } + protected Task On(ContentRestored @event, EnvelopeHeaders headers) + { + return ForAppIdAsync(@event.AppId.Id, collection => + { + return collection.UpdateAsync(@event, headers, x => + { + x.IsDeleted = false; + }); + }); + } + protected Task On(ContentDeleted @event, EnvelopeHeaders headers) { return ForAppIdAsync(@event.AppId.Id, async collection => @@ -124,7 +136,10 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.ContentId)), Update.AddToSet(x => x.ReferencedIdsDeleted, @event.ContentId)); - await collection.DeleteOneAsync(x => x.Id == headers.AggregateId()); + await collection.UpdateAsync(@event, headers, x => + { + x.IsDeleted = true; + }); }); } diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/Visitors/FindExtensions.cs index c6fa3880a..47b6a56ba 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/Visitors/FindExtensions.cs @@ -67,7 +67,8 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents.Visitors { var filters = new List> { - Filter.Eq(x => x.SchemaId, schemaId) + Filter.Eq(x => x.SchemaId, schemaId), + Filter.Eq(x => x.IsDeleted, false) }; if (!nonPublished) diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventEntity.cs b/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventEntity.cs index e8ea33263..b0563ae70 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventEntity.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventEntity.cs @@ -22,7 +22,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.History [BsonRequired] [BsonElement] - public int SessionEventIndex { get; set; } + public long Version { get; set; } [BsonRequired] [BsonElement] diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventRepository.cs b/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventRepository.cs index ba3ef3fe7..f597ea9fd 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventRepository.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventRepository.cs @@ -9,7 +9,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; using Squidex.Domain.Apps.Events; @@ -25,7 +24,6 @@ namespace Squidex.Domain.Apps.Read.MongoDb.History { private readonly List creators; private readonly Dictionary texts = new Dictionary(); - private int sessionEventCount; public string Name { @@ -64,7 +62,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.History .Ascending(x => x.AppId) .Ascending(x => x.Channel) .Descending(x => x.Created) - .Descending(x => x.SessionEventIndex)), + .Descending(x => x.Version)), collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Created), new CreateIndexOptions { ExpireAfter = TimeSpan.FromDays(365) })); } @@ -72,7 +70,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.History { var historyEventEntities = await Collection.Find(x => x.AppId == appId && x.Channel == channelPrefix) - .SortByDescending(x => x.Created).ThenByDescending(x => x.SessionEventIndex).Limit(count) + .SortByDescending(x => x.Created).ThenBy(x => x.Version).Limit(count) .ToListAsync(); return historyEventEntities.Select(x => (IHistoryEventEntity)new ParsedHistoryEvent(x, texts)).ToList(); @@ -90,7 +88,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.History { entity.Id = Guid.NewGuid(); - entity.SessionEventIndex = Interlocked.Increment(ref sessionEventCount); + entity.Version = @event.Headers.EventStreamNumber(); entity.Channel = message.Channel; entity.Message = message.Message; diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/History/ParsedHistoryEvent.cs b/src/Squidex.Domain.Apps.Read.MongoDb/History/ParsedHistoryEvent.cs index cee3487f7..461af2374 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/History/ParsedHistoryEvent.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/History/ParsedHistoryEvent.cs @@ -44,6 +44,11 @@ namespace Squidex.Domain.Apps.Read.MongoDb.History get { return inner.LastModified; } } + public long Version + { + get { return inner.Version; } + } + public string Channel { get { return inner.Channel; } diff --git a/src/Squidex.Domain.Apps.Read/Contents/ContentHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Read/Contents/ContentHistoryEventsCreator.cs index 0943801d9..4a6c25ba3 100644 --- a/src/Squidex.Domain.Apps.Read/Contents/ContentHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Read/Contents/ContentHistoryEventsCreator.cs @@ -20,19 +20,22 @@ namespace Squidex.Domain.Apps.Read.Contents : base(typeNameRegistry) { AddEventMessage( - "created content element."); + "created content item."); AddEventMessage( - "updated content element."); + "updated content item."); AddEventMessage( - "deleted content element."); + "deleted content item."); + + AddEventMessage( + "restored content item."); AddEventMessage( - "published content element."); + "published content item."); AddEventMessage( - "unpublished content element."); + "unpublished content item."); } protected override Task CreateEventCoreAsync(Envelope @event) diff --git a/src/Squidex.Domain.Apps.Read/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Read/Contents/ContentQueryService.cs index 732d698ba..1177c3620 100644 --- a/src/Squidex.Domain.Apps.Read/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Read/Contents/ContentQueryService.cs @@ -156,6 +156,7 @@ namespace Squidex.Domain.Apps.Read.Contents public Guid Id { get; set; } public Guid AppId { get; set; } public long Version { get; set; } + public bool IsDeleted { get; set; } public bool IsPublished { get; set; } public Instant Created { get; set; } public Instant LastModified { get; set; } diff --git a/src/Squidex.Domain.Apps.Read/Contents/IContentEntity.cs b/src/Squidex.Domain.Apps.Read/Contents/IContentEntity.cs index 4e1f355a5..64f2b3970 100644 --- a/src/Squidex.Domain.Apps.Read/Contents/IContentEntity.cs +++ b/src/Squidex.Domain.Apps.Read/Contents/IContentEntity.cs @@ -14,6 +14,8 @@ namespace Squidex.Domain.Apps.Read.Contents { bool IsPublished { get; } + bool IsDeleted { get; } + NamedContentData Data { get; } } } diff --git a/src/Squidex.Domain.Apps.Read/History/IHistoryEventEntity.cs b/src/Squidex.Domain.Apps.Read/History/IHistoryEventEntity.cs index 5a7df96e9..ab35d743b 100644 --- a/src/Squidex.Domain.Apps.Read/History/IHistoryEventEntity.cs +++ b/src/Squidex.Domain.Apps.Read/History/IHistoryEventEntity.cs @@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Read.History string Message { get; } + long Version { get; } + RefToken Actor { get; } } } diff --git a/src/Squidex.Domain.Apps.Write/Contents/Commands/RestoreContent.cs b/src/Squidex.Domain.Apps.Write/Contents/Commands/RestoreContent.cs new file mode 100644 index 000000000..3563aa716 --- /dev/null +++ b/src/Squidex.Domain.Apps.Write/Contents/Commands/RestoreContent.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// RestoreContent.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Write.Contents.Commands +{ + public sealed class RestoreContent : ContentCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs b/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs index 7cc97d1f4..4f055b2e9 100644 --- a/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs @@ -150,6 +150,14 @@ namespace Squidex.Domain.Apps.Write.Contents }); } + protected Task On(RestoreContent command, CommandContext context) + { + return handler.UpdateAsync(context, content => + { + content.Restore(command); + }); + } + public async Task HandleAsync(CommandContext context, Func next) { if (!await this.DispatchActionAsync(context.Command, context)) diff --git a/src/Squidex.Domain.Apps.Write/Contents/ContentDomainObject.cs b/src/Squidex.Domain.Apps.Write/Contents/ContentDomainObject.cs index df6ae3a99..d4812c129 100644 --- a/src/Squidex.Domain.Apps.Write/Contents/ContentDomainObject.cs +++ b/src/Squidex.Domain.Apps.Write/Contents/ContentDomainObject.cs @@ -72,6 +72,11 @@ namespace Squidex.Domain.Apps.Write.Contents isDeleted = true; } + protected void On(ContentRestored @event) + { + isDeleted = false; + } + public ContentDomainObject Create(CreateContent command) { Guard.Valid(command, nameof(command), () => "Cannot create content"); @@ -99,6 +104,17 @@ namespace Squidex.Domain.Apps.Write.Contents return this; } + public ContentDomainObject Restore(RestoreContent command) + { + Guard.NotNull(command, nameof(command)); + + VerifyDeleted(); + + RaiseEvent(SimpleMapper.Map(command, new ContentRestored())); + + return this; + } + public ContentDomainObject Publish(PublishContent command) { Guard.NotNull(command, nameof(command)); @@ -159,6 +175,14 @@ namespace Squidex.Domain.Apps.Write.Contents } } + private void VerifyDeleted() + { + if (!isDeleted) + { + throw new DomainException("Content has not been deleted."); + } + } + private void VerifyCreatedAndNotDeleted() { if (isDeleted || !isCreated) diff --git a/src/Squidex/Controllers/Api/History/Models/HistoryEventDto.cs b/src/Squidex/Controllers/Api/History/Models/HistoryEventDto.cs index 4eff213d3..92bfeb91e 100644 --- a/src/Squidex/Controllers/Api/History/Models/HistoryEventDto.cs +++ b/src/Squidex/Controllers/Api/History/Models/HistoryEventDto.cs @@ -35,5 +35,10 @@ namespace Squidex.Controllers.Api.History.Models /// The time when the event happened. /// public Instant Created { get; set; } + + /// + /// The version identifier. + /// + public long Version { get; set; } } } diff --git a/src/Squidex/Controllers/ContentApi/ContentsController.cs b/src/Squidex/Controllers/ContentApi/ContentsController.cs index 2484c7028..5ac516a9b 100644 --- a/src/Squidex/Controllers/ContentApi/ContentsController.cs +++ b/src/Squidex/Controllers/ContentApi/ContentsController.cs @@ -208,6 +208,21 @@ namespace Squidex.Controllers.ContentApi return NoContent(); } + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/restore")] + [ApiCosts(1)] + public async Task RestoreContent(string name, Guid id) + { + await contentQuery.FindSchemaAsync(App, name); + + var command = new RestoreContent { ContentId = id, User = User }; + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + [MustBeAppEditor] [HttpDelete] [Route("content/{app}/{name}/{id}")] diff --git a/src/Squidex/Controllers/ContentApi/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Controllers/ContentApi/Generator/SchemaSwaggerGenerator.cs index 7bc4e2f3c..53e649ea1 100644 --- a/src/Squidex/Controllers/ContentApi/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Controllers/ContentApi/Generator/SchemaSwaggerGenerator.cs @@ -92,6 +92,7 @@ namespace Squidex.Controllers.ContentApi.Generator GenerateSchemaGetOperation(), GenerateSchemaUpdateOperation(), GenerateSchemaPatchOperation(), + GenerateSchemaRestoreOperation(), GenerateSchemaPublishOperation(), GenerateSchemaUnpublishOperation(), GenerateSchemaDeleteOperation() @@ -109,6 +110,7 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Query{schemaKey}Contents"; operation.Summary = $"Queries {schemaName} contents."; + operation.Security = ReaderSecurity; operation.Description = SchemaQueryDescription; @@ -119,8 +121,6 @@ namespace Squidex.Controllers.ContentApi.Generator operation.AddQueryParameter("orderby", JsonObjectType.String, "Optional OData order definition."); operation.AddResponse("200", $"{schemaName} content retrieved.", CreateContentsSchema(schemaName, contentSchema)); - - operation.Security = ReaderSecurity; }); } @@ -130,10 +130,9 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Get{schemaKey}Content"; operation.Summary = $"Get a {schemaName} content."; + operation.Security = ReaderSecurity; operation.AddResponse("200", $"{schemaName} content found.", contentSchema); - - operation.Security = ReaderSecurity; }); } @@ -143,13 +142,12 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Create{schemaKey}Content"; operation.Summary = $"Create a {schemaName} content."; + operation.Security = EditorSecurity; operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); operation.AddQueryParameter("publish", JsonObjectType.Boolean, "Set to true to autopublish content."); operation.AddResponse("201", $"{schemaName} created.", contentSchema); - - operation.Security = EditorSecurity; }); } @@ -159,12 +157,11 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Update{schemaKey}Content"; operation.Summary = $"Update a {schemaName} content."; + operation.Security = EditorSecurity; operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); operation.AddResponse("201", $"{schemaName} element updated.", dataSchema); - - operation.Security = EditorSecurity; }); } @@ -174,12 +171,11 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Path{schemaKey}Content"; operation.Summary = $"Patchs a {schemaName} content."; + operation.Security = EditorSecurity; operation.AddBodyParameter("data", contentSchema, SchemaBodyDescription); operation.AddResponse("201", $"{schemaName} element patched.", dataSchema); - - operation.Security = EditorSecurity; }); } @@ -189,10 +185,9 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Publish{schemaKey}Content"; operation.Summary = $"Publish a {schemaName} content."; + operation.Security = EditorSecurity; operation.AddResponse("204", $"{schemaName} element published."); - - operation.Security = EditorSecurity; }); } @@ -202,10 +197,21 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Unpublish{schemaKey}Content"; operation.Summary = $"Unpublish a {schemaName} content."; + operation.Security = EditorSecurity; operation.AddResponse("204", $"{schemaName} element unpublished."); + }); + } + private SwaggerOperations GenerateSchemaRestoreOperation() + { + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/restore", operation => + { + operation.OperationId = $"Restore{schemaKey}Content"; + operation.Summary = $"Restore a {schemaName} content."; operation.Security = EditorSecurity; + + operation.AddResponse("204", $"{schemaName} element restored."); }); } @@ -215,10 +221,9 @@ namespace Squidex.Controllers.ContentApi.Generator { operation.OperationId = $"Delete{schemaKey}Content"; operation.Summary = $"Delete a {schemaName} content."; + operation.Security = EditorSecurity; operation.AddResponse("204", $"{schemaName} content deleted."); - - operation.Security = EditorSecurity; }); } diff --git a/src/Squidex/Controllers/ContentApi/Models/ContentDto.cs b/src/Squidex/Controllers/ContentApi/Models/ContentDto.cs index 3457d10e8..37469c6c3 100644 --- a/src/Squidex/Controllers/ContentApi/Models/ContentDto.cs +++ b/src/Squidex/Controllers/ContentApi/Models/ContentDto.cs @@ -52,15 +52,25 @@ namespace Squidex.Controllers.ContentApi.Models public Instant LastModified { get; set; } /// - /// Indicates if the content element is publihed. + /// Indicates if the content element is published. /// public bool? IsPublished { get; set; } + /// + /// Indicates if the content element is deleted. + /// + public bool IsDeleted { get; set; } + /// /// The version of the content. /// public long Version { get; set; } + public bool ShouldSerializeIsDeleted() + { + return IsDeleted; + } + public static ContentDto Create(CreateContent command, EntityCreatedResult result) { var now = SystemClock.Instance.GetCurrentInstant(); diff --git a/src/Squidex/app/features/content/declarations.ts b/src/Squidex/app/features/content/declarations.ts index 25ee9dac3..61875624f 100644 --- a/src/Squidex/app/features/content/declarations.ts +++ b/src/Squidex/app/features/content/declarations.ts @@ -6,6 +6,7 @@ */ export * from './pages/content/content-field.component'; +export * from './pages/content/content-history.component'; export * from './pages/content/content-page.component'; export * from './pages/contents/contents-page.component'; export * from './pages/contents/search-form.component'; diff --git a/src/Squidex/app/features/content/module.ts b/src/Squidex/app/features/content/module.ts index 5a605c7ea..7b91bb002 100644 --- a/src/Squidex/app/features/content/module.ts +++ b/src/Squidex/app/features/content/module.ts @@ -11,7 +11,6 @@ import { DndModule } from 'ng2-dnd'; import { CanDeactivateGuard, - HistoryComponent, ResolveAppLanguagesGuard, ResolveContentGuard, ResolvePublishedSchemaGuard, @@ -22,6 +21,7 @@ import { import { AssetsEditorComponent, ContentFieldComponent, + ContentHistoryComponent, ContentPageComponent, ContentItemComponent, ContentsPageComponent, @@ -76,7 +76,7 @@ const routes: Routes = [ children: [ { path: 'history', - component: HistoryComponent, + component: ContentHistoryComponent, data: { channel: 'contents.{contentId}' } @@ -112,6 +112,7 @@ const routes: Routes = [ declarations: [ AssetsEditorComponent, ContentFieldComponent, + ContentHistoryComponent, ContentItemComponent, ContentPageComponent, ContentsPageComponent, diff --git a/src/Squidex/app/features/content/pages/content/content-history.component.html b/src/Squidex/app/features/content/pages/content/content-history.component.html new file mode 100644 index 000000000..7175d30f3 --- /dev/null +++ b/src/Squidex/app/features/content/pages/content/content-history.component.html @@ -0,0 +1,29 @@ + +
+
+

Activity

+
+ + + + +
+ +
+
+
+
+ +
+
+
+ {{event.actor | sqxUserNameRef:'I'}} +
+
{{event.created | sqxFromNow}}
+ + Load this Version +
+
+
+
+
\ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/content/content-history.component.scss b/src/Squidex/app/features/content/pages/content/content-history.component.scss new file mode 100644 index 000000000..358649609 --- /dev/null +++ b/src/Squidex/app/features/content/pages/content/content-history.component.scss @@ -0,0 +1,39 @@ +@import '_vars'; +@import '_mixins'; + +.event { + & { + @include flex-box; + margin-bottom: 1rem; + } + + &-main { + @include flex-grow(1); + } + + &-load { + & { + font-size: .9rem; + font-weight: normal; + cursor: pointer; + color: $color-theme-blue !important; + } + + &:focus, + &:hover { + text-decoration: underline !important; + } + } + + &-left { + min-width: 2.8rem; + max-width: 2.8rem; + margin-top: .3rem; + } + + &-created { + font-size: .65rem; + font-weight: normal; + color: $color-text-decent; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/content/content-history.component.ts b/src/Squidex/app/features/content/pages/content/content-history.component.ts new file mode 100644 index 000000000..e6df00a82 --- /dev/null +++ b/src/Squidex/app/features/content/pages/content/content-history.component.ts @@ -0,0 +1,101 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; + +import { + allParams, + AppComponentBase, + AppsStoreService, + DialogService, + HistoryChannelUpdated, + HistoryEventDto, + HistoryService, + MessageBus, + UsersProviderService +} from 'shared'; + +const REPLACEMENT_TEMP = '$TEMP$'; + +@Component({ + selector: 'sqx-history', + styleUrls: ['./content-history.component.scss'], + templateUrl: './content-history.component.html' +}) +export class ContentHistoryComponent extends AppComponentBase { + public get channel(): string { + let channelPath = this.route.snapshot.data['channel']; + + if (channelPath) { + const params = allParams(this.route); + + for (let key in params) { + if (params.hasOwnProperty(key)) { + const value = params[key]; + + channelPath = channelPath.replace(`{${key}}`, value); + } + } + } + + return channelPath; + } + + public events: Observable = + Observable.timer(0, 10000) + .merge(this.messageBus.of(HistoryChannelUpdated).delay(1000)) + .switchMap(() => this.appNameOnce()) + .switchMap(app => this.historyService.getHistory(app, this.channel).retry(2)); + + constructor(appsStore: AppsStoreService, dialogs: DialogService, + private readonly users: UsersProviderService, + private readonly historyService: HistoryService, + private readonly messageBus: MessageBus, + private readonly route: ActivatedRoute + ) { + super(dialogs, appsStore); + } + + private userName(userId: string): Observable { + const parts = userId.split(':'); + + if (parts[0] === 'subject') { + return this.users.getUser(parts[1], 'Me').map(u => u.displayName); + } else { + if (parts[1].endsWith('client')) { + return Observable.of(parts[1]); + } else { + return Observable.of(`${parts[1]}-client`); + } + } + } + + public format(message: string): Observable { + let foundUserId: string | null = null; + + message = message.replace(/{([^\s:]*):([^}]*)}/, (match: string, type: string, id: string) => { + if (type === 'user') { + foundUserId = id; + return REPLACEMENT_TEMP; + } else { + return id; + } + }); + + message = message.replace(/{([^}]*)}/g, (match: string, marker: string) => { + return `${marker}`; + }); + + if (foundUserId) { + return this.userName(foundUserId).map(t => message.replace(REPLACEMENT_TEMP, `${t}`)); + } + + return Observable.of(message); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/messages.ts b/src/Squidex/app/features/content/pages/messages.ts index 580651082..569c201ad 100644 --- a/src/Squidex/app/features/content/pages/messages.ts +++ b/src/Squidex/app/features/content/pages/messages.ts @@ -26,4 +26,12 @@ export class ContentDeleted { public readonly content: ContentDto ) { } +} + +export class ContentVersionSelected { + constructor( + public readonly id: string, + public readonly version: number + ) { + } } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/history.component.scss b/src/Squidex/app/shared/components/history.component.scss index 0fe973d59..5e93d36ed 100644 --- a/src/Squidex/app/shared/components/history.component.scss +++ b/src/Squidex/app/shared/components/history.component.scss @@ -7,10 +7,6 @@ margin-bottom: 1rem; } - &-message { - font-size: .9rem; - } - &-main { @include flex-grow(1); } diff --git a/src/Squidex/app/shared/services/contents.service.spec.ts b/src/Squidex/app/shared/services/contents.service.spec.ts index 4c3c2db15..41eaa39cc 100644 --- a/src/Squidex/app/shared/services/contents.service.spec.ts +++ b/src/Squidex/app/shared/services/contents.service.spec.ts @@ -22,7 +22,7 @@ describe('ContentDto', () => { it('should update data property and user info when updating', () => { const now = DateTime.now(); - const content_1 = new ContentDto('1', false, 'other', 'other', DateTime.now(), DateTime.now(), { data: 1 }, null); + const content_1 = new ContentDto('1', false, false, 'other', 'other', DateTime.now(), DateTime.now(), { data: 1 }, null); const content_2 = content_1.update({ data: 2 }, 'me', now); expect(content_2.data).toEqual({ data: 2 }); @@ -33,7 +33,7 @@ describe('ContentDto', () => { it('should update isPublished property and user info when publishing', () => { const now = DateTime.now(); - const content_1 = new ContentDto('1', false, 'other', 'other', DateTime.now(), DateTime.now(), { data: 1 }, null); + const content_1 = new ContentDto('1', false, false, 'other', 'other', DateTime.now(), DateTime.now(), { data: 1 }, null); const content_2 = content_1.publish('me', now); expect(content_2.isPublished).toBeTruthy(); @@ -44,7 +44,7 @@ describe('ContentDto', () => { it('should update isPublished property and user info when unpublishing', () => { const now = DateTime.now(); - const content_1 = new ContentDto('1', true, 'other', 'other', DateTime.now(), DateTime.now(), { data: 1 }, null); + const content_1 = new ContentDto('1', true, false, 'other', 'other', DateTime.now(), DateTime.now(), { data: 1 }, null); const content_2 = content_1.unpublish('me', now); expect(content_2.isPublished).toBeFalsy(); @@ -115,12 +115,12 @@ describe('ContentsService', () => { expect(contents).toEqual( new ContentsDto(10, [ - new ContentDto('id1', true, 'Created1', 'LastModifiedBy1', + new ContentDto('id1', true, false, 'Created1', 'LastModifiedBy1', DateTime.parseISO_UTC('2016-12-12T10:10'), DateTime.parseISO_UTC('2017-12-12T10:10'), {}, new Version('11')), - new ContentDto('id2', true, 'Created2', 'LastModifiedBy2', + new ContentDto('id2', true, false, 'Created2', 'LastModifiedBy2', DateTime.parseISO_UTC('2016-10-12T10:10'), DateTime.parseISO_UTC('2017-10-12T10:10'), {}, @@ -205,7 +205,7 @@ describe('ContentsService', () => { }); expect(content).toEqual( - new ContentDto('id1', true, 'Created1', 'LastModifiedBy1', + new ContentDto('id1', true, false, 'Created1', 'LastModifiedBy1', DateTime.parseISO_UTC('2016-12-12T10:10'), DateTime.parseISO_UTC('2017-12-12T10:10'), {}, @@ -263,7 +263,7 @@ describe('ContentsService', () => { }); expect(content).toEqual( - new ContentDto('id1', true, 'Created1', 'LastModifiedBy1', + new ContentDto('id1', true, false, 'Created1', 'LastModifiedBy1', DateTime.parseISO_UTC('2016-12-12T10:10'), DateTime.parseISO_UTC('2017-12-12T10:10'), {}, diff --git a/src/Squidex/app/shared/services/contents.service.ts b/src/Squidex/app/shared/services/contents.service.ts index f38255c22..a4cad43c3 100644 --- a/src/Squidex/app/shared/services/contents.service.ts +++ b/src/Squidex/app/shared/services/contents.service.ts @@ -31,6 +31,7 @@ export class ContentDto { constructor( public readonly id: string, public readonly isPublished: boolean, + public readonly isDeleted: boolean, public readonly createdBy: string, public readonly lastModifiedBy: string, public readonly created: DateTime, @@ -44,6 +45,7 @@ export class ContentDto { return new ContentDto( this.id, true, + this.isDeleted, this.createdBy, user, this.created, now || DateTime.now(), this.data, @@ -54,6 +56,7 @@ export class ContentDto { return new ContentDto( this.id, false, + this.isDeleted, this.createdBy, user, this.created, now || DateTime.now(), this.data, @@ -64,6 +67,7 @@ export class ContentDto { return new ContentDto( this.id, this.isPublished, + this.isDeleted, this.createdBy, user, this.created, now || DateTime.now(), data, @@ -117,6 +121,7 @@ export class ContentsService { return new ContentDto( item.id, item.isPublished, + item.isDeleted === true, item.createdBy, item.lastModifiedBy, DateTime.parseISO_UTC(item.created), @@ -136,6 +141,7 @@ export class ContentsService { return new ContentDto( response.id, response.isPublished, + response.isDeleted === true, response.createdBy, response.lastModifiedBy, DateTime.parseISO_UTC(response.created), @@ -164,7 +170,7 @@ export class ContentsService { .map(response => { return new ContentDto( response.id, - response.isPublished, + response.isPublished, false, response.createdBy, response.lastModifiedBy, DateTime.parseISO_UTC(response.created), diff --git a/src/Squidex/app/shared/services/history.service.spec.ts b/src/Squidex/app/shared/services/history.service.spec.ts index 1badf7aba..5c1b651bb 100644 --- a/src/Squidex/app/shared/services/history.service.spec.ts +++ b/src/Squidex/app/shared/services/history.service.spec.ts @@ -52,20 +52,22 @@ describe('HistoryService', () => { actor: 'User1', eventId: '1', message: 'Message 1', + version: 2, created: '2016-12-12T10:10' }, { actor: 'User2', eventId: '2', message: 'Message 2', + version: 3, created: '2016-12-13T10:10' } ]); expect(events).toEqual( [ - new HistoryEventDto('1', 'User1', 'Message 1', DateTime.parseISO_UTC('2016-12-12T10:10')), - new HistoryEventDto('2', 'User2', 'Message 2', DateTime.parseISO_UTC('2016-12-13T10:10')) + new HistoryEventDto('1', 'User1', 'Message 1', 2, DateTime.parseISO_UTC('2016-12-12T10:10')), + new HistoryEventDto('2', 'User2', 'Message 2', 3, DateTime.parseISO_UTC('2016-12-13T10:10')) ]); })); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/history.service.ts b/src/Squidex/app/shared/services/history.service.ts index c27ba997e..987303cad 100644 --- a/src/Squidex/app/shared/services/history.service.ts +++ b/src/Squidex/app/shared/services/history.service.ts @@ -22,6 +22,7 @@ export class HistoryEventDto { public readonly eventId: string, public readonly actor: string, public readonly message: string, + public readonly version: number, public readonly created: DateTime ) { } @@ -47,6 +48,7 @@ export class HistoryService { item.eventId, item.actor, item.message, + item.version, DateTime.parseISO_UTC(item.created)); }); }) diff --git a/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs index 525b7d862..9a04d774a 100644 --- a/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentCommandMiddlewareTests.cs @@ -229,6 +229,21 @@ namespace Squidex.Domain.Apps.Write.Contents A.CallTo(() => scriptEngine.Execute(A.Ignored, "", "delete content")).MustHaveHappened(); } + [Fact] + public async Task Restore_should_update_domain_object() + { + CreateContent(); + + content.Delete(new DeleteContent()); + + var command = CreateContextForCommand(new RestoreContent { ContentId = contentId, User = user }); + + await TestUpdate(content, async _ => + { + await sut.HandleAsync(command); + }); + } + private void CreateContent() { content.Create(new CreateContent { Data = data }); diff --git a/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentDomainObjectTests.cs b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentDomainObjectTests.cs index be472b1fc..bd23910bb 100644 --- a/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentDomainObjectTests.cs +++ b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentDomainObjectTests.cs @@ -303,6 +303,31 @@ namespace Squidex.Domain.Apps.Write.Contents ); } + [Fact] + public void Restore_should_throw_exception_if_not_deleted() + { + Assert.Throws(() => + { + sut.Delete(CreateContentCommand(new DeleteContent())); + }); + } + + [Fact] + public void Restore_should_update_properties_create_events() + { + CreateContent(); + DeleteContent(); + + sut.Restore(CreateContentCommand(new RestoreContent())); + + Assert.False(sut.IsDeleted); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateContentEvent(new ContentRestored()) + ); + } + private void CreateContent() { sut.Create(CreateContentCommand(new CreateContent { Data = data }));