Browse Source

Refactorings

pull/1/head
Sebastian 9 years ago
parent
commit
1e46482e04
  1. 20
      src/Squidex.Store.MongoDb/Contents/MongoContentRepository.cs
  2. 10
      src/Squidex.Write/Contents/ContentCommandHandler.cs
  3. 38
      src/Squidex.Write/Contents/ContentDomainObject.cs
  4. 4
      src/Squidex.Write/Schemas/SchemaDomainObject.cs
  5. 22
      src/Squidex/Controllers/ContentApi/ContentsController.cs
  6. 174
      src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs
  7. 1
      src/Squidex/app/features/content/declarations.ts
  8. 2
      src/Squidex/app/features/content/module.ts
  9. 4
      src/Squidex/app/features/content/pages/content/content-field.component.html
  10. 14
      src/Squidex/app/features/content/pages/content/content-field.component.ts
  11. 1
      src/Squidex/app/features/content/pages/content/content-page.component.html
  12. 92
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  13. 34
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  14. 14
      src/Squidex/app/features/content/pages/contents/contents-page.component.scss
  15. 117
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  16. 23
      src/Squidex/app/features/content/pages/messages.ts
  17. 3
      src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html
  18. 7
      src/Squidex/app/framework/utils/immutable-array.ts
  19. 32
      src/Squidex/app/shared/services/contents.service.spec.ts
  20. 18
      src/Squidex/app/shared/services/contents.service.ts
  21. 20
      src/Squidex/app/theme/_bootstrap.scss
  22. 28
      tests/Squidex.Write.Tests/Contents/ContentCommandHandlerTests.cs
  23. 72
      tests/Squidex.Write.Tests/Contents/ContentDomainObjectTests.cs

20
src/Squidex.Store.MongoDb/Contents/MongoContentRepository.cs

