diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index d0a9c692c..21865a47b 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -390,6 +390,9 @@ "contents.autotranslate": "Autotranslate from master language", "contents.bulkFailed": "Failed to delete or update content. Please reload.", "contents.calendar": "Scheduled Contents", + "contents.cancelStatus": "Cancel scheduled status", + "contents.cancelStatusConfirmText": "Do you really want to cancel the scheduled status update?", + "contents.cancelStatusConfirmTitle": "Cancel scheduled status", "contents.changeStatusTo": "Change content item(s) to {action}", "contents.changeStatusToImmediately": "Set to {action} immediately.", "contents.changeStatusToLater": "Set to {action} at a later point date and time.", @@ -452,7 +455,7 @@ "contents.scheduledAt": "at", "contents.scheduledBy": "by", "contents.scheduledTo": "to", - "contents.scheduledToLabel": "Will be changed to", + "contents.scheduledToLabel": "Scheduled to", "contents.schemasPageTitle": "Contents", "contents.searchPlaceholder": "Fulltext search", "contents.searchSchemasPlaceholder": "Search", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index ae5506236..899073d4f 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -390,6 +390,9 @@ "contents.autotranslate": "Traduci in automatico dalla lingua principale", "contents.bulkFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.", "contents.calendar": "Scheduled Contents", + "contents.cancelStatus": "Cancel scheduled status", + "contents.cancelStatusConfirmText": "Do you really want to cancel the scheduled status update?", + "contents.cancelStatusConfirmTitle": "Cancel scheduled status", "contents.changeStatusTo": "Cambia gli elementi del contenuto in {action}", "contents.changeStatusToImmediately": "Imposta {action} immediatamente.", "contents.changeStatusToLater": "Imposta {action} ad una data e ora successiva.", @@ -452,7 +455,7 @@ "contents.scheduledAt": "alle", "contents.scheduledBy": "by", "contents.scheduledTo": "a", - "contents.scheduledToLabel": "Will be changed to", + "contents.scheduledToLabel": "Scheduled to", "contents.schemasPageTitle": "Contenuti", "contents.searchPlaceholder": "Ricerca testuale", "contents.searchSchemasPlaceholder": "Cerca schemi...", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 7da6ff950..d02bfce52 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -390,6 +390,9 @@ "contents.autotranslate": "Automatisch vertalen vanuit de hoofdtaal", "contents.bulkFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.", "contents.calendar": "Scheduled Contents", + "contents.cancelStatus": "Cancel scheduled status", + "contents.cancelStatusConfirmText": "Do you really want to cancel the scheduled status update?", + "contents.cancelStatusConfirmTitle": "Cancel scheduled status", "contents.changeStatusTo": "Verander inhoud item(s) in {action}", "contents.changeStatusToImmediately": "Zet onmiddellijk op {action}.", "contents.changeStatusToLater": "Zet op {action} op een latere datum en tijd.", @@ -452,7 +455,7 @@ "contents.scheduledAt": "bij", "contents.scheduledBy": "by", "contents.scheduledTo": "naar", - "contents.scheduledToLabel": "Will be changed to", + "contents.scheduledToLabel": "Scheduled to", "contents.schemasPageTitle": "Inhoud", "contents.searchPlaceholder": "Zoeken in volledige tekst", "contents.searchSchemasPlaceholder": "Zoek schema's ...", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index dc09acfbc..30aba46fd 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -390,6 +390,9 @@ "contents.autotranslate": "从母语自动翻译", "contents.bulkFailed": "删除或更新内容失败。请重新加载。", "contents.calendar": "Scheduled Contents", + "contents.cancelStatus": "Cancel scheduled status", + "contents.cancelStatusConfirmText": "Do you really want to cancel the scheduled status update?", + "contents.cancelStatusConfirmTitle": "Cancel scheduled status", "contents.changeStatusTo": "将内容项更改为 {action}", "contents.changeStatusToImmediately": "立即设置为 {action}。", "contents.changeStatusToLater": "在稍后的日期和时间设置为 {action}。", @@ -452,7 +455,7 @@ "contents.scheduledAt": "at", "contents.scheduledBy": "by", "contents.scheduledTo": "to", - "contents.scheduledToLabel": "Will be changed to", + "contents.scheduledToLabel": "Scheduled to", "contents.schemasPageTitle": "内容", "contents.searchPlaceholder": "全文搜索", "contents.searchSchemasPlaceholder": "搜索Schemas...", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index d0a9c692c..21865a47b 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -390,6 +390,9 @@ "contents.autotranslate": "Autotranslate from master language", "contents.bulkFailed": "Failed to delete or update content. Please reload.", "contents.calendar": "Scheduled Contents", + "contents.cancelStatus": "Cancel scheduled status", + "contents.cancelStatusConfirmText": "Do you really want to cancel the scheduled status update?", + "contents.cancelStatusConfirmTitle": "Cancel scheduled status", "contents.changeStatusTo": "Change content item(s) to {action}", "contents.changeStatusToImmediately": "Set to {action} immediately.", "contents.changeStatusToLater": "Set to {action} at a later point date and time.", @@ -452,7 +455,7 @@ "contents.scheduledAt": "at", "contents.scheduledBy": "by", "contents.scheduledTo": "to", - "contents.scheduledToLabel": "Will be changed to", + "contents.scheduledToLabel": "Scheduled to", "contents.schemasPageTitle": "Contents", "contents.searchPlaceholder": "Fulltext search", "contents.searchSchemasPlaceholder": "Search", diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CancelContentSchedule.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CancelContentSchedule.cs new file mode 100644 index 000000000..a8f5367ed --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CancelContentSchedule.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Contents.Commands +{ + public sealed class CancelContentSchedule : ContentCommand + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs index d80aff0e9..9b3dfbc1a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs @@ -166,6 +166,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject return Snapshot; }); + case CancelContentSchedule cancelContentSchedule: + return UpdateReturnAsync(cancelContentSchedule, async c => + { + var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot); + + operation.MustHavePermission(Permissions.AppContentsChangeStatusCancel); + + if (Snapshot.ScheduleJob != null) + { + CancelChangeStatus(c); + } + + return Snapshot; + }); + case ChangeContentStatus changeContentStatus: return UpdateReturnAsync(changeContentStatus, async c => { @@ -407,7 +422,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject Raise(command, new ContentStatusScheduled { DueTime = dueTime }); } - private void CancelChangeStatus(ChangeContentStatus command) + private void CancelChangeStatus(ContentCommand command) { Raise(command, new ContentSchedulingCancelled()); } diff --git a/backend/src/Squidex.Shared/Permissions.cs b/backend/src/Squidex.Shared/Permissions.cs index 2085f0528..fa4caeba5 100644 --- a/backend/src/Squidex.Shared/Permissions.cs +++ b/backend/src/Squidex.Shared/Permissions.cs @@ -150,6 +150,8 @@ namespace Squidex.Shared public const string AppContentsCreate = "squidex.apps.{app}.contents.{schema}.create"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{schema}.update"; public const string AppContentsUpdateOwn = "squidex.apps.{app}.contents.{schema}.update.own"; + public const string AppContentsChangeStatusCancel = "squidex.apps.{app}.contents.{schema}.changestatus.cancel"; + public const string AppContentsChangeStatusCancelOwn = "squidex.apps.{app}.contents.{schema}.changestatus.cancel.own"; public const string AppContentsChangeStatus = "squidex.apps.{app}.contents.{schema}.changestatus"; public const string AppContentsChangeStatusOwn = "squidex.apps.{app}.contents.{schema}.changestatus.own"; public const string AppContentsUpsert = "squidex.apps.{app}.contents.{schema}.upsert"; diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs index ffef8a186..334deee6b 100644 --- a/backend/src/Squidex.Web/Resources.cs +++ b/backend/src/Squidex.Web/Resources.cs @@ -30,6 +30,8 @@ namespace Squidex.Web public bool CanDeleteContentVersion(string schema) => IsAllowedForSchema(Permissions.AppContentsVersionDeleteOwn, schema); + public bool CanCancelContentStatus(string schema) => IsAllowedForSchema(Permissions.AppContentsChangeStatusCancelOwn, schema); + public bool CanUpdateContent(string schema) => IsAllowedForSchema(Permissions.AppContentsUpdateOwn, schema); // Schemas diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index a983ed3d3..d768427e1 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -543,6 +543,34 @@ namespace Squidex.Areas.Api.Controllers.Contents return Ok(response); } + /// + /// Cancel status change of a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to cancel. + /// + /// 200 => Content status change cancelled. + /// 400 => Content request not valid. + /// 404 => Content, schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs. + /// + [HttpDelete] + [Route("content/{app}/{schema}/{id}/status/")] + [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(Permissions.AppContentsChangeStatusOwn)] + [ApiCosts(1)] + public async Task DeleteContentStatus(string app, string schema, DomainId id) + { + var command = new CancelContentSchedule { ContentId = id }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + /// /// Create a new draft version. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index c03286b88..61cf41f01 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -181,6 +181,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models } } + if (content.ScheduleJob != null && resources.CanCancelContentStatus(schema)) + { + AddDeleteLink($"cancel", resources.Url(x => nameof(x.DeleteContentStatus), values)); + } + if (content.IsSingleton == false && resources.CanDeleteContent(schema)) { AddDeleteLink("delete", resources.Url(x => nameof(x.DeleteContent), values)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs index 75a52f5ea..8c135b191 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs @@ -794,6 +794,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject await PublishAsync(command); } + [Fact] + public async Task CancelContentSchedule_create_events_and_unset_schedule() + { + var dueTime = Instant.MaxValue; + + var command = new CancelContentSchedule(); + + await ExecuteCreateAsync(); + await ExecuteChangeStatusAsync(Status.Published, SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromDays(1))); + + var result = await PublishAsync(command); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Null(sut.Snapshot.ScheduleJob); + + LastEvents + .ShouldHaveSameEvents( + CreateContentEvent(new ContentSchedulingCancelled()) + ); + } + [Fact] public async Task Validate_should_not_update_state() { diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs index 29fbec27a..52fb74b07 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs @@ -17,6 +17,7 @@ using Xunit; namespace Squidex.Infrastructure.EventSourcing.Grains { + [Trait("Category", "Dependencies")] public class EventConsumerGrainTests { public sealed class MyEventConsumerGrain : EventConsumerGrain diff --git a/frontend/app/features/content/pages/calendar/calendar-page.component.html b/frontend/app/features/content/pages/calendar/calendar-page.component.html index c5d09dc01..573ede26d 100644 --- a/frontend/app/features/content/pages/calendar/calendar-page.component.html +++ b/frontend/app/features/content/pages/calendar/calendar-page.component.html @@ -45,7 +45,7 @@ -
+
@@ -55,7 +55,7 @@
-
+
@@ -65,7 +65,7 @@
-
+
@@ -80,7 +80,7 @@
-
+
@@ -93,7 +93,7 @@
-
+
@@ -101,13 +101,29 @@
-
+
{{content.scheduleJob.scheduledBy | sqxUserNameRef}}
+ + +
+ +
+
+ +
+
+
diff --git a/frontend/app/features/content/pages/calendar/calendar-page.component.ts b/frontend/app/features/content/pages/calendar/calendar-page.component.ts index cc4ced108..5d1ba7249 100644 --- a/frontend/app/features/content/pages/calendar/calendar-page.component.ts +++ b/frontend/app/features/content/pages/calendar/calendar-page.component.ts @@ -74,7 +74,7 @@ export class CalendarPageComponent implements AfterViewInit, OnDestroy { this.changeDetector.detectChanges(); }); - this.calendar.on('clickDayname', (event: any) => { + this.calendar.on('click', (event: any) => { if (this.calendar.getViewName() === 'day') { this.calendar.setDate(new Date(event.date)); this.calendar.changeView('day', true); @@ -107,6 +107,16 @@ export class CalendarPageComponent implements AfterViewInit, OnDestroy { this.load(); } + public cancelStatus() { + this.contentsService.cancelStatus(this.appsState.appName, this.content!, this.content!.version) + .subscribe(content => { + this.calendar?.deleteSchedule(content.id, '1'); + + this.contentDialog.hide(); + this.content = undefined; + }); + } + private load() { if (!this.calendar) { return; diff --git a/frontend/app/features/content/pages/content/content-history-page.component.html b/frontend/app/features/content/pages/content/content-history-page.component.html index 9cfd76d89..25282d500 100644 --- a/frontend/app/features/content/pages/content/content-history-page.component.html +++ b/frontend/app/features/content/pages/content/content-history-page.component.html @@ -92,6 +92,16 @@ + + {{ 'contents.cancelStatus' | sqxTranslate }} + + + + - - - + + {{text | sqxTranslate}} diff --git a/frontend/app/shared/components/table-header.component.ts b/frontend/app/shared/components/table-header.component.ts index 494da279d..8c1798206 100644 --- a/frontend/app/shared/components/table-header.component.ts +++ b/frontend/app/shared/components/table-header.component.ts @@ -5,11 +5,11 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { LanguageDto, Query, SortMode, Types } from '@app/shared/internal'; @Component({ - selector: 'sqx-table-header', + selector: 'sqx-table-header[text]', styleUrls: ['./table-header.component.scss'], templateUrl: './table-header.component.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -19,13 +19,13 @@ export class TableHeaderComponent implements OnChanges { public queryChange = new EventEmitter(); @Input() - public query: Query | undefined; + public query: Query | undefined | null; @Input() public text: string; @Input() - public fieldPath: string; + public fieldPath?: string | undefined | null; @Input() public language: LanguageDto; @@ -33,21 +33,24 @@ export class TableHeaderComponent implements OnChanges { @Input() public sortable?: boolean | null; - public order: SortMode | null; + @Input() + public defaultOrder: SortMode | undefined | null; + + public order: SortMode | undefined | null; - public ngOnChanges(changes: SimpleChanges) { + public ngOnChanges() { if (this.sortable) { - if (changes['query'] || changes['fieldPath']) { - if (this.fieldPath && - this.query && - this.query.sort && - this.query.sort.length === 1 && - this.query.sort[0].path === this.fieldPath) { - this.order = this.query.sort[0].order; - } else { - this.order = null; - } + const { sort } = this.query || {}; + + if (this.fieldPath && sort && sort.length === 1 && sort[0].path === this.fieldPath) { + this.order = sort[0].order; + } else if (this.defaultOrder && (!sort || sort.length === 0)) { + this.order = this.defaultOrder; + } else { + this.order = null; } + } else { + this.order = null; } } diff --git a/frontend/app/shared/services/contents.service.spec.ts b/frontend/app/shared/services/contents.service.spec.ts index 0f4700d6b..60b7d52cb 100644 --- a/frontend/app/shared/services/contents.service.spec.ts +++ b/frontend/app/shared/services/contents.service.spec.ts @@ -9,7 +9,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { inject, TestBed } from '@angular/core/testing'; import { ErrorDto } from '@app/framework'; import { AnalyticsService, ApiUrlConfig, ContentDto, ContentsDto, ContentsService, DateTime, Resource, ResourceLinks, ScheduleDto, Version, Versioned } from '@app/shared/internal'; -import { encodeQuery, sanitize } from './../state/query'; +import { sanitize } from './../state/query'; import { BulkResultDto, BulkUpdateDto } from './contents.service'; describe('ContentsService', () => { @@ -32,18 +32,23 @@ describe('ContentsService', () => { httpMock.verify(); })); - it('should make get request to get contents', + it('should make post request to get contents with json query', inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { + const query = { fullText: 'my-query' }; + let contents: ContentsDto; - contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13 }).subscribe(result => { + contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query }).subscribe(result => { contents = result; }); - const req = httpMock.expectOne(`http://service/p/api/content/my-app/my-schema?q=${encodeQuery({ take: 17, skip: 13 })}`); + const expectedQuery = { ...query, take: 17, skip: 13 }; - expect(req.request.method).toEqual('GET'); + const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query'); + + expect(req.request.method).toEqual('POST'); expect(req.request.headers.get('If-Match')).toBeNull(); + expect(req.request.body).toEqual({ q: sanitize(expectedQuery) }); req.flush({ total: 10, @@ -63,88 +68,12 @@ describe('ContentsService', () => { ])); })); - it('should make get request to get contents with json query', - inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { - const query = { fullText: 'my-query' }; - - contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query }).subscribe(); - - const expectedQuery = { ...query, take: 17, skip: 13 }; - - const req = httpMock.expectOne(`http://service/p/api/content/my-app/my-schema?q=${encodeQuery(expectedQuery)}`); - - expect(req.request.method).toEqual('GET'); - expect(req.request.headers.get('If-Match')).toBeNull(); - - req.flush({ total: 10, items: [] }); - })); - - it('should make post request to get contents with json query if request limit reached', - inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { - const query = { fullText: 'my-query' }; - - contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query, maxLength: 5 }).subscribe(); - - const expectedQuery = { ...query, take: 17, skip: 13 }; - - const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query'); - - expect(req.request.method).toEqual('POST'); - expect(req.request.headers.get('If-Match')).toBeNull(); - expect(req.request.body).toEqual({ q: sanitize(expectedQuery) }); - - req.flush({ total: 10, items: [] }); - })); - - it('should make get request to get contents with ids', - inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { - const ids = ['1', '2']; - - contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, ids }).subscribe(); - - const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema?ids=1,2'); - - expect(req.request.method).toEqual('GET'); - expect(req.request.headers.get('If-Match')).toBeNull(); - - req.flush({ total: 10, items: [] }); - })); - - it('should make post request to get contents with ids if request limit reached', - inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { - const ids = ['1', '2']; - - contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, ids, maxLength: 5 }).subscribe(); - - const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query'); - - expect(req.request.method).toEqual('POST'); - expect(req.request.headers.get('If-Match')).toBeNull(); - expect(req.request.body).toEqual({ ids }); - - req.flush({ total: 10, items: [] }); - })); - - it('should make get request to get contents with odata filter', + it('should make post request to get contents with odata filter', inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { const query = { fullText: '$filter=my-filter' }; contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query }).subscribe(); - const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema?$filter=my-filter&$top=17&$skip=13'); - - expect(req.request.method).toEqual('GET'); - expect(req.request.headers.get('If-Match')).toBeNull(); - - req.flush({ total: 10, items: [] }); - })); - - it('should make post request to get contents with odata filter if request limit reached', - inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { - const query = { fullText: '$filter=my-filter' }; - - contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query, maxLength: 5 }).subscribe(); - const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query'); expect(req.request.method).toEqual('POST'); @@ -154,26 +83,12 @@ describe('ContentsService', () => { req.flush({ total: 10, items: [] }); })); - it('should make get request to get all contents by ids', + it('should make post request to get all contents by ids', inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { const ids = ['1', '2', '3']; contentsService.getAllContents('my-app', { ids }).subscribe(); - const req = httpMock.expectOne('http://service/p/api/content/my-app?ids=1,2,3'); - - expect(req.request.method).toEqual('GET'); - expect(req.request.headers.get('If-Match')).toBeNull(); - - req.flush({ total: 10, items: [] }); - })); - - it('should make post request to get all contents by ids if request limit reached', - inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { - const ids = ['1', '2', '3']; - - contentsService.getAllContents('my-app', { ids, maxLength: 5 }).subscribe(); - const req = httpMock.expectOne('http://service/p/api/content/my-app'); expect(req.request.method).toEqual('POST'); @@ -341,6 +256,30 @@ describe('ContentsService', () => { expect(content!).toEqual(createContent(12)); })); + it('should make delete request to cancel content', + inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { + const resource: Resource = { + _links: { + cancel: { method: 'DELETE', href: '/api/content/my-app/my-schema/content1/status' }, + }, + }; + + let content: ContentDto; + + contentsService.cancelStatus('my-app', resource, version).subscribe(result => { + content = result; + }); + + const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1/status'); + + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.get('If-Match')).toBe(version.value); + + req.flush(contentResponse(12)); + + expect(content!).toEqual(createContent(12)); + })); + it('should make post request to for bulk update', inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { const dto: BulkUpdateDto = { diff --git a/frontend/app/shared/services/contents.service.ts b/frontend/app/shared/services/contents.service.ts index 6ea95c993..a42e930b6 100644 --- a/frontend/app/shared/services/contents.service.ts +++ b/frontend/app/shared/services/contents.service.ts @@ -50,6 +50,7 @@ export class ContentDto { public readonly canDelete: boolean; public readonly canDraftDelete: boolean; public readonly canDraftCreate: boolean; + public readonly canCancelStatus: boolean; public readonly canUpdate: boolean; public get canPublish() { @@ -79,6 +80,7 @@ export class ContentDto { this.canDelete = hasAnyLink(links, 'delete'); this.canDraftCreate = hasAnyLink(links, 'draft/create'); this.canDraftDelete = hasAnyLink(links, 'draft/delete'); + this.canCancelStatus = hasAnyLink(links, 'cancel'); this.canUpdate = hasAnyLink(links, 'update'); const updates: StatusInfo[] = []; @@ -123,8 +125,14 @@ export type BulkUpdateDto = export type BulkUpdateJobDto = Readonly<{ id: string; type: BulkUpdateType; status?: string; schema?: string; dueTime?: string | null; expectedVersion?: number }>; -export type ContentQueryDto = - Readonly<{ ids?: ReadonlyArray; maxLength?: number; query?: Query; skip?: number; take?: number; scheduledFrom?: string | null; scheduledTo?: string | null }>; +export type ContentsByIds = + Readonly<{ ids: ReadonlyArray }>; + +export type ContentsBySchedule = + Readonly<{ scheduledFrom: string | null; scheduledTo: string | null }>; + +export type ContentsByQuery = + Readonly<{ query?: Query; skip?: number; take?: number }>; @Injectable() export class ContentsService { @@ -135,70 +143,38 @@ export class ContentsService { ) { } - public getContents(appName: string, schemaName: string, q?: ContentQueryDto): Observable { - const { ids, maxLength } = q || {}; - - const { fullQuery, odataParts: queryOdataParts, queryObj } = buildQuery(q); - - if (fullQuery.length > (maxLength || 2000)) { - const body: any = {}; - - if (ids && ids.length > 0) { - body.ids = ids; - } else if (queryOdataParts.length > 0) { - body.odataQuery = queryOdataParts.join('&'); - } else if (queryObj) { - body.q = queryObj; - } - - const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/query`); - - return this.http.post<{ total: number; items: []; statuses: StatusInfo[] } & Resource>(url, body).pipe( - map(({ total, items, statuses, _links }) => { - const contents = items.map(parseContent); - - return new ContentsDto(statuses, total, contents, _links); - }), - pretifyError('i18n:contents.loadFailed')); - } else { - const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}?${fullQuery}`); + public getContents(appName: string, schemaName: string, q?: ContentsByQuery): Observable { + const { odataParts, queryObj } = buildQuery(q); - return this.http.get<{ total: number; items: []; statuses: StatusInfo[] } & Resource>(url).pipe( - map(({ total, items, statuses, _links }) => { - const contents = items.map(parseContent); + const body: any = {}; - return new ContentsDto(statuses, total, contents, _links); - }), - pretifyError('i18n:contents.loadFailed')); + if (odataParts.length > 0) { + body.odataQuery = odataParts.join('&'); + } else if (queryObj) { + body.q = queryObj; } - } - - public getAllContents(appName: string, q?: ContentQueryDto): Observable { - const { maxLength, ...body } = q || {}; - const { fullQuery } = buildQuery(q); + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/query`); - if (fullQuery.length > (maxLength || 2000)) { - const url = this.apiUrl.buildUrl(`/api/content/${appName}`); + return this.http.post<{ total: number; items: []; statuses: StatusInfo[] } & Resource>(url, body).pipe( + map(({ total, items, statuses, _links }) => { + const contents = items.map(parseContent); - return this.http.post<{ total: number; items: []; statuses: StatusInfo[] } & Resource>(url, body).pipe( - map(({ total, items, statuses, _links }) => { - const contents = items.map(parseContent); + return new ContentsDto(statuses, total, contents, _links); + }), + pretifyError('i18n:contents.loadFailed')); + } - return new ContentsDto(statuses, total, contents, _links); - }), - pretifyError('i18n:contents.loadFailed')); - } else { - const url = this.apiUrl.buildUrl(`/api/content/${appName}?${fullQuery}`); + public getAllContents(appName: string, q: ContentsByIds | ContentsBySchedule): Observable { + const url = this.apiUrl.buildUrl(`/api/content/${appName}`); - return this.http.get<{ total: number; items: []; statuses: StatusInfo[] } & Resource>(url).pipe( - map(({ total, items, statuses, _links }) => { - const contents = items.map(parseContent); + return this.http.post<{ total: number; items: []; statuses: StatusInfo[] } & Resource>(url, q).pipe( + map(({ total, items, statuses, _links }) => { + const contents = items.map(parseContent); - return new ContentsDto(statuses, total, contents, _links); - }), - pretifyError('i18n:contents.loadFailed')); - } + return new ContentsDto(statuses, total, contents, _links); + }), + pretifyError('i18n:contents.loadFailed')); } public getContent(appName: string, schemaName: string, id: string): Observable { @@ -211,7 +187,7 @@ export class ContentsService { pretifyError('i18n:contents.loadContentFailed')); } - public getContentReferences(appName: string, schemaName: string, id: string, q?: ContentQueryDto): Observable { + public getContentReferences(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable { const { fullQuery } = buildQuery(q); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/references?${fullQuery}`); @@ -225,7 +201,7 @@ export class ContentsService { pretifyError('i18n:contents.loadFailed')); } - public getContentReferencing(appName: string, schemaName: string, id: string, q?: ContentQueryDto): Observable { + public getContentReferencing(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable { const { fullQuery } = buildQuery(q); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/referencing?${fullQuery}`); @@ -307,6 +283,21 @@ export class ContentsService { pretifyError('i18n:contents.loadVersionFailed')); } + public cancelStatus(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['cancel']; + + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version).pipe( + map(({ payload }) => { + return parseContent(payload.body); + }), + tap(() => { + this.analytics.trackEvent('Content', 'Cancelled', appName); + }), + pretifyError('i18n:contents.updateFailed')); + } + public deleteVersion(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['draft/delete']; @@ -336,17 +327,15 @@ export class ContentsService { } } -function buildQuery(q?: ContentQueryDto) { - const { ids, query, scheduledFrom, scheduledTo, skip, take } = q || {}; +function buildQuery(q?: ContentsByQuery) { + const { query, skip, take } = q || {}; const queryParts: string[] = []; const odataParts: string[] = []; let queryObj: Query | undefined; - if (ids && ids.length > 0) { - queryParts.push(`ids=${ids.join(',')}`); - } else if (query && query.fullText && query.fullText.indexOf('$') >= 0) { + if (query && query.fullText && query.fullText.indexOf('$') >= 0) { odataParts.push(`${query.fullText.trim()}`); if (take && take > 0) { @@ -356,9 +345,6 @@ function buildQuery(q?: ContentQueryDto) { if (skip && skip > 0) { odataParts.push(`$skip=${skip}`); } - } else if (scheduledFrom && scheduledTo) { - queryParts.push(`scheduledFrom=${encodeURIComponent(scheduledFrom)}`); - queryParts.push(`scheduledTo=${encodeURIComponent(scheduledTo)}`); } else { queryObj = { ...query }; diff --git a/frontend/app/shared/state/contents.state.ts b/frontend/app/shared/state/contents.state.ts index 89e6f747d..6e0cb5313 100644 --- a/frontend/app/shared/state/contents.state.ts +++ b/frontend/app/shared/state/contents.state.ts @@ -279,6 +279,14 @@ export abstract class ContentsStateBase extends State { shareSubscribed(this.dialogs, { silent: true })); } + public cancelStatus(content: ContentDto): Observable { + return this.contentsService.cancelStatus(this.appName, content, content.version).pipe( + tap(updated => { + this.replaceContent(updated, content.version, 'i18n:contents.updated'); + }), + shareSubscribed(this.dialogs, { silent: true })); + } + public createDraft(content: ContentDto): Observable { return this.contentsService.createVersion(this.appName, content, content.version).pipe( tap(updated => {