Browse Source

Archive app.

pull/262/head
Sebastian Stehle 8 years ago
parent
commit
6342f92255
  1. 4
      src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs
  2. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs
  3. 3
      src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository_SnapshotStore.cs
  4. 34
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  5. 23
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  6. 13
      src/Squidex.Domain.Apps.Entities/Apps/Commands/ArchiveApp.cs
  7. 2
      src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  8. 10
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  9. 16
      src/Squidex.Domain.Apps.Events/Apps/AppArchived.cs
  10. 2
      src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs
  11. 20
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  12. 1
      src/Squidex/app/features/settings/declarations.ts
  13. 6
      src/Squidex/app/features/settings/module.ts
  14. 39
      src/Squidex/app/features/settings/pages/more/more-page.component.html
  15. 2
      src/Squidex/app/features/settings/pages/more/more-page.component.scss
  16. 36
      src/Squidex/app/features/settings/pages/more/more-page.component.ts
  17. 6
      src/Squidex/app/features/settings/settings-area.component.html
  18. 56
      src/Squidex/app/shared/services/apps-store.service.spec.ts
  19. 9
      src/Squidex/app/shared/services/apps-store.service.ts
  20. 13
      src/Squidex/app/shared/services/apps.service.spec.ts
  21. 10
      src/Squidex/app/shared/services/apps.service.ts
  22. 33
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs
  23. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs

4
src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs

@ -36,5 +36,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
[BsonElement]
[BsonRequired]
public string[] UserIds { get; set; }
[BsonElement]
[BsonIgnoreIfDefault]
public bool IsArchived { get; set; }
}
}

2
src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs

@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
public async Task<IReadOnlyList<Guid>> QueryUserAppIdsAsync(string userId)
{
var appEntities =
await Collection.Find(x => x.UserIds.Contains(userId)).Only(x => x.Id)
await Collection.Find(x => x.UserIds.Contains(userId) && x.IsArchived != true).Only(x => x.Id)
.ToListAsync();
return appEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList();

3
src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository_SnapshotStore.cs

@ -37,7 +37,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u
.Set(x => x.Name, value.Name)
.Set(x => x.State, value)
.Set(x => x.UserIds, value.Contributors.Keys.ToArray()));
.Set(x => x.UserIds, value.Contributors.Keys.ToArray())
.Set(x => x.IsArchived, value.IsArchived));
}
}
}

34
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities
{
@ -48,14 +49,14 @@ namespace Squidex.Domain.Apps.Entities
{
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsFound(app.Value))
if (!IsExisting(app))
{
return (null, null);
}
var schema = await grainFactory.GetGrain<ISchemaGrain>(id).GetStateAsync();
if (!IsFound(schema.Value) || schema.Value.IsDeleted)
if (!IsExisting(schema, false))
{
return (null, null);
}
@ -72,7 +73,14 @@ namespace Squidex.Domain.Apps.Entities
return null;
}
return (await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync()).Value;
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsExisting(app))
{
return null;
}
return app.Value;
}
public async Task<ISchemaEntity> GetSchemaAsync(Guid appId, string name)
@ -84,14 +92,14 @@ namespace Squidex.Domain.Apps.Entities
return null;
}
return (await grainFactory.GetGrain<ISchemaGrain>(schemaId).GetStateAsync()).Value;
return await GetSchemaAsync(appId, schemaId, false);
}
public async Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false)
{
var schema = await grainFactory.GetGrain<ISchemaGrain>(id).GetStateAsync();
if (!IsFound(schema.Value) || (schema.Value.IsDeleted && !allowDeleted) || schema.Value.AppId.Id != appId)
if (!IsExisting(schema, allowDeleted) || schema.Value.AppId.Id != appId)
{
return null;
}
@ -107,7 +115,7 @@ namespace Squidex.Domain.Apps.Entities
await Task.WhenAll(
ids.Select(id => grainFactory.GetGrain<ISchemaGrain>(id).GetStateAsync()));
return schemas.Where(s => IsFound(s.Value)).Select(s => (ISchemaEntity)s.Value).ToList();
return schemas.Where(s => IsFound(s.Value)).Select(s => s.Value).ToList();
}
public async Task<List<IRuleEntity>> GetRulesAsync(Guid appId)
@ -118,7 +126,7 @@ namespace Squidex.Domain.Apps.Entities
await Task.WhenAll(
ids.Select(id => grainFactory.GetGrain<IRuleGrain>(id).GetStateAsync()));
return rules.Where(r => IsFound(r.Value)).Select(r => (IRuleEntity)r.Value).ToList();
return rules.Where(r => IsFound(r.Value)).Select(r => r.Value).ToList();
}
public async Task<List<IAppEntity>> GetUserApps(string userId)
@ -129,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities
await Task.WhenAll(
ids.Select(id => grainFactory.GetGrain<IAppGrain>(id).GetStateAsync()));
return apps.Where(a => IsFound(a.Value)).Select(a => (IAppEntity)a.Value).ToList();
return apps.Where(a => IsFound(a.Value)).Select(a => a.Value).ToList();
}
private Task<Guid> GetAppIdAsync(string name)
@ -146,5 +154,15 @@ namespace Squidex.Domain.Apps.Entities
{
return entity.Version > EtagVersion.Empty;
}
private static bool IsExisting(J<ISchemaEntity> schema, bool allowDeleted)
{
return IsFound(schema.Value) && (!schema.Value.IsDeleted || allowDeleted);
}
private static bool IsExisting(J<IAppEntity> app)
{
return IsFound(app.Value) && !app.Value.IsArchived;
}
}
}

