Browse Source

Cancel content status. (#744)

* Cancel content status.

* Fix table header.

* default => defaultOrder

* Disable flaky tests for now.
pull/745/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
a1a7b0b006
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      backend/i18n/frontend_en.json
  2. 5
      backend/i18n/frontend_it.json
  3. 5
      backend/i18n/frontend_nl.json
  4. 5
      backend/i18n/frontend_zh.json
  5. 5
      backend/i18n/source/frontend_en.json
  6. 13
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CancelContentSchedule.cs
  7. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
  8. 2
      backend/src/Squidex.Shared/Permissions.cs
  9. 2
      backend/src/Squidex.Web/Resources.cs
  10. 28
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  11. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  12. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
  13. 1
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/EventConsumerGrainTests.cs
  14. 28
      frontend/app/features/content/pages/calendar/calendar-page.component.html
  15. 12
      frontend/app/features/content/pages/calendar/calendar-page.component.ts
  16. 10
      frontend/app/features/content/pages/content/content-history-page.component.html
  17. 4
      frontend/app/features/content/pages/content/content-history-page.component.ts
  18. 2
      frontend/app/shared/components/contents/content-list-header.component.html
  19. 4
      frontend/app/shared/components/table-header.component.html
  20. 35
      frontend/app/shared/components/table-header.component.ts
  21. 133
      frontend/app/shared/services/contents.service.spec.ts
  22. 120
      frontend/app/shared/services/contents.service.ts
  23. 8
      frontend/app/shared/state/contents.state.ts

5
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",

5
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...",

5
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 ...",

5
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...",

5
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",

13
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
{
}
}

17
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());
}

2
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";

2
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

28
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -543,6 +543,34 @@ namespace Squidex.Areas.Api.Controllers.Contents
return Ok(response);
}
/// <summary>
/// Cancel status change of a content item.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The id of the content item to cancel.</param>
/// <returns>
/// 200 => Content status change cancelled.
/// 400 => Content request not valid.
/// 404 => Content, schema or app not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpDelete]
[Route("content/{app}/{schema}/{id}/status/")]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsChangeStatusOwn)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteContentStatus(string app, string schema, DomainId id)
{
var command = new CancelContentSchedule { ContentId = id };
var response = await InvokeCommandAsync(command);
return Ok(response);
}
/// <summary>
/// Create a new draft version.
/// </summary>

