mirror of https://github.com/Squidex/squidex.git
61 changed files with 1052 additions and 182 deletions
@ -1 +1,15 @@ |
|||
<router-outlet></router-outlet> |
|||
<nav class="navbar navbar-fixed-top navbar-dark bg-primary bg-faded"> |
|||
<span class="navbar-brand">Squidex</span> |
|||
|
|||
<div class="float-xs-left apps-menu"> |
|||
<sqx-apps-menu></sqx-apps-menu> |
|||
</div> |
|||
|
|||
<div class="float-xs-left search-form"> |
|||
<sqx-search-form></sqx-search-form> |
|||
</div> |
|||
</nav> |
|||
|
|||
<main> |
|||
<router-outlet></router-outlet> |
|||
</main> |
|||
|
|||
@ -1,8 +1,13 @@ |
|||
@import 'theme/mixins.scss'; |
|||
|
|||
.navbar { |
|||
@include box-shadow(0, 4px, 4px, 0.2px); |
|||
} |
|||
|
|||
.search-form { |
|||
margin-left: 15px; |
|||
} |
|||
|
|||
main { |
|||
padding: 1em; |
|||
font-family: Arial, Helvetica, sans-serif; |
|||
font-size: 1.1rem; |
|||
text-align: center; |
|||
margin-top: 50px; |
|||
display: block; |
|||
margin-top: 54px |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
<content> |
|||
<div class="apps-empty"> |
|||
<h3 class="apps-empty-headline">You are not collaborating to any app yet</h3> |
|||
|
|||
<button class="apps-empty-button btn btn-success" (click)="modalDialog.show()">Create App</button> |
|||
</div> |
|||
</content> |
|||
|
|||
<div class="modal" [(sqxModalView)]="modalDialog" [@fade]="modalDialog.isOpenChanges | async"> |
|||
<div class="modal-dialog"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" (click)="modalDialog.hide()"> |
|||
<span aria-hidden="true">×</span> |
|||
</button> |
|||
|
|||
<h4 class="modal-title">Create App</h4> |
|||
</div> |
|||
|
|||
<div class="modal-body"> |
|||
<sqx-app-form |
|||
(onCreated)="modalDialog.hide()" |
|||
(onCancelled)="modalDialog.hide()"></sqx-app-form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,17 @@ |
|||
@import '../../theme/_vars.scss'; |
|||
@import '../../theme/_mixins.scss'; |
|||
|
|||
content { |
|||
padding: 20px; |
|||
} |
|||
|
|||
.apps-empty { |
|||
& { |
|||
text-align: center; |
|||
} |
|||
|
|||
&-headline { |
|||
margin-top: 100px; |
|||
margin-bottom: 20px; |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import * as Ng2 from '@angular/core'; |
|||
|
|||
import { fadeAnimation, ModalView } from './../../framework'; |
|||
|
|||
@Ng2.Component({ |
|||
selector: 'sqx-apps-page', |
|||
styles, |
|||
template, |
|||
animations: [ |
|||
fadeAnimation() |
|||
] |
|||
}) |
|||
export class AppsPageComponent { |
|||
public modalDialog = new ModalView(); |
|||
} |
|||
@ -1,3 +0,0 @@ |
|||
<main> |
|||
<h1>Hello from Angular 2 App with Webpack!</h1> |
|||
</main> |
|||
@ -0,0 +1,31 @@ |
|||
<form [formGroup]="createForm" (ngSubmit)="submit()"> |
|||
<div class="form-group"> |
|||
<label for="app-name">Name</label> |
|||
|
|||
<div class="errors-box" *ngIf="createForm.get('name').invalid && createForm.get('name').dirty" [@fade]> |
|||
<div class="errors"> |
|||
<span *ngIf="createForm.get('name').hasError('required')"> |
|||
Name is required. |
|||
</span> |
|||
<span *ngIf="createForm.get('name').hasError('maxlength')"> |
|||
Name can not have more than 40 characters. |
|||
</span> |
|||
<span *ngIf="createForm.get('name').hasError('pattern')"> |
|||
Name can contain lower case letters (a-z), numbers and dashes only. |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<input type="text" class="form-control" id="app-name" formControlName="name" /> |
|||
|
|||
<span class="form-hint"> |
|||
The app name becomes part of the api url, e.g, https://<b>{{appName | async}}</b>.squidex.io/.<br /> |
|||
It must contain lower case letters (a-z), numbers and dashes only, and cannot be longer than 40 characters. The name cannot be changed later. |
|||
</span> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<button type="reset" class="btn btn-link" (click)="cancel()">Cancel</button> |
|||
<button type="submit" class="btn btn-success">Create</button> |
|||
</div> |
|||
</form> |
|||
@ -0,0 +1,91 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import * as Ng2 from '@angular/core'; |
|||
import * as Ng2Forms from '@angular/forms'; |
|||
|
|||
import { Observable } from 'rxjs'; |
|||
|
|||
import { fadeAnimation } from './../../framework'; |
|||
|
|||
import { AppCreateDto, AppsStoreService } from './../../shared'; |
|||
|
|||
const FALLBACK_NAME = 'my-app'; |
|||
|
|||
@Ng2.Component({ |
|||
selector: 'sqx-app-form', |
|||
styles, |
|||
template, |
|||
animations: [ |
|||
fadeAnimation() |
|||
] |
|||
}) |
|||
export class AppFormComponent implements Ng2.OnInit { |
|||
public createForm: Ng2Forms.FormGroup; |
|||
|
|||
public appName: Observable<string>; |
|||
|
|||
@Ng2.Input() |
|||
public showClose = false; |
|||
|
|||
@Ng2.Output() |
|||
public onCreated = new Ng2.EventEmitter(); |
|||
|
|||
@Ng2.Output() |
|||
public onCancelled = new Ng2.EventEmitter(); |
|||
|
|||
public creating = new Ng2.EventEmitter<boolean>(); |
|||
|
|||
public creationError = new Ng2.EventEmitter<any>(); |
|||
|
|||
constructor( |
|||
private readonly appsStore: AppsStoreService, |
|||
private readonly formBuilder: Ng2Forms.FormBuilder |
|||
) { |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.createForm = this.formBuilder.group({ |
|||
name: ['', |
|||
[ |
|||
Ng2Forms.Validators.required, |
|||
Ng2Forms.Validators.maxLength(40), |
|||
Ng2Forms.Validators.pattern('[a-z0-9]+(\-[a-z0-9]+)*'), |
|||
]] |
|||
}); |
|||
|
|||
this.appName = this.createForm.controls['name'].valueChanges.map(name => name || FALLBACK_NAME).publishBehavior(FALLBACK_NAME).refCount(); |
|||
} |
|||
|
|||
public submit() { |
|||
if (this.createForm.valid) { |
|||
this.createForm.disable(); |
|||
this.creating.emit(true); |
|||
|
|||
const dto = new AppCreateDto(this.createForm.controls['name'].value); |
|||
|
|||
this.appsStore.createApp(dto) |
|||
.finally(() => { |
|||
this.reset(); |
|||
}) |
|||
.subscribe(() => { |
|||
this.onCreated.emit(); |
|||
}, error => { |
|||
this.creationError.emit(error); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private reset() { |
|||
this.createForm.enable(); |
|||
this.creating.emit(false); |
|||
} |
|||
|
|||
public cancel() { |
|||
this.onCancelled.emit(); |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
<ul class="nav navbar-nav" *ngIf="apps"> |
|||
<li class="nav-item dropdown"> |
|||
<span class="nav-link dropdown-toggle" id="app-name" (click)="modalMenu.toggle()">My App with a really very long name</span> |
|||
|
|||
<div class="dropdown-menu" [(sqxModalView)]="modalMenu"> |
|||
<a class="dropdown-item" href="#">Action</a> |
|||
<a class="dropdown-item" href="#">Another action</a> |
|||
<a class="dropdown-item" href="#">Something else here</a> |
|||
|
|||
<div class="dropdown-divider"></div> |
|||
|
|||
<a class="dropdown-item" routerLink="/apps">All apps</a> |
|||
|
|||
<div class="dropdown-divider"></div> |
|||
|
|||
<div class="drodown-button"> |
|||
<button class="btn btn-block btn-success" id="app-create" (click)="createApp()">Create App</button> |
|||
</div> |
|||
</div> |
|||
</li> |
|||
</ul> |
|||
|
|||
<div class="modal ng-animate" [(sqxModalView)]="modalDialog" [@fade]="(modalDialog.isOpenChanges | async)"> |
|||
<div class="modal-dialog" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" (click)="modalDialog.hide()"> |
|||
<span aria-hidden="true">×</span> |
|||
</button> |
|||
|
|||
<h4 class="modal-title">Create App</h4> |
|||
</div> |
|||
|
|||
<div class="modal-body"> |
|||
<sqx-app-form |
|||
(onCreated)="modalDialog.hide()" |
|||
(onCancelled)="modalDialog.hide()"></sqx-app-form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,33 @@ |
|||
@import '../../theme/_vars.scss'; |
|||
@import '../../theme/_mixins.scss'; |
|||
|
|||
.navbar-dark .navbar-nav .nav-link { |
|||
color: white; |
|||
} |
|||
|
|||
.nav-link { |
|||
@include truncate(); |
|||
} |
|||
|
|||
.drodown-button { |
|||
padding: 3px 1.5rem; |
|||
} |
|||
|
|||
#app-name { |
|||
& { |
|||
padding-right: 15px; |
|||
@include transition(opacity 0.4 ease); |
|||
@include opacity(0.95); |
|||
color: white; |
|||
cursor: pointer; |
|||
width: 200px; |
|||
} |
|||
|
|||
&:hover { |
|||
@include opacity(1); |
|||
} |
|||
|
|||
&:after { |
|||
@include absolute(50%, 0px, auto, auto); |
|||
} |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import * as Ng2 from '@angular/core'; |
|||
|
|||
import { |
|||
AppDto, |
|||
AppsStoreService |
|||
} from './../../shared'; |
|||
|
|||
import { fadeAnimation, ModalView } from './../../framework'; |
|||
|
|||
@Ng2.Component({ |
|||
selector: 'sqx-apps-menu', |
|||
styles, |
|||
template, |
|||
animations: [ |
|||
fadeAnimation() |
|||
] |
|||
}) |
|||
export class AppsMenuComponent implements Ng2.OnInit, Ng2.OnDestroy { |
|||
private subscription: any | null = null; |
|||
|
|||
public modalMenu = new ModalView(); |
|||
public modalDialog = new ModalView(); |
|||
|
|||
public apps: AppDto[] | null = null; |
|||
|
|||
constructor( |
|||
private readonly appsStore: AppsStoreService |
|||
) { |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.subscription = this.appsStore.appsChanges.subscribe(apps => { |
|||
this.apps = apps; |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
if (this.subscription) { |
|||
this.subscription.unsubscribe(); |
|||
} |
|||
} |
|||
|
|||
public createApp() { |
|||
this.modalMenu.hide(); |
|||
this.modalDialog.show(); |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
export * from './app-form.component'; |
|||
export * from './apps-menu.component'; |
|||
export * from './search-form.component'; |
|||
@ -0,0 +1,10 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
export * from './declarations'; |
|||
|
|||
export * from './layout.module'; |
|||
@ -0,0 +1,33 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import * as Ng2 from '@angular/core'; |
|||
|
|||
import { SqxFrameworkModule } from './../../framework'; |
|||
|
|||
import { |
|||
AppFormComponent, |
|||
AppsMenuComponent, |
|||
SearchFormComponent |
|||
} from './declarations'; |
|||
|
|||
@Ng2.NgModule({ |
|||
imports: [ |
|||
SqxFrameworkModule |
|||
], |
|||
declarations: [ |
|||
AppFormComponent, |
|||
AppsMenuComponent, |
|||
SearchFormComponent, |
|||
], |
|||
exports: [ |
|||
AppFormComponent, |
|||
AppsMenuComponent, |
|||
SearchFormComponent, |
|||
] |
|||
}) |
|||
export class SqxLayoutModule { } |
|||
@ -0,0 +1,3 @@ |
|||
<form class="form-inline"> |
|||
<input class="form-control search" type="text" /> |
|||
</form> |
|||
@ -0,0 +1,16 @@ |
|||
@import '../../theme/_vars.scss'; |
|||
@import '../../theme/_mixins.scss'; |
|||
|
|||
.search { |
|||
& { |
|||
@include transition(background 0.4s ease); |
|||
color: white; |
|||
background: $accent-blue-dark; |
|||
border-color: $accent-blue-dark; |
|||
border-width: 1px; |
|||
} |
|||
|
|||
&:focus { |
|||
background: darken($accent-blue-dark, 5%); |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
|
|||
import * as Ng2 from '@angular/core'; |
|||
|
|||
export const fadeAnimation = (name = 'fade', timing = '200ms'): Ng2.AnimationEntryMetadata => { |
|||
return Ng2.trigger( |
|||
name, [ |
|||
Ng2.transition(':enter', [ |
|||
Ng2.style({ opacity: 0 }), |
|||
Ng2.animate(timing, Ng2.style({ opacity: 1 })) |
|||
]), |
|||
Ng2.transition(':leave', [ |
|||
Ng2.style({ 'opacity': 1 }), |
|||
Ng2.animate(timing, Ng2.style({ opacity: 0 })) |
|||
]), |
|||
Ng2.state('true', |
|||
Ng2.style({ opacity: 1 }) |
|||
), |
|||
Ng2.state('false', |
|||
Ng2.style({ opacity: 0 }) |
|||
), |
|||
Ng2.transition('1 => 0', Ng2.animate(timing)), |
|||
Ng2.transition('0 => 1', Ng2.animate(timing)) |
|||
] |
|||
); |
|||
} |
|||
|
|||
@ -0,0 +1,69 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import * as Ng2 from '@angular/core'; |
|||
|
|||
import { ModalView } from './../utils/modal-view'; |
|||
|
|||
@Ng2.Directive({ |
|||
selector: '[sqxModalView]' |
|||
}) |
|||
export class ModalViewDirective implements Ng2.OnChanges { |
|||
private subscription: any | null; |
|||
private isEnabled = true; |
|||
|
|||
@Ng2.Input('sqxModalView') |
|||
public modalView: ModalView; |
|||
|
|||
constructor( |
|||
private readonly elementRef: Ng2.ElementRef, |
|||
private readonly renderer: Ng2.Renderer, |
|||
) { |
|||
} |
|||
|
|||
@Ng2.HostListener('document:click', ['$event', '$event.target']) |
|||
public clickOutside(event: MouseEvent, targetElement: HTMLElement) { |
|||
if (!targetElement) { |
|||
return; |
|||
} |
|||
|
|||
const clickedInside = this.elementRef.nativeElement.contains(targetElement); |
|||
|
|||
if (!clickedInside && this.modalView && this.isEnabled) { |
|||
this.modalView.hide(); |
|||
} |
|||
} |
|||
|
|||
public ngOnChanges() { |
|||
if (this.subscription) { |
|||
this.subscription.unsubscribe(); |
|||
this.subscription = null; |
|||
} |
|||
|
|||
if (this.modalView) { |
|||
this.subscription = this.modalView.isOpenChanges.subscribe(isOpen => { |
|||
if (this.isEnabled) { |
|||
if (isOpen) { |
|||
this.renderer.setElementStyle(this.elementRef.nativeElement, 'display', 'block'); |
|||
} else { |
|||
this.renderer.setElementStyle(this.elementRef.nativeElement, 'display', 'none'); |
|||
} |
|||
|
|||
this.updateEnabled(); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private updateEnabled() { |
|||
this.isEnabled = false; |
|||
|
|||
setTimeout(() => { |
|||
this.isEnabled = true; |
|||
}, 500); |
|||
} |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import { ModalView } from './../'; |
|||
|
|||
describe('ModalView', () => { |
|||
it('should have initial true value', () => { |
|||
const dialog = new ModalView(true); |
|||
|
|||
checkValue(dialog, true); |
|||
}); |
|||
|
|||
it('should have initial false value', () => { |
|||
const dialog = new ModalView(false); |
|||
|
|||
checkValue(dialog, false); |
|||
}); |
|||
|
|||
it('should become open after show', () => { |
|||
const dialog = new ModalView(false); |
|||
|
|||
dialog.show(); |
|||
|
|||
checkValue(dialog, true); |
|||
}); |
|||
|
|||
it('should become open after toggle', () => { |
|||
const dialog = new ModalView(false); |
|||
|
|||
dialog.toggle(); |
|||
|
|||
checkValue(dialog, true); |
|||
}); |
|||
|
|||
it('should become closed after hide', () => { |
|||
const dialog = new ModalView(true); |
|||
|
|||
dialog.hide(); |
|||
|
|||
checkValue(dialog, false); |
|||
}); |
|||
|
|||
it('should become closed after toggle', () => { |
|||
const dialog = new ModalView(true); |
|||
|
|||
dialog.toggle(); |
|||
|
|||
checkValue(dialog, false); |
|||
}); |
|||
|
|||
function checkValue(dialog: ModalView, expected: boolean) { |
|||
let result: boolean | null = null; |
|||
|
|||
dialog.isOpenChanges.subscribe(value => { |
|||
result = value; |
|||
}).unsubscribe(); |
|||
|
|||
expect(result).toBe(expected); |
|||
} |
|||
}); |
|||
|
|||
@ -0,0 +1,38 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import { BehaviorSubject, Observable } from 'rxjs'; |
|||
|
|||
export class ModalView { |
|||
private readonly isOpen$: BehaviorSubject<boolean>; |
|||
|
|||
public get isOpenChanges(): Observable<boolean> { |
|||
return this.isOpen$.distinctUntilChanged(); |
|||
} |
|||
|
|||
constructor(isOpen = false) { |
|||
this.isOpen$ = new BehaviorSubject(isOpen); |
|||
} |
|||
|
|||
public show() { |
|||
this.isOpen$.next(true); |
|||
} |
|||
|
|||
public hide() { |
|||
this.isOpen$.next(false); |
|||
} |
|||
|
|||
public toggle() { |
|||
let value = false; |
|||
|
|||
this.isOpenChanges.subscribe(v => { |
|||
value = v; |
|||
}).unsubscribe(); |
|||
|
|||
this.isOpen$.next(!value); |
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
import * as TypeMoq from 'typemoq'; |
|||
|
|||
import { Observable } from 'rxjs'; |
|||
|
|||
import { |
|||
AppCreateDto, |
|||
AppDto, |
|||
AppsStoreService, |
|||
AppsService, |
|||
AuthService |
|||
} from './../'; |
|||
|
|||
describe('AppsStoreService', () => { |
|||
const oldApps = [new AppDto('id', 'name', null, null)]; |
|||
const newApp = new AppDto('id', 'new-name', null, null); |
|||
|
|||
let appsService: TypeMoq.Mock<AppsService>; |
|||
let authService: TypeMoq.Mock<AuthService>; |
|||
|
|||
beforeEach(() => { |
|||
appsService = TypeMoq.Mock.ofType(AppsService); |
|||
authService = TypeMoq.Mock.ofType(AuthService); |
|||
}); |
|||
|
|||
it('should load when authenticated once', () => { |
|||
authService.setup(x => x.isAuthenticatedChanges) |
|||
.returns(() => Observable.of(true)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
appsService.setup(x => x.getApps()) |
|||
.returns(() => Observable.of(oldApps)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
const store = new AppsStoreService(authService.object, appsService.object); |
|||
|
|||
let result1: AppDto[]; |
|||
let result2: AppDto[]; |
|||
|
|||
store.appsChanges.subscribe(x => { |
|||
result1 = x; |
|||
}).unsubscribe(); |
|||
|
|||
store.appsChanges.subscribe(x => { |
|||
result2 = x; |
|||
}).unsubscribe(); |
|||
|
|||
expect(result1).toEqual(oldApps); |
|||
expect(result2).toEqual(oldApps); |
|||
|
|||
appsService.verifyAll(); |
|||
}); |
|||
|
|||
it('should reload value from apps-service when called', () => { |
|||
authService.setup(x => x.isAuthenticated) |
|||
.returns(() => true) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
authService.setup(x => x.isAuthenticatedChanges) |
|||
.returns(() => Observable.of(true)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
appsService.setup(x => x.getApps()) |
|||
.returns(() => Observable.of(oldApps)) |
|||
.verifiable(TypeMoq.Times.exactly(2)); |
|||
|
|||
const store = new AppsStoreService(authService.object, appsService.object); |
|||
|
|||
let result1: AppDto[]; |
|||
let result2: AppDto[]; |
|||
|
|||
store.appsChanges.subscribe(x => { |
|||
result1 = x; |
|||
}).unsubscribe(); |
|||
|
|||
store.reload(); |
|||
|
|||
store.appsChanges.subscribe(x => { |
|||
result2 = x; |
|||
}).unsubscribe(); |
|||
|
|||
expect(result1).toEqual(oldApps); |
|||
expect(result2).toEqual(oldApps); |
|||
|
|||
appsService.verifyAll(); |
|||
}); |
|||
|
|||
it('should add app to cache when created', () => { |
|||
authService.setup(x => x.isAuthenticatedChanges) |
|||
.returns(() => Observable.of(true)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
appsService.setup(x => x.getApps()) |
|||
.returns(() => Observable.of(oldApps)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
appsService.setup(x => x.postApp(TypeMoq.It.isAny())) |
|||
.returns(() => Observable.of(newApp)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
const store = new AppsStoreService(authService.object, appsService.object); |
|||
|
|||
let result1: AppDto[]; |
|||
let result2: AppDto[]; |
|||
|
|||
store.appsChanges.subscribe(x => { |
|||
result1 = x; |
|||
}).unsubscribe(); |
|||
|
|||
store.createApp(new AppCreateDto('new-name')).subscribe(x => { }); |
|||
|
|||
store.appsChanges.subscribe(x => { |
|||
result2 = x; |
|||
}).unsubscribe(); |
|||
|
|||
expect(result1).toEqual(oldApps); |
|||
expect(JSON.stringify(result2)).toEqual(JSON.stringify(oldApps.concat([newApp]))); |
|||
|
|||
appsService.verifyAll(); |
|||
}); |
|||
|
|||
it('should not add app to cache when cache is null', () => { |
|||
authService.setup(x => x.isAuthenticatedChanges) |
|||
.returns(() => Observable.of(false)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
appsService.setup(x => x.postApp(TypeMoq.It.isAny())) |
|||
.returns(() => Observable.of(newApp)) |
|||
.verifiable(TypeMoq.Times.once()); |
|||
|
|||
const store = new AppsStoreService(authService.object, appsService.object); |
|||
|
|||
let result: AppDto[]; |
|||
|
|||
store.createApp(new AppCreateDto('new-name')).subscribe(x => { }); |
|||
|
|||
store.appsChanges.subscribe(x => { |
|||
result = x; |
|||
}).unsubscribe(); |
|||
|
|||
expect(result).toBeNull(); |
|||
|
|||
appsService.verifyAll(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,67 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Sebastian Stehle. All rights reserved |
|||
*/ |
|||
|
|||
import * as Ng2 from '@angular/core'; |
|||
|
|||
import { BehaviorSubject, Observable } from 'rxjs'; |
|||
|
|||
import { |
|||
AppCreateDto, |
|||
AppDto, |
|||
AppsService |
|||
} from './apps.service'; |
|||
|
|||
import { AuthService } from './auth.service'; |
|||
|
|||
@Ng2.Injectable() |
|||
export class AppsStoreService { |
|||
private lastApps: AppDto[] = null; |
|||
private readonly apps$ = new BehaviorSubject<AppDto[]>(null); |
|||
|
|||
public get appsChanges(): Observable<AppDto[]> { |
|||
return this.apps$; |
|||
} |
|||
|
|||
constructor( |
|||
private readonly authService: AuthService, |
|||
private readonly appService: AppsService |
|||
) { |
|||
if (!authService || !appService) { |
|||
return; |
|||
} |
|||
|
|||
this.apps$.subscribe(apps => { |
|||
this.lastApps = apps; |
|||
}); |
|||
|
|||
this.authService.isAuthenticatedChanges.subscribe(isAuthenticated => { |
|||
if (isAuthenticated) { |
|||
this.load(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public reload() { |
|||
if (this.authService.isAuthenticated) { |
|||
this.load(); |
|||
} |
|||
} |
|||
|
|||
private load() { |
|||
this.appService.getApps().subscribe(apps => { |
|||
this.apps$.next(apps); |
|||
}); |
|||
} |
|||
|
|||
public createApp(appToCreate: AppCreateDto): Observable<any> { |
|||
return this.appService.postApp(appToCreate).do(app => { |
|||
if (this.lastApps && app) { |
|||
this.apps$.next(this.lastApps.concat([app])); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -1,69 +1,50 @@ |
|||
@import '_mixins.scss'; |
|||
@import '_vars.scss'; |
|||
|
|||
.nav-icon { |
|||
& { |
|||
margin-top: -0.5rem; |
|||
text-align: center; |
|||
text-decoration: none; |
|||
cursor: default; |
|||
} |
|||
|
|||
span { |
|||
display: block; |
|||
font-size: 0.8em; |
|||
font-weight: normal; |
|||
cursor: default; |
|||
} |
|||
.form-hint { |
|||
font-size: 0.8rem; |
|||
} |
|||
|
|||
.navbar-light .navbar-nav .nav-link, .btn-icon { |
|||
& { |
|||
color: $nav-text-color; |
|||
} |
|||
|
|||
&:hover, &:focus { |
|||
text-decoration: none; |
|||
} |
|||
|
|||
&:hover { |
|||
color: black; |
|||
} |
|||
.ng-invalid.ng-dirty { |
|||
border-color: $accent-error; |
|||
} |
|||
|
|||
&.disabled { |
|||
color: lighten($nav-text-color, 55%); |
|||
} |
|||
.ng-invalid.ng-dirty :focus, .ng-invalid.ng-dirty :hover { |
|||
border-color: $accent-error-dark; |
|||
} |
|||
|
|||
.navbar-light .navbar-nav .nav-link.btn-blue, .btn-icon.btn-blue, .btn-blue { |
|||
&:hover { |
|||
color: $accent-normal; |
|||
.errors { |
|||
&-box { |
|||
position: relative; |
|||
} |
|||
|
|||
&:focus { |
|||
color: $accent-dark |
|||
&:after { |
|||
@include absolute(2rem, auto, auto, 0.6rem); |
|||
content: ''; |
|||
height: 0; |
|||
border-style: solid; |
|||
border-width: 0.4rem; |
|||
border-color: $accent-error transparent transparent transparent; |
|||
width: 0; |
|||
} |
|||
|
|||
&.disabled { |
|||
color: lighten($nav-text-color, 55%); |
|||
& { |
|||
@include absolute(-2.4rem, 0px, auto, 0px); |
|||
@include border-radius(2px); |
|||
color: white; |
|||
cursor: none; |
|||
font-size: 0.9rem; |
|||
font-weight: normal; |
|||
line-height: 2rem; |
|||
padding: 0 0.4rem; |
|||
background: $accent-error; |
|||
} |
|||
} |
|||
|
|||
.btn-icon { |
|||
padding-left: 0.4rem; |
|||
padding-right: 0.4rem; |
|||
cursor: default; |
|||
} |
|||
|
|||
.navbar-nav .nav-item + .nav-nomargin { |
|||
margin-left: 0; |
|||
margin-right: -1rem; |
|||
.modal-content { |
|||
@include box-shadow(0px, 6px, 16px, 0.2px); |
|||
} |
|||
|
|||
.nav-separator { |
|||
margin-top: .425rem; |
|||
margin-bottom: .425rem; |
|||
margin-left: 1.2em; |
|||
margin-right: 1.2em; |
|||
display: block; |
|||
.modal-content, .modal-header { |
|||
border: 0; |
|||
} |
|||
@ -1,4 +1,10 @@ |
|||
$nav-text-color: #333; |
|||
|
|||
$accent-normal: blue; |
|||
$accent-dark: darkblue; |
|||
$accent-blue: #438CEF; |
|||
$accent-blue-dark: #3F83DF; |
|||
|
|||
$accent-green: #4CC159; |
|||
$accent-green-dark: #47B353; |
|||
|
|||
$accent-error: red; |
|||
$accent-error-dark: darken(red, 5%); |
|||
@ -0,0 +1,7 @@ |
|||
@import '_mixins.scss'; |
|||
@import '_vars.scss'; |
|||
|
|||
$fa-font-path: "~font-awesome/fonts"; |
|||
|
|||
$brand-primary: $accent-blue; |
|||
$brand-success: $accent-green; |
|||
Loading…
Reference in new issue