Browse Source

Status fixes for singleton contents.

pull/502/head
Sebastian 7 years ago
parent
commit
10d7341577
  1. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  2. 19
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
  3. 37
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  4. 11
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs
  5. 49
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithWorkflowsTests.cs
  6. 196
      frontend/app/features/content/pages/content/content-history-page.component.html
  7. 2
      frontend/app/features/content/pages/content/content-history-page.component.scss
  8. 19
      frontend/app/features/content/pages/content/content-history-page.component.ts
  9. 34
      frontend/app/features/content/pages/content/content-page.component.html
  10. 2
      frontend/app/shared/components/schema-category.component.ts

17
backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -23,11 +23,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
Guard.NotNull(command); Guard.NotNull(command);
Validate.It(() => "Cannot created content.", e =>
{
ValidateData(command, e);
});
if (schema.SchemaDef.IsSingleton && command.ContentId != schema.Id) if (schema.SchemaDef.IsSingleton && command.ContentId != schema.Id)
{ {
throw new DomainException("Singleton content cannot be created."); throw new DomainException("Singleton content cannot be created.");
@ -37,6 +32,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
throw new DomainException("Content workflow prevents publishing."); throw new DomainException("Content workflow prevents publishing.");
} }
Validate.It(() => "Cannot created content.", e =>
{
ValidateData(command, e);
});
} }
public static async Task CanUpdate(ContentState content, IContentWorkflow contentWorkflow, UpdateContent command) public static async Task CanUpdate(ContentState content, IContentWorkflow contentWorkflow, UpdateContent command)
@ -99,7 +99,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
if (schema.SchemaDef.IsSingleton) if (schema.SchemaDef.IsSingleton)
{ {
throw new DomainException("Singleton content cannot be updated."); if (content.NewVersion == null || command.Status != Status.Published)
{
throw new DomainException("Singleton content cannot be updated.");
}
return Task.CompletedTask;
} }
return Validate.It(() => "Cannot change status.", async e => return Validate.It(() => "Cannot change status.", async e =>

19
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs

@ -46,7 +46,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{ {
var editingStatus = content.NewStatus ?? content.Status; var editingStatus = content.NewStatus ?? content.Status;
content.NextStatuses = await contentWorkflow.GetNextAsync(content, editingStatus, context.User); if (content.IsSingleton)
{
if (editingStatus == Status.Draft)
{
content.NextStatuses = new[]
{
new StatusInfo(Status.Published, StatusColors.Published)
};
}
else
{
content.NextStatuses = Array.Empty<StatusInfo>();
}
}
else
{
content.NextStatuses = await contentWorkflow.GetNextAsync(content, editingStatus, context.User);
}
} }
private async Task EnrichCanUpdateAsync(ContentEntity content, Context context) private async Task EnrichCanUpdateAsync(ContentEntity content, Context context)

37
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -150,35 +150,32 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
AddGetLink("previous", controller.Url<ContentsController>(x => nameof(x.GetContentVersion), versioned)); AddGetLink("previous", controller.Url<ContentsController>(x => nameof(x.GetContentVersion), versioned));
} }
if (!content.IsSingleton) if (NewStatus.HasValue)
{ {
if (NewStatus.HasValue) if (controller.HasPermission(Permissions.AppContentsVersionDelete, app, schema))
{ {
if (controller.HasPermission(Permissions.AppContentsVersionDelete, app, schema)) AddDeleteLink("draft/delete", controller.Url<ContentsController>(x => nameof(x.DeleteVersion), values));
{
AddDeleteLink("draft/delete", controller.Url<ContentsController>(x => nameof(x.DeleteVersion), values));
}
} }
else if (Status == Status.Published) }
else if (Status == Status.Published)
{
if (controller.HasPermission(Permissions.AppContentsVersionCreate, app, schema))
{ {
if (controller.HasPermission(Permissions.AppContentsVersionCreate, app, schema)) AddPostLink("draft/create", controller.Url<ContentsController>(x => nameof(x.CreateDraft), values));
{
AddPostLink("draft/create", controller.Url<ContentsController>(x => nameof(x.CreateDraft), values));
}
} }
}
if (content.NextStatuses != null) if (content.NextStatuses != null)
{
foreach (var next in content.NextStatuses)
{ {
foreach (var next in content.NextStatuses) AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
{
AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
}
} }
}
if (controller.HasPermission(Permissions.AppContentsDelete, app, schema)) if (content.IsSingleton == false && controller.HasPermission(Permissions.AppContentsDelete, app, schema))
{ {
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values)); AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
} }
if (content.CanUpdate) if (content.CanUpdate)

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