@ -182,6 +182,26 @@ namespace Squidex.Store.MongoDb.Contents
}); });
} }
protected Task On(ContentPublished @event, EnvelopeHeaders headers)
{
var collection = GetCollection(headers.SchemaId());
return collection.UpdateAsync(headers, x =>
{
x.IsPublished = true;
});
}
protected Task On(ContentUnpublished @event, EnvelopeHeaders headers)
{
var collection = GetCollection(headers.SchemaId());
return collection.UpdateAsync(headers, x =>
{
x.IsPublished = false;
});
}
protected Task On(ContentDeleted @event, EnvelopeHeaders headers) protected Task On(ContentDeleted @event, EnvelopeHeaders headers)
{ {
var collection = GetCollection(headers.SchemaId()); var collection = GetCollection(headers.SchemaId());

10
src/Squidex.Write/Contents/ContentCommandHandler.cs

@ -57,6 +57,16 @@ namespace Squidex.Write.Contents
await handler.UpdateAsync<ContentDomainObject>(command, s => s.Update(command)); await handler.UpdateAsync<ContentDomainObject>(command, s => s.Update(command));
} }
protected Task On(PublishContent command, CommandContext context)
{
return handler.UpdateAsync<ContentDomainObject>(command, s => s.Publish(command));
}
protected Task On(UnpublishContent command, CommandContext context)
{
return handler.UpdateAsync<ContentDomainObject>(command, s => s.Unpublish(command));
}
protected Task On(DeleteContent command, CommandContext context) protected Task On(DeleteContent command, CommandContext context)
{ {
return handler.UpdateAsync<ContentDomainObject>(command, s => s.Delete(command)); return handler.UpdateAsync<ContentDomainObject>(command, s => s.Delete(command));

38
src/Squidex.Write/Contents/ContentDomainObject.cs

@ -21,12 +21,18 @@ namespace Squidex.Write.Contents
{ {
private bool isDeleted; private bool isDeleted;
private bool isCreated; private bool isCreated;
private bool isPublished;
public bool IsDeleted public bool IsDeleted
{ {
get { return isDeleted; } get { return isDeleted; }
} }
public bool IsPublished
{
get { return isPublished; }
}
public ContentDomainObject(Guid id, int version) public ContentDomainObject(Guid id, int version)
: base(id, version) : base(id, version)
{ {
@ -37,6 +43,16 @@ namespace Squidex.Write.Contents
isCreated = true; isCreated = true;
} }
protected void On(ContentPublished @event)
{
isPublished = true;
}
protected void On(ContentUnpublished @event)
{
isPublished = false;
}
protected void On(ContentDeleted @event) protected void On(ContentDeleted @event)
{ {
isDeleted = true; isDeleted = true;
@ -75,6 +91,28 @@ namespace Squidex.Write.Contents
return this; return this;
} }
public ContentDomainObject Publish(PublishContent command)
{
Guard.NotNull(command, nameof(command));
VerifyCreatedAndNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new ContentPublished()));
return this;
}
public ContentDomainObject Unpublish(UnpublishContent command)
{
Guard.NotNull(command, nameof(command));
VerifyCreatedAndNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new ContentUnpublished()));
return this;
}
private void VerifyNotCreated() private void VerifyNotCreated()
{ {
if (isCreated) if (isCreated)

4
src/Squidex.Write/Schemas/SchemaDomainObject.cs

@ -207,6 +207,8 @@ namespace Squidex.Write.Schemas
public SchemaDomainObject Publish(PublishSchema command) public SchemaDomainObject Publish(PublishSchema command)
{ {
Guard.NotNull(command, nameof(command));
VerifyCreatedAndNotDeleted(); VerifyCreatedAndNotDeleted();
RaiseEvent(new SchemaPublished()); RaiseEvent(new SchemaPublished());
@ -216,6 +218,8 @@ namespace Squidex.Write.Schemas
public SchemaDomainObject Unpublish(UnpublishSchema command) public SchemaDomainObject Unpublish(UnpublishSchema command)
{ {
Guard.NotNull(command, nameof(command));
VerifyCreatedAndNotDeleted(); VerifyCreatedAndNotDeleted();
RaiseEvent(new SchemaUnpublished()); RaiseEvent(new SchemaUnpublished());

22
src/Squidex/Controllers/ContentApi/ContentsController.cs

@ -126,6 +126,28 @@ namespace Squidex.Controllers.ContentApi
return NoContent(); return NoContent();
} }
[HttpPut]
[Route("content/{app}/{name}/{id}/publish")]
public async Task<IActionResult> PublishContent(Guid id)
{
var command = new PublishContent { AggregateId = id };
await CommandBus.PublishAsync(command);
return NoContent();
}
[HttpPut]
[Route("content/{app}/{name}/{id}/unpublish")]
public async Task<IActionResult> UnpublishContent(Guid id)
{
var command = new UnpublishContent { AggregateId = id };
await CommandBus.PublishAsync(command);
return NoContent();
}
[HttpDelete] [HttpDelete]
[Route("content/{app}/{name}/{id}")] [Route("content/{app}/{name}/{id}")]
public async Task<IActionResult> PutContent(Guid id) public async Task<IActionResult> PutContent(Guid id)

174
src/Squidex/Controllers/ContentApi/Generator/SchemasSwaggerGenerator.cs

@ -24,6 +24,7 @@ using Squidex.Infrastructure;
using Squidex.Pipeline.Swagger; using Squidex.Pipeline.Swagger;
using Squidex.Read.Apps; using Squidex.Read.Apps;
using Squidex.Read.Schemas; using Squidex.Read.Schemas;
// ReSharper disable InvertIf
// ReSharper disable SuggestBaseTypeForParameter // ReSharper disable SuggestBaseTypeForParameter
// ReSharper disable PrivateFieldCanBeConvertedToLocalVariable // ReSharper disable PrivateFieldCanBeConvertedToLocalVariable
@ -81,6 +82,7 @@ When you change the field to be localizable the value will become the value for
GenerateSchemasOperations(schemas); GenerateSchemasOperations(schemas);
GenerateSecurityDefinitions(); GenerateSecurityDefinitions();
GenerateSecurityRequirements(); GenerateSecurityRequirements();
GenerateDefaultErrors();
return document; return document;
} }
@ -152,6 +154,14 @@ When you change the field to be localizable the value will become the value for
} }
} }
private void GenerateDefaultErrors()
{
foreach (var operation in document.Paths.Values.SelectMany(x => x.Values))
{
operation.Responses.Add("500", new SwaggerResponse { Description = "Operations failed with internal server error.", Schema = errorDtoSchema });
}
}
private void GenerateSchemasOperations(IEnumerable<ISchemaEntityWithSchema> schemas) private void GenerateSchemasOperations(IEnumerable<ISchemaEntityWithSchema> schemas)
{ {
foreach (var schema in schemas.Select(x => x.Schema)) foreach (var schema in schemas.Select(x => x.Schema))
@ -170,123 +180,135 @@ When you change the field to be localizable the value will become the value for
Name = schemaName, Description = $"API to managed {schemaName} content elements." Name = schemaName, Description = $"API to managed {schemaName} content elements."
}); });
var noIdItemOperations = var schemaOperations = new List<SwaggerOperations>
document.Paths.GetOrAdd($"{appBasePath}/{schema.Name}/", k => new SwaggerOperations()); {
GenerateSchemaQueryOperation(schema, schemaName),
var idItemOperations = GenerateSchemaCreateOperation(schema, schemaName),
document.Paths.GetOrAdd($"{appBasePath}/{schema.Name}/{{id}}/", k => new SwaggerOperations()); GenerateSchemaGetOperation(schema, schemaName),
GenerateSchemaUpdateOperation(schema, schemaName),
GenerateSchemaQueryOperation(noIdItemOperations, schema, schemaName); GenerateSchemaPublishOperation(schema, schemaName),
GenerateSchemaCreateOperation(noIdItemOperations, schema, schemaName); GenerateSchemaUnpublishOperation(schema, schemaName),
GenerateSchemaDeleteOperation(schema, schemaName)
GenerateSchemaGetOperation(idItemOperations, schema, schemaName); };
GenerateSchemaUpdateOperation(idItemOperations, schema, schemaName);
GenerateSchemaDeleteOperation(idItemOperations, schemaName);
foreach (var operation in idItemOperations.Values.Union(noIdItemOperations.Values)) foreach (var operation in schemaOperations.SelectMany(x => x.Values).Distinct())
{ {
operation.Tags = new List<string> { schemaName }; operation.Tags = new List<string> { schemaName };
} }
}
foreach (var operation in idItemOperations.Values) private SwaggerOperations GenerateSchemaQueryOperation(Schema schema, string schemaName)
{
return AddOperation(SwaggerOperationMethod.Get, null, $"{appBasePath}/{schema.Name}", operation =>
{ {
operation.Responses.Add("404", operation.Summary = $"Queries {schemaName} content elements.";
new SwaggerResponse { Description = $"App, schema or {schemaName} not found." });
operation.Parameters.AddPathParameter("id", JsonObjectType.String, $"The id of the {schemaName} (GUID)."); operation.Parameters.AddQueryParameter("take", JsonObjectType.Number, "The number of elements to take.");
} operation.Parameters.AddQueryParameter("skip", JsonObjectType.Number, "The number of elements to skip.");
operation.Parameters.AddQueryParameter("query", JsonObjectType.String, "Optional full text query skip.");
var responseSchema = CreateContentsSchema(schema.BuildSchema(languages, AppendSchema), schemaName, schema.Name);
operation.Responses.Add("200",
new SwaggerResponse { Description = $"{schemaName} content elements retrieved.", Schema = responseSchema });
});
} }
private void GenerateSchemaQueryOperation(SwaggerOperations operations, Schema schema, string schemaName) private SwaggerOperations GenerateSchemaGetOperation(Schema schema, string schemaName)
{ {
var operation = new SwaggerOperation return AddOperation(SwaggerOperationMethod.Get, schemaName, $"{appBasePath}/{schema.Name}/{{id}}", operation =>
{ {
Summary = $"Queries {schemaName} content elements." operation.Summary = $"Get a {schemaName} content element.";
};
operation.Parameters.AddQueryParameter("take", JsonObjectType.Number, "The number of elements to take."); var responseSchema = CreateContentSchema(schema.BuildSchema(languages, AppendSchema), schemaName, schema.Name);
operation.Parameters.AddQueryParameter("skip", JsonObjectType.Number, "The number of elements to skip.");
operation.Parameters.AddQueryParameter("query", JsonObjectType.String, "Optional full text query skip."); operation.Responses.Add("200",
new SwaggerResponse { Description = $"{schemaName} element found.", Schema = responseSchema });
});
}
var responseSchema = CreateContentsSchema(schema.BuildSchema(languages, AppendSchema), schemaName, schema.Name); private SwaggerOperations GenerateSchemaCreateOperation(Schema schema, string schemaName)
{
return AddOperation(SwaggerOperationMethod.Post, null, $"{appBasePath}/{schema.Name}", operation =>
{
operation.Summary = $"Create a {schemaName} content element.";
var bodySchema = AppendSchema($"{schema.Name}Dto", schema.BuildSchema(languages, AppendSchema));
operation.Responses.Add("200", operation.Parameters.AddBodyParameter(bodySchema, "data", string.Format(BodyDescription, schemaName));
new SwaggerResponse { Description = $"{schemaName} content elements retrieved.", Schema = responseSchema });
operation.Responses.Add("500",
new SwaggerResponse { Description = $"Querying {schemaName} element failed with internal server error.", Schema = errorDtoSchema });
operations[SwaggerOperationMethod.Get] = operation; operation.Responses.Add("201",
new SwaggerResponse { Description = $"{schemaName} created.", Schema = entityCreatedDtoSchema });
});
} }
private void GenerateSchemaCreateOperation(SwaggerOperations operations, Schema schema, string schemaName) private SwaggerOperations GenerateSchemaUpdateOperation(Schema schema, string schemaName)
{ {
var operation = new SwaggerOperation return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appBasePath}/{schema.Name}/{{id}}", operation =>
{ {
Summary = $"Create a {schemaName} content element." operation.Summary = $"Update a {schemaName} content element.";
};
var bodySchema = AppendSchema($"{schema.Name}Dto", schema.BuildSchema(languages, AppendSchema));
operation.Parameters.AddBodyParameter(bodySchema, "data", string.Format(BodyDescription, schemaName)); var bodySchema = AppendSchema($"{schema.Name}Dto", schema.BuildSchema(languages, AppendSchema));
operation.Responses.Add("201", operation.Parameters.AddBodyParameter(bodySchema, "data", string.Format(BodyDescription, schemaName));
new SwaggerResponse { Description = $"{schemaName} created.", Schema = entityCreatedDtoSchema });
operation.Responses.Add("500",
new SwaggerResponse { Description = $"Creating {schemaName} element failed with internal server error.", Schema = errorDtoSchema });
operations[SwaggerOperationMethod.Post] = operation; operation.Responses.Add("204",
new SwaggerResponse { Description = $"{schemaName} element updated." });
});
} }
private void GenerateSchemaGetOperation(SwaggerOperations operations, Schema schema, string schemaName) private SwaggerOperations GenerateSchemaPublishOperation(Schema schema, string schemaName)
{ {
var operation = new SwaggerOperation return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appBasePath}/{schema.Name}/{{id}}/publish", operation =>
{ {
Summary = $"Gets a {schemaName} content element" operation.Summary = $"Publish a {schemaName} content element.";
};
var responseSchema = CreateContentSchema(schema.BuildSchema(languages, AppendSchema), schemaName, schema.Name); operation.Responses.Add("204",
new SwaggerResponse { Description = $"{schemaName} element published." });
});
}
operation.Responses.Add("209", private SwaggerOperations GenerateSchemaUnpublishOperation(Schema schema, string schemaName)
new SwaggerResponse { Description = $"{schemaName} element found.", Schema = responseSchema }); {
operation.Responses.Add("500", return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appBasePath}/{schema.Name}/{{id}}/unpublish", operation =>
new SwaggerResponse { Description = $"Retrieving {schemaName} element failed with internal server error.", Schema = errorDtoSchema }); {
operation.Summary = $"Unpublish a {schemaName} content element.";
operations[SwaggerOperationMethod.Get] = operation; operation.Responses.Add("204",
new SwaggerResponse { Description = $"{schemaName} element unpublished." });
});
} }
private void GenerateSchemaUpdateOperation(SwaggerOperations operations, Schema schema, string schemaName) private SwaggerOperations GenerateSchemaDeleteOperation(Schema schema, string schemaName)
{ {
var operation = new SwaggerOperation return AddOperation(SwaggerOperationMethod.Delete, schemaName, $"{appBasePath}/{schema.Name}/{{id}}/", operation =>
{ {
Summary = $"Update {schemaName} content element." operation.Summary = $"Delete a {schemaName} content element.";
};
var bodySchema = AppendSchema($"{schema.Name}Dto", schema.BuildSchema(languages, AppendSchema)); operation.Responses.Add("204",
new SwaggerResponse { Description = $"{schemaName} element deleted." });
});
}
operation.Parameters.AddBodyParameter(bodySchema, "data", string.Format(BodyDescription, schemaName)); private SwaggerOperations AddOperation(SwaggerOperationMethod method, string entityName, string path, Action<SwaggerOperation> updater)
{
var operations = document.Paths.GetOrAdd(path, k => new SwaggerOperations());
var operation = new SwaggerOperation();
operation.Responses.Add("204", updater(operation);
new SwaggerResponse { Description = $"{schemaName} element updated." });
operation.Responses.Add("500",
new SwaggerResponse { Description = $"Updating {schemaName} element failed with internal server error.", Schema = errorDtoSchema });
operations[SwaggerOperationMethod.Put] = operation; operations[method] = operation;
}
private void GenerateSchemaDeleteOperation(SwaggerOperations operations, string schemaName) if (entityName != null)
{
var operation = new SwaggerOperation
{ {
Summary = $"Delete a {schemaName} content element." operation.Parameters.AddPathParameter("id", JsonObjectType.String, $"The id of the {entityName} (GUID).");
};
operation.Responses.Add("404",
operation.Responses.Add("204", new SwaggerResponse { Description = $"App, schema or {entityName} not found." });
new SwaggerResponse { Description = $"{schemaName} element deleted." }); }
operation.Responses.Add("500",
new SwaggerResponse { Description = $"Deleting {schemaName} element failed with internal server error.", Schema = errorDtoSchema });
operations[SwaggerOperationMethod.Delete] = operation; return operations;
} }
private JsonSchema4 CreateContentsSchema(JsonSchema4 dataSchema, string schemaName, string id) private JsonSchema4 CreateContentsSchema(JsonSchema4 dataSchema, string schemaName, string id)