23
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -57,6 +57,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
protected override Task<object> ExecuteAsync(IAggregateCommand command)
{
VerifyNotArchived();
switch (command)
{
case CreateApp createApp:
@ -179,6 +181,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
}
});
case ArchiveApp archiveApp:
return UpdateAsync(archiveApp, async c =>
{
await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, null);
ArchiveApp(c);
});
default:
throw new NotSupportedException();
}
@ -277,6 +287,19 @@ namespace Squidex.Domain.Apps.Entities.Apps
RaiseEvent(SimpleMapper.Map(command, new AppPatternUpdated()));
}
public void ArchiveApp(ArchiveApp command)
{
RaiseEvent(SimpleMapper.Map(command, new AppArchived()));
}
private void VerifyNotArchived()
{
if (Snapshot.IsArchived)
{
throw new DomainException("App has already been archived.");
}
}
private void RaiseEvent(AppEvent @event)
{
if (@event.AppId == null)

13
src/Squidex.Domain.Apps.Entities/Apps/Commands/ArchiveApp.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.Apps.Commands
{
public sealed class ArchiveApp : AppCommand
{
}
}

2
src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs

@ -26,5 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
AppContributors Contributors { get; }
LanguagesConfig LanguagesConfig { get; }
bool IsArchived { get; }
}
}

10
src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -39,6 +39,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
[JsonProperty]
public LanguagesConfig LanguagesConfig { get; set; } = English;
[JsonProperty]
public bool IsArchived { get; set; }
protected void On(AppCreated @event)
{
SimpleMapper.Map(@event, this);
@ -114,6 +117,13 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
}
}
protected void On(AppArchived @event)
{
Plan = null;
IsArchived = true;
}
public AppState Apply(Envelope<IEvent> @event)
{
var payload = (SquidexEvent)@event.Payload;

16
src/Squidex.Domain.Apps.Events/Apps/AppArchived.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
{
[EventType(nameof(AppArchived))]
public sealed class AppArchived : AppEvent
{
}
}

2
src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs

@ -105,7 +105,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
}
/// <summary>
/// Revoke an app client
/// Revoke an app client.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the pattern to be deleted.</param>

20
src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -112,5 +112,25 @@ namespace Squidex.Areas.Api.Controllers.Apps
return CreatedAtAction(nameof(GetApps), response);
}
/// <summary>
/// Archive the app.
/// /// </summary>
/// <param name="app">The name of the app to archive.</param>
/// <returns>
/// 204 => App archived.
/// 404 => App not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/")]
[AppApi]
[ApiCosts(1)]
[MustBeAppOwner]
public async Task<IActionResult> DeleteApp(string app)
{
await CommandBus.PublishAsync(new ArchiveApp());
return NoContent();
}
}
}

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

@ -10,6 +10,7 @@ export * from './pages/clients/clients-page.component';
export * from './pages/contributors/contributors-page.component';
export * from './pages/languages/language.component';
export * from './pages/languages/languages-page.component';
export * from './pages/more/more-page.component';
export * from './pages/patterns/pattern.component';
export * from './pages/patterns/patterns-page.component';
export * from './pages/plans/plans-page.component';

6
src/Squidex/app/features/settings/module.ts