@ -204,6 +204,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
new ValidationError("Cannot change status from Draft to Published.", "Status")); new ValidationError("Cannot change status from Draft to Published.", "Status"));
} }
[Fact]
public async Task CanChangeStatus_should_not_throw_exception_if_singleton_is_published()
{
var schema = CreateSchema(true);
var content = CreateDraftContent(Status.Draft);
var command = new ChangeContentStatus { Status = Status.Published };
await GuardContent.CanChangeStatus(schema, content, contentWorkflow, command);
}
[Fact] [Fact]
public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_valid() public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_valid()
{ {

49
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithWorkflowsTests.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
@ -27,11 +28,55 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
public EnrichWithWorkflowsTests() public EnrichWithWorkflowsTests()
{ {
requestContext = new Context(Mocks.ApiUser(), Mocks.App(appId)); requestContext = new Context(Mocks.FrontendUser(), Mocks.App(appId));
sut = new EnrichWithWorkflows(contentWorkflow); sut = new EnrichWithWorkflows(contentWorkflow);
} }
[Fact]
public async Task Should_enrich_content_with_next_statuses()
{
var content = new ContentEntity { SchemaId = schemaId };
var nexts = new[]
{
new StatusInfo(Status.Published, StatusColors.Published)
};
A.CallTo(() => contentWorkflow.GetNextAsync(content, content.Status, requestContext.User))
.Returns(nexts);
await sut.EnrichAsync(requestContext, new[] { content }, null!);
Assert.Equal(nexts, content.NextStatuses);
}
[Fact]
public async Task Should_enrich_content_with_next_statuses_if_draft_singleton()
{
var content = new ContentEntity { SchemaId = schemaId, IsSingleton = true, Status = Status.Draft };
await sut.EnrichAsync(requestContext, new[] { content }, null!);
Assert.Equal(Status.Published, content.NextStatuses.Single().Status);
A.CallTo(() => contentWorkflow.GetNextAsync(content, A<Status>._, requestContext.User))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_enrich_content_with_next_statuses_if_published_singleton()
{
var content = new ContentEntity { SchemaId = schemaId, IsSingleton = true, Status = Status.Published };
await sut.EnrichAsync(requestContext, new[] { content }, null!);
Assert.Empty(content.NextStatuses);
A.CallTo(() => contentWorkflow.GetNextAsync(content, A<Status>._, requestContext.User))
.MustNotHaveHappened();
}
[Fact] [Fact]
public async Task Should_enrich_content_with_status_color() public async Task Should_enrich_content_with_status_color()
{ {
@ -108,7 +153,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var content = new ContentEntity { SchemaId = schemaId }; var content = new ContentEntity { SchemaId = schemaId };
var ctx = requestContext.WithResolveFlow(false); var ctx = new Context(Mocks.ApiUser(), Mocks.App(appId)).WithResolveFlow(false);
await sut.EnrichAsync(ctx, new[] { content }, null!); await sut.EnrichAsync(ctx, new[] { content }, null!);

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

@ -4,116 +4,114 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<ng-container *ngIf="!schema.isSingleton"> <div class="section mb-4" *ngIf="content.canDraftCreate || content.canDraftDelete">
<div class="section mb-4" *ngIf="content.canDraftCreate || content.canDraftDelete"> <ng-container *ngIf="!content.newStatus; else newVersion">
<ng-container *ngIf="!content.newStatus; else newVersion"> <button class="btn btn-success btn-block" (click)="createDraft() ">
<button class="btn btn-success btn-block" (click)="createDraft() "> New Draft
New Draft </button>
</button> </ng-container>
</ng-container>
<ng-template #newVersion>
<label>New Version</label>
<button type="button" class="btn btn-outline-secondary btn-block btn-status" (click)="dropdownNew.toggle()" [class.active]="dropdownNew.isOpen | async" #buttonOptions>
<sqx-content-status
layout="multiline"
[status]="content.newStatus"
[statusColor]="content.newStatusColor"
[scheduled]="content.scheduleJob">
</sqx-content-status>
</button>
<ng-container *sqxModal="dropdownNew;closeAlways:true">
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade>
<ng-container *ngIf="content.statusUpdates.length > 0">
<a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="changeStatus(info.status)">
Change to <i class="icon-circle icon-sm" [style.color]="info.color"></i> {{info.status}}
</a>
<div class="dropdown-divider"></div>
</ng-container>
<a class="dropdown-item dropdown-item-delete" <ng-template #newVersion>
[class.disabled]="!content.canDraftDelete" <label>New Version</label>
(sqxConfirmClick)="deleteDraft()"
confirmTitle="Delete content" <button type="button" class="btn btn-outline-secondary btn-block btn-status" (click)="dropdownNew.toggle()" [class.active]="dropdownNew.isOpen | async" #buttonOptions>
confirmText="Do you really want to delete this version?"> <sqx-content-status
Delete this Version layout="multiline"
[status]="content.newStatus"
[statusColor]="content.newStatusColor"
[scheduled]="content.scheduleJob">
</sqx-content-status>
</button>
<ng-container *sqxModal="dropdownNew;closeAlways:true">
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade>
<ng-container *ngIf="content.statusUpdates.length > 0">
<a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="changeStatus(info.status)">
Change to <i class="icon-circle icon-sm" [style.color]="info.color"></i> {{info.status}}
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
</ng-container>
<a class="dropdown-item dropdown-item-delete"
[class.disabled]="!content.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="Delete content"
confirmText="Do you really want to delete the content?">
Delete
</a>
</div>
</ng-container>
</ng-template>
</div>
<div class="section"> <a class="dropdown-item dropdown-item-delete"
<label>Current Version</label> [class.disabled]="!content.canDraftDelete"
(sqxConfirmClick)="deleteDraft()"
confirmTitle="Delete content"
confirmText="Do you really want to delete this version?">
Delete this Version
</a>
<div *ngIf="!content.newStatus; else newStatusOld"> <div class="dropdown-divider"></div>
<button type="button" class="btn btn-outline-secondary btn-block btn-status" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
<sqx-content-status small="true" <a class="dropdown-item dropdown-item-delete"
layout="multiline" [class.disabled]="!content.canDelete"
[status]="content.status" (sqxConfirmClick)="delete()"
[statusColor]="content.statusColor" confirmTitle="Delete content"
[scheduled]="content.scheduleJob"> confirmText="Do you really want to delete the content?">
</sqx-content-status> Delete
</button> </a>
</div>
<ng-container *sqxModal="dropdown;closeAlways:true"> </ng-container>
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade> </ng-template>
<ng-container *ngIf="content.statusUpdates.length > 0"> </div>
<a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="changeStatus(info.status)">
Change to <div class="section">
<label>Current Version</label>
<sqx-content-status small="true"
layout="text" <div *ngIf="!content.newStatus; else newStatusOld">
[status]="info.status" <button type="button" class="btn btn-outline-secondary btn-block btn-status" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
[statusColor]="info.color"> <sqx-content-status small="true"
</sqx-content-status> layout="multiline"
</a> [status]="content.status"
[statusColor]="content.statusColor"
<div class="dropdown-divider"></div> [scheduled]="content.scheduleJob">
</ng-container> </sqx-content-status>
</button>
<a class="dropdown-item dropdown-item-delete"
[class.disabled]="!content.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="Delete content"
confirmText="Do you really want to delete the content?">
Delete
</a>
</div>
</ng-container>
</div>
<ng-template #newStatusOld> <ng-container *sqxModal="dropdown;closeAlways:true">
<button type="button" class="btn btn-outline-secondary btn-block btn-status"> <div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade>
<sqx-content-status <ng-container *ngIf="content.statusUpdates.length > 0">
[status]="content.status" <a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="changeStatus(info.status)">
[statusColor]="content.statusColor" Change to
layout="multiline">
</sqx-content-status> <sqx-content-status small="true"
</button> layout="text"
</ng-template> [status]="info.status"
[statusColor]="info.color">
</sqx-content-status>
</a>
<div class="dropdown-divider"></div>
</ng-container>
<sqx-form-hint marginTop="1"> <a class="dropdown-item dropdown-item-delete"
Last Updated: {{content.lastModified | sqxFromNow}} [class.disabled]="!content.canDelete"
</sqx-form-hint> (sqxConfirmClick)="delete()"
confirmTitle="Delete content"
confirmText="Do you really want to delete the content?">
Delete
</a>
</div>
</ng-container>
</div> </div>
</ng-container>
<ng-template #newStatusOld>
<button type="button" class="btn btn-outline-secondary btn-block btn-status">
<sqx-content-status
[status]="content.status"
[statusColor]="content.statusColor"
layout="multiline">
</sqx-content-status>
</button>
</ng-template>
<sqx-form-hint marginTop="1">
Last Updated: {{content.lastModified | sqxFromNow}}
</sqx-form-hint>
</div>
<div class="section"> <div class="section">
<h3 class="bordered" *ngIf="!schema.isSingleton">History</h3> <h3 class="bordered">History</h3>
<sqx-content-event *ngFor="let event of contentEvents | async; trackBy: trackByEvent" <sqx-content-event *ngFor="let event of contentEvents | async; trackBy: trackByEvent"
[content]="content" [content]="content"

2
frontend/app/features/content/pages/content/content-history-page.component.scss

@ -1,5 +1,5 @@
.section { .section {
margin-bottom: 1rem; margin-bottom: 2rem;
&:last-child { &:last-child {
margin: 0; margin: 0;

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

@ -9,7 +9,7 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { Observable, timer } from 'rxjs'; import { Observable, timer } from 'rxjs';
import { filter, onErrorResumeNext, switchMap } from 'rxjs/operators'; import { filter, map, onErrorResumeNext, switchMap } from 'rxjs/operators';
import { import {
AppsState, AppsState,
@ -62,22 +62,25 @@ export class ContentHistoryPageComponent extends ResourceOwner implements OnInit
this.own( this.own(
this.schemasState.selectedSchema this.schemasState.selectedSchema
.subscribe(schema => { .subscribe(schema => {
this.schema = schema; if (schema) {
this.schema = schema;
}
})); }));
this.own( this.own(
this.contentsState.selectedContent this.contentsState.selectedContent
.subscribe(content => { .subscribe(content => {
if (content) { if (content) {
const channel = `contents.${content.id}`;
this.contentEvents =
timer(0, 5000).pipe(
switchSafe((() => this.historyService.getHistory(this.appsState.appName, channel))));
this.content = content; this.content = content;
} }
})); }));
this.contentEvents =
this.contentsState.selectedContent.pipe(
filter(x => !!x),
map(content => `contents.${content?.id}`),
switchSafe(channel => timer(0, 5000).pipe(map(() => channel))),
switchSafe(channel => this.historyService.getHistory(this.appsState.appName, channel)));
} }
public changeStatus(status: string) { public changeStatus(status: string) {

34
frontend/app/features/content/pages/content/content-page.component.html

@ -18,28 +18,26 @@
New Content New Content
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<ng-container *ngIf="content; else noContent"> <ng-container *ngIf="content; else noContent">
<ng-container *ngIf="!schema.isSingleton"> <ng-container>
<sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button> <sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button>
<div class="dropdown dropdown-options ml-1"> <div class="dropdown dropdown-options ml-1" *ngIf="content?.canDelete">
<ng-container *ngIf="content?.canDelete"> <button type="button" class="btn btn-outline-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
<button type="button" class="btn btn-outline-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions> <i class="icon-more"></i>
<i class="icon-more"></i> </button>
</button>
<ng-container *sqxModal="dropdown;closeAlways:true">
<ng-container *sqxModal="dropdown;closeAlways:true"> <div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade>
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade> <a class="dropdown-item dropdown-item-delete"
<a class="dropdown-item dropdown-item-delete" (sqxConfirmClick)="delete()"
(sqxConfirmClick)="delete()" confirmTitle="Delete content"
confirmTitle="Delete content" confirmText="Do you really want to delete the content?">
confirmText="Do you really want to delete the content?"> Delete
Delete </a>
</a> </div>
</div>
</ng-container>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-container>

2
frontend/app/shared/components/schema-category.component.ts

@ -88,7 +88,7 @@ export class SchemaCategoryComponent extends StatefulComponent<State> implements
public schemaRoute(schema: SchemaDto) { public schemaRoute(schema: SchemaDto) {
if (schema.isSingleton && this.forContent) { if (schema.isSingleton && this.forContent) {
return [schema.name, schema.id]; return [schema.name, schema.id, 'history'];
} else { } else {
return [schema.name]; return [schema.name];
} }

Loading…
Cancel
Save