diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 22120a31d..d9bbaa630 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -133,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents foreach (var entity in contentItems) { - var schema = schemas.FirstOrDefault(x => x.Id == entity.IndexedSchemaId); + var schema = schemas.FirstOrDefault(x => x?.Id == entity.IndexedSchemaId); if (schema != null) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 49c9ebb52..fff15b861 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -81,6 +81,8 @@ namespace Squidex.Areas.Api.Controllers.Apps var response = Deferred.Response(() => { + var userPermissions = HttpContext.Permissions(); + return apps.OrderBy(x => x.Name).Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); }); @@ -89,6 +91,34 @@ namespace Squidex.Areas.Api.Controllers.Apps return Ok(response); } + /// + /// Get an app by name. + /// + /// The name of the app. + /// + /// 200 => Apps returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}")] + [ProducesResponseType(typeof(AppDto), 200)] + [ApiPermission] + [ApiCosts(0)] + public IActionResult GetApp(string app) + { + var response = Deferred.Response(() => + { + var userOrClientId = HttpContext.User.UserOrClientId()!; + var userPermissions = HttpContext.Permissions(); + + return AppDto.FromApp(App, userOrClientId, userPermissions, appPlansProvider, this); + }); + + Response.Headers[HeaderNames.ETag] = App.ToEtag(); + + return Ok(response); + } + /// /// Create a new app. /// diff --git a/frontend/app/features/settings/pages/more/more-page.component.ts b/frontend/app/features/settings/pages/more/more-page.component.ts index d293e06a8..cbe2b32ad 100644 --- a/frontend/app/features/settings/pages/more/more-page.component.ts +++ b/frontend/app/features/settings/pages/more/more-page.component.ts @@ -8,6 +8,7 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { Router } from '@angular/router'; +import { map } from 'rxjs/operators'; import { AppDto, @@ -44,7 +45,7 @@ export class MorePageComponent extends ResourceOwner implements OnInit { public ngOnInit() { this.own( - this.appsState.selectedApp + this.appsState.reloadSelected().pipe(map(x => x!)) .subscribe(app => { this.app = app; @@ -66,8 +67,8 @@ export class MorePageComponent extends ResourceOwner implements OnInit { if (value) { this.appsState.update(this.app, value) - .subscribe(user => { - this.updateForm.submitCompleted({ newValue: user }); + .subscribe(app => { + this.updateForm.submitCompleted({ newValue: app }); }, error => { this.updateForm.submitFailed(error); }); diff --git a/frontend/app/framework/angular/modals/dialog-renderer.component.scss b/frontend/app/framework/angular/modals/dialog-renderer.component.scss index 2e7f2fc21..c486e537c 100644 --- a/frontend/app/framework/angular/modals/dialog-renderer.component.scss +++ b/frontend/app/framework/angular/modals/dialog-renderer.component.scss @@ -46,7 +46,7 @@ .overlay { @include absolute(0, auto, 0, 0); animation: width 10s 1 linear; - background: $color-white; + background: $color-black; border: 0; opacity: .1; overflow: hidden; diff --git a/frontend/app/shared/services/apps.service.spec.ts b/frontend/app/shared/services/apps.service.spec.ts index b947aacbe..31c7c49ad 100644 --- a/frontend/app/shared/services/apps.service.spec.ts +++ b/frontend/app/shared/services/apps.service.spec.ts @@ -62,6 +62,25 @@ describe('AppsService', () => { expect(apps!).toEqual([createApp(12), createApp(13)]); })); + it('should make get request to get app', + inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => { + + let app: AppDto; + + appsService.getApp('my-app').subscribe(result => { + app = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush(appResponse(12)); + + expect(app!).toEqual(createApp(12)); + })); + it('should make post request to create app', inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => { diff --git a/frontend/app/shared/services/apps.service.ts b/frontend/app/shared/services/apps.service.ts index fb54ec4c9..321fc6de9 100644 --- a/frontend/app/shared/services/apps.service.ts +++ b/frontend/app/shared/services/apps.service.ts @@ -119,6 +119,18 @@ export class AppsService { pretifyError('Failed to load apps. Please reload.')); } + public getApp(name: string): Observable { + const url = this.apiUrl.buildUrl(`/api/apps/${name}`); + + return this.http.get(url).pipe( + map(body => { + const app = parseApp(body); + + return app; + }), + pretifyError('Failed to load app. Please reload.')); + } + public postApp(dto: CreateAppDto): Observable { const url = this.apiUrl.buildUrl('api/apps'); diff --git a/frontend/app/shared/state/apps.state.spec.ts b/frontend/app/shared/state/apps.state.spec.ts index fd5689443..b85b7a1ae 100644 --- a/frontend/app/shared/state/apps.state.spec.ts +++ b/frontend/app/shared/state/apps.state.spec.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { IMock, Mock } from 'typemoq'; import { @@ -56,7 +56,7 @@ describe('AppsState', () => { expect(appsState.snapshot.selectedApp).toBe(app1); }); - it('should return null on select when unselecting user', () => { + it('should return null on select when unselecting app', () => { let selectedApp: AppDto; appsState.select(null).subscribe(x => { @@ -67,9 +67,40 @@ describe('AppsState', () => { expect(appsState.snapshot.selectedApp).toBeNull(); }); - it('should return null on select when apps is not found', () => { + it('should return new app when loaded', () => { + const newApp = createApp(1, '_new'); + + appsService.setup(x => x.getApp(app1.name)) + .returns(() => of(newApp)); + + let selectedApp: AppDto; + + appsState.loadApp(app1.name).subscribe(x => { + selectedApp = x!; + }); + + expect(selectedApp!).toEqual(newApp); + expect(appsState.snapshot.selectedApp).toBeNull(); + }); + + it('should return new app when reloaded', () => { + const newApp = createApp(1, '_new'); + + appsService.setup(x => x.getApp(app1.name)) + .returns(() => of(newApp)); + + appsState.select(app1.name).subscribe(); + appsState.reloadSelected(); + + expect(appsState.snapshot.selectedApp).toEqual(newApp); + }); + + it('should return null on select when app is not found', () => { let selectedApp: AppDto; + appsService.setup(x => x.getApp('unknown')) + .returns(() => throwError(new Error('404'))); + appsState.select('unknown').subscribe(x => { selectedApp = x!; }); @@ -152,6 +183,9 @@ describe('AppsState', () => { appsService.setup(x => x.deleteApp(app1)) .returns(() => of({})).verifiable(); + appsService.setup(x => x.getApp(app1.name)) + .returns(() => of(app1)); + appsState.select(app1.name).subscribe(); appsState.delete(app1).subscribe(); diff --git a/frontend/app/shared/state/apps.state.ts b/frontend/app/shared/state/apps.state.ts index 5b9b9a958..dbc60840e 100644 --- a/frontend/app/shared/state/apps.state.ts +++ b/frontend/app/shared/state/apps.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { catchError, tap } from 'rxjs/operators'; import { defined, @@ -58,18 +58,40 @@ export class AppsState extends State { super({ apps: [], selectedApp: null }); } - public select(name: string | null): Observable { - const observable = - !name ? - of(null) : - of(this.snapshot.apps.find(x => x.name === name) || null); + public reloadSelected() { + return this.loadApp(this.appName).pipe( + shareSubscribed(this.dialogs)); + } - return observable.pipe( + public select(name: string | null): Observable { + return this.loadApp(name, true).pipe( tap(selectedApp => { - this.next(s => ({ ...s, selectedApp })); + this.next(s => { + return { ...s, selectedApp }; + }); })); } + public loadApp(name: string | null, cached = false) { + if (!name) { + return of(null); + } + + if (cached) { + const found = this.snapshot.apps.find(x => x.name === name); + + if (found) { + return of(found); + } + } + + return this.appsService.getApp(name).pipe( + tap(app => { + this.replaceApp(app, app); + }), + catchError(() => of(null))); + } + public load(): Observable { return this.appsService.getApps().pipe( tap(apps => { diff --git a/frontend/app/shared/state/schemas.state.spec.ts b/frontend/app/shared/state/schemas.state.spec.ts index 94023646c..6515f2732 100644 --- a/frontend/app/shared/state/schemas.state.spec.ts +++ b/frontend/app/shared/state/schemas.state.spec.ts @@ -236,7 +236,7 @@ describe('SchemasState', () => { expect().nothing(); }); - it('should return null on select when loading failed', () => { + it('should return null on select when loading failed', () => { schemasService.setup(x => x.getSchema(app, 'failed')) .returns(() => throwError({})).verifiable(); diff --git a/frontend/app/theme/_vars.scss b/frontend/app/theme/_vars.scss index 90d90a126..a6d6d1c91 100644 --- a/frontend/app/theme/_vars.scss +++ b/frontend/app/theme/_vars.scss @@ -35,6 +35,7 @@ $color-theme-orange-dark: #a65b00; $color-theme-error: #eb3142; $color-theme-error-dark: #c00; +$color-black: #000; $color-white: #fff; $color-theme-info: #5bc0de;