@ -22,6 +22,7 @@ import {
ContributorsPageComponent,
LanguageComponent,
LanguagesPageComponent,
MorePageComponent,
PatternComponent,
PatternsPageComponent,
PlansPageComponent,
@ -40,6 +41,10 @@ const routes: Routes = [
path: 'plans',
component: PlansPageComponent
},
{
path: 'more',
component: MorePageComponent
},
{
path: 'clients',
component: ClientsPageComponent,
@ -137,6 +142,7 @@ const routes: Routes = [
ContributorsPageComponent,
LanguageComponent,
LanguagesPageComponent,
MorePageComponent,
PatternComponent,
PatternsPageComponent,
PlansPageComponent,

39
src/Squidex/app/features/settings/pages/more/more-page.component.html

@ -0,0 +1,39 @@
<sqx-title message="{app} | More | Settings" parameter1="app" [value1]="ctx.appName"></sqx-title>
<sqx-panel desiredWidth="50rem">
<div class="panel-header">
<div class="panel-title-row">
<h3 class="panel-title">More</h3>
</div>
<a class="panel-close" sqxParentLink>
<i class="icon-close"></i>
</a>
</div>
<div class="panel-main">
<div class="panel-content">
<div class="card border-danger">
<h3 class="card-header">Danger Zone</h3>
<div class="card-body">
<div class="row">
<div class="col">
<h4>Archive App</h4>
<div>Once you archive an app, there is no going back. Please be certain.</div>
</div>
<div class="col-auto">
<button class="btn btn-danger"
(sqxConfirmClick)="archiveApp()"
confirmTitle="Archive App"
confirmText="Do you really want to archive this app?">
Archive App
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</sqx-panel>

2
src/Squidex/app/features/settings/pages/more/more-page.component.scss

@ -0,0 +1,2 @@
@import '_vars';
@import '_mixins';

36
src/Squidex/app/features/settings/pages/more/more-page.component.ts

@ -0,0 +1,36 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AppContext } from 'shared';
@Component({
selector: 'sqx-more-page',
styleUrls: ['./more-page.component.scss'],
templateUrl: './more-page.component.html',
providers: [
AppContext
]
})
export class MorePageComponent {
constructor(public readonly ctx: AppContext,
private readonly router: Router
) {
}
public archiveApp() {
this.ctx.appsStore.deleteApp(this.ctx.appName)
.subscribe(() => {
this.router.navigate(['/app']);
}, error => {
this.ctx.notifyError(error);
});
}
}

6
src/Squidex/app/features/settings/settings-area.component.html

@ -44,6 +44,12 @@
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *ngIf="ctx.app.permission === 'Owner'">
<a class="nav-link" routerLink="more" routerLinkActive="active">
More
<i class="icon-angle-right"></i>
</a>
</li>
</ul>
</div>
</div>

56
src/Squidex/app/shared/services/apps-store.service.spec.ts

@ -20,10 +20,10 @@ describe('AppsStoreService', () => {
const now = DateTime.now();
const oldApps = [
new AppDto('id', 'old-name', 'Owner', now, now, 'Free', 'Plan'),
new AppDto('id', 'old-name', 'Owner', now, now, 'Free', 'Plan')
new AppDto('id1', 'old-name1', 'Owner', now, now, 'Free', 'Plan'),
new AppDto('id2', 'old-name2', 'Owner', now, now, 'Free', 'Plan')
];
const newApp = new AppDto('id', 'new-name', 'Owner', now, now, 'Free', 'Plan');
const newApp = new AppDto('id3', 'new-name', 'Owner', now, now, 'Free', 'Plan');
let appsService: IMock<AppsService>;
@ -56,27 +56,69 @@ describe('AppsStoreService', () => {
});
it('should add app to cache when created', () => {
appsService.setup(x => x.postApp(It.isAny()))
const request = new CreateAppDto(newApp.name);
appsService.setup(x => x.postApp(request))
.returns(() => Observable.of(newApp))
.verifiable(Times.once());
const store = new AppsStoreService(appsService.object);
let result1: AppDto[] | null = null;
let result2: AppDto[] | null = null;
store.apps.subscribe(x => {
result1 = x;
}).unsubscribe();
store.createApp(request, now).subscribe();
store.apps.subscribe(x => {
result2 = x;
}).unsubscribe();
expect(result1).toEqual(oldApps);
expect(result2).toEqual(oldApps.concat([newApp]));
appsService.verifyAll();
});
it('should remove app from cache when archived', () => {
const request = new CreateAppDto(newApp.name);
appsService.setup(x => x.postApp(request))
.returns(() => Observable.of(newApp))
.verifiable(Times.once());
appsService.setup(x => x.deleteApp(newApp.name))
.returns(() => Observable.of({}))
.verifiable(Times.once());
const store = new AppsStoreService(appsService.object);
let result1: AppDto[] | null = null;
let result2: AppDto[] | null = null;
let result3: AppDto[] | null = null;
store.apps.subscribe(x => {
result1 = x;
}).unsubscribe();
store.createApp(new CreateAppDto('new-name'), now).subscribe();
store.createApp(request, now).subscribe();
store.apps.subscribe(x => {
result2 = x;
}).unsubscribe();
store.deleteApp(newApp.name).subscribe();
store.apps.subscribe(x => {
result3 = x;
}).unsubscribe();
expect(result1).toEqual(oldApps);
expect(JSON.stringify(result2)).toEqual(JSON.stringify(oldApps.concat([newApp])));
expect(result2).toEqual(oldApps.concat([newApp]));
expect(result3).toEqual(oldApps);
appsService.verifyAll();
});
@ -84,7 +126,7 @@ describe('AppsStoreService', () => {
it('should select app', (done) => {
const store = new AppsStoreService(appsService.object);
store.selectApp('old-name').subscribe(isSelected => {
store.selectApp(oldApps[0].name).subscribe(isSelected => {
expect(isSelected).toBeTruthy();
appsService.verifyAll();

9
src/Squidex/app/shared/services/apps-store.service.ts

@ -67,4 +67,13 @@ export class AppsStoreService {
});
});
}
public deleteApp(appName: string): Observable<any> {
return this.appsService.deleteApp(appName)
.do(app => {
this.apps$.take(1).subscribe(apps => {
this.apps$.next(apps.filter(a => a.name !== appName));
});
});
}
}

13
src/Squidex/app/shared/services/apps.service.spec.ts

@ -103,4 +103,17 @@ describe('AppsService', () => {
expect(app).toEqual(new AppDto('123', dto.name, 'Reader', now, now, 'Basic', 'Enterprise'));
}));
it('should make delete request to archive app',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {
appsService.deleteApp('my-app').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
});

10
src/Squidex/app/shared/services/apps.service.ts

@ -87,4 +87,14 @@ export class AppsService {
})
.pretifyError('Failed to create app. Please reload.');
}
public deleteApp(appName: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}`);
return this.http.delete(url)
.do(() => {
this.analytics.trackEvent('App', 'Archived', appName);
})
.pretifyError('Failed to archive app. Please reload.');
}
}

33
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs

@ -61,6 +61,15 @@ namespace Squidex.Domain.Apps.Entities.Apps
sut.OnActivateAsync(Id).Wait();
}
[Fact]
public async Task Command_should_throw_exception_if_app_is_archived()
{
await ExecuteCreateAsync();
await ExecuteArchiveAsync();
await Assert.ThrowsAsync<DomainException>(ExecuteAttachClientAsync);
}
[Fact]
public async Task Create_should_create_events_and_update_state()
{
@ -360,6 +369,25 @@ namespace Squidex.Domain.Apps.Entities.Apps
);
}
[Fact]
public async Task ArchiveApp_should_create_events_and_update_state()
{
var command = new ArchiveApp();
await ExecuteCreateAsync();
var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(5));
LastEvents
.ShouldHaveSameEvents(
CreateEvent(new AppArchived())
);
A.CallTo(() => appPlansBillingManager.ChangePlanAsync(command.Actor.Identifier, AppId, AppName, null));
}
private Task ExecuteAddPatternAsync()
{
return sut.ExecuteAsync(CreateCommand(new AddPattern { PatternId = patternId3, Name = "Name", Pattern = ".*" }));
@ -384,5 +412,10 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
return sut.ExecuteAsync(CreateCommand(new AddLanguage { Language = language }));
}
private Task ExecuteArchiveAsync()
{
return sut.ExecuteAsync(CreateCommand(new ArchiveApp()));
}
}
}

2
tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs

@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
}
[Fact]
public async Task Command_should_throw_exception_if_rule_is_deleted()
public async Task Command_should_throw_exception_if_schema_is_deleted()
{
await ExecuteCreateAsync();
await ExecuteDeleteAsync();

Loading…
Cancel
Save