5
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<ContentsController>(x => nameof(x.DeleteContentStatus), values));
}
if (content.IsSingleton == false && resources.CanDeleteContent(schema))
{
AddDeleteLink("delete", resources.Url<ContentsController>(x => nameof(x.DeleteContent), values));

22
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()
{

1
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

28
frontend/app/features/content/pages/calendar/calendar-page.component.html

@ -45,7 +45,7 @@
</div>
</div>
<div class="form-group form-group-aligned row">
<div class="form-group form-group-aligned row">
<label class="col-4 col-form-label">{{ 'common.content' | sqxTranslate }}</label>
<div class="col-8">
@ -55,7 +55,7 @@
</div>
</div>
<div class="form-group form-group-aligned row">
<div class="form-group form-group-aligned row">
<label class="col-4 col-form-label">{{ 'common.schema' | sqxTranslate }}</label>
<div class="col-8">
@ -65,7 +65,7 @@
</div>
</div>
<div class="form-group form-group-aligned row">
<div class="form-group form-group-aligned row">
<label class="col-4 col-form-label">{{ 'common.status' | sqxTranslate }}</label>
<div class="col-8">
@ -80,7 +80,7 @@
<hr />
<div class="form-group form-group-aligned row">
<div class="form-group form-group-aligned row">
<label class="col-4 col-form-label">{{ 'contents.scheduledToLabel' | sqxTranslate }}</label>
<div class="col-8">
@ -93,7 +93,7 @@
</div>
</div>
<div class="form-group form-group-aligned row">
<div class="form-group form-group-aligned row">
<label class="col-4 col-form-label">{{ 'contents.scheduledAt' | sqxTranslate }}</label>
<div class="col-8">
@ -101,13 +101,29 @@
</div>
</div>
<div class="form-group form-group-aligned row">
<div class="form-group form-group-aligned row">
<label class="col-4 col-form-label">{{ 'contents.scheduledBy' | sqxTranslate }}</label>
<div class="col-8">
<img class="user-picture" [src]="content.scheduleJob.scheduledBy | sqxUserPictureRef"> {{content.scheduleJob.scheduledBy | sqxUserNameRef}}
</div>
</div>
<ng-container *ngIf="content.canCancelStatus">
<hr />
<div class="row">
<div class="col-8 offset-4">
<button type="button" class="btn btn-outline-danger" [class.disabled]="!content.canCancelStatus"
(sqxConfirmClick)="cancelStatus()"
confirmTitle="i18n:contents.cancelStatusConfirmTitle"
confirmText="i18n:contents.cancelStatusConfirmText"
confirmRememberKey="cancelStatus">
{{ 'contents.cancelStatus' | sqxTranslate }}
</button>
</div>
</div>
</ng-container>
</div>
</ng-container>
</sqx-modal-dialog>

12
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;

10
frontend/app/features/content/pages/content/content-history-page.component.html

@ -92,6 +92,16 @@
<div class="dropdown-divider"></div>
</ng-container>
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canCancelStatus"
(sqxConfirmClick)="cancelStatus()"
confirmTitle="i18n:contents.cancelStatusConfirmTitle"
confirmText="i18n:contents.cancelStatusConfirmText"
confirmRememberKey="cancelStatus">
{{ 'contents.cancelStatus' | sqxTranslate }}
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:contents.deleteConfirmTitle"

4
frontend/app/features/content/pages/content/content-history-page.component.ts

@ -74,6 +74,10 @@ export class ContentHistoryPageComponent extends ResourceOwner implements OnInit
.subscribe();
}
public cancelStatus() {
this.contentsState.cancelStatus(this.content);
}
public delete() {
this.contentsState.deleteMany([this.content]);
}

2
frontend/app/shared/components/contents/content-list-header.component.html

@ -18,7 +18,7 @@
<sqx-table-header text="i18n:contents.tableHeaders.createdBy"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModified">
<sqx-table-header text="i18n:contents.tableHeaders.lastModified"
<sqx-table-header text="i18n:contents.tableHeaders.lastModified" defaultOrder="ascending"
[sortable]="true"
[fieldPath]="'lastModified'"
[query]="query"

4
frontend/app/shared/components/table-header.component.html

@ -1,7 +1,7 @@
<a *ngIf="sortable; else notSortable" (click)="sort()" class="pointer truncate">
<span class="truncate">
<i *ngIf="order === 'ascending'" class="icon-caret-down"></i>
<i *ngIf="order === 'descending'" class="icon-caret-up"></i>
<i *ngIf="order === 'ascending'" class="icon-caret-up"></i>
<i *ngIf="order === 'descending'" class="icon-caret-down"></i>
{{text | sqxTranslate}}
</span>

35
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<Query>();
@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;
}
}

133
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 = {

120
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<string>; maxLength?: number; query?: Query; skip?: number; take?: number; scheduledFrom?: string | null; scheduledTo?: string | null }>;
export type ContentsByIds =
Readonly<{ ids: ReadonlyArray<string> }>;
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<ContentsDto> {
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<ContentsDto> {
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<ContentsDto> {
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<ContentsDto> {
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<ContentDto> {
@ -211,7 +187,7 @@ export class ContentsService {
pretifyError('i18n:contents.loadContentFailed'));
}
public getContentReferences(appName: string, schemaName: string, id: string, q?: ContentQueryDto): Observable<ContentsDto> {
public getContentReferences(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable<ContentsDto> {
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<ContentsDto> {
public getContentReferencing(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable<ContentsDto> {
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<ContentDto> {
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<ContentDto> {
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 };

8
frontend/app/shared/state/contents.state.ts

@ -279,6 +279,14 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs, { silent: true }));
}
public cancelStatus(content: ContentDto): Observable<ContentDto> {
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<ContentDto> {
return this.contentsService.createVersion(this.appName, content, content.version).pipe(
tap(updated => {

Loading…
Cancel
Save