1
src/Squidex/app/features/content/declarations.ts

@ -7,5 +7,6 @@
export * from './pages/content/content-field.component'; export * from './pages/content/content-field.component';
export * from './pages/content/content-page.component'; export * from './pages/content/content-page.component';
export * from './pages/contents/content-item.component';
export * from './pages/contents/contents-page.component'; export * from './pages/contents/contents-page.component';
export * from './pages/schemas/schemas-page.component'; export * from './pages/schemas/schemas-page.component';

2
src/Squidex/app/features/content/module.ts

@ -20,6 +20,7 @@ import {
import { import {
ContentFieldComponent, ContentFieldComponent,
ContentPageComponent, ContentPageComponent,
ContentItemComponent,
ContentsPageComponent, ContentsPageComponent,
SchemasPageComponent SchemasPageComponent
} from './declarations'; } from './declarations';
@ -77,6 +78,7 @@ const routes: Routes = [
], ],
declarations: [ declarations: [
ContentFieldComponent, ContentFieldComponent,
ContentItemComponent,
ContentPageComponent, ContentPageComponent,
ContentsPageComponent, ContentsPageComponent,
SchemasPageComponent SchemasPageComponent

4
src/Squidex/app/features/content/pages/content/content-field.component.html

@ -10,14 +10,14 @@
<div class="btn-group btn-group-sm languages-buttons" role="group"> <div class="btn-group btn-group-sm languages-buttons" role="group">
<button type="button" class="btn btn-secondary" *ngFor="let language of languages" [attr.title]="language.englishName" <button type="button" class="btn btn-secondary" *ngFor="let language of languages" [attr.title]="language.englishName"
[class.btn-danger]="fieldForm.controls[language.iso2Code].invalid && (fieldForm.controls[language.iso2Code].touched || contentFormSubmitted)" [class.btn-danger]="fieldForm.controls[language.iso2Code].invalid && (fieldForm.controls[language.iso2Code].touched || contentFormSubmitted)"
[class.active]="language.iso2Code == selectedLanguage" (click)="selectLanguage(language)"> [class.active]="language.iso2Code == fieldLanguage" (click)="selectLanguage(language)">
{{language.iso2Code}} {{language.iso2Code}}
</button> </button>
</div> </div>
</div> </div>
<div *ngFor="let language of fieldLanguages"> <div *ngFor="let language of fieldLanguages">
<div *ngIf="language == selectedLanguage"> <div *ngIf="language == fieldLanguage">
<sqx-control-errors [for]="language" fieldName="{{field|displayName:'properties.label':'name'}}" [submitted]="contentFormSubmitted"></sqx-control-errors> <sqx-control-errors [for]="language" fieldName="{{field|displayName:'properties.label':'name'}}" [submitted]="contentFormSubmitted"></sqx-control-errors>
<div [ngSwitch]="field.properties.fieldType"> <div [ngSwitch]="field.properties.fieldType">

14
src/Squidex/app/features/content/pages/content/content-field.component.ts

@ -8,10 +8,7 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { import { AppLanguageDto, FieldDto } from 'shared';
AppLanguageDto,
FieldDto
} from 'shared';
@Component({ @Component({
selector: 'sqx-content-field', selector: 'sqx-content-field',
@ -32,11 +29,10 @@ export class ContentFieldComponent implements OnInit {
public contentFormSubmitted: boolean; public contentFormSubmitted: boolean;
public fieldLanguages: string[]; public fieldLanguages: string[];
public fieldLanguage: string;
public selectedLanguage: string;
public selectLanguage(language: AppLanguageDto) { public selectLanguage(language: AppLanguageDto) {
this.selectedLanguage = language.iso2Code; this.fieldLanguage = language.iso2Code;
} }
public ngOnInit() { public ngOnInit() {
@ -46,10 +42,10 @@ export class ContentFieldComponent implements OnInit {
if (this.field.properties.isLocalizable) { if (this.field.properties.isLocalizable) {
this.fieldLanguages = this.languages.map(t => t.iso2Code); this.fieldLanguages = this.languages.map(t => t.iso2Code);
this.selectedLanguage = this.fieldLanguages[0]; this.fieldLanguage = this.fieldLanguages[0];
} else { } else {
this.fieldLanguages = ['iv']; this.fieldLanguages = ['iv'];
this.selectedLanguage = 'iv'; this.fieldLanguage = 'iv';
} }
} }
} }

1
src/Squidex/app/features/content/pages/content/content-page.component.html

@ -11,6 +11,7 @@
</div> </div>
<h3 class="panel-title" *ngIf="isNewMode">New {{schema|displayName}}</h3> <h3 class="panel-title" *ngIf="isNewMode">New {{schema|displayName}}</h3>
<h3 class="panel-title" *ngIf="!isNewMode">Edit {{schema|displayName}}</h3>
<a class="panel-close" routerLink="../"> <a class="panel-close" routerLink="../">
<i class="icon-close"></i> <i class="icon-close"></i>

92
src/Squidex/app/features/content/pages/content/content-page.component.ts

@ -5,11 +5,16 @@
* Copyright (c) Sebastian Stehle. All rights reserved * Copyright (c) Sebastian Stehle. All rights reserved
*/ */
import { Component } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { AbstractControl, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { AbstractControl, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { ContentChanged } from './../messages'; import {
ContentCreated,
ContentDeleted,
ContentUpdated
} from './../messages';
import { import {
AppComponentBase, AppComponentBase,
@ -31,17 +36,20 @@ import {
styleUrls: ['./content-page.component.scss'], styleUrls: ['./content-page.component.scss'],
templateUrl: './content-page.component.html' templateUrl: './content-page.component.html'
}) })
export class ContentPageComponent extends AppComponentBase { export class ContentPageComponent extends AppComponentBase implements OnDestroy, OnInit {
private messageSubscription: Subscription;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public contentFormSubmitted = false; public contentFormSubmitted = false;
public contentForm: FormGroup; public contentForm: FormGroup;
public content: ContentDto = null; public contentData: any = null;
public contentId: string;
public languages: AppLanguageDto[] = []; public languages: AppLanguageDto[] = [];
public get isNewMode() { public get isNewMode() {
return this.content !== null; return !this.contentData;
} }
constructor(apps: AppsStoreService, notifications: NotificationService, users: UsersProviderService, constructor(apps: AppsStoreService, notifications: NotificationService, users: UsersProviderService,
@ -53,13 +61,24 @@ export class ContentPageComponent extends AppComponentBase {
super(apps, notifications, users); super(apps, notifications, users);
} }
public ngOnDestroy() {
this.messageSubscription.unsubscribe();
}
public ngOnInit() { public ngOnInit() {
this.messageSubscription =
this.messageBus.of(ContentDeleted).subscribe(message => {
if (message.id === this.contentId) {
this.router.navigate(['../'], { relativeTo: this.route });
}
});
this.route.parent.data.map(p => p['appLanguages']).subscribe((languages: AppLanguageDto[]) => { this.route.parent.data.map(p => p['appLanguages']).subscribe((languages: AppLanguageDto[]) => {
this.languages = languages; this.languages = languages;
}); });
this.route.parent.data.map(p => p['schema']).subscribe((schema: SchemaDetailsDto) => { this.route.parent.data.map(p => p['schema']).subscribe((schema: SchemaDetailsDto) => {
this.setupForm(schema, this.route.snapshot.data['content']); this.setupForm(schema);
}); });
this.route.data.map(p => p['content']).subscribe((content: ContentDto) => { this.route.data.map(p => p['content']).subscribe((content: ContentDto) => {
@ -75,33 +94,33 @@ export class ContentPageComponent extends AppComponentBase {
const data = this.contentForm.value; const data = this.contentForm.value;
this.appName() if (this.isNewMode) {
.switchMap(app => { this.appName()
if (this.isNewMode) { .switchMap(app => this.contentsService.postContent(app, this.schema.name, data))
return this.contentsService.postContent(app, this.schema.name, data); .subscribe(created => {
} else { this.messageBus.publish(new ContentCreated(created.id, data));
return this.contentsService.putContent(app, this.schema.name, data, this.content.id);
} this.router.navigate(['../'], { relativeTo: this.route });
}) }, error => {
.subscribe(() => { this.notifyError(error);
this.router.navigate(['../'], { relativeTo: this.route }); this.enable();
});
this.messageBus.publish(new ContentChanged()); } else {
}, error => { this.appName()
this.notifyError(error); .switchMap(app => this.contentsService.putContent(app, this.schema.name, this.contentId, data))
this.enable(); .subscribe(() => {
}); this.messageBus.publish(new ContentUpdated(this.contentId, data));
this.router.navigate(['../'], { relativeTo: this.route });
}, error => {
this.notifyError(error);
this.enable();
});
}
} }
} }
public reset() { private enable() {
this.enable();
this.contentForm.reset();
this.contentFormSubmitted = false;
}
public enable() {
for (const field of this.schema.fields.filter(f => !f.isDisabled)) { for (const field of this.schema.fields.filter(f => !f.isDisabled)) {
const fieldForm = this.contentForm.controls[field.name]; const fieldForm = this.contentForm.controls[field.name];
@ -109,7 +128,7 @@ export class ContentPageComponent extends AppComponentBase {
} }
} }
public disable() { private disable() {
for (const field of this.schema.fields.filter(f => !f.isDisabled)) { for (const field of this.schema.fields.filter(f => !f.isDisabled)) {
const fieldForm = this.contentForm.controls[field.name]; const fieldForm = this.contentForm.controls[field.name];
@ -117,7 +136,7 @@ export class ContentPageComponent extends AppComponentBase {
} }
} }
private setupForm(schema: SchemaDetailsDto, content?: ContentDto) { private setupForm(schema: SchemaDetailsDto) {
this.schema = schema; this.schema = schema;
const controls: { [key: string]: AbstractControl } = {}; const controls: { [key: string]: AbstractControl } = {};
@ -160,7 +179,14 @@ export class ContentPageComponent extends AppComponentBase {
} }
private populateForm(content: ContentDto) { private populateForm(content: ContentDto) {
this.content = content; if (!content) {
this.contentData = undefined;
this.contentId = undefined;
return;
} else {
this.contentData = content.data;
this.contentId = content.id;
}
for (const field of this.schema.fields) { for (const field of this.schema.fields) {
const fieldValue = content.data[field.name] || {}; const fieldValue = content.data[field.name] || {};

34
src/Squidex/app/features/content/pages/contents/contents-page.component.html

@ -5,7 +5,7 @@
<div class="panel-header-title-row"> <div class="panel-header-title-row">
<div class="float-xs-right"> <div class="float-xs-right">
<div class="btn-group languages-buttons" role="group"> <div class="btn-group languages-buttons" role="group">
<button type="button" class="btn btn-secondary" *ngFor="let language of languages" [attr.title]="language.englishName" [class.active]="language == selectedLanguage" (click)="selectLanguage(language)"> <button type="button" class="btn btn-secondary" *ngFor="let language of languages" [attr.title]="language.englishName" [class.active]="language == languageSelected" (click)="selectLanguage(language)">
{{language.iso2Code}} {{language.iso2Code}}
</button> </button>
</div> </div>
@ -25,10 +25,10 @@
<div class="panel-main"> <div class="panel-main">
<div class="panel-content"> <div class="panel-content">
<table class="table table-items table-fixed" *ngIf="contents"> <table class="table table-items table-fixed" *ngIf="contentItems">
<colgroup> <colgroup>
<col *ngFor="let field of contentFields" [style.width]="columnWidth + '%'" /> <col *ngFor="let field of contentFields" [style.width]="columnWidth + '%'" />
<col style="width: 170px" /> <col style="width: 190px" />
<col style="width: 80px" /> <col style="width: 80px" />
<col style="width: 80px" /> <col style="width: 80px" />
</colgroup> </colgroup>
@ -51,25 +51,15 @@
</thead> </thead>
<tbody> <tbody>
<template ngFor let-content [ngForOf]="contents.items"> <template ngFor let-content [ngForOf]="contentItems">
<tr [routerLink]="[content.id]" class="content"> <tr [routerLink]="[content.id]" routerLinkActive="active" class="content"
<td *ngFor="let field of contentFields"> [sqxContent]="content"
<span class="field"> [language]="languageSelected"
{{getFieldContent(content, field)}} [fields]="contentFields"
</span> [schema]="schema"
</td> (unpublished)="unpublishContent(content)"
<td> (published)="publishContent(content)"
{{content.lastModified|fromNow}} (deleted)="deleteContent(content)"></tr>
</td>
<td>
<img class="user-picture" [attr.title]="userName(content.lastModifiedBy) | async" [attr.src]="userPicture(content.lastModifiedBy, true) | async" />
</td>
<td>
<button type="button" class="btn btn-simple">
<i class="icon-dots"></i>
</button>
</td>
</tr>
<tr class="spacer"></tr> <tr class="spacer"></tr>
</template> </template>
</tbody> </tbody>

14
src/Squidex/app/features/content/pages/contents/contents-page.component.scss

@ -6,6 +6,10 @@
max-width: 64rem; max-width: 64rem;
} }
.panel-content {
overflow-y: scroll;
}
.languages-buttons { .languages-buttons {
margin-right: 1rem; margin-right: 1rem;
} }
@ -16,14 +20,4 @@
.content { .content {
cursor: pointer; cursor: pointer;
}
.user-picture {
& {
@include circle(2.2rem);
}
&:not([src]) {
@include opacity(0);
}
} }

117
src/Squidex/app/features/content/pages/contents/contents-page.component.ts

@ -5,20 +5,26 @@
* Copyright (c) Sebastian Stehle. All rights reserved * Copyright (c) Sebastian Stehle. All rights reserved
*/ */
import { Component, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { ContentChanged } from './../messages'; import {
ContentCreated,
ContentDeleted,
ContentUpdated
} from './../messages';
import { import {
AppComponentBase, AppComponentBase,
AppLanguageDto, AppLanguageDto,
AppsStoreService, AppsStoreService,
AuthService,
ContentDto, ContentDto,
ContentsDto,
ContentsService, ContentsService,
DateTime,
FieldDto, FieldDto,
ImmutableArray,
MessageBus, MessageBus,
NotificationService, NotificationService,
SchemaDetailsDto, SchemaDetailsDto,
@ -30,17 +36,18 @@ import {
styleUrls: ['./contents-page.component.scss'], styleUrls: ['./contents-page.component.scss'],
templateUrl: './contents-page.component.html' templateUrl: './contents-page.component.html'
}) })
export class ContentsPageComponent extends AppComponentBase implements OnInit { export class ContentsPageComponent extends AppComponentBase implements OnDestroy, OnInit {
private messageSubscription: Subscription; private messageCreatedSubscription: Subscription;
private messageUpdatedSubscription: Subscription;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public contents: ContentsDto; public contentItems: ImmutableArray<ContentDto>;
public contentFields: FieldDto[]; public contentFields: FieldDto[];
public contentTotal = 0;
public languages: AppLanguageDto[] = []; public languages: AppLanguageDto[] = [];
public languageSelected: AppLanguageDto;
public selectedLanguage: AppLanguageDto;
public page = 0; public page = 0;
public query = ''; public query = '';
@ -50,26 +57,34 @@ export class ContentsPageComponent extends AppComponentBase implements OnInit {
} }
constructor(apps: AppsStoreService, notifications: NotificationService, users: UsersProviderService, constructor(apps: AppsStoreService, notifications: NotificationService, users: UsersProviderService,
private readonly authService: AuthService,
private readonly contentsService: ContentsService, private readonly contentsService: ContentsService,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly messageBus: MessageBus private readonly messageBus: MessageBus
) { ) {
super(apps, notifications, users); super(apps, notifications, users);
} }
public selectLanguage(language: AppLanguageDto) {
this.selectedLanguage = language; public ngOnDestroy() {
this.messageCreatedSubscription.unsubscribe();
this.messageUpdatedSubscription.unsubscribe();
} }
public ngOnInit() { public ngOnInit() {
this.messageSubscription = this.messageUpdatedSubscription =
this.messageBus.of(ContentChanged).delay(2000).subscribe(message => { this.messageBus.of(ContentUpdated).subscribe(message => {
this.load(); this.contentItems = this.contentItems.replaceAll(x => x.id === message.id, c => this.updateContent(c, true, message.data));
});
this.messageCreatedSubscription =
this.messageBus.of(ContentCreated).subscribe(message => {
this.contentTotal++;
this.contentItems = this.contentItems.pushFront(this.createContent(message.id, message.data));
}); });
this.route.data.map(p => p['appLanguages']).subscribe((languages: AppLanguageDto[]) => { this.route.data.map(p => p['appLanguages']).subscribe((languages: AppLanguageDto[]) => {
this.languages = languages; this.languages = languages;
this.languageSelected = languages.filter(t => t.isMasterLanguage)[0];
this.selectedLanguage = languages.filter(t => t.isMasterLanguage)[0];
}); });
this.route.data.map(p => p['schema']).subscribe(schema => { this.route.data.map(p => p['schema']).subscribe(schema => {
@ -81,18 +96,40 @@ export class ContentsPageComponent extends AppComponentBase implements OnInit {
}); });
} }
public getFieldContent(content: ContentDto, field: FieldDto): any { public publishContent(content: ContentDto) {
const contentField = content.data[field.name]; this.appName()
.switchMap(app => this.contentsService.publishContent(app, this.schema.name, content.id))
.subscribe(() => {
this.contentItems = this.contentItems.replaceAll(x => x.id === content.id, c => this.updateContent(c, true, content.data));
}, error => {
this.notifyError(error);
});
}
if (!contentField) { public unpublishContent(content: ContentDto) {
return ''; this.appName()
} .switchMap(app => this.contentsService.unpublishContent(app, this.schema.name, content.id))
.subscribe(() => {
this.contentItems = this.contentItems.replaceAll(x => x.id === content.id, c => this.updateContent(c, false, content.data));
}, error => {
this.notifyError(error);
});
}
if (field.properties.isLocalizable) { public deleteContent(content: ContentDto) {
return contentField[this.selectedLanguage.iso2Code]; this.appName()
} else { .switchMap(app => this.contentsService.deleteContent(app, this.schema.name, content.id))
return contentField['iv']; .subscribe(() => {
} this.contentItems = this.contentItems.removeAll(x => x.id === content.id);
this.messageBus.publish(new ContentDeleted(content.id));
}, error => {
this.notifyError(error);
});
}
public selectLanguage(language: AppLanguageDto) {
this.languageSelected = language;
} }
private reset() { private reset() {
@ -111,10 +148,38 @@ export class ContentsPageComponent extends AppComponentBase implements OnInit {
this.appName() this.appName()
.switchMap(app => this.contentsService.getContents(app, this.schema.name, 20, this.page * 20, this.query)) .switchMap(app => this.contentsService.getContents(app, this.schema.name, 20, this.page * 20, this.query))
.subscribe(dtos => { .subscribe(dtos => {
this.contents = dtos; this.contentItems = ImmutableArray.of(dtos.items);
this.contentTotal = dtos.total;
}, error => { }, error => {
this.notifyError(error); this.notifyError(error);
}); });
} }
private createContent(id: string, data: any): ContentDto {
const me = `subject:${this.authService.user!.id}`;
const newContent =
new ContentDto(
id, false,
me, me,
DateTime.now(),
DateTime.now(),
data);
return newContent;
}
private updateContent(content: ContentDto, isPublished: boolean, data: any): ContentDto {
const me = `subject:${this.authService.user!.id}`;
const newContent =
new ContentDto(
content.id, isPublished,
content.createdBy, me,
content.created, DateTime.now(),
data);
return newContent;
}
} }

23
src/Squidex/app/features/content/pages/messages.ts

@ -5,4 +5,25 @@
* Copyright (c) Sebastian Stehle. All rights reserved * Copyright (c) Sebastian Stehle. All rights reserved
*/ */
export class ContentChanged { } export class ContentCreated {
constructor(
public readonly id: string,
public readonly data: any
) {
}
}
export class ContentUpdated {
constructor(
public readonly id: string,
public readonly data: any
) {
}
}
export class ContentDeleted {
constructor(
public readonly id: string
) {
}
}

3
src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html

@ -38,9 +38,8 @@
</span> </span>
</div> </div>
<div class="col-xs-4 schema-col-right"> <div class="col-xs-4 schema-col-right">
<span class="schema-modified">{{schema.lastModified | fromNow}}</span>
<span class="schema-published" [class.unpublished]="!schema.isPublished"></span> <span class="schema-published" [class.unpublished]="!schema.isPublished"></span>
<span class="schema-modified">{{schema.lastModified | fromNow}}</span>
</div> </div>
</div> </div>
</div> </div>

7
src/Squidex/app/framework/utils/immutable-array.ts

@ -59,6 +59,13 @@ export class ImmutableArray<T> implements Iterable<T> {
return new ImmutableArray<T>(clone); return new ImmutableArray<T>(clone);
} }
public pushFront(...items: T[]): ImmutableArray<T> {
if (!items || items.length === 0) {
return this;
}
return new ImmutableArray<T>([...freeze(items), ...this.items]);
}
public push(...items: T[]): ImmutableArray<T> { public push(...items: T[]): ImmutableArray<T> {
if (!items || items.length === 0) { if (!items || items.length === 0) {
return this; return this;

32
src/Squidex/app/shared/services/contents.service.spec.ts

@ -147,6 +147,38 @@ describe('ContentsService', () => {
authService.verifyAll(); authService.verifyAll();
}); });
it('should make put request to publish content', () => {
const dto = {};
authService.setup(x => x.authPut('http://service/p/api/content/my-app/my-schema/content1/publish', dto))
.returns(() => Observable.of(
new Response(
new ResponseOptions()
)
))
.verifiable(Times.once());
contentsService.publishContent('my-app', 'my-schema', 'content1');
authService.verifyAll();
});
it('should make put request to unpublish content', () => {
const dto = {};
authService.setup(x => x.authPut('http://service/p/api/content/my-app/my-schema/content1/unpublish', dto))
.returns(() => Observable.of(
new Response(
new ResponseOptions()
)
))
.verifiable(Times.once());
contentsService.unpublishContent('my-app', 'my-schema', 'content1');
authService.verifyAll();
});
it('should make delete request to delete content', () => { it('should make delete request to delete content', () => {
authService.setup(x => x.authDelete('http://service/p/api/content/my-app/my-schema/content1')) authService.setup(x => x.authDelete('http://service/p/api/content/my-app/my-schema/content1'))
.returns(() => Observable.of( .returns(() => Observable.of(

18
src/Squidex/app/shared/services/contents.service.ts

@ -102,13 +102,27 @@ export class ContentsService {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);
return this.authService.authPut(url, dto) return this.authService.authPut(url, dto)
.catchError('Failed to update Content. Please reload.'); .catchError('Failed to update content. Please reload.');
}
public publishContent(appName: string, schemaName: string, id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/publish`);
return this.authService.authPut(url, {})
.catchError('Failed to publish content. Please reload.');
}
public unpublishContent(appName: string, schemaName: string, id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/unpublish`);
return this.authService.authPut(url, {})
.catchError('Failed to unpublish content. Please reload.');
} }
public deleteContent(appName: string, schemaName: string, id: string): Observable<any> { public deleteContent(appName: string, schemaName: string, id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);
return this.authService.authDelete(url) return this.authService.authDelete(url)
.catchError('Failed to delete Content. Please reload.'); .catchError('Failed to delete content. Please reload.');
} }
} }

20
src/Squidex/app/theme/_bootstrap.scss

@ -70,10 +70,22 @@
} }
tr { tr {
background: $color-table; & {
border: 1px solid $color-border; background: $color-table;
border-bottom: 2px solid $color-border; border: 1px solid $color-border;
margin-bottom: .5rem; border-bottom: 2px solid $color-border;
margin-bottom: .5rem;
}
&:hover {
background: $color-table-footer;
}
&.active {
background: $color-theme-blue;
border-color: $color-theme-blue;
color: $color-accent-dark;
}
} }
} }

28
tests/Squidex.Write.Tests/Contents/ContentCommandHandlerTests.cs

@ -109,6 +109,34 @@ namespace Squidex.Write.Contents
}); });
} }
[Fact]
public async Task Publish_should_publish_domain_object()
{
CreateContent();
var command = new PublishContent { AggregateId = Id, AppId = appId, SchemaId = schemaId };
var context = new CommandContext(command);
await TestUpdate(content, async _ =>
{
await sut.HandleAsync(context);
});
}
[Fact]
public async Task Unpublish_should_unpublish_domain_object()
{
CreateContent();
var command = new UnpublishContent { AggregateId = Id, AppId = appId, SchemaId = schemaId };
var context = new CommandContext(command);
await TestUpdate(content, async _ =>
{
await sut.HandleAsync(context);
});
}
[Fact] [Fact]
public async Task Delete_should_update_domain_object() public async Task Delete_should_update_domain_object()
{ {

72
tests/Squidex.Write.Tests/Contents/ContentDomainObjectTests.cs

@ -98,6 +98,71 @@ namespace Squidex.Write.Contents
}); });
} }
[Fact]
public void Publish_should_throw_if_not_created()
{
Assert.Throws<DomainException>(() => sut.Publish(new PublishContent()));
}
[Fact]
public void Publish_should_throw_if_schema_is_deleted()
{
CreateContent();
DeleteContent();
Assert.Throws<DomainException>(() => sut.Publish(new PublishContent()));
}
[Fact]
public void Publish_should_refresh_properties_and_create_events()
{
CreateContent();
sut.Publish(new PublishContent());
Assert.True(sut.IsPublished);
sut.GetUncomittedEvents().Select(x => x.Payload).ToArray()
.ShouldBeEquivalentTo(
new IEvent[]
{
new ContentPublished()
});
}
[Fact]
public void Unpublish_should_throw_if_not_created()
{
Assert.Throws<DomainException>(() => sut.Unpublish(new UnpublishContent()));
}
[Fact]
public void Unpublish_should_throw_if_schema_is_deleted()
{
CreateContent();
DeleteContent();
Assert.Throws<DomainException>(() => sut.Unpublish(new UnpublishContent()));
}
[Fact]
public void Unpublish_should_refresh_properties_and_create_events()
{
CreateContent();
PublishContent();
sut.Unpublish(new UnpublishContent());
Assert.False(sut.IsPublished);
sut.GetUncomittedEvents().Select(x => x.Payload).ToArray()
.ShouldBeEquivalentTo(
new IEvent[]
{
new ContentUnpublished()
});
}
[Fact] [Fact]
public void Delete_should_throw_if_not_created() public void Delete_should_throw_if_not_created()
{ {
@ -137,6 +202,13 @@ namespace Squidex.Write.Contents
((IAggregate)sut).ClearUncommittedEvents(); ((IAggregate)sut).ClearUncommittedEvents();
} }
private void PublishContent()
{
sut.Publish(new PublishContent());
((IAggregate)sut).ClearUncommittedEvents();
}
private void DeleteContent() private void DeleteContent()
{ {
sut.Delete(new DeleteContent()); sut.Delete(new DeleteContent());

Loading…
Cancel